ui/machines: add machine workflow

This commit is contained in:
Brian McGee
2025-08-26 16:10:51 +01:00
committed by Johannes Kirschbauer
parent 54a8ec717e
commit 5a3381d9ff
8 changed files with 634 additions and 1 deletions

View File

@@ -15,7 +15,7 @@ export const Loader = (props: LoaderProps) => {
class={cx( class={cx(
styles.loader, styles.loader,
styles[props.hierarchy || "primary"], styles[props.hierarchy || "primary"],
props.class, props.class
)} )}
> >
<div class={styles.wrapper}> <div class={styles.wrapper}>

View File

@@ -0,0 +1,117 @@
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
import { InstallModal } from "@/src/workflows/InstallMachine/InstallMachine";
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";
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> = {
"list_machines": {
pandora: {
name: "pandora"
},
enceladus: {
name: "enceladus"
},
dione: {
name: "dione"
}
},
};
return {
uuid: "mock",
cancel: () => Promise.resolve(),
result: new Promise((resolve) => {
setTimeout(() => {
resolve({
op_key: "1",
status: "success",
data: resultData[name],
} as OperationResponse<K>);
}, 1500);
}),
};
};
const meta: Meta<typeof AddMachine> = {
title: "workflows/add-machine",
component: AddMachine,
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 AddMachine>;
export const General: Story = {
args: {}
};
export const Host: Story = {
args: {
initialStep: "host"
}
}
export const Tags: Story = {
args: {
initialStep: "tags"
}
}
export const Progress: Story = {
args: {
initialStep: "progress"
}
}

View File

@@ -0,0 +1,132 @@
import {
createStepper,
defineSteps,
StepperProvider,
useStepper,
} from "@/src/hooks/stepper";
import { StepGeneral } from "@/src/workflows/AddMachine/StepGeneral";
import { Modal } from "@/src/components/Modal/Modal";
import cx from "classnames";
import { Dynamic } from "solid-js/web";
import { Show } from "solid-js";
import { Typography } from "@/src/components/Typography/Typography";
import { StepHost } from "@/src/workflows/AddMachine/StepHost";
import { StepTags } from "@/src/workflows/AddMachine/StepTags";
import { ApiCall } from "@/src/hooks/api";
import { StepProgress } from "@/src/workflows/AddMachine/StepProgress";
interface AddMachineStepperProps {
onDone: () => void;
}
const AddMachineStepper = (props: AddMachineStepperProps) => {
const stepSignal = useStepper<AddMachineSteps>();
return (
<Dynamic
component={stepSignal.currentStep().content}
onDone={props.onDone}
/>
);
};
export interface AddMachineProps {
onClose: () => void;
initialStep?: AddMachineSteps[number]["id"];
}
export interface AddMachineStoreType {
general?: {
name: string;
description: string;
machineClass: "nixos" | "darwin";
};
deploy?: {
targetHost: string;
};
tags?: {
tags: string[];
};
}
const steps = defineSteps([
{
id: "general",
title: "General",
content: StepGeneral,
},
{
id: "host",
title: "Host",
content: StepHost,
},
{
id: "tags",
title: "Tags",
content: StepTags,
},
{
id: "progress",
content: StepProgress,
isSplash: true,
},
] as const);
export type AddMachineSteps = typeof steps;
export const AddMachine = (props: AddMachineProps) => {
const stepper = createStepper(
{
steps,
},
{ initialStep: props.initialStep || "general" },
);
const MetaHeader = () => {
const title = stepper.currentStep()?.title;
return (
<Show when={title}>
<Typography
hierarchy="label"
family="mono"
size="default"
weight="medium"
>
{title}
</Typography>
</Show>
);
};
const sizeClasses = () => {
const defaultClass = "max-w-3xl h-fit";
const currentStep = stepper.currentStep();
if (!currentStep) {
return defaultClass;
}
switch (currentStep.id) {
default:
return defaultClass;
}
};
return (
<StepperProvider
stepper={stepper}>
<Modal
class={cx("w-screen", sizeClasses())}
title="Add Machine"
onClose={props.onClose}
open={true}
// @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}
>
<AddMachineStepper onDone={() => props.onClose} />
</Modal>
</StepperProvider>
);
};

View File

@@ -0,0 +1,168 @@
import { NextButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot";
import { value } from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
clearError,
createForm,
getError,
getErrors,
setError,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import {
AddMachineSteps,
AddMachineStoreType,
} from "@/src/workflows/AddMachine/AddMachine";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { TextInput } from "@/src/components/Form/TextInput";
import { Divider } from "@/src/components/Divider/Divider";
import { TextArea } from "@/src/components/Form/TextArea";
import { Select } from "@/src/components/Select/Select";
import { Show } from "solid-js";
import { Alert } from "@/src/components/Alert/Alert";
import { useMachinesQuery } from "@/src/hooks/queries";
import { useClanURI } from "@/src/hooks/clan";
const PlatformOptions = [
{ label: "NixOS", value: "nixos" },
{ label: "Darwin", value: "darwin" },
];
const GeneralSchema = v.object({
name: v.pipe(
v.string("Name must be a string"),
v.nonEmpty("Please enter a machine name"),
v.regex(
new RegExp(/^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$/),
"Name must be a valid hostname e.g. alphanumeric characters and - only",
),
),
description: v.optional(v.string("Description must be a string")),
machineClass: v.pipe(v.string(), v.nonEmpty()),
});
type GeneralForm = v.InferInput<typeof GeneralSchema>;
export const StepGeneral = () => {
const stepSignal = useStepper<AddMachineSteps>();
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
const clanURI = useClanURI();
const machines = useMachinesQuery(clanURI);
const machineNames = () => {
if (!machines.isSuccess) {
return [];
}
return Object.keys(machines.data || {});
}
const [formStore, { Form, Field }] = createForm<GeneralForm>({
validate: valiForm(GeneralSchema),
initialValues: { machineClass: "nixos", ...store.general },
});
const handleSubmit: SubmitHandler<GeneralForm> = (values, event) => {
if (machineNames().includes(values.name)) {
setError(formStore, "name", `A machine named '${values.name}' already exists. Please choose a different one.`)
return
}
clearError(formStore, "name");
set("general", (s) => ({
...s,
...values,
}));
stepSignal.next();
};
const formError = () => {
const errors = getErrors(formStore);
return errors.name || errors.description || errors.machineClass;
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
body={
<div class="flex flex-col gap-2">
<Show when={formError()}>
<Alert
type="error"
icon="WarningFilled"
title="Error"
description={formError()}
/>
</Show>
<Fieldset>
<Field name="name">
{(field, input) => (
<TextInput
{...field}
value={field.value}
label="Name"
required
orientation="horizontal"
input={{
...input,
placeholder: "A unique machine name.",
}}
validationState={
getError(formStore, "name") ? "invalid" : "valid"
}
/>
)}
</Field>
<Divider />
<Field name="description">
{(field, input) => (
<TextArea
{...field}
value={field.value}
label="Description"
orientation="horizontal"
input={{
...input,
placeholder: "A short description of the machine.",
}}
validationState={
getError(formStore, "description") ? "invalid" : "valid"
}
/>
)}
</Field>
</Fieldset>
<Fieldset>
<Field name="machineClass">
{(field, props) => (
<Select
zIndex={100}
{...props}
value={field.value}
error={field.error}
required
label={{
label: "Platform",
}}
options={PlatformOptions}
name={field.name}
/>
)}
</Field>
</Fieldset>
</div>
}
footer={
<div class="flex justify-end">
<NextButton type="submit" />
</div>
}
/>
</Form>
);
};

View File

@@ -0,0 +1,79 @@
import { BackButton, NextButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
createForm,
getError,
SubmitHandler,
valiForm,
} from "@modular-forms/solid";
import {
AddMachineSteps,
AddMachineStoreType,
} from "@/src/workflows/AddMachine/AddMachine";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { TextInput } from "@/src/components/Form/TextInput";
import { Divider } from "@/src/components/Divider/Divider";
import { TextArea } from "@/src/components/Form/TextArea";
const HostSchema = v.object({
targetHost: v.pipe(v.string("Name must be a string")),
});
type HostForm = v.InferInput<typeof HostSchema>;
export const StepHost = () => {
const stepSignal = useStepper<AddMachineSteps>();
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
const [formStore, { Form, Field }] = createForm<HostForm>({
validate: valiForm(HostSchema),
initialValues: store.deploy,
});
const handleSubmit: SubmitHandler<HostForm> = (values, event) => {
set("deploy", (s) => ({
...s,
...values,
}));
stepSignal.next();
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
body={
<div class="flex flex-col gap-2">
<Fieldset>
<Field name="targetHost">
{(field, input) => (
<TextInput
{...field}
value={field.value}
label="Target"
required
orientation="horizontal"
input={{
...input,
placeholder: "root@flashinstaller.local",
}}
validationState={
getError(formStore, "targetHost") ? "invalid" : "valid"
}
/>
)}
</Field>
</Fieldset>
</div>
}
footer={
<div class="flex justify-between">
<BackButton />
<NextButton type="submit" />
</div>
}
/>
</Form>
);
};

View File

@@ -0,0 +1,68 @@
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
AddMachineSteps,
AddMachineStoreType,
} from "@/src/workflows/AddMachine/AddMachine";
import { Loader } from "@/src/components/Loader/Loader";
import { Typography } from "@/src/components/Typography/Typography";
import { createSignal, onMount, Show } from "solid-js";
import { Alert } from "@/src/components/Alert/Alert";
import { useApiClient } from "@/src/hooks/ApiClient";
import { callApi } from "@/src/hooks/api";
import { useClanURI } from "@/src/hooks/clan";
export interface StepProgressProps {
onDone: () => void;
}
export const StepProgress = (props: StepProgressProps) => {
const stepSignal = useStepper<AddMachineSteps>();
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
const clanURI = useClanURI();
const [error, setError] = createSignal<string | undefined>(undefined);
onMount(async () => {
const call = callApi("create_machine", {
opts: {
clan_dir: {
identifier: clanURI,
},
machine: {
...store.general,
...store.deploy,
...store.tags,
},
},
});
const result = await call.result;
if (result.status == "error") {
setError(result.errors[0].message);
}
if (result.status == "success") {
console.log("Machine creation was successful");
props.onDone();
}
});
return (
<div class="flex flex-col items-center justify-center gap-2.5 px-6 pb-7 pt-4">
<Show
when={error()}
fallback={
<>
<Loader class="size-8" />
<Typography hierarchy="body" size="s" weight="medium" family="mono">
Brian is being created
</Typography>
</>
}
>
<Alert type="error" title="There was an error" description={error()} />
</Show>
</div>
);
};

View File

@@ -0,0 +1,69 @@
import { BackButton, NextButton, StepLayout } from "@/src/workflows/Steps";
import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import { createForm, SubmitHandler, valiForm } from "@modular-forms/solid";
import {
AddMachineSteps,
AddMachineStoreType,
} from "@/src/workflows/AddMachine/AddMachine";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { MachineTags } from "@/src/components/Form/MachineTags";
import { Button } from "@/src/components/Button/Button";
const TagsSchema = v.object({
tags: v.array(v.string()),
});
type TagsForm = v.InferInput<typeof TagsSchema>;
export const StepTags = () => {
const stepSignal = useStepper<AddMachineSteps>();
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
const [formStore, { Form, Field }] = createForm<TagsForm>({
validate: valiForm(TagsSchema),
initialValues: store.tags,
});
const handleSubmit: SubmitHandler<TagsForm> = (values, event) => {
set("tags", (s) => ({
...s,
...values,
}));
stepSignal.next();
};
return (
<Form onSubmit={handleSubmit} class="h-full">
<StepLayout
body={
<div class="flex flex-col gap-2">
<Fieldset>
<Field name="tags" type="string[]">
{(field, input) => (
<MachineTags
{...field}
required
orientation="horizontal"
defaultValue={field.value}
defaultOptions={[]}
input={input}
/>
)}
</Field>
</Fieldset>
</div>
}
footer={
<div class="flex justify-between">
<BackButton />
<Button hierarchy="primary" type="submit" endIcon="Flash">
Create Machine
</Button>
</div>
}
/>
</Form>
);
};