ui/machines: add machine workflow
This commit is contained in:
committed by
Johannes Kirschbauer
parent
54a8ec717e
commit
5a3381d9ff
@@ -15,7 +15,7 @@ export const Loader = (props: LoaderProps) => {
|
||||
class={cx(
|
||||
styles.loader,
|
||||
styles[props.hierarchy || "primary"],
|
||||
props.class,
|
||||
props.class
|
||||
)}
|
||||
>
|
||||
<div class={styles.wrapper}>
|
||||
|
||||
117
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx
Normal file
117
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx
Normal 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"
|
||||
}
|
||||
}
|
||||
132
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.tsx
Normal file
132
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
168
pkgs/clan-app/ui/src/workflows/AddMachine/StepGeneral.tsx
Normal file
168
pkgs/clan-app/ui/src/workflows/AddMachine/StepGeneral.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
79
pkgs/clan-app/ui/src/workflows/AddMachine/StepHost.tsx
Normal file
79
pkgs/clan-app/ui/src/workflows/AddMachine/StepHost.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
68
pkgs/clan-app/ui/src/workflows/AddMachine/StepProgress.tsx
Normal file
68
pkgs/clan-app/ui/src/workflows/AddMachine/StepProgress.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
69
pkgs/clan-app/ui/src/workflows/AddMachine/StepTags.tsx
Normal file
69
pkgs/clan-app/ui/src/workflows/AddMachine/StepTags.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user