From 6cb728a4ca56add3dd89a206b25474aa9cdcd3f7 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Wed, 3 Sep 2025 12:29:23 +0200 Subject: [PATCH] ui/update: integrate with api --- .../InstallMachine/UpdateMachine.stories.tsx | 198 ++++++++++++++++++ .../InstallMachine/UpdateMachine.tsx | 63 +++++- .../InstallMachine/steps/installSteps.tsx | 15 +- 3 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.stories.tsx diff --git a/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.stories.tsx b/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.stories.tsx new file mode 100644 index 000000000..242df8329 --- /dev/null +++ b/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.stories.tsx @@ -0,0 +1,198 @@ +import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid"; +import { + createMemoryHistory, + MemoryRouter, + RouteDefinition, +} from "@solidjs/router"; +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; +import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient"; +import { + ApiCall, + OperationNames, + OperationResponse, + SuccessQuery, +} from "@/src/hooks/api"; +import { UpdateModal } from "./UpdateMachine"; + +type ResultDataMap = { + [K in OperationNames]: SuccessQuery["data"]; +}; + +const mockFetcher: Fetcher = ( + name: K, + _args: unknown, +): ApiCall => { + // TODO: Make this configurable for every story + const resultData: Partial = { + get_generators: [ + { + name: "funny.gritty", + prompts: [ + { + name: "gritty.name", + description: "Name of the gritty", + prompt_type: "line", + display: { + helperText: null, + label: "(1) Name", + group: "User", + required: true, + }, + }, + { + name: "gritty.foo", + description: "Name of the gritty", + prompt_type: "line", + display: { + helperText: null, + label: "(2) Password", + group: "Root", + required: true, + }, + }, + { + name: "gritty.bar", + description: "Name of the gritty", + prompt_type: "line", + display: { + helperText: null, + label: "(3) Gritty", + group: "Root", + required: true, + }, + }, + ], + }, + { + name: "funny.dodo", + prompts: [ + { + name: "gritty.name", + description: "Name of the gritty", + prompt_type: "line", + display: { + helperText: null, + label: "(4) Name", + group: "User", + required: true, + }, + }, + { + name: "gritty.foo", + description: "Name of the gritty", + prompt_type: "line", + display: { + helperText: null, + label: "(5) Password", + group: "Lonely", + required: true, + }, + }, + { + name: "gritty.bar", + description: "Name of the gritty", + prompt_type: "line", + display: { + helperText: null, + label: "(6) Batty", + group: "Root", + required: true, + }, + }, + ], + }, + ], + run_generators: null, + run_machine_update: null, + }; + + return { + uuid: "mock", + cancel: () => Promise.resolve(), + result: new Promise((resolve) => { + setTimeout(() => { + const status = name === "run_machine_update" ? "error" : "success"; + + resolve({ + op_key: "1", + status: status, + errors: [ + { + message: "Mock error message", + description: + "This is a more detailed description of the mock error.", + }, + ], + data: resultData[name], + } as OperationResponse); + }, 1500); + }), + }; +}; + +const meta: Meta = { + title: "workflows/update", + component: UpdateModal, + decorators: [ + (Story: StoryObj, context: StoryContext) => { + const Routes: RouteDefinition[] = [ + { + path: "/clans/:clanURI", + component: () => ( +
+ +
+ ), + }, + ]; + const history = createMemoryHistory(); + history.set({ value: "/clans/dGVzdA==", replace: true }); + + const queryClient = new QueryClient(); + + return ( + + + { + console.debug("Rendering MemoryRouter root with props:", props); + return props.children; + }} + history={history} + > + {Routes} + + + + ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Init: Story = { + description: "Welcome step for the update workflow", + args: { + open: true, + machineName: "Jon", + }, +}; +export const Address: Story = { + description: "Welcome step for the update workflow", + args: { + open: true, + machineName: "Jon", + initialStep: "update:address", + }, +}; +export const UpdateProgress: Story = { + description: "Welcome step for the update workflow", + args: { + open: true, + machineName: "Jon", + initialStep: "update:progress", + }, +}; diff --git a/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.tsx b/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.tsx index a70de06e0..5efce9745 100644 --- a/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.tsx +++ b/pkgs/clan-app/ui/src/workflows/InstallMachine/UpdateMachine.tsx @@ -5,7 +5,7 @@ import { StepperProvider, useStepper, } from "@/src/hooks/stepper"; -import { Show } from "solid-js"; +import { createSignal, Show } from "solid-js"; import { Dynamic } from "solid-js/web"; import { ConfigureAddress, ConfigureData } from "./steps/installSteps"; @@ -16,6 +16,9 @@ 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"; +import { useApiClient } from "@/src/hooks/ApiClient"; +import { useClanURI } from "@/src/hooks/clan"; +import { AlertProps } from "@/src/components/Alert/Alert"; // TODO: Deduplicate interface UpdateStepperProps { @@ -24,21 +27,77 @@ interface UpdateStepperProps { const UpdateStepper = (props: UpdateStepperProps) => { const stepSignal = useStepper(); + const [store, _set] = getStepStore(stepSignal); + + const [alert, setAlert] = createSignal(); + + const clanURI = useClanURI(); + + const client = useApiClient(); + const handleUpdate = async () => { + console.log("Starting update for", store.install.machineName); + + if (!store.install.targetHost) { + console.error("No target host specified, API requires it"); + return; + } + + const port = store.install.port + ? parseInt(store.install.port, 10) + : undefined; + + const call = client.fetch("run_machine_update", { + machine: { + flake: { identifier: clanURI }, + name: store.install.machineName, + }, + build_host: null, + target_host: { + address: store.install.targetHost, + port, + password: store.install.password, + ssh_options: { + StrictHostKeyChecking: "no", + UserKnownHostsFile: "/dev/null", + }, + }, + }); + + const result = await call.result; + + if (result.status === "error") { + console.error("Update failed", result.errors); + setAlert(() => ({ + type: "error", + title: "Update failed", + description: result.errors[0].message, + })); + stepSignal.previous(); + return; + } + if (result.status === "success") { + stepSignal.next(); + return; + } + }; + return ( ); }; export interface UpdateModalProps { machineName: string; + open: boolean; initialStep?: UpdateSteps[number]["id"]; mount?: Node; onClose?: () => void; - open: boolean; } export const UpdateHeader = (props: { machineName: string }) => { diff --git a/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx b/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx index 79e86f08d..9dbd01aeb 100644 --- a/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx +++ b/pkgs/clan-app/ui/src/workflows/InstallMachine/steps/installSteps.tsx @@ -17,7 +17,7 @@ import { PromptValues, } from "../InstallMachine"; import { TextInput } from "@/src/components/Form/TextInput"; -import { Alert } from "@/src/components/Alert/Alert"; +import { Alert, AlertProps } from "@/src/components/Alert/Alert"; import { createSignal, For, Match, Show, Switch } from "solid-js"; import { Divider } from "@/src/components/Divider/Divider"; import { Orienter } from "@/src/components/Form/Orienter"; @@ -60,7 +60,11 @@ const ConfigureAdressSchema = v.object({ type ConfigureAdressForm = v.InferInput; -export const ConfigureAddress = (props: { next?: string }) => { +export const ConfigureAddress = (props: { + next?: string; + stepFinished: () => void; + alert?: AlertProps; +}) => { const stepSignal = useStepper(); const [store, set] = getStepStore(stepSignal); @@ -89,8 +93,8 @@ export const ConfigureAddress = (props: { next?: string }) => { password: values.password, })); - // Here you would typically trigger the ISO creation process stepSignal.next(); + props.stepFinished?.(); }; const tryReachable = async () => { @@ -129,6 +133,7 @@ export const ConfigureAddress = (props: { next?: string }) => { + {(alert) => }
{(field, props) => ( @@ -256,7 +261,7 @@ const CheckHardware = () => { const call = client.fetch("run_machine_hardware_info", { target_host: { address: store.install.targetHost, - ...(port && { port }), + port, password: store.install.password, ssh_options: { StrictHostKeyChecking: "no", @@ -706,7 +711,7 @@ const InstallSummary = () => { }, target_host: { address: store.install.targetHost, - ...(port && { port }), + port, password: store.install.password, ssh_options: { StrictHostKeyChecking: "no",