diff --git a/pkgs/webview-ui/app/src/routes/machines/details.tsx b/pkgs/webview-ui/app/src/routes/machines/details.tsx index 9d7c69a7e..9e8249712 100644 --- a/pkgs/webview-ui/app/src/routes/machines/details.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/details.tsx @@ -1,19 +1,19 @@ -import { callApi, SuccessData, SuccessQuery } from "@/src/api"; +import { callApi, SuccessData } from "@/src/api"; import { activeURI } from "@/src/App"; import { Button } from "@/src/components/button"; -import { FileInput } from "@/src/components/FileInput"; -import Icon, { IconVariant } from "@/src/components/icon"; +import Icon from "@/src/components/icon"; import { TextInput } from "@/src/Form/fields/TextInput"; -import { selectSshKeys } from "@/src/hooks"; + import { createForm, FieldValues, getValue, + getValues, setValue, } from "@modular-forms/solid"; import { useParams } from "@solidjs/router"; import { createQuery } from "@tanstack/solid-query"; -import { createSignal, For, JSX, Match, Show, Switch } from "solid-js"; +import { createSignal, For, Match, Show, Switch } from "solid-js"; import toast from "solid-toast"; import { MachineAvatar } from "./avatar"; import { Header } from "@/src/layout/header"; @@ -22,8 +22,10 @@ import { FieldLayout } from "@/src/Form/fields/layout"; import { Modal } from "@/src/components/modal"; import { Typography } from "@/src/components/Typography"; import cx from "classnames"; -import { SelectInput } from "@/src/Form/fields/Select"; -import { HWStep } from "./install/hardware-step"; +import { HardwareValues, HWStep } from "./install/hardware-step"; +import { DiskStep, DiskValues } from "./install/disk-step"; +import { SummaryStep } from "./install/summary-step"; +import { SectionHeader } from "@/src/components/group"; type MachineFormInterface = MachineData & { sshKey?: File; @@ -32,76 +34,26 @@ type MachineFormInterface = MachineData & { type MachineData = SuccessData<"get_inventory_machine_details">; -type Disks = SuccessQuery<"show_block_devices">["data"]["blockdevices"]; - -interface InstallForm extends FieldValues { - disk?: string; -} - -interface GroupProps { - children: JSX.Element; -} -export const Group = (props: GroupProps) => ( -
- {props.children} -
-); - -type AdmonitionVariant = "attention" | "danger"; -interface SectionHeaderProps { - variant: AdmonitionVariant; - headline: JSX.Element; -} -const variantColorsMap: Record = { - attention: cx("bg-[#9BD8F2] fg-def-1"), - danger: cx("bg-semantic-2 fg-semantic-2"), -}; - -const variantIconColorsMap: Record = { - attention: cx("fg-def-1"), - danger: cx("fg-semantic-3"), -}; - -const variantIconMap: Record = { - attention: "Attention", - danger: "Warning", -}; - -export const SectionHeader = (props: SectionHeaderProps) => ( -
- { - - } - {props.headline} -
-); - -const steps = { +const steps: Record = { "1": "Hardware detection", "2": "Disk schema", "3": "Installation", }; -interface SectionProps { - children: JSX.Element; -} -const Section = (props: SectionProps) => ( -
{props.children}
-); +type StepIdx = keyof AllStepsValues; +export interface AllStepsValues extends FieldValues { + "1": HardwareValues; + "2": DiskValues; + "3": NonNullable; +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} interface InstallMachineProps { name?: string; targetHost?: string | null; - sshKey?: File; - disks: Disks; } const InstallMachine = (props: InstallMachineProps) => { const curr = activeURI(); @@ -110,17 +62,19 @@ const InstallMachine = (props: InstallMachineProps) => { return No Clan selected; } - const diskPlaceholder = "Select the boot disk of the remote machine"; + const [formStore, { Form, Field }] = createForm(); - const [formStore, { Form, Field }] = createForm(); + const [isInstalling, setIsInstalling] = createSignal(false); + const [progressText, setProgressText] = createSignal(); + const [installError, setInstallError] = createSignal(); - const hasDisk = () => getValue(formStore, "disk") !== diskPlaceholder; - - const [confirmDisk, setConfirmDisk] = createSignal(!hasDisk()); - - const handleInstall = async (values: InstallForm) => { + const handleInstall = async (values: AllStepsValues) => { console.log("Installing", values); const curr_uri = activeURI(); + + const target = values["1"].target; + const diskValues = values["2"]; + if (!curr_uri) { return; } @@ -131,6 +85,42 @@ const InstallMachine = (props: InstallMachineProps) => { const loading_toast = toast.loading( "Installing machine. Grab coffee (15min)...", ); + setIsInstalling(true); + setProgressText("Setting up disk ... (1/5)"); + + const disk_response = await callApi("set_machine_disk_schema", { + base_path: curr_uri, + machine_name: props.name, + placeholders: diskValues.placeholders, + schema_name: diskValues.schema, + force: true, + }); + + if (disk_response.status === "error") { + toast.error( + `Failed to set disk schema: ${disk_response.errors[0].message}`, + ); + setProgressText( + "Failed to set disk schema. \n" + disk_response.errors[0].message, + ); + return; + } + + // Next step + if (disk_response.status === "success") { + setProgressText("Evaluate configuration ... (2/5)"); + } + // Next step + await sleep(2000); + setProgressText("Building machine ... (3/5)"); + await sleep(2000); + setProgressText("Formatting remote disk ... (4/5)"); + await sleep(2000); + setProgressText("Copying system ... (5/5)"); + await sleep(2000); + setProgressText("Rebooting remote system ... "); + await sleep(2000); + const r = await callApi("install_machine", { opts: { machine: { @@ -139,7 +129,7 @@ const InstallMachine = (props: InstallMachineProps) => { loc: curr_uri, }, }, - target_host: props.targetHost, + target_host: target, password: "", }, }); @@ -153,44 +143,6 @@ const InstallMachine = (props: InstallMachineProps) => { } }; - const handleDiskConfirm = async (e: Event) => { - const curr_uri = activeURI(); - const disk = getValue(formStore, "disk"); - const disk_id = props.disks.find((d) => d.name === disk)?.id_link; - if (!curr_uri || !disk_id || !props.name) { - return; - } - }; - const [stepsDone, setStepsDone] = createSignal([]); - - const generateReport = async (e: Event) => { - const curr_uri = activeURI(); - if (!curr_uri || !props.name) { - return; - } - - const loading_toast = toast.loading("Generating hardware report..."); - const r = await callApi("generate_machine_hardware_info", { - opts: { - flake: { loc: curr_uri }, - machine: props.name, - keyfile: props.sshKey?.name, - target_host: props.targetHost, - backend: "nixos-facter", - }, - }); - toast.dismiss(loading_toast); - // TODO: refresh the machine details - - if (r.status === "error") { - toast.error(`Failed to generate report. ${r.errors[0].message}`); - } - if (r.status === "success") { - toast.success("Report generated successfully"); - } - }; - - type StepIdx = keyof typeof steps; const [step, setStep] = createSignal("1"); const handleNext = () => { @@ -223,171 +175,184 @@ const InstallMachine = (props: InstallMachineProps) => { ); - return ( -
-
- - Install:{" "} - - - {props.name} - -
- {/* Stepper container */} -
- {/* A Step with a circle a number inside. Label is below */} - - {([idx, label]) => ( -
- - = step()} - fallback={} - > - {idx} - - - - {label} - -
- )} -
-
-
- - - handleNext()} - footer={
} - /> - - - - - Single Disk - - - Change schema - - - - - -
- - -
- - Hardware Report - - - Target} - field={ - - 192.157.124.81 - - } - > - -
-
- - Disk Configuration - - - Disk Layout} - field={ - - Single Disk - - } - > -
- Main Disk} - field={ - - Samsung evo 850 efkjhasd - - } - > -
-
- + return ( + + {/* Register each step as form field */} + {/* @ts-expect-error: object type is not statically supported */} + {(field, fieldProps) => <>} + {/* @ts-expect-error: object type is not statically supported */} + {(field, fieldProps) => <>} + + {/* Modal Header */} +
+ + Install:{" "} + + + {props.name} + +
+ {/* Stepper header */} +
+ + {([idx, label]) => ( +
- Setup your device. + = step()} + fallback={} + > + {idx} + - This will erase the disk and bootstrap fresh. + {label} - - } - /> -
- - - -
-
+
+ )} + +
+ +
+ + + { + const prev = getValue(formStore, "1"); + setValue(formStore, "1", { ...prev, ...data }); + handleNext(); + }} + initial={ + getValue(formStore, "1") || { + target: props.targetHost || "", + report: false, + } + } + footer={
} + /> + + + } + handleNext={(data) => { + const prev = getValue(formStore, "2"); + setValue(formStore, "2", { ...prev, ...data }); + handleNext(); + }} + initial={getValue(formStore, "2")} + /> + + + handleNext()} + // @ts-expect-error: This cannot be known. + initial={getValues(formStore)} + footer={ +
+ + +
+ } + /> +
+ +
+ + } + > + +
+
+ + Install: + + + {props.name} + +
+
+ +
+ + {progressText()} + + +
+
+
+ ); }; @@ -401,7 +366,6 @@ const MachineForm = (props: MachineDetailsProps) => { initialValues: props.initialData, }); - const sshKey = () => getValue(formStore, "sshKey"); const targetHost = () => getValue(formStore, "machine.deploy.targetHost"); const machineName = () => getValue(formStore, "machine.name") || props.initialData.machine.name; @@ -479,9 +443,10 @@ const MachineForm = (props: MachineDetailsProps) => { }; return ( <> -
- General - +
+ + +
{(field, props) => ( @@ -564,37 +529,6 @@ const MachineForm = (props: MachineDetailsProps) => { /> )} - - {(field, props) => ( - <> - { - 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" - /> - - )} -
@@ -641,9 +575,7 @@ const MachineForm = (props: MachineDetailsProps) => { > @@ -692,140 +624,16 @@ export const MachineDetails = () => { return ( <>
-
- } - > - {(data) => ( - <> - - - )} - -
+ } + > + {(data) => ( + <> + + + )} + ); }; - -interface Wifi extends FieldValues { - name: string; - ssid?: string; - password?: string; -} - -interface WifiForm extends FieldValues { - networks: Wifi[]; -} - -interface MachineWifiProps { - base_url: string; - machine_name: string; - initialData: Wifi[]; -} -function WifiModule(props: MachineWifiProps) { - // You can use formData to initialize your form fields: - // const initialFormState = formData(); - - const [formStore, { Form, Field }] = createForm({ - initialValues: { - networks: props.initialData, - }, - }); - - const [nets, setNets] = createSignal<1[]>( - new Array(props.initialData.length || 1).fill(1), - ); - - const handleSubmit = async (values: WifiForm) => { - const networks = values.networks - .filter((i) => i.ssid) - .reduce( - (acc, curr) => ({ - ...acc, - [curr.ssid || ""]: { ssid: curr.ssid, password: curr.password }, - }), - {}, - ); - - console.log("submitting", values, networks); - // const r = await callApi("set_iwd_service_for_machine", { - // base_url: props.base_url, - // machine_name: props.machine_name, - // networks: networks, - // }); - // if (r.status === "error") { - toast.error("Failed to set wifi. Feature disabled temporarily"); - // } - // if (r.status === "success") { - // toast.success("Wifi set successfully"); - // } - }; - - return ( - - Preconfigure wireless networks - - {(_, idx) => ( -
- - {(field, props) => ( - - )} - - - {(field, props) => ( - - )} - - -
- )} -
- - { -
- -
- } - - ); -} diff --git a/pkgs/webview-ui/app/src/routes/machines/install/disk-step.tsx b/pkgs/webview-ui/app/src/routes/machines/install/disk-step.tsx new file mode 100644 index 000000000..a23e9388b --- /dev/null +++ b/pkgs/webview-ui/app/src/routes/machines/install/disk-step.tsx @@ -0,0 +1,110 @@ +import { callApi } from "@/src/api"; +import { + createForm, + SubmitHandler, + validate, + required, + FieldValues, +} from "@modular-forms/solid"; +import { createQuery } from "@tanstack/solid-query"; +import { StepProps } from "./hardware-step"; +import { SelectInput } from "@/src/Form/fields/Select"; +import { Typography } from "@/src/components/Typography"; +import { Group } from "@/src/components/group"; + +export interface DiskValues extends FieldValues { + placeholders: { + mainDisk: string; + }; + schema: string; +} +export const DiskStep = (props: StepProps) => { + const [formStore, { Form, Field }] = createForm({ + initialValues: { ...props.initial, schema: "single-disk" }, + }); + + const handleSubmit: SubmitHandler = async (values, event) => { + console.log("Submit Disk", { values }); + const valid = await validate(formStore); + console.log("Valid", valid); + if (!valid) return; + props.handleNext(values); + }; + + const diskSchemaQuery = createQuery(() => ({ + queryKey: [props.dir, props.machine_id, "disk_schemas"], + queryFn: async () => { + const result = await callApi("get_disk_schemas", { + base_path: props.dir, + machine_name: props.machine_id, + }); + if (result.status === "error") throw new Error("Failed to fetch data"); + return result.data; + }, + })); + + return ( +
+ + + {(field, fieldProps) => ( + <> + + {(field.value || "No schema selected").split("-").join(" ")} + + + Change schema + + + )} + + + + + {(field, fieldProps) => ( + ({ label: o, value: o })) || [ + { label: "No options", value: "" }, + ] + } + // options={ + // deviceQuery.data?.blockdevices.map((d) => ({ + // value: d.path, + // label: `${d.path} -- ${d.size} bytes`, + // })) || [] + // } + error={field.error} + label="Main Disk" + value={field.value || ""} + placeholder="Select a disk" + selectProps={fieldProps} + required + /> + )} + + + {props.footer} +
+ ); +}; diff --git a/pkgs/webview-ui/app/src/routes/machines/install/hardware-step.tsx b/pkgs/webview-ui/app/src/routes/machines/install/hardware-step.tsx index 8ce0ab499..e29577020 100644 --- a/pkgs/webview-ui/app/src/routes/machines/install/hardware-step.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/install/hardware-step.tsx @@ -16,33 +16,34 @@ import { } from "@modular-forms/solid"; import { createEffect, createSignal, JSX, Match, Switch } from "solid-js"; import toast from "solid-toast"; -import { Group } from "../details"; import { TextInput } from "@/src/Form/fields"; import { createQuery } from "@tanstack/solid-query"; +import { Badge } from "@/src/components/badge"; +import { Group } from "@/src/components/group"; -interface Hardware extends FieldValues { +export type HardwareValues = FieldValues & { report: boolean; target: string; -} +}; -export interface StepProps { +export interface StepProps { machine_id: string; dir: string; - handleNext: () => void; + handleNext: (data: T) => void; footer: JSX.Element; - initial?: Partial; + initial?: T; } -export const HWStep = (props: StepProps) => { - const [formStore, { Form, Field }] = createForm({ - initialValues: props.initial || {}, +export const HWStep = (props: StepProps) => { + const [formStore, { Form, Field }] = createForm({ + initialValues: (props.initial as HardwareValues) || {}, }); - const handleSubmit: SubmitHandler = async (values, event) => { + const handleSubmit: SubmitHandler = async (values, event) => { console.log("Submit Hardware", { values }); const valid = await validate(formStore); console.log("Valid", valid); if (!valid) return; - props.handleNext(); + props.handleNext(values); }; const [isGenerating, setIsGenerating] = createSignal(false); @@ -148,20 +149,46 @@ export const HWStep = (props: StepProps) => { <> + + No report + -
Detected
+ + Report detected + +
-
Nixos report Detected
+ + Legacy Report detected + +
diff --git a/pkgs/webview-ui/app/src/routes/machines/install/summary-step.tsx b/pkgs/webview-ui/app/src/routes/machines/install/summary-step.tsx new file mode 100644 index 000000000..01dc27518 --- /dev/null +++ b/pkgs/webview-ui/app/src/routes/machines/install/summary-step.tsx @@ -0,0 +1,99 @@ +import { StepProps } from "./hardware-step"; +import { Typography } from "@/src/components/Typography"; +import { FieldLayout } from "@/src/Form/fields/layout"; +import { InputLabel } from "@/src/components/inputBase"; +import { Group, Section, SectionHeader } from "@/src/components/group"; +import { AllStepsValues } from "../details"; +import { Badge } from "@/src/components/badge"; +import Icon from "@/src/components/icon"; + +export const SummaryStep = (props: StepProps) => { + const hwValues = () => props.initial?.["1"]; + const diskValues = () => props.initial?.["2"]; + return ( + <> +
+ + Hardware Report + + + Detected} + field={ + hwValues()?.report ? ( + + + + ) : ( + + + + ) + } + > + Target} + field={ + + {hwValues()?.target} + + } + > + +
+
+ + Disk Configuration + + + Disk Layout} + field={ + + {diskValues()?.schema} + + } + > +
+ Main Disk} + field={ + + {diskValues()?.placeholders.mainDisk} + + } + > +
+
+ + + Setup your device. + + + This will erase the disk and bootstrap fresh. + + + } + /> + {props.footer} + + ); +};