Merge pull request 'UI: install improve UI workflow' (#1952) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2024-08-21 13:40:01 +00:00
4 changed files with 404 additions and 207 deletions

View File

@@ -8,12 +8,21 @@ from typing import Literal
from clan_cli.api import API
from clan_cli.cmd import run_no_stdout
from clan_cli.errors import ClanCmdError, ClanError
from clan_cli.inventory import Machine, load_inventory_eval
from clan_cli.inventory import Machine, load_inventory_eval, save_inventory
from clan_cli.nix import nix_eval, nix_shell
log = logging.getLogger(__name__)
@API.register
def set_machine(flake_url: str | Path, machine_name: str, machine: Machine) -> None:
inventory = load_inventory_eval(flake_url)
inventory.machines[machine_name] = machine
save_inventory(inventory, flake_url, "machines: edit '{machine_name}'")
@API.register
def list_inventory_machines(flake_url: str | Path) -> dict[str, Machine]:
inventory = load_inventory_eval(flake_url)
@@ -86,7 +95,7 @@ def check_machine_online(
if not hostname:
raise ClanError(f"Machine {machine_name} does not specify a targetHost")
timeout = opts.timeout if opts and opts.timeout else 2
timeout = opts.timeout if opts and opts.timeout else 20
cmd = nix_shell(
["nixpkgs#util-linux", *(["nixpkgs#openssh"] if hostname else [])],

View File

@@ -16,6 +16,7 @@ interface FileInputProps {
label?: string;
error?: string;
helperText?: string;
placeholder?: JSX.Element;
}
/**
@@ -30,6 +31,7 @@ export function FileInput(props: FileInputProps) {
"value",
"label",
"error",
"placeholder",
]);
// Create file list
@@ -68,7 +70,11 @@ export function FileInput(props: FileInputProps) {
>
<Show
when={getFiles().length}
fallback={<>Click to select file{props.multiple && "s"}</>}
fallback={
props.placeholder || (
<>Click to select file{props.multiple && "s"}</>
)
}
>
Selected file{props.multiple && "s"}:{" "}
{getFiles()

View File

@@ -1,6 +1,7 @@
import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid";
import { Show } from "solid-js";
import { type JSX } from "solid-js";
import cx from "classnames";
interface SelectInputProps<T extends FieldValues, R extends ResponseData> {
formStore: FormStore<T, R>;
@@ -11,6 +12,7 @@ interface SelectInputProps<T extends FieldValues, R extends ResponseData> {
error?: string;
required?: boolean;
topRightLabel?: JSX.Element;
class?: string;
}
export function SelectInput<T extends FieldValues, R extends ResponseData>(
@@ -18,7 +20,7 @@ export function SelectInput<T extends FieldValues, R extends ResponseData>(
) {
return (
<label
class="form-control w-full"
class={cx("form-control w-full", props.class)}
aria-disabled={props.formStore.submitting}
>
<div class="label">

View File

@@ -1,15 +1,380 @@
import { callApi } from "@/src/api";
import { callApi, SuccessData } from "@/src/api";
import { activeURI } from "@/src/App";
import { FileInput } from "@/src/components/FileInput";
import { SelectInput } from "@/src/components/SelectInput";
import { createForm } from "@modular-forms/solid";
import { TextInput } from "@/src/components/TextInput";
import { createForm, getValue, reset } from "@modular-forms/solid";
import { useParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query";
import { createSignal, For, Show } from "solid-js";
import { createSignal, For, Show, createEffect } from "solid-js";
import toast from "solid-toast";
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type InstallForm = {
disk: string;
type MachineFormInterface = {
name: string;
description: string;
targetHost?: string;
sshKey?: File;
disk?: string;
};
type Disks = SuccessData<"show_block_devices">["data"]["blockdevices"];
/**
* 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_file",
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;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
type InstallForm = { disk?: string };
interface InstallMachineProps {
name?: string;
targetHost?: string;
sshKey?: File;
disks: Disks;
}
const InstallMachine = (props: InstallMachineProps) => {
const diskPlaceholder = "Select the boot disk of the remote machine";
const [formStore, { Form, Field }] = createForm<InstallForm>();
const hasDisk = () => getValue(formStore, "disk") !== diskPlaceholder;
const [confirmDisk, setConfirmDisk] = createSignal(!hasDisk());
const handleInstall = async (values: InstallForm) => {
console.log("Installing", values);
};
return (
<>
<Form onSubmit={handleInstall}>
<h3 class="text-lg font-bold">{props.name}</h3>
<p class="py-4">Install to {props.targetHost}</p>
<p class="py-4">
Using {props.sshKey?.name || "default ssh key"} for authentication
</p>
<Field name="disk">
{(field, fieldProps) => (
<SelectInput
formStore={formStore}
selectProps={{
...fieldProps,
// @ts-expect-error: disabled is supported by htmlSelect
disabled: confirmDisk(),
}}
label="Remote Disk to use"
value={String(field.value)}
error={field.error}
required
options={
<>
<option disabled>{diskPlaceholder}</option>
<For each={props.disks}>
{(dev) => (
<option value={dev.name}>
{dev.name}
{" -- "}
{dev.size}
{"bytes @"}
{props.targetHost?.split("@")?.[1]}
</option>
)}
</For>
</>
}
/>
)}
</Field>
<div class="modal-action">
<Show
when={confirmDisk()}
fallback={
<button
class="btn btn-primary btn-wide"
onClick={() => setConfirmDisk(true)}
disabled={!hasDisk()}
>
<span class="material-icons">check</span>
Confirm Disk
</button>
}
>
<button class="btn btn-primary btn-wide" type="submit">
<span class="material-icons">send_to_mobile</span>
Install
</button>
</Show>
<form method="dialog">
<button onClick={() => setConfirmDisk(false)} class="btn">
Close
</button>
</form>
</div>
</Form>
</>
);
};
interface MachineDetailsProps {
initialData: MachineFormInterface;
}
const MachineForm = (props: MachineDetailsProps) => {
const [formStore, { Form, Field }] = createForm<MachineFormInterface>({
initialValues: props.initialData,
});
const sshKey = () => getValue(formStore, "sshKey");
const targetHost = () => getValue(formStore, "targetHost");
const machineName = () =>
getValue(formStore, "name") || props.initialData.name;
const onlineStatusQuery = createQuery(() => ({
queryKey: [activeURI(), "machine", targetHost(), "check_machine_online"],
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("check_machine_online", {
flake_url: curr,
machine_name: machineName(),
opts: {
keyfile: sshKey()?.name,
},
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
refetchInterval: 5000,
}));
const online = () => onlineStatusQuery.data === "Online";
const remoteDiskQuery = createQuery(() => ({
queryKey: [
activeURI(),
"machine",
machineName(),
targetHost(),
"show_block_devices",
],
enabled: online(),
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("show_block_devices", {
options: {
hostname: targetHost(),
keyfile: sshKey()?.name,
},
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
const handleSubmit = async (values: MachineFormInterface) => {
const curr_uri = activeURI();
if (!curr_uri) {
return;
}
const machine_response = await callApi("set_machine", {
flake_url: curr_uri,
machine_name: props.initialData.name,
machine: {
name: values.name,
description: values.description,
deploy: {
targetHost: values.targetHost,
},
},
});
if (machine_response.status === "error") {
toast.error(
`Failed to set machine: ${machine_response.errors[0].message}`,
);
}
if (machine_response.status === "success") {
toast.success("Machine set successfully");
}
return null;
};
return (
<div class="m-2 w-full max-w-xl">
<Form onSubmit={handleSubmit}>
<div class="flex w-full justify-center p-2">
<div
class="avatar placeholder"
classList={{
online: onlineStatusQuery.data === "Online",
offline: onlineStatusQuery.data === "Offline",
}}
>
<div class="w-24 rounded-full bg-neutral text-neutral-content">
<Show
when={onlineStatusQuery.isFetching}
fallback={<span class="material-icons text-4xl">devices</span>}
>
<span class="loading loading-bars loading-sm justify-self-end"></span>
</Show>
</div>
</div>
</div>
<div class="my-2 w-full text-2xl">Details</div>
<Field name="name">
{(field, props) => (
<TextInput
formStore={formStore}
inputProps={props}
label="Name"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
<Field name="description">
{(field, props) => (
<TextInput
formStore={formStore}
inputProps={props}
label="Description"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
<div class="collapse collapse-arrow" tabindex="0">
<input type="checkbox" />
<div class="collapse-title link px-0 text-xl ">
Connection Settings
</div>
<div class="collapse-content">
<Field name="targetHost">
{(field, props) => (
<TextInput
formStore={formStore}
inputProps={props}
label="Target Host"
value={field.value ?? ""}
error={field.error}
class="col-span-2"
required
/>
)}
</Field>
<Field name="sshKey" type="File">
{(field, props) => (
<>
<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);
}}
placeholder={"When empty the default key(s) will be used"}
value={field.value}
error={field.error}
helperText="Provide the SSH key used to connect to the machine"
label="SSH Key"
/>
</>
)}
</Field>
</div>
</div>
<div class="my-2 w-full">
<button
class="btn btn-primary btn-wide"
type="submit"
disabled={!formStore.dirty}
>
Save
</button>
</div>
</Form>
<div class="my-2 w-full text-2xl">Remote Interactions</div>
<div class="my-2 flex w-full flex-col gap-2">
<span class="max-w-md text-neutral">
Installs the system for the first time. Used to bootstrap the remote
device.
</span>
<div class="tooltip w-fit" data-tip="Machine must be online">
<button
class="btn btn-primary btn-sm btn-wide"
disabled={!online()}
// @ts-expect-error: This string method is not supported by ts
onClick="install_modal.showModal()"
>
<span class="material-icons">send_to_mobile</span>
Install
</button>
</div>
<dialog id="install_modal" class="modal">
<div class="modal-box w-11/12 max-w-5xl">
<InstallMachine
name={machineName()}
sshKey={sshKey()}
targetHost={getValue(formStore, "targetHost")}
disks={remoteDiskQuery.data?.blockdevices || []}
/>
</div>
</dialog>
<span class="max-w-md text-neutral">
Installs the system for the first time. Used to bootstrap the remote
device.
</span>
<div class="tooltip w-fit" data-tip="Machine must be online">
<button class="btn btn-primary btn-sm btn-wide" disabled={!online()}>
<span class="material-icons">update</span>
Update
</button>
</div>
</div>
</div>
);
};
export const MachineDetails = () => {
@@ -34,205 +399,20 @@ export const MachineDetails = () => {
},
}));
const [sshKey, setSshKey] = createSignal<string>();
const [formStore, { Form, Field }] = createForm<InstallForm>({});
const handleSubmit = async (values: InstallForm) => {
const curr_uri = activeURI();
if (!curr_uri) {
return;
}
console.log("Setting disk", values.disk);
const r = await callApi("set_single_disk_uuid", {
base_path: curr_uri,
machine_name: params.id,
disk_uuid: values.disk,
});
return null;
};
const targetHost = () => query?.data?.machine.deploy.targetHost;
const remoteDiskQuery = createQuery(() => ({
queryKey: [activeURI(), "machine", targetHost(), "show_block_devices"],
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("show_block_devices", {
options: {
hostname: targetHost(),
keyfile: sshKey(),
},
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
}));
const onlineStatusQuery = createQuery(() => ({
queryKey: [activeURI(), "machine", targetHost(), "check_machine_online"],
queryFn: async () => {
const curr = activeURI();
if (curr) {
const result = await callApi("check_machine_online", {
flake_url: curr,
machine_name: params.id,
opts: {
keyfile: sshKey(),
},
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
}
},
refetchInterval: 5000,
}));
return (
<div>
{query.isLoading && <span class="loading loading-bars" />}
<Show when={!query.isLoading && query.data}>
{(data) => (
<div class="grid grid-cols-2 gap-2 text-lg">
<Show when={onlineStatusQuery.isFetching} fallback={<span></span>}>
<span class="loading loading-bars loading-sm justify-self-end"></span>
</Show>
<span
class="badge badge-outline text-lg"
classList={{
"badge-primary": onlineStatusQuery.data === "Online",
}}
>
{onlineStatusQuery.data}
</span>
<label class="justify-self-end font-light">Name</label>
<span>{data().machine.name}</span>
<span class="justify-self-end font-light">description</span>
<span>{data().machine.description}</span>
<span class="justify-self-end font-light">targetHost</span>
<span>{data().machine.deploy.targetHost}</span>
<div class="join col-span-2 justify-self-center">
<button
class="btn join-item btn-sm"
onClick={async () => {
const response = await callApi("open_file", {
file_request: {
title: "Select SSH Key",
mode: "open_file",
initial_folder: "~/.ssh",
},
});
if (response.status === "success" && response.data) {
setSshKey(response.data[0]);
}
}}
>
<span>{sshKey() || "Default ssh key"}</span>
</button>
<button
disabled={!sshKey()}
class="btn btn-accent join-item btn-sm"
onClick={() => setSshKey(undefined)}
>
<span class="material-icons">close</span>
</button>
</div>
<span class="justify-self-end font-light">has hw spec</span>
<span>{data().has_hw_specs ? "Yes" : "Not yet"}</span>
<div class="col-span-2 justify-self-center">
<button
class="btn btn-primary join-item btn-sm"
onClick={async () => {
const curr_uri = activeURI();
if (!curr_uri) {
return;
}
if (!query.data?.machine.deploy.targetHost) {
return;
}
const lt = toast.loading("Generating HW spec ...");
const response = await callApi(
"generate_machine_hardware_info",
{
machine_name: params.id,
clan_dir: { loc: curr_uri },
hostname: query.data.machine.deploy.targetHost,
},
);
toast.dismiss(lt);
if (response.status === "success") {
toast.success("HW specification processed successfully");
}
if (response.status === "error") {
toast.error(
"Failed to generate. " + response.errors[0].message,
);
}
query.refetch();
}}
>
Generate HW Spec
</button>
</div>
<button
class="btn self-end"
onClick={() => remoteDiskQuery.refetch()}
>
Refresh remote disks
</button>
<Form onSubmit={handleSubmit} class="w-full">
<Field name="disk">
{(field, props) => (
<SelectInput
formStore={formStore}
selectProps={props}
label="Remote Disk to use"
value={String(field.value)}
error={field.error}
required
options={
<Show when={remoteDiskQuery.data}>
{(disks) => (
<>
<option disabled>
Select the boot disk of the remote machine
</option>
<For each={disks().blockdevices}>
{(dev) => (
<option value={dev.name}>
{dev.name}
{" -- "}
{dev.size}
{"bytes @"}
{
query.data?.machine.deploy.targetHost?.split(
"@",
)?.[1]
}
</option>
)}
</For>
</>
)}
</Show>
}
/>
)}
</Field>
<button class="btn btn-primary" type="submit">
Set disk
</button>
</Form>
</div>
)}
<>
<Show
when={query.data}
fallback={<span class="loading loading-lg"></span>}
>
<MachineForm
initialData={{
name: query.data?.machine.name ?? "",
description: query.data?.machine.description ?? "",
targetHost: query.data?.machine.deploy.targetHost ?? "",
}}
/>
</Show>
</div>
</>
);
};