ui/update: init update machine

This commit is contained in:
Johannes Kirschbauer
2025-09-02 22:13:59 +02:00
parent e398d98b42
commit 3e5f84dcb4
6 changed files with 272 additions and 15 deletions

View File

@@ -1,9 +1,9 @@
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
import { Button } from "@/src/components/Button/Button"; import { Button } from "@/src/components/Button/Button";
import { InstallModal } from "@/src/workflows/InstallMachine/InstallMachine";
import { useMachineName } from "@/src/hooks/clan"; import { useMachineName } from "@/src/hooks/clan";
import { useMachineStateQuery } from "@/src/hooks/queries"; import { useMachineStateQuery } from "@/src/hooks/queries";
import styles from "./SidebarSectionInstall.module.css"; import styles from "./SidebarSectionInstall.module.css";
import { UpdateModal } from "@/src/workflows/InstallMachine/UpdateMachine";
export interface SidebarSectionUpdateProps { export interface SidebarSectionUpdateProps {
clanURI: string; clanURI: string;
@@ -26,7 +26,7 @@ export const SidebarSectionUpdate = (props: SidebarSectionUpdateProps) => {
Update machine Update machine
</Button> </Button>
<Show when={showUpdate()}> <Show when={showUpdate()}>
<InstallModal <UpdateModal
open={showUpdate()} open={showUpdate()}
machineName={useMachineName()} machineName={useMachineName()}
onClose={() => setShowUpdate(false)} onClose={() => setShowUpdate(false)}

View File

@@ -111,10 +111,7 @@ export const AddMachine = (props: AddMachineProps) => {
return defaultClass; return defaultClass;
} }
switch (currentStep.id) {
default:
return defaultClass; return defaultClass;
}
}; };
return ( return (

View File

@@ -51,11 +51,11 @@ export interface InstallStoreType {
progress: ApiCall<"run_machine_flash">; progress: ApiCall<"run_machine_flash">;
}; };
install: { install: {
targetHost: string; targetHost?: string;
port?: string; port?: string;
password?: string; password?: string;
machineName: string; machineName: string;
mainDisk: string; mainDisk?: string;
// ...TODO Vars // ...TODO Vars
progress: ApiCall<"run_machine_install">; progress: ApiCall<"run_machine_install">;
promptValues: PromptValues; promptValues: PromptValues;

View File

@@ -0,0 +1,242 @@
import { Modal } from "@/src/components/Modal/Modal";
import {
createStepper,
getStepStore,
StepperProvider,
useStepper,
} from "@/src/hooks/stepper";
import { Show } from "solid-js";
import { Dynamic } from "solid-js/web";
import { ConfigureAddress, ConfigureData } from "./steps/installSteps";
import cx from "classnames";
import { InstallStoreType } from "./InstallMachine";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import Icon from "@/src/components/Icon/Icon";
import { ProcessMessage, useNotifyOrigin } from "@/src/hooks/notify";
import { LoadingBar } from "@/src/components/LoadingBar/LoadingBar";
// TODO: Deduplicate
interface UpdateStepperProps {
onDone: () => void;
}
const UpdateStepper = (props: UpdateStepperProps) => {
const stepSignal = useStepper<UpdateSteps>();
return (
<Dynamic
component={stepSignal.currentStep().content}
onDone={props.onDone}
next="update"
/>
);
};
export interface UpdateModalProps {
machineName: string;
initialStep?: UpdateSteps[number]["id"];
mount?: Node;
onClose?: () => void;
open: boolean;
}
export const UpdateHeader = (props: { machineName: string }) => {
return (
<Typography hierarchy="label" size="default">
Update: {props.machineName}
</Typography>
);
};
type UpdateTopic = [
"generators",
"upload-secrets",
"nixos-anywhere",
"formatting",
"rebooting",
"installing",
][number];
const UpdateProgress = () => {
const stepSignal = useStepper<UpdateSteps>();
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
const handleCancel = async () => {
const progress = store.install.progress;
if (progress) {
await progress.cancel();
}
stepSignal.previous();
};
const updateState =
useNotifyOrigin<ProcessMessage<unknown, UpdateTopic>>("run_machine_update");
return (
<div class="relative flex size-full flex-col items-center justify-end bg-inv-4">
<img
src="/logos/usb-stick-min.png"
alt="usb logo"
class="absolute top-2 z-0"
/>
<div class="z-10 mb-6 flex w-full max-w-md flex-col items-center gap-2 fg-inv-1">
<Typography
hierarchy="title"
size="default"
weight="bold"
color="inherit"
>
Machine is being updated
</Typography>
<LoadingBar />
<Typography
hierarchy="label"
size="default"
class=""
color="secondary"
inverted
>
Update {updateState()?.topic}...
</Typography>
<Button
hierarchy="primary"
class="mt-3 w-fit"
size="s"
onClick={handleCancel}
>
Cancel
</Button>
</div>
</div>
);
};
interface UpdateDoneProps {
onDone: () => void;
}
const UpdateDone = (props: UpdateDoneProps) => {
const stepSignal = useStepper<UpdateSteps>();
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
return (
<div class="flex size-full flex-col items-center justify-center bg-inv-4">
<div class="flex w-full max-w-md flex-col items-center gap-3 py-6 fg-inv-1">
<div class="rounded-full bg-semantic-success-4">
<Icon icon="Checkmark" class="size-9" />
</div>
<Typography
hierarchy="title"
size="default"
weight="bold"
color="inherit"
>
Machine update finished!
</Typography>
<div class="mt-3 flex w-full justify-center">
<Button
hierarchy="primary"
endIcon="Close"
size="s"
onClick={() => props.onDone()}
>
Close
</Button>
</div>
</div>
</div>
);
};
const steps = [
{
id: "update:data",
title: UpdateHeader,
content: ConfigureData,
},
{
id: "update:address",
title: UpdateHeader,
content: ConfigureAddress,
},
{
id: "update:progress",
content: UpdateProgress,
isSplash: true,
class: "max-w-[30rem] h-[18rem]",
},
{
id: "update:done",
content: UpdateDone,
isSplash: true,
class: "max-w-[30rem] h-[18rem]",
},
] as const;
export type UpdateSteps = typeof steps;
export type PromptValues = Record<string, Record<string, string>>;
export const UpdateModal = (props: UpdateModalProps) => {
const stepper = createStepper(
{
steps,
},
{
initialStep: props.initialStep || "update:data",
initialStoreData: {
install: { machineName: props.machineName },
} as Partial<InstallStoreType>,
},
);
const MetaHeader = () => {
// @ts-expect-error some steps might not provide a title
const HeaderComponent = () => stepper.currentStep()?.title;
return (
<Show when={HeaderComponent()}>
{(C) => <Dynamic component={C()} machineName={props.machineName} />}
</Show>
);
};
const [store, set] = getStepStore<InstallStoreType>(stepper);
set("install", { machineName: props.machineName });
// allows each step to adjust the size of the modal
const sizeClasses = () => {
const defaultClass = "max-w-3xl h-[30rem]";
const currentStep = stepper.currentStep();
if (!currentStep) {
return defaultClass;
}
switch (currentStep.id) {
case "update:progress":
case "update:done":
return currentStep.class;
default:
return defaultClass;
}
};
return (
<StepperProvider stepper={stepper}>
<Modal
class={cx("w-screen", sizeClasses())}
title="Update machine"
onClose={() => {
console.log("Update modal closed");
props.onClose?.();
}}
open={props.open}
// @ts-expect-error some steps might not have
metaHeader={stepper.currentStep()?.title ? <MetaHeader /> : undefined}
// @ts-expect-error some steps might not have
disablePadding={stepper.currentStep()?.isSplash}
>
<UpdateStepper onDone={() => props.onClose} />
</Modal>
</StepperProvider>
);
};

View File

@@ -59,7 +59,7 @@ const ConfigureAdressSchema = v.object({
type ConfigureAdressForm = v.InferInput<typeof ConfigureAdressSchema>; type ConfigureAdressForm = v.InferInput<typeof ConfigureAdressSchema>;
const ConfigureAddress = () => { export const ConfigureAddress = (props: { next?: string }) => {
const stepSignal = useStepper<InstallSteps>(); const stepSignal = useStepper<InstallSteps>();
const [store, set] = getStepStore<InstallStoreType>(stepSignal); const [store, set] = getStepStore<InstallStoreType>(stepSignal);
@@ -134,7 +134,7 @@ const ConfigureAddress = () => {
<TextInput <TextInput
{...field} {...field}
label="IP Address" label="IP Address"
description="Hostname of the installation target" description="Hostname of the machine"
value={field.value} value={field.value}
required required
orientation="horizontal" orientation="horizontal"
@@ -197,7 +197,9 @@ const ConfigureAddress = () => {
!isReachable() || !isReachable() ||
isReachable() !== getValue(formStore, "targetHost") isReachable() !== getValue(formStore, "targetHost")
} }
fallback={<NextButton type="submit">Next</NextButton>} fallback={
<NextButton type="submit">{props.next || "next"}</NextButton>
}
> >
<Button <Button
endIcon="ArrowRight" endIcon="ArrowRight"
@@ -234,6 +236,14 @@ const CheckHardware = () => {
createSignal(false); createSignal(false);
const handleUpdateSummary = async () => { const handleUpdateSummary = async () => {
if (!store.install.targetHost) {
console.error(
"Target host not set, this is required for updating hardware report",
);
setUpdatingHardwareReport(false);
return;
}
setUpdatingHardwareReport(true); setUpdatingHardwareReport(true);
const port = store.install.port const port = store.install.port
@@ -409,7 +419,7 @@ const ConfigureDisk = () => {
); );
}; };
const ConfigureData = () => { export const ConfigureData = () => {
const stepSignal = useStepper<InstallSteps>(); const stepSignal = useStepper<InstallSteps>();
const [store, get] = getStepStore<InstallStoreType>(stepSignal); const [store, get] = getStepStore<InstallStoreType>(stepSignal);
@@ -523,7 +533,7 @@ const PromptsFields = (props: PromptsFieldsProps) => {
}; };
return ( return (
<Form onSubmit={handleSubmit}> <Form onSubmit={handleSubmit} class="h-full">
<StepLayout <StepLayout
body={ body={
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -583,7 +593,7 @@ const PromptsFields = (props: PromptsFieldsProps) => {
); );
}; };
const Display = (props: { value: string; label: string }) => { const Display = (props: { value?: string; label: string }) => {
return ( return (
<> <>
<Typography hierarchy="label" size="xs" color="primary" weight="bold"> <Typography hierarchy="label" size="xs" color="primary" weight="bold">
@@ -606,7 +616,15 @@ const InstallSummary = () => {
const handleInstall = async () => { const handleInstall = async () => {
// Here you would typically trigger the installation process // Here you would typically trigger the installation process
console.log("Installation started"); console.log("Installation started");
if (!store.install.mainDisk) {
console.error("Main disk not set");
return;
}
if (!store.install.targetHost) {
console.error("Target host not set, this is required for installing");
return;
}
stepSignal.setActiveStep("install:progress"); stepSignal.setActiveStep("install:progress");
const setDisk = client.fetch("set_machine_disk_schema", { const setDisk = client.fetch("set_machine_disk_schema", {
@@ -717,7 +735,7 @@ const InstallSummary = () => {
</Orienter> </Orienter>
<Divider orientation="horizontal" /> <Divider orientation="horizontal" />
<Orienter orientation="horizontal"> <Orienter orientation="horizontal">
<Display label="Main Disk" value={store.install.mainDisk} /> <Display label="Main Disk" value={store.install?.mainDisk} />
</Orienter> </Orienter>
</Fieldset> </Fieldset>
</div> </div>