Merge pull request 'UI: install improve UI workflow' (#1952) from hsjobeki/clan-core:hsjobeki-main into main
This commit is contained in:
@@ -8,12 +8,21 @@ from typing import Literal
|
|||||||
from clan_cli.api import API
|
from clan_cli.api import API
|
||||||
from clan_cli.cmd import run_no_stdout
|
from clan_cli.cmd import run_no_stdout
|
||||||
from clan_cli.errors import ClanCmdError, ClanError
|
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
|
from clan_cli.nix import nix_eval, nix_shell
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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
|
@API.register
|
||||||
def list_inventory_machines(flake_url: str | Path) -> dict[str, Machine]:
|
def list_inventory_machines(flake_url: str | Path) -> dict[str, Machine]:
|
||||||
inventory = load_inventory_eval(flake_url)
|
inventory = load_inventory_eval(flake_url)
|
||||||
@@ -86,7 +95,7 @@ def check_machine_online(
|
|||||||
if not hostname:
|
if not hostname:
|
||||||
raise ClanError(f"Machine {machine_name} does not specify a targetHost")
|
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(
|
cmd = nix_shell(
|
||||||
["nixpkgs#util-linux", *(["nixpkgs#openssh"] if hostname else [])],
|
["nixpkgs#util-linux", *(["nixpkgs#openssh"] if hostname else [])],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface FileInputProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
|
placeholder?: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +31,7 @@ export function FileInput(props: FileInputProps) {
|
|||||||
"value",
|
"value",
|
||||||
"label",
|
"label",
|
||||||
"error",
|
"error",
|
||||||
|
"placeholder",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create file list
|
// Create file list
|
||||||
@@ -68,7 +70,11 @@ export function FileInput(props: FileInputProps) {
|
|||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={getFiles().length}
|
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"}:{" "}
|
Selected file{props.multiple && "s"}:{" "}
|
||||||
{getFiles()
|
{getFiles()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid";
|
import { FieldValues, FormStore, ResponseData } from "@modular-forms/solid";
|
||||||
import { Show } from "solid-js";
|
import { Show } from "solid-js";
|
||||||
import { type JSX } from "solid-js";
|
import { type JSX } from "solid-js";
|
||||||
|
import cx from "classnames";
|
||||||
|
|
||||||
interface SelectInputProps<T extends FieldValues, R extends ResponseData> {
|
interface SelectInputProps<T extends FieldValues, R extends ResponseData> {
|
||||||
formStore: FormStore<T, R>;
|
formStore: FormStore<T, R>;
|
||||||
@@ -11,6 +12,7 @@ interface SelectInputProps<T extends FieldValues, R extends ResponseData> {
|
|||||||
error?: string;
|
error?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
topRightLabel?: JSX.Element;
|
topRightLabel?: JSX.Element;
|
||||||
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectInput<T extends FieldValues, R extends ResponseData>(
|
export function SelectInput<T extends FieldValues, R extends ResponseData>(
|
||||||
@@ -18,7 +20,7 @@ export function SelectInput<T extends FieldValues, R extends ResponseData>(
|
|||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
class="form-control w-full"
|
class={cx("form-control w-full", props.class)}
|
||||||
aria-disabled={props.formStore.submitting}
|
aria-disabled={props.formStore.submitting}
|
||||||
>
|
>
|
||||||
<div class="label">
|
<div class="label">
|
||||||
|
|||||||
@@ -1,15 +1,380 @@
|
|||||||
import { callApi } from "@/src/api";
|
import { callApi, SuccessData } from "@/src/api";
|
||||||
import { activeURI } from "@/src/App";
|
import { activeURI } from "@/src/App";
|
||||||
|
import { FileInput } from "@/src/components/FileInput";
|
||||||
import { SelectInput } from "@/src/components/SelectInput";
|
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 { useParams } from "@solidjs/router";
|
||||||
import { createQuery } from "@tanstack/solid-query";
|
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";
|
import toast from "solid-toast";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
type InstallForm = {
|
type MachineFormInterface = {
|
||||||
disk: string;
|
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 = () => {
|
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 (
|
return (
|
||||||
<div>
|
<>
|
||||||
{query.isLoading && <span class="loading loading-bars" />}
|
<Show
|
||||||
<Show when={!query.isLoading && query.data}>
|
when={query.data}
|
||||||
{(data) => (
|
fallback={<span class="loading loading-lg"></span>}
|
||||||
<div class="grid grid-cols-2 gap-2 text-lg">
|
>
|
||||||
<Show when={onlineStatusQuery.isFetching} fallback={<span></span>}>
|
<MachineForm
|
||||||
<span class="loading loading-bars loading-sm justify-self-end"></span>
|
initialData={{
|
||||||
</Show>
|
name: query.data?.machine.name ?? "",
|
||||||
<span
|
description: query.data?.machine.description ?? "",
|
||||||
class="badge badge-outline text-lg"
|
targetHost: query.data?.machine.deploy.targetHost ?? "",
|
||||||
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>
|
</Show>
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user