Merge pull request 'ui/update: integrate with api' (#5079) from ui/update-machine into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5079
This commit is contained in:
hsjobeki
2025-09-03 10:46:45 +00:00
4 changed files with 273 additions and 9 deletions

View File

@@ -57,7 +57,7 @@ export interface InstallStoreType {
machineName: string;
mainDisk?: string;
// ...TODO Vars
progress: ApiCall<"run_machine_install">;
progress: ApiCall<"run_machine_install" | "run_machine_update">;
promptValues: PromptValues;
prepareStep: "disk" | "generators" | "install";
};

View File

@@ -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<K>["data"];
};
const mockFetcher: Fetcher = <K extends OperationNames>(
name: K,
_args: unknown,
): ApiCall<K> => {
// TODO: Make this configurable for every story
const resultData: Partial<ResultDataMap> = {
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<K>);
}, 1500);
}),
};
};
const meta: Meta<typeof UpdateModal> = {
title: "workflows/update",
component: UpdateModal,
decorators: [
(Story: StoryObj, context: StoryContext) => {
const Routes: RouteDefinition[] = [
{
path: "/clans/:clanURI",
component: () => (
<div class="w-[600px]">
<Story />
</div>
),
},
];
const history = createMemoryHistory();
history.set({ value: "/clans/dGVzdA==", replace: true });
const queryClient = new QueryClient();
return (
<ApiClientProvider client={{ fetch: mockFetcher }}>
<QueryClientProvider client={queryClient}>
<MemoryRouter
root={(props) => {
console.debug("Rendering MemoryRouter root with props:", props);
return props.children;
}}
history={history}
>
{Routes}
</MemoryRouter>
</QueryClientProvider>
</ApiClientProvider>
);
},
],
};
export default meta;
type Story = StoryObj<typeof UpdateModal>;
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",
},
};

View File

@@ -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,79 @@ interface UpdateStepperProps {
const UpdateStepper = (props: UpdateStepperProps) => {
const stepSignal = useStepper<UpdateSteps>();
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
const [alert, setAlert] = createSignal<AlertProps>();
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",
},
},
});
// For cancel
set("install", "progress", call);
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 (
<Dynamic
component={stepSignal.currentStep().content}
onDone={props.onDone}
next="update"
stepFinished={handleUpdate}
alert={alert()}
/>
);
};
export interface UpdateModalProps {
machineName: string;
open: boolean;
initialStep?: UpdateSteps[number]["id"];
mount?: Node;
onClose?: () => void;
open: boolean;
}
export const UpdateHeader = (props: { machineName: string }) => {
@@ -235,7 +296,7 @@ export const UpdateModal = (props: UpdateModalProps) => {
// @ts-expect-error some steps might not have
disablePadding={stepper.currentStep()?.isSplash}
>
<UpdateStepper onDone={() => props.onClose} />
<UpdateStepper onDone={() => props.onClose?.()} />
</Modal>
</StepperProvider>
);

View File

@@ -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<typeof ConfigureAdressSchema>;
export const ConfigureAddress = (props: { next?: string }) => {
export const ConfigureAddress = (props: {
next?: string;
stepFinished: () => void;
alert?: AlertProps;
}) => {
const stepSignal = useStepper<InstallSteps>();
const [store, set] = getStepStore<InstallStoreType>(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 }) => {
<StepLayout
body={
<div class="flex flex-col gap-2">
<Show when={props.alert}>{(alert) => <Alert {...alert()} />}</Show>
<Fieldset>
<Field name="targetHost">
{(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",