Merge pull request 'ui/implement-add-machine-workflow' (#5021) from ui/implement-add-machine-workflow into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5021
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
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/Install/install";
|
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";
|
||||||
|
|||||||
@@ -24,16 +24,11 @@ import {
|
|||||||
useClanListQuery,
|
useClanListQuery,
|
||||||
useMachinesQuery,
|
useMachinesQuery,
|
||||||
} from "@/src/hooks/queries";
|
} from "@/src/hooks/queries";
|
||||||
import { callApi } from "@/src/hooks/api";
|
|
||||||
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
import { clanURIs, setStore, store } from "@/src/stores/clan";
|
||||||
import { produce } from "solid-js/store";
|
import { produce } from "solid-js/store";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
|
||||||
import { Splash } from "@/src/scene/splash";
|
import { Splash } from "@/src/scene/splash";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import styles from "./Clan.module.css";
|
import styles from "./Clan.module.css";
|
||||||
import { Modal } from "@/src/components/Modal/Modal";
|
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
|
||||||
import { createForm, FieldValues, reset } from "@modular-forms/solid";
|
|
||||||
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
import { Sidebar } from "@/src/components/Sidebar/Sidebar";
|
||||||
import { UseQueryResult } from "@tanstack/solid-query";
|
import { UseQueryResult } from "@tanstack/solid-query";
|
||||||
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal";
|
||||||
@@ -43,6 +38,7 @@ import {
|
|||||||
} from "@/src/workflows/Service/Service";
|
} from "@/src/workflows/Service/Service";
|
||||||
import { useApiClient } from "@/src/hooks/ApiClient";
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
import toast from "solid-toast";
|
import toast from "solid-toast";
|
||||||
|
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
|
||||||
interface ClanContextProps {
|
interface ClanContextProps {
|
||||||
clanURI: string;
|
clanURI: string;
|
||||||
@@ -134,56 +130,6 @@ export const Clan: Component<RouteSectionProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CreateFormValues extends FieldValues {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MockProps {
|
|
||||||
onClose: () => void;
|
|
||||||
onSubmit: (formValues: CreateFormValues) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MockCreateMachine = (props: MockProps) => {
|
|
||||||
const [form, { Form, Field, FieldArray }] = createForm<CreateFormValues>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={true}
|
|
||||||
onClose={() => {
|
|
||||||
reset(form);
|
|
||||||
props.onClose();
|
|
||||||
}}
|
|
||||||
class={cx(styles.createModal)}
|
|
||||||
title="Create Machine"
|
|
||||||
>
|
|
||||||
<Form class="flex flex-col" onSubmit={props.onSubmit}>
|
|
||||||
<Field name="name">
|
|
||||||
{(field, props) => (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
{...field}
|
|
||||||
label="Name"
|
|
||||||
size="s"
|
|
||||||
required={true}
|
|
||||||
input={{ ...props, placeholder: "name", autofocus: true }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="mt-4 flex w-full items-center justify-end gap-4">
|
|
||||||
<Button size="s" hierarchy="secondary" onClick={props.onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="s" type="submit" hierarchy="primary" onClick={close}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ClanSceneController = (props: RouteSectionProps) => {
|
const ClanSceneController = (props: RouteSectionProps) => {
|
||||||
const ctx = useContext(ClanContext);
|
const ctx = useContext(ClanContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
@@ -194,7 +140,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
|
|
||||||
const [showService, setShowService] = createSignal(false);
|
const [showService, setShowService] = createSignal(false);
|
||||||
|
|
||||||
const [showModal, setShowModal] = createSignal(false);
|
const [showCreate, setShowCreate] = createSignal(false);
|
||||||
const [currentPromise, setCurrentPromise] = createSignal<{
|
const [currentPromise, setCurrentPromise] = createSignal<{
|
||||||
resolve: ({ id }: { id: string }) => void;
|
resolve: ({ id }: { id: string }) => void;
|
||||||
reject: (err: unknown) => void;
|
reject: (err: unknown) => void;
|
||||||
@@ -202,45 +148,11 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
|
|
||||||
const onCreate = async (): Promise<{ id: string }> => {
|
const onCreate = async (): Promise<{ id: string }> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
setShowModal(true);
|
setShowCreate(true);
|
||||||
setCurrentPromise({ resolve, reject });
|
setCurrentPromise({ resolve, reject });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddService = async (): Promise<{ id: string }> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
setShowService((v) => !v);
|
|
||||||
console.log("setting current promise");
|
|
||||||
setCurrentPromise({ resolve, reject });
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendCreate = async (values: CreateFormValues) => {
|
|
||||||
const api = callApi("create_machine", {
|
|
||||||
opts: {
|
|
||||||
clan_dir: {
|
|
||||||
identifier: ctx.clanURI,
|
|
||||||
},
|
|
||||||
machine: {
|
|
||||||
name: values.name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const res = await api.result;
|
|
||||||
if (res.status === "error") {
|
|
||||||
// TODO: Handle displaying errors
|
|
||||||
console.error("Error creating machine:");
|
|
||||||
|
|
||||||
// Important: rejects the promise
|
|
||||||
throw new Error(res.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// trigger a refetch of the machines query
|
|
||||||
ctx.machinesQuery.refetch();
|
|
||||||
|
|
||||||
return { id: values.name };
|
|
||||||
};
|
|
||||||
|
|
||||||
const [loadingError, setLoadingError] = createSignal<
|
const [loadingError, setLoadingError] = createSignal<
|
||||||
{ title: string; description: string } | undefined
|
{ title: string; description: string } | undefined
|
||||||
>();
|
>();
|
||||||
@@ -312,8 +224,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
console.error("Error creating service instance", result.errors);
|
console.error("Error creating service instance", result.errors);
|
||||||
}
|
}
|
||||||
toast.success("Created");
|
toast.success("Created");
|
||||||
//
|
|
||||||
currentPromise()?.resolve({ id: "0" });
|
|
||||||
setShowService(false);
|
setShowService(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -322,7 +232,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
if (mode === "service") {
|
if (mode === "service") {
|
||||||
setShowService(true);
|
setShowService(true);
|
||||||
} else {
|
} else {
|
||||||
// todo: request close instead of force close
|
// TODO: request soft close instead of forced close
|
||||||
setShowService(false);
|
setShowService(false);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -333,22 +243,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
|
|||||||
<Show when={loadingError()}>
|
<Show when={loadingError()}>
|
||||||
<ListClansModal error={loadingError()} />
|
<ListClansModal error={loadingError()} />
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={showModal()}>
|
<Show when={showCreate()}>
|
||||||
<MockCreateMachine
|
<AddMachine
|
||||||
onClose={() => {
|
onCreated={async (id) => {
|
||||||
setShowModal(false);
|
const promise = currentPromise();
|
||||||
currentPromise()?.reject(new Error("User cancelled"));
|
if (promise) {
|
||||||
}}
|
await ctx.machinesQuery.refetch();
|
||||||
onSubmit={async (values) => {
|
promise.resolve({ id });
|
||||||
try {
|
setCurrentPromise(null);
|
||||||
const result = await sendCreate(values);
|
|
||||||
currentPromise()?.resolve(result);
|
|
||||||
setShowModal(false);
|
|
||||||
} catch (err) {
|
|
||||||
currentPromise()?.reject(err);
|
|
||||||
setShowModal(false);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
setShowCreate(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ export class MachineManager {
|
|||||||
|
|
||||||
const actualIds = Object.keys(machinesQueryResult.data);
|
const actualIds = Object.keys(machinesQueryResult.data);
|
||||||
const machinePositions = machinePositionsSignal();
|
const machinePositions = machinePositionsSignal();
|
||||||
|
|
||||||
// Remove stale
|
// Remove stale
|
||||||
for (const id of Object.keys(machinePositions)) {
|
for (const id of Object.keys(machinePositions)) {
|
||||||
if (!actualIds.includes(id)) {
|
if (!actualIds.includes(id)) {
|
||||||
|
console.log("Removing stale machine", id);
|
||||||
setMachinePos(id, null);
|
setMachinePos(id, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,10 +61,11 @@ export class MachineManager {
|
|||||||
//
|
//
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const positions = machinePositionsSignal();
|
const positions = machinePositionsSignal();
|
||||||
|
if (!positions) return;
|
||||||
|
|
||||||
// Remove machines from scene
|
// Remove machines from scene
|
||||||
for (const [id, repr] of this.machines) {
|
for (const [id, repr] of this.machines) {
|
||||||
if (!(id in positions)) {
|
if (!Object.keys(positions).includes(id)) {
|
||||||
repr.dispose(scene);
|
repr.dispose(scene);
|
||||||
this.machines.delete(id);
|
this.machines.delete(id);
|
||||||
}
|
}
|
||||||
|
|||||||
119
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx
Normal file
119
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.stories.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
|
import { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
136
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.tsx
Normal file
136
pkgs/clan-app/ui/src/workflows/AddMachine/AddMachine.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
createStepper,
|
||||||
|
defineSteps,
|
||||||
|
StepperProvider,
|
||||||
|
useStepper,
|
||||||
|
} from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
GeneralForm,
|
||||||
|
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 { StepProgress } from "./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;
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
initialStep?: AddMachineSteps[number]["id"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddMachineStoreType {
|
||||||
|
general: GeneralForm;
|
||||||
|
deploy: {
|
||||||
|
targetHost: string;
|
||||||
|
};
|
||||||
|
tags: {
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
onCreated: (id: string) => void;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = defineSteps([
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
title: "General",
|
||||||
|
content: StepGeneral,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "host",
|
||||||
|
title: "Host",
|
||||||
|
content: StepHost,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
title: "Tags",
|
||||||
|
content: StepTags,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "progress",
|
||||||
|
title: "Creating...",
|
||||||
|
content: StepProgress,
|
||||||
|
isSplash: true,
|
||||||
|
},
|
||||||
|
] as const);
|
||||||
|
|
||||||
|
export type AddMachineSteps = typeof steps;
|
||||||
|
|
||||||
|
export const AddMachine = (props: AddMachineProps) => {
|
||||||
|
const stepper = createStepper(
|
||||||
|
{
|
||||||
|
steps,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialStep: props.initialStep || "general",
|
||||||
|
initialStoreData: { onCreated: props.onCreated },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
176
pkgs/clan-app/ui/src/workflows/AddMachine/StepGeneral.tsx
Normal file
176
pkgs/clan-app/ui/src/workflows/AddMachine/StepGeneral.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { NextButton, StepLayout } from "@/src/workflows/Steps";
|
||||||
|
import * as v from "valibot";
|
||||||
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
|
import {
|
||||||
|
clearError,
|
||||||
|
createForm,
|
||||||
|
FieldValues,
|
||||||
|
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()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface GeneralForm extends FieldValues {
|
||||||
|
machineClass: "nixos" | "darwin";
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: { ...store.general, machineClass: "nixos" },
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
pkgs/clan-app/ui/src/workflows/AddMachine/StepHost.tsx
Normal file
76
pkgs/clan-app/ui/src/workflows/AddMachine/StepHost.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
40
pkgs/clan-app/ui/src/workflows/AddMachine/StepProgress.tsx
Normal file
40
pkgs/clan-app/ui/src/workflows/AddMachine/StepProgress.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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 { Show } from "solid-js";
|
||||||
|
import { Alert } from "@/src/components/Alert/Alert";
|
||||||
|
|
||||||
|
export interface StepProgressProps {
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StepProgress = (props: StepProgressProps) => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex flex-col items-center justify-center gap-2.5 px-6 pb-7 pt-4">
|
||||||
|
<Show
|
||||||
|
when={store.error}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<Loader class="size-8" />
|
||||||
|
<Typography hierarchy="body" size="s" weight="medium" family="mono">
|
||||||
|
{store.general?.name} is being created
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
title="There was an error"
|
||||||
|
description={store.error}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
pkgs/clan-app/ui/src/workflows/AddMachine/StepTags.tsx
Normal file
102
pkgs/clan-app/ui/src/workflows/AddMachine/StepTags.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { BackButton, 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";
|
||||||
|
import { useApiClient } from "@/src/hooks/ApiClient";
|
||||||
|
import { useClanURI } from "@/src/hooks/clan";
|
||||||
|
|
||||||
|
const TagsSchema = v.object({
|
||||||
|
tags: v.array(v.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TagsForm = v.InferInput<typeof TagsSchema>;
|
||||||
|
|
||||||
|
export const StepTags = (props: { onDone: () => void }) => {
|
||||||
|
const stepSignal = useStepper<AddMachineSteps>();
|
||||||
|
const [store, set] = getStepStore<AddMachineStoreType>(stepSignal);
|
||||||
|
|
||||||
|
const [formStore, { Form, Field }] = createForm<TagsForm>({
|
||||||
|
validate: valiForm(TagsSchema),
|
||||||
|
initialValues: store.tags,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiClient = useApiClient();
|
||||||
|
const clanURI = useClanURI();
|
||||||
|
|
||||||
|
const handleSubmit: SubmitHandler<TagsForm> = async (values, event) => {
|
||||||
|
set("tags", (s) => ({
|
||||||
|
...s,
|
||||||
|
...values,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const call = apiClient.fetch("create_machine", {
|
||||||
|
opts: {
|
||||||
|
clan_dir: {
|
||||||
|
identifier: clanURI,
|
||||||
|
},
|
||||||
|
machine: {
|
||||||
|
...store.general,
|
||||||
|
...store.tags,
|
||||||
|
deploy: store.deploy,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
stepSignal.next();
|
||||||
|
|
||||||
|
const result = await call.result;
|
||||||
|
|
||||||
|
if (result.status == "error") {
|
||||||
|
// setError(result.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.status == "success") {
|
||||||
|
console.log("Machine creation was successful");
|
||||||
|
if (store.general) {
|
||||||
|
store.onCreated(store.general.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("Done creating machine");
|
||||||
|
props.onDone();
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
|
||||||
import { InstallModal } from "./install";
|
import { InstallModal } from "./InstallMachine";
|
||||||
import {
|
import {
|
||||||
createMemoryHistory,
|
createMemoryHistory,
|
||||||
MemoryRouter,
|
MemoryRouter,
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineSteps, useStepper } from "@/src/hooks/stepper";
|
import { defineSteps, useStepper } from "@/src/hooks/stepper";
|
||||||
import { InstallSteps } from "../install";
|
import { InstallSteps } from "../InstallMachine";
|
||||||
import { Button } from "@/src/components/Button/Button";
|
import { Button } from "@/src/components/Button/Button";
|
||||||
import { StepLayout } from "../../Steps";
|
import { StepLayout } from "../../Steps";
|
||||||
import { NavSection } from "@/src/components/NavSection/NavSection";
|
import { NavSection } from "@/src/components/NavSection/NavSection";
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
valiForm,
|
valiForm,
|
||||||
} from "@modular-forms/solid";
|
} from "@modular-forms/solid";
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
import { InstallSteps, InstallStoreType } from "../install";
|
import { InstallSteps, InstallStoreType } from "../InstallMachine";
|
||||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
import { HostFileInput } from "@/src/components/Form/HostFileInput";
|
||||||
import { Select } from "@/src/components/Select/Select";
|
import { Select } from "@/src/components/Select/Select";
|
||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
import { Fieldset } from "@/src/components/Form/Fieldset";
|
import { Fieldset } from "@/src/components/Form/Fieldset";
|
||||||
import * as v from "valibot";
|
import * as v from "valibot";
|
||||||
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
import { getStepStore, useStepper } from "@/src/hooks/stepper";
|
||||||
import { InstallSteps, InstallStoreType, PromptValues } from "../install";
|
import {
|
||||||
|
InstallSteps,
|
||||||
|
InstallStoreType,
|
||||||
|
PromptValues,
|
||||||
|
} from "../InstallMachine";
|
||||||
import { TextInput } from "@/src/components/Form/TextInput";
|
import { TextInput } from "@/src/components/Form/TextInput";
|
||||||
import { Alert } from "@/src/components/Alert/Alert";
|
import { Alert } from "@/src/components/Alert/Alert";
|
||||||
import { createSignal, For, Match, Show, Switch } from "solid-js";
|
import { createSignal, For, Match, Show, Switch } from "solid-js";
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { JSX } from "solid-js";
|
import { JSX } from "solid-js";
|
||||||
import { useStepper } from "../hooks/stepper";
|
import { useStepper } from "../hooks/stepper";
|
||||||
import { Button, ButtonProps } from "../components/Button/Button";
|
import { Button, ButtonProps } from "../components/Button/Button";
|
||||||
import { InstallSteps } from "./Install/install";
|
import { InstallSteps } from "@/src/workflows/InstallMachine/InstallMachine";
|
||||||
import styles from "./Steps.module.css";
|
import styles from "./Steps.module.css";
|
||||||
|
|
||||||
interface StepLayoutProps {
|
interface StepLayoutProps {
|
||||||
|
|||||||
Reference in New Issue
Block a user