Merge pull request 'Clan-app: Add loading animations & improve async data handling' (#1854) from hsjobeki/clan-core:hsjobeki-main into main
This commit is contained in:
@@ -40,7 +40,7 @@ def inspect_flake(flake_url: str | Path, machine_name: str) -> FlakeConfig:
|
||||
system = config["system"]
|
||||
|
||||
# Check if the machine exists
|
||||
machines: list[str] = list_nixos_machines(flake_url, False)
|
||||
machines: list[str] = list_nixos_machines(flake_url)
|
||||
if machine_name not in machines:
|
||||
raise ClanError(
|
||||
f"Machine {machine_name} not found in {flake_url}. Available machines: {', '.join(machines)}"
|
||||
|
||||
@@ -13,15 +13,13 @@ log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@API.register
|
||||
def list_inventory_machines(
|
||||
flake_url: str | Path, debug: bool = False
|
||||
) -> dict[str, Machine]:
|
||||
def list_inventory_machines(flake_url: str | Path) -> dict[str, Machine]:
|
||||
inventory = load_inventory_eval(flake_url)
|
||||
return inventory.machines
|
||||
|
||||
|
||||
@API.register
|
||||
def list_nixos_machines(flake_url: str | Path, debug: bool = False) -> list[str]:
|
||||
def list_nixos_machines(flake_url: str | Path) -> list[str]:
|
||||
cmd = nix_eval(
|
||||
[
|
||||
f"{flake_url}#nixosConfigurations",
|
||||
@@ -42,7 +40,7 @@ def list_nixos_machines(flake_url: str | Path, debug: bool = False) -> list[str]
|
||||
|
||||
def list_command(args: argparse.Namespace) -> None:
|
||||
flake_path = args.flake.path
|
||||
for name in list_nixos_machines(flake_path, args.debug):
|
||||
for name in list_nixos_machines(flake_path):
|
||||
print(name)
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,6 @@ import { makePersisted } from "@solid-primitives/storage";
|
||||
|
||||
// Some global state
|
||||
const [route, setRoute] = createSignal<Route>("machines");
|
||||
createEffect(() => {
|
||||
console.log(route());
|
||||
});
|
||||
|
||||
export { route, setRoute };
|
||||
|
||||
|
||||
@@ -69,6 +69,11 @@ export const routes = {
|
||||
label: "diskConfig",
|
||||
icon: "disk",
|
||||
},
|
||||
"machines/edit": {
|
||||
child: CreateMachine,
|
||||
label: "Edit Machine",
|
||||
icon: "edit",
|
||||
},
|
||||
};
|
||||
|
||||
interface RouterProps {
|
||||
|
||||
@@ -123,7 +123,7 @@ export const callApi = <K extends OperationNames>(
|
||||
return new Promise<OperationResponse<K>>((resolve) => {
|
||||
const id = nanoid();
|
||||
pyApi[method].receive((response) => {
|
||||
console.log("Received response: ", { response });
|
||||
console.log(method, "Received response: ", { response });
|
||||
resolve(response);
|
||||
}, id);
|
||||
|
||||
@@ -136,7 +136,6 @@ const deserialize =
|
||||
(str: string) => {
|
||||
try {
|
||||
const r = JSON.parse(str) as T;
|
||||
console.log("Received: ", r);
|
||||
fn(r);
|
||||
} catch (e) {
|
||||
console.log("Error parsing JSON: ", e);
|
||||
|
||||
91
pkgs/webview-ui/app/src/components/FileInput.tsx
Normal file
91
pkgs/webview-ui/app/src/components/FileInput.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import cx from "classnames";
|
||||
import { createMemo, JSX, Show, splitProps } from "solid-js";
|
||||
|
||||
interface FileInputProps {
|
||||
ref: (element: HTMLInputElement) => void;
|
||||
name: string;
|
||||
value?: File[] | File;
|
||||
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
|
||||
onClick: JSX.EventHandler<HTMLInputElement, Event>;
|
||||
onChange: JSX.EventHandler<HTMLInputElement, Event>;
|
||||
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
|
||||
accept?: string;
|
||||
required?: boolean;
|
||||
multiple?: boolean;
|
||||
class?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* File input field that users can click or drag files into. Various
|
||||
* decorations can be displayed in or around the field to communicate the entry
|
||||
* requirements.
|
||||
*/
|
||||
export function FileInput(props: FileInputProps) {
|
||||
// Split input element props
|
||||
const [, inputProps] = splitProps(props, [
|
||||
"class",
|
||||
"value",
|
||||
"label",
|
||||
"error",
|
||||
]);
|
||||
|
||||
// Create file list
|
||||
const getFiles = createMemo(() =>
|
||||
props.value
|
||||
? Array.isArray(props.value)
|
||||
? props.value
|
||||
: [props.value]
|
||||
: [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div class={cx("form-control w-full", props.class)}>
|
||||
<div class="label">
|
||||
<span
|
||||
class="label-text block"
|
||||
classList={{
|
||||
"after:ml-0.5 after:text-primary after:content-['*']":
|
||||
props.required,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={cx(
|
||||
"relative flex min-h-[96px] w-full items-center justify-center rounded-2xl border-[3px] border-dashed p-8 text-center focus-within:ring-4 md:min-h-[112px] md:text-lg lg:min-h-[128px] lg:p-10 lg:text-xl",
|
||||
!getFiles().length && "text-slate-500",
|
||||
props.error
|
||||
? "border-red-500/25 focus-within:border-red-500/50 focus-within:ring-red-500/10 hover:border-red-500/40 dark:border-red-400/25 dark:focus-within:border-red-400/50 dark:focus-within:ring-red-400/10 dark:hover:border-red-400/40"
|
||||
: "border-slate-200 focus-within:border-sky-500/50 focus-within:ring-sky-500/10 hover:border-slate-300 dark:border-slate-800 dark:focus-within:border-sky-400/50 dark:focus-within:ring-sky-400/10 dark:hover:border-slate-700",
|
||||
)}
|
||||
>
|
||||
<Show
|
||||
when={getFiles().length}
|
||||
fallback={<>Click to select file{props.multiple && "s"}</>}
|
||||
>
|
||||
Selected file{props.multiple && "s"}:{" "}
|
||||
{getFiles()
|
||||
.map(({ name }) => name)
|
||||
.join(", ")}
|
||||
</Show>
|
||||
<input
|
||||
{...inputProps}
|
||||
// Disable drag n drop
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
class="absolute size-full cursor-pointer opacity-0"
|
||||
type="file"
|
||||
id={props.name}
|
||||
aria-invalid={!!props.error}
|
||||
aria-errormessage={`${props.name}-error`}
|
||||
/>
|
||||
{props.error && (
|
||||
<span class="label-text-alt font-bold text-error">{props.error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +1,67 @@
|
||||
import { createSignal, Match, Show, Switch } from "solid-js";
|
||||
import { ErrorData, pyApi, SuccessData } from "../api";
|
||||
import { Accessor, createEffect, Show } from "solid-js";
|
||||
import { SuccessData } from "../api";
|
||||
import { Menu } from "./Menu";
|
||||
import { setRoute } from "../App";
|
||||
|
||||
type MachineDetails = SuccessData<"list_inventory_machines">["data"][string];
|
||||
|
||||
interface MachineListItemProps {
|
||||
name: string;
|
||||
info?: MachineDetails;
|
||||
nixOnly?: boolean;
|
||||
}
|
||||
|
||||
type HWInfo = Record<string, SuccessData<"show_machine_hardware_info">["data"]>;
|
||||
type DeploymentInfo = Record<
|
||||
string,
|
||||
SuccessData<"show_machine_deployment_target">["data"]
|
||||
>;
|
||||
|
||||
type MachineErrors = Record<string, ErrorData<"show_machine">["errors"]>;
|
||||
|
||||
const [hwInfo, setHwInfo] = createSignal<HWInfo>({});
|
||||
|
||||
const [deploymentInfo, setDeploymentInfo] = createSignal<DeploymentInfo>({});
|
||||
|
||||
const [errors, setErrors] = createSignal<MachineErrors>({});
|
||||
|
||||
// pyApi.show_machine_hardware_info.receive((r) => {
|
||||
// const { op_key } = r;
|
||||
// if (r.status === "error") {
|
||||
// console.error(r.errors);
|
||||
// if (op_key) {
|
||||
// setHwInfo((d) => ({ ...d, [op_key]: { system: null } }));
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
// if (op_key) {
|
||||
// setHwInfo((d) => ({ ...d, [op_key]: r.data }));
|
||||
// }
|
||||
// });
|
||||
|
||||
// pyApi.show_machine_deployment_target.receive((r) => {
|
||||
// const { op_key } = r;
|
||||
// if (r.status === "error") {
|
||||
// console.error(r.errors);
|
||||
// if (op_key) {
|
||||
// setDeploymentInfo((d) => ({ ...d, [op_key]: null }));
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
// if (op_key) {
|
||||
// setDeploymentInfo((d) => ({ ...d, [op_key]: r.data }));
|
||||
// }
|
||||
// });
|
||||
|
||||
export const MachineListItem = (props: MachineListItemProps) => {
|
||||
const { name, info } = props;
|
||||
const { name, info, nixOnly } = props;
|
||||
|
||||
return (
|
||||
<li>
|
||||
<div class="card card-side m-2 bg-base-100 shadow-lg">
|
||||
<div class="card card-side m-2 bg-base-200">
|
||||
<figure class="pl-2">
|
||||
<span class="material-icons content-center text-5xl">
|
||||
<span
|
||||
class="material-icons content-center text-5xl"
|
||||
classList={{
|
||||
"text-neutral-500": nixOnly,
|
||||
}}
|
||||
>
|
||||
devices_other
|
||||
</span>
|
||||
</figure>
|
||||
<div class="card-body flex-row justify-between">
|
||||
<div class="card-body flex-row justify-between ">
|
||||
<div class="flex flex-col">
|
||||
<h2 class="card-title">{name}</h2>
|
||||
<h2
|
||||
class="card-title"
|
||||
classList={{
|
||||
"text-neutral-500": nixOnly,
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</h2>
|
||||
<div class="text-slate-600">
|
||||
<Show when={info}>{(d) => d()?.description}</Show>
|
||||
</div>
|
||||
<div class="flex flex-row flex-wrap gap-4 py-2"></div>
|
||||
{/* Show only the first error at the bottom */}
|
||||
<Show when={errors()[name]?.[0]}>
|
||||
{(error) => (
|
||||
<div class="badge badge-error py-4">
|
||||
Error: {error().message}: {error().description}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-ghost">
|
||||
<span class="material-icons">more_vert</span>
|
||||
</button>
|
||||
<Menu
|
||||
popoverid={`menu-${props.name}`}
|
||||
label={<span class="material-icons">more_vert</span>}
|
||||
>
|
||||
<ul class="menu z-[1] w-52 rounded-box bg-base-100 p-2 shadow">
|
||||
<li>
|
||||
<a
|
||||
onClick={() => {
|
||||
setRoute("machines/edit");
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a>Deploy</a>
|
||||
</li>
|
||||
</ul>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
84
pkgs/webview-ui/app/src/components/Menu.tsx
Normal file
84
pkgs/webview-ui/app/src/components/Menu.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { children, Component, createSignal, type JSX } from "solid-js";
|
||||
import { useFloating } from "@/src/floating";
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
hide,
|
||||
offset,
|
||||
Placement,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import cx from "classnames";
|
||||
|
||||
interface MenuProps {
|
||||
/**
|
||||
* Used by the html API to associate the popover with the dispatcher button
|
||||
*/
|
||||
popoverid: string;
|
||||
|
||||
label: JSX.Element;
|
||||
|
||||
children?: JSX.Element;
|
||||
buttonProps?: JSX.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
buttonClass?: string;
|
||||
/**
|
||||
* @default "bottom"
|
||||
*/
|
||||
placement?: Placement;
|
||||
}
|
||||
export const Menu = (props: MenuProps) => {
|
||||
const c = children(() => props.children);
|
||||
const [reference, setReference] = createSignal<HTMLElement>();
|
||||
const [floating, setFloating] = createSignal<HTMLElement>();
|
||||
|
||||
// `position` is a reactive object.
|
||||
const position = useFloating(reference, floating, {
|
||||
placement: "bottom",
|
||||
|
||||
// pass options. Ensure the cleanup function is returned.
|
||||
whileElementsMounted: (reference, floating, update) =>
|
||||
autoUpdate(reference, floating, update, {
|
||||
animationFrame: true,
|
||||
}),
|
||||
middleware: [
|
||||
offset(5),
|
||||
shift(),
|
||||
flip(),
|
||||
|
||||
hide({
|
||||
strategy: "referenceHidden",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
popovertarget={props.popoverid}
|
||||
popovertargetaction="toggle"
|
||||
ref={setReference}
|
||||
class={cx(
|
||||
"btn btn-ghost btn-outline join-item btn-sm",
|
||||
props.buttonClass,
|
||||
)}
|
||||
{...props.buttonProps}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
<div
|
||||
popover="auto"
|
||||
id={props.popoverid}
|
||||
ref={setFloating}
|
||||
style={{
|
||||
margin: 0,
|
||||
position: position.strategy,
|
||||
top: `${position.y ?? 0}px`,
|
||||
left: `${position.x ?? 0}px`,
|
||||
}}
|
||||
class="bg-transparent"
|
||||
>
|
||||
{c()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
48
pkgs/webview-ui/app/src/components/SelectInput.tsx
Normal file
48
pkgs/webview-ui/app/src/components/SelectInput.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid";
|
||||
import { type JSX } from "solid-js";
|
||||
|
||||
interface SelectInputProps<T extends FieldValues, R extends ResponseData> {
|
||||
formStore: FormStore<T, R>;
|
||||
value: string;
|
||||
options: JSX.Element;
|
||||
selectProps: JSX.HTMLAttributes<HTMLSelectElement>;
|
||||
label: JSX.Element;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export function SelectInput<T extends FieldValues, R extends ResponseData>(
|
||||
props: SelectInputProps<T, R>,
|
||||
) {
|
||||
return (
|
||||
<label
|
||||
class="form-control w-full"
|
||||
aria-disabled={props.formStore.submitting}
|
||||
>
|
||||
<div class="label">
|
||||
<span
|
||||
class="label-text block"
|
||||
classList={{
|
||||
"after:ml-0.5 after:text-primary after:content-['*']":
|
||||
props.required,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
{...props.selectProps}
|
||||
required={props.required}
|
||||
class="select select-bordered w-full"
|
||||
value={props.value}
|
||||
>
|
||||
{props.options}
|
||||
</select>
|
||||
|
||||
{props.error && (
|
||||
<span class="label-text-alt font-bold text-error">{props.error}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
54
pkgs/webview-ui/app/src/components/TextInput.tsx
Normal file
54
pkgs/webview-ui/app/src/components/TextInput.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid";
|
||||
import { type JSX } from "solid-js";
|
||||
|
||||
interface TextInputProps<T extends FieldValues, R extends ResponseData> {
|
||||
formStore: FormStore<T, R>;
|
||||
value: string;
|
||||
inputProps: JSX.HTMLAttributes<HTMLInputElement>;
|
||||
label: JSX.Element;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
inlineLabel?: JSX.Element;
|
||||
}
|
||||
|
||||
export function TextInput<T extends FieldValues, R extends ResponseData>(
|
||||
props: TextInputProps<T, R>,
|
||||
) {
|
||||
return (
|
||||
<label
|
||||
class="form-control w-full"
|
||||
aria-disabled={props.formStore.submitting}
|
||||
>
|
||||
<div class="label">
|
||||
<span
|
||||
class="label-text block"
|
||||
classList={{
|
||||
"after:ml-0.5 after:text-primary after:content-['*']":
|
||||
props.required,
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="input input-bordered flex items-center gap-2">
|
||||
{props.inlineLabel}
|
||||
<input
|
||||
{...props.inputProps}
|
||||
value={props.value}
|
||||
type="text"
|
||||
class="grow"
|
||||
classList={{
|
||||
"input-disabled": props.formStore.submitting,
|
||||
}}
|
||||
placeholder="name"
|
||||
required
|
||||
disabled={props.formStore.submitting}
|
||||
/>
|
||||
</div>
|
||||
{props.error && (
|
||||
<span class="label-text-alt font-bold text-error">{props.error}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { activeURI, setRoute } from "../App";
|
||||
import { callApi } from "../api";
|
||||
import { Accessor, createEffect, Show } from "solid-js";
|
||||
import { Accessor, Show } from "solid-js";
|
||||
|
||||
interface HeaderProps {
|
||||
clan_dir: Accessor<string | null>;
|
||||
@@ -34,7 +34,14 @@ export const Header = (props: HeaderProps) => {
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Show when={!query.isFetching && query.data}>
|
||||
<Show when={query.isLoading && !query.data}>
|
||||
<div class="skeleton mx-4 size-11 rounded-full"></div>
|
||||
<span class="flex flex-col gap-2">
|
||||
<div class="skeleton h-3 w-32"></div>
|
||||
<div class="skeleton h-3 w-40"></div>
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={query.data}>
|
||||
{(meta) => (
|
||||
<div class="tooltip tooltip-right" data-tip={activeURI()}>
|
||||
<div class="avatar placeholder online mx-4">
|
||||
@@ -46,7 +53,7 @@ export const Header = (props: HeaderProps) => {
|
||||
)}
|
||||
</Show>
|
||||
<span class="flex flex-col">
|
||||
<Show when={!query.isFetching && query.data}>
|
||||
<Show when={query.data}>
|
||||
{(meta) => [
|
||||
<span class="text-primary">{meta().name}</span>,
|
||||
<span class="text-neutral">{meta()?.description}</span>,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { callApi, OperationResponse } from "@/src/api";
|
||||
import {
|
||||
createForm,
|
||||
required,
|
||||
FieldValues,
|
||||
setValue,
|
||||
} from "@modular-forms/solid";
|
||||
import { FileInput } from "@/src/components/FileInput";
|
||||
import { SelectInput } from "@/src/components/SelectInput";
|
||||
import { TextInput } from "@/src/components/TextInput";
|
||||
import { createForm, required, FieldValues } from "@modular-forms/solid";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { createEffect, createSignal, For } from "solid-js";
|
||||
import { For } from "solid-js";
|
||||
import toast from "solid-toast";
|
||||
|
||||
interface FlashFormValues extends FieldValues {
|
||||
machine: {
|
||||
@@ -16,58 +15,32 @@ interface FlashFormValues extends FieldValues {
|
||||
disk: string;
|
||||
language: string;
|
||||
keymap: string;
|
||||
sshKeys: string[];
|
||||
sshKeys: File[];
|
||||
}
|
||||
|
||||
type BlockDevices = Extract<
|
||||
OperationResponse<"show_block_devices">,
|
||||
{ status: "success" }
|
||||
>["data"]["blockdevices"];
|
||||
|
||||
export const Flash = () => {
|
||||
const [formStore, { Form, Field }] = createForm<FlashFormValues>({});
|
||||
const [sshKeys, setSshKeys] = createSignal<string[]>([]);
|
||||
const [isFlashing, setIsFlashing] = createSignal(false);
|
||||
|
||||
const selectSshPubkey = async () => {
|
||||
try {
|
||||
const loc = await callApi("open_file", {
|
||||
file_request: {
|
||||
title: "Select SSH Key",
|
||||
mode: "open_multiple_files",
|
||||
filters: { patterns: ["*.pub"] },
|
||||
initial_folder: "~/.ssh",
|
||||
const [formStore, { Form, Field }] = createForm<FlashFormValues>({
|
||||
initialValues: {
|
||||
machine: {
|
||||
flake: "git+https://git.clan.lol/clan/clan-core",
|
||||
devicePath: "flash-installer",
|
||||
},
|
||||
language: "en_US.UTF-8",
|
||||
keymap: "en",
|
||||
},
|
||||
});
|
||||
console.log({ loc }, loc.status);
|
||||
if (loc.status === "success" && loc.data) {
|
||||
setSshKeys(loc.data);
|
||||
return loc.data;
|
||||
}
|
||||
} catch (e) {
|
||||
//
|
||||
}
|
||||
};
|
||||
|
||||
// Create an effect that updates the form when externalUsername changes
|
||||
createEffect(() => {
|
||||
const newSshKeys = sshKeys();
|
||||
if (newSshKeys) {
|
||||
setValue(formStore, "sshKeys", newSshKeys);
|
||||
}
|
||||
});
|
||||
|
||||
const { data: devices, isFetching } = createQuery(() => ({
|
||||
const deviceQuery = createQuery(() => ({
|
||||
queryKey: ["block_devices"],
|
||||
queryFn: async () => {
|
||||
const result = await callApi("show_block_devices", {});
|
||||
if (result.status === "error") throw new Error("Failed to fetch data");
|
||||
return result.data;
|
||||
},
|
||||
staleTime: 1000 * 60 * 2, // 1 minutes
|
||||
staleTime: 1000 * 60 * 1, // 1 minutes
|
||||
}));
|
||||
|
||||
const { data: keymaps, isFetching: isFetchingKeymaps } = createQuery(() => ({
|
||||
const keymapQuery = createQuery(() => ({
|
||||
queryKey: ["list_keymaps"],
|
||||
queryFn: async () => {
|
||||
const result = await callApi("list_possible_keymaps", {});
|
||||
@@ -77,8 +50,7 @@ export const Flash = () => {
|
||||
staleTime: 1000 * 60 * 15, // 15 minutes
|
||||
}));
|
||||
|
||||
const { data: languages, isFetching: isFetchingLanguages } = createQuery(
|
||||
() => ({
|
||||
const langQuery = createQuery(() => ({
|
||||
queryKey: ["list_languages"],
|
||||
queryFn: async () => {
|
||||
const result = await callApi("list_possible_languages", {});
|
||||
@@ -86,11 +58,38 @@ export const Flash = () => {
|
||||
return result.data;
|
||||
},
|
||||
staleTime: 1000 * 60 * 15, // 15 minutes
|
||||
}),
|
||||
);
|
||||
}));
|
||||
|
||||
/**
|
||||
* Opens the custom file dialog
|
||||
* Returns a native FileList to allow interaction with the native input type="file"
|
||||
*/
|
||||
const selectSshKeys = async (): Promise<FileList> => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
const response = await callApi("open_file", {
|
||||
file_request: {
|
||||
title: "Select SSH Key",
|
||||
mode: "open_multiple_files",
|
||||
filters: { patterns: ["*.pub"] },
|
||||
initial_folder: "~/.ssh",
|
||||
},
|
||||
});
|
||||
if (response.status === "success" && response.data) {
|
||||
// Add synthetic files to the DataTransfer object
|
||||
// FileList cannot be instantiated directly.
|
||||
response.data.forEach((filename) => {
|
||||
dataTransfer.items.add(new File([], filename));
|
||||
});
|
||||
}
|
||||
return dataTransfer.files;
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: FlashFormValues) => {
|
||||
setIsFlashing(true);
|
||||
console.log(
|
||||
"Submit SSH Keys:",
|
||||
values.sshKeys.map((file) => file.name),
|
||||
);
|
||||
try {
|
||||
await callApi("flash_machine", {
|
||||
machine: {
|
||||
@@ -104,16 +103,15 @@ export const Flash = () => {
|
||||
system_config: {
|
||||
language: values.language,
|
||||
keymap: values.keymap,
|
||||
ssh_keys_path: values.sshKeys,
|
||||
ssh_keys_path: values.sshKeys.map((file) => file.name),
|
||||
},
|
||||
dry_run: false,
|
||||
write_efi_boot_entries: false,
|
||||
debug: false,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(`Error could not flash disk: ${error}`);
|
||||
console.error("Error submitting form:", error);
|
||||
} finally {
|
||||
setIsFlashing(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -126,24 +124,15 @@ export const Flash = () => {
|
||||
>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<span class="material-icons">file_download</span>
|
||||
<input
|
||||
type="text"
|
||||
class="grow"
|
||||
//placeholder="machine.flake"
|
||||
value="git+https://git.clan.lol/clan/clan-core"
|
||||
<TextInput
|
||||
formStore={formStore}
|
||||
inputProps={props}
|
||||
label="Installer (flake URL)"
|
||||
value={String(field.value)}
|
||||
inlineLabel={<span class="material-icons">file_download</span>}
|
||||
error={field.error}
|
||||
required
|
||||
{...props}
|
||||
/>
|
||||
</label>
|
||||
<div class="label">
|
||||
{field.error && (
|
||||
<span class="label-text-alt font-bold text-error">
|
||||
{field.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
@@ -153,155 +142,125 @@ export const Flash = () => {
|
||||
>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<span class="material-icons">devices</span>
|
||||
<input
|
||||
type="text"
|
||||
class="grow"
|
||||
value="flash-installer"
|
||||
<TextInput
|
||||
formStore={formStore}
|
||||
inputProps={props}
|
||||
label="Installer Image (attribute name)"
|
||||
value={String(field.value)}
|
||||
inlineLabel={<span class="material-icons">devices</span>}
|
||||
error={field.error}
|
||||
required
|
||||
{...props}
|
||||
/>
|
||||
</label>
|
||||
<div class="label">
|
||||
{field.error && (
|
||||
<span class="label-text-alt font-bold text-error">
|
||||
{field.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="disk" validate={[required("This field is required")]}>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<label class="form-control input-bordered flex w-full items-center gap-2">
|
||||
<select
|
||||
<SelectInput
|
||||
formStore={formStore}
|
||||
selectProps={props}
|
||||
label="Flash Disk"
|
||||
value={String(field.value)}
|
||||
error={field.error}
|
||||
required
|
||||
class="select select-bordered w-full"
|
||||
{...props}
|
||||
>
|
||||
options={
|
||||
<>
|
||||
<option value="" disabled>
|
||||
Select a disk
|
||||
</option>
|
||||
<For each={devices?.blockdevices}>
|
||||
<For each={deviceQuery.data?.blockdevices}>
|
||||
{(device) => (
|
||||
<option value={device.path}>
|
||||
{device.path} -- {device.size} bytes
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</select>
|
||||
<div class="label">
|
||||
{isFetching && (
|
||||
<span class="label-text-alt">
|
||||
<span class="loading loading-bars"></span>
|
||||
</span>
|
||||
)}
|
||||
{field.error && (
|
||||
<span class="label-text-alt font-bold text-error">
|
||||
{field.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="language" validate={[required("This field is required")]}>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<label class="form-control input-bordered flex w-full items-center gap-2">
|
||||
<select
|
||||
<SelectInput
|
||||
formStore={formStore}
|
||||
selectProps={props}
|
||||
label="Language"
|
||||
value={String(field.value)}
|
||||
error={field.error}
|
||||
required
|
||||
class="select select-bordered w-full"
|
||||
{...props}
|
||||
>
|
||||
<option>en_US.UTF-8</option>
|
||||
<For each={languages}>
|
||||
options={
|
||||
<For each={langQuery.data}>
|
||||
{(language) => <option value={language}>{language}</option>}
|
||||
</For>
|
||||
</select>
|
||||
<div class="label">
|
||||
{isFetchingLanguages && (
|
||||
<span class="label-text-alt">
|
||||
<span class="loading loading-bars">en_US.UTF-8</span>
|
||||
</span>
|
||||
)}
|
||||
{field.error && (
|
||||
<span class="label-text-alt font-bold text-error">
|
||||
{field.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="keymap" validate={[required("This field is required")]}>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<label class="form-control input-bordered flex w-full items-center gap-2">
|
||||
<select
|
||||
<SelectInput
|
||||
formStore={formStore}
|
||||
selectProps={props}
|
||||
label="Keymap"
|
||||
value={String(field.value)}
|
||||
error={field.error}
|
||||
required
|
||||
class="select select-bordered w-full"
|
||||
{...props}
|
||||
>
|
||||
<option>en</option>
|
||||
<For each={keymaps}>
|
||||
options={
|
||||
<For each={keymapQuery.data}>
|
||||
{(keymap) => <option value={keymap}>{keymap}</option>}
|
||||
</For>
|
||||
</select>
|
||||
<div class="label">
|
||||
{isFetchingKeymaps && (
|
||||
<span class="label-text-alt">
|
||||
<span class="loading loading-bars"></span>
|
||||
</span>
|
||||
)}
|
||||
{field.error && (
|
||||
<span class="label-text-alt font-bold text-error">
|
||||
{field.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="sshKeys" validate={[]} type="string[]">
|
||||
|
||||
<Field name="sshKeys" type="File[]">
|
||||
{(field, props) => (
|
||||
<>
|
||||
<label class="input input-bordered flex items-center gap-2">
|
||||
<span class="material-icons">key</span>
|
||||
<input
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="Select SSH Key"
|
||||
value={field.value ? field.value.join(", ") : ""}
|
||||
readOnly
|
||||
onClick={() => selectSshPubkey()}
|
||||
required
|
||||
<FileInput
|
||||
{...props}
|
||||
onClick={async (event) => {
|
||||
event.preventDefault(); // Prevent the native file dialog from opening
|
||||
const input = event.target;
|
||||
const files = await selectSshKeys();
|
||||
|
||||
// Set the files
|
||||
Object.defineProperty(input, "files", {
|
||||
value: files,
|
||||
writable: true,
|
||||
});
|
||||
// Define the files property on the input element
|
||||
const changeEvent = new Event("input", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
input.dispatchEvent(changeEvent);
|
||||
}}
|
||||
value={field.value}
|
||||
error={field.error}
|
||||
label="Authorized SSH Keys"
|
||||
multiple
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<div class="label">
|
||||
{field.error && (
|
||||
<span class="label-text-alt font-bold text-error">
|
||||
{field.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<button class="btn btn-error" type="submit" disabled={isFlashing()}>
|
||||
{isFlashing() ? (
|
||||
<button
|
||||
class="btn btn-error"
|
||||
type="submit"
|
||||
disabled={formStore.submitting}
|
||||
>
|
||||
{formStore.submitting ? (
|
||||
<span class="loading loading-spinner"></span>
|
||||
) : (
|
||||
<span class="material-icons">bolt</span>
|
||||
)}
|
||||
{isFlashing() ? "Flashing..." : "Flash Installer"}
|
||||
{formStore.submitting ? "Flashing..." : "Flash Installer"}
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { callApi, OperationArgs, pyApi, OperationResponse } from "@/src/api";
|
||||
import { activeURI, setRoute } from "@/src/App";
|
||||
import { TextInput } from "@/src/components/TextInput";
|
||||
import { createForm, required, reset } from "@modular-forms/solid";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { createQuery, useQueryClient } from "@tanstack/solid-query";
|
||||
import { Match, Switch } from "solid-js";
|
||||
import toast from "solid-toast";
|
||||
|
||||
@@ -23,9 +24,7 @@ export function CreateMachine() {
|
||||
},
|
||||
});
|
||||
|
||||
const { refetch: refetchMachines } = createQuery(() => ({
|
||||
queryKey: [activeURI(), "list_inventory_machines"],
|
||||
}));
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleSubmit = async (values: CreateMachineForm) => {
|
||||
const active_dir = activeURI();
|
||||
@@ -46,7 +45,10 @@ export function CreateMachine() {
|
||||
if (response.status === "success") {
|
||||
toast.success(`Successfully created ${values.machine.name}`);
|
||||
reset(formStore);
|
||||
refetchMachines();
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [activeURI(), "list_machines"],
|
||||
});
|
||||
setRoute("machines");
|
||||
} else {
|
||||
toast.error(
|
||||
@@ -56,93 +58,44 @@ export function CreateMachine() {
|
||||
};
|
||||
return (
|
||||
<div class="px-1">
|
||||
Create new Machine
|
||||
<button
|
||||
onClick={() => {
|
||||
reset(formStore);
|
||||
}}
|
||||
>
|
||||
reset
|
||||
</button>
|
||||
<span class="px-2">Create new Machine</span>
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Field
|
||||
name="machine.name"
|
||||
validate={[required("This field is required")]}
|
||||
>
|
||||
{(field, props) => (
|
||||
<>
|
||||
<label
|
||||
class="input input-bordered flex items-center gap-2"
|
||||
classList={{
|
||||
"input-disabled": formStore.submitting,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
{...props}
|
||||
value={field.value}
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="name"
|
||||
<TextInput
|
||||
inputProps={props}
|
||||
formStore={formStore}
|
||||
value={`${field.value}`}
|
||||
label={"name"}
|
||||
error={field.error}
|
||||
required
|
||||
disabled={formStore.submitting}
|
||||
/>
|
||||
</label>
|
||||
<div class="label">
|
||||
{field.error && (
|
||||
<span class="label-text-alt font-bold text-error">
|
||||
{field.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="machine.description">
|
||||
{(field, props) => (
|
||||
<>
|
||||
<label
|
||||
class="input input-bordered flex items-center gap-2"
|
||||
classList={{
|
||||
"input-disabled": formStore.submitting,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={String(field.value)}
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="description"
|
||||
required
|
||||
{...props}
|
||||
<TextInput
|
||||
inputProps={props}
|
||||
formStore={formStore}
|
||||
value={`${field.value}`}
|
||||
label={"description"}
|
||||
error={field.error}
|
||||
/>
|
||||
</label>
|
||||
<div class="label">
|
||||
{field.error && (
|
||||
<span class="label-text-alt font-bold text-error">
|
||||
{field.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field name="machine.deploy.targetHost">
|
||||
{(field, props) => (
|
||||
<>
|
||||
<label
|
||||
class="input input-bordered flex items-center gap-2"
|
||||
classList={{
|
||||
"input-disabled": formStore.submitting,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={String(field.value)}
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="root@flash-installer.local"
|
||||
required
|
||||
{...props}
|
||||
<TextInput
|
||||
inputProps={props}
|
||||
formStore={formStore}
|
||||
value={`${field.value}`}
|
||||
label={"Deployment target"}
|
||||
error={field.error}
|
||||
/>
|
||||
</label>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-neutral">
|
||||
Must be set before deployment for the following tasks:
|
||||
@@ -158,11 +111,6 @@ export function CreateMachine() {
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
{field.error && (
|
||||
<span class="label-text-alt font-bold text-error">
|
||||
{field.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -3,43 +3,28 @@ import { activeURI, setRoute } from "@/src/App";
|
||||
import { callApi, OperationResponse } from "@/src/api";
|
||||
import toast from "solid-toast";
|
||||
import { MachineListItem } from "@/src/components/MachineListItem";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import {
|
||||
createQueries,
|
||||
createQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/solid-query";
|
||||
|
||||
type MachinesModel = Extract<
|
||||
OperationResponse<"list_inventory_machines">,
|
||||
{ status: "success" }
|
||||
>["data"];
|
||||
|
||||
type ExtendedMachine = MachinesModel & {
|
||||
nixOnly: boolean;
|
||||
};
|
||||
|
||||
export const MachineListView: Component = () => {
|
||||
const {
|
||||
data: nixosMachines,
|
||||
isFetching: isLoadingNixos,
|
||||
refetch: refetchNixos,
|
||||
} = createQuery<string[]>(() => ({
|
||||
queryKey: [activeURI(), "list_nixos_machines"],
|
||||
queryFn: async () => {
|
||||
const uri = activeURI();
|
||||
if (uri) {
|
||||
const response = await callApi("list_nixos_machines", {
|
||||
flake_url: uri,
|
||||
});
|
||||
if (response.status === "error") {
|
||||
toast.error("Failed to fetch data");
|
||||
} else {
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
}));
|
||||
const {
|
||||
data: inventoryMachines,
|
||||
isFetching: isLoadingInventory,
|
||||
refetch: refetchInventory,
|
||||
} = createQuery<MachinesModel>(() => ({
|
||||
queryKey: [activeURI(), "list_inventory_machines"],
|
||||
initialData: {},
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const inventoryQuery = createQuery<MachinesModel>(() => ({
|
||||
queryKey: [activeURI(), "list_machines", "inventory"],
|
||||
placeholderData: {},
|
||||
enabled: !!activeURI(),
|
||||
queryFn: async () => {
|
||||
const uri = activeURI();
|
||||
if (uri) {
|
||||
@@ -54,26 +39,43 @@ export const MachineListView: Component = () => {
|
||||
}
|
||||
return {};
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
}));
|
||||
|
||||
const nixosQuery = createQuery<string[]>(() => ({
|
||||
queryKey: [activeURI(), "list_machines", "nixos"],
|
||||
enabled: !!activeURI(),
|
||||
placeholderData: [],
|
||||
queryFn: async () => {
|
||||
const uri = activeURI();
|
||||
if (uri) {
|
||||
const response = await callApi("list_nixos_machines", {
|
||||
flake_url: uri,
|
||||
});
|
||||
if (response.status === "error") {
|
||||
toast.error("Failed to fetch data");
|
||||
} else {
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
},
|
||||
}));
|
||||
|
||||
const refresh = async () => {
|
||||
refetchInventory();
|
||||
refetchNixos();
|
||||
queryClient.invalidateQueries({
|
||||
// Invalidates the cache for of all types of machine list at once
|
||||
queryKey: [activeURI(), "list_machines"],
|
||||
});
|
||||
};
|
||||
|
||||
const unpackedMachines = () => Object.entries(inventoryMachines);
|
||||
const inventoryMachines = () => Object.entries(inventoryQuery.data || {});
|
||||
const nixOnlyMachines = () =>
|
||||
nixosMachines?.filter(
|
||||
(name) => !unpackedMachines().some(([key, machine]) => key === name),
|
||||
nixosQuery.data?.filter(
|
||||
(name) => !inventoryMachines().some(([key, machine]) => key === name),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
console.log(nixOnlyMachines(), unpackedMachines());
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="max-w-screen-lg">
|
||||
<div>
|
||||
<div class="tooltip tooltip-bottom" data-tip="Open Clan"></div>
|
||||
<div class="tooltip tooltip-bottom" data-tip="Refresh">
|
||||
<button class="btn btn-ghost" onClick={() => refresh()}>
|
||||
@@ -86,7 +88,7 @@ export const MachineListView: Component = () => {
|
||||
</button>
|
||||
</div>
|
||||
<Switch>
|
||||
<Match when={isLoadingInventory}>
|
||||
<Match when={inventoryQuery.isLoading}>
|
||||
{/* Loading skeleton */}
|
||||
<div>
|
||||
<div class="card card-side m-2 bg-base-100 shadow-lg">
|
||||
@@ -104,20 +106,20 @@ export const MachineListView: Component = () => {
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
!isLoadingInventory &&
|
||||
unpackedMachines().length === 0 &&
|
||||
!inventoryQuery.isLoading &&
|
||||
inventoryMachines().length === 0 &&
|
||||
nixOnlyMachines()?.length === 0
|
||||
}
|
||||
>
|
||||
No machines found
|
||||
</Match>
|
||||
<Match when={!isLoadingInventory}>
|
||||
<Match when={!inventoryQuery.isLoading}>
|
||||
<ul>
|
||||
<For each={unpackedMachines()}>
|
||||
<For each={inventoryMachines()}>
|
||||
{([name, info]) => <MachineListItem name={name} info={info} />}
|
||||
</For>
|
||||
<For each={nixOnlyMachines()}>
|
||||
{(name) => <MachineListItem name={name} />}
|
||||
{(name) => <MachineListItem name={name} nixOnly={true} />}
|
||||
</For>
|
||||
</ul>
|
||||
</Match>
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { callApi } from "@/src/api";
|
||||
import {
|
||||
createForm,
|
||||
required,
|
||||
setValue,
|
||||
SubmitHandler,
|
||||
} from "@modular-forms/solid";
|
||||
import {
|
||||
activeURI,
|
||||
clanList,
|
||||
@@ -12,15 +6,7 @@ import {
|
||||
setClanList,
|
||||
setRoute,
|
||||
} from "@/src/App";
|
||||
import {
|
||||
createEffect,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
Setter,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js";
|
||||
import { createSignal, For, Match, Setter, Show, Switch } from "solid-js";
|
||||
import { createQuery } from "@tanstack/solid-query";
|
||||
import { useFloating } from "@/src/floating";
|
||||
import { autoUpdate, flip, hide, offset, shift } from "@floating-ui/dom";
|
||||
@@ -156,6 +142,9 @@ const ClanDetails = (props: ClanDetailsProps) => {
|
||||
</div>
|
||||
<div class="stat-title">{clan_dir}</div>
|
||||
|
||||
<Show when={details.isLoading}>
|
||||
<div class="skeleton h-12 w-80" />
|
||||
</Show>
|
||||
<Show when={details.isSuccess}>
|
||||
<div class="stat-value">{details.data?.name}</div>
|
||||
</Show>
|
||||
|
||||
Reference in New Issue
Block a user