Merge pull request 'Add vars step to UI' (#2710) from hsjobeki/clan-core:hsjobeki-main into main

This commit is contained in:
clan-bot
2025-01-10 12:15:33 +00:00
7 changed files with 463 additions and 313 deletions

View File

@@ -5,6 +5,7 @@ import {
type JSX, type JSX,
For, For,
createMemo, createMemo,
Accessor,
} from "solid-js"; } from "solid-js";
import { Portal } from "solid-js/web"; import { Portal } from "solid-js/web";
import { useFloating } from "../base"; import { useFloating } from "../base";
@@ -46,11 +47,12 @@ interface SelectInputpProps {
placeholder?: string; placeholder?: string;
multiple?: boolean; multiple?: boolean;
loading?: boolean; loading?: boolean;
dialogContextId?: string; portalRef?: Accessor<HTMLElement | null>;
} }
export function SelectInput(props: SelectInputpProps) { export function SelectInput(props: SelectInputpProps) {
const dialogContext = useContext(props.dialogContextId); const dialogContext = (dialogContextId?: string) =>
useContext(dialogContextId);
const _id = createUniqueId(); const _id = createUniqueId();
@@ -228,7 +230,11 @@ export function SelectInput(props: SelectInputpProps) {
} }
/> />
<Portal mount={dialogContext.contentRef?.() || document.body}> <Portal
mount={
props.portalRef ? props.portalRef() || document.body : document.body
}
>
<div <div
id={_id} id={_id}
popover popover

View File

@@ -28,11 +28,20 @@ export const Modal = (props: ModalProps) => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (dragging()) { if (dragging()) {
const newTop = e.clientY - startOffset().y; let newTop = e.clientY - startOffset().y;
const newLeft = e.clientX - startOffset().x; let newLeft = e.clientX - startOffset().x;
if (newTop < 0) {
newTop = 0;
}
if (newLeft < 0) {
newLeft = 0;
}
dialogRef.style.top = `${newTop}px`; dialogRef.style.top = `${newTop}px`;
dialogRef.style.left = `${newLeft}px`; dialogRef.style.left = `${newLeft}px`;
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
// dialogRef.style.maxHeight = `calc(100vh - ${newTop}px - 2rem)`;
} }
}; };
@@ -53,7 +62,7 @@ export const Modal = (props: ModalProps) => {
)} )}
classList={{ classList={{
"!cursor-grabbing": dragging(), "!cursor-grabbing": dragging(),
[cx("scale-105 transition-transform")]: dragging(), [cx("scale-[101%] transition-transform")]: dragging(),
}} }}
ref={(el) => { ref={(el) => {
dialogRef = el; dialogRef = el;
@@ -112,7 +121,10 @@ export const Modal = (props: ModalProps) => {
/> />
</div> </div>
</Dialog.Label> </Dialog.Label>
<Dialog.Description class="flex flex-col bg-def-1" as="div"> <Dialog.Description
class="flex max-h-[90vh] flex-col overflow-y-hidden bg-def-1"
as="div"
>
{props.children} {props.children}
</Dialog.Description> </Dialog.Description>
</Dialog.Content> </Dialog.Content>

View File

@@ -25,7 +25,7 @@ import { HardwareValues, HWStep } from "./install/hardware-step";
import { DiskStep, DiskValues } from "./install/disk-step"; import { DiskStep, DiskValues } from "./install/disk-step";
import { SummaryStep } from "./install/summary-step"; import { SummaryStep } from "./install/summary-step";
import cx from "classnames"; import cx from "classnames";
import { SectionHeader } from "@/src/components/group"; import { VarsStep, VarsValues } from "./install/vars-step";
type MachineFormInterface = MachineData & { type MachineFormInterface = MachineData & {
sshKey?: File; sshKey?: File;
@@ -37,7 +37,8 @@ type MachineData = SuccessData<"get_inventory_machine_details">;
const steps: Record<StepIdx, string> = { const steps: Record<StepIdx, string> = {
"1": "Hardware detection", "1": "Hardware detection",
"2": "Disk schema", "2": "Disk schema",
"3": "Installation", "3": "Credentials & Data",
"4": "Installation",
}; };
type StepIdx = keyof AllStepsValues; type StepIdx = keyof AllStepsValues;
@@ -45,7 +46,8 @@ type StepIdx = keyof AllStepsValues;
export interface AllStepsValues extends FieldValues { export interface AllStepsValues extends FieldValues {
"1": HardwareValues; "1": HardwareValues;
"2": DiskValues; "2": DiskValues;
"3": NonNullable<unknown>; "3": VarsValues;
"4": NonNullable<unknown>;
} }
const LoadingBar = () => ( const LoadingBar = () => (
@@ -190,7 +192,7 @@ const InstallMachine = (props: InstallMachineProps) => {
}; };
const Footer = () => ( const Footer = () => (
<div class="flex justify-between"> <div class="flex justify-between p-4">
<Button <Button
startIcon={<Icon icon="ArrowLeft" />} startIcon={<Icon icon="ArrowLeft" />}
variant="light" variant="light"
@@ -214,7 +216,10 @@ const InstallMachine = (props: InstallMachineProps) => {
return ( return (
<Switch <Switch
fallback={ fallback={
<Form onSubmit={handleInstall}> <Form
onSubmit={handleInstall}
class="relative top-0 flex h-full flex-col gap-0"
>
{/* Register each step as form field */} {/* Register each step as form field */}
{/* @ts-expect-error: object type is not statically supported */} {/* @ts-expect-error: object type is not statically supported */}
<Field name="1">{(field, fieldProps) => <></>}</Field> <Field name="1">{(field, fieldProps) => <></>}</Field>
@@ -269,78 +274,90 @@ const InstallMachine = (props: InstallMachineProps) => {
)} )}
</For> </For>
</div> </div>
<Switch fallback={"Undefined content. This Step seems to not exist."}>
<div class="flex flex-col gap-6 p-6"> <Match when={step() === "1"}>
<Switch <HWStep
fallback={"Undefined content. This Step seems to not exist."} // @ts-expect-error: This cannot be undefined in this context.
> machine_id={props.name}
<Match when={step() === "1"}> // @ts-expect-error: This cannot be undefined in this context.
<HWStep dir={activeURI()}
// @ts-expect-error: This cannot be undefined in this context. handleNext={(data) => {
machine_id={props.name} const prev = getValue(formStore, "1");
// @ts-expect-error: This cannot be undefined in this context. setValue(formStore, "1", { ...prev, ...data });
dir={activeURI()} handleNext();
handleNext={(data) => { }}
const prev = getValue(formStore, "1"); initial={
setValue(formStore, "1", { ...prev, ...data }); getValue(formStore, "1") || {
handleNext(); target: props.targetHost || "",
}} report: false,
initial={
getValue(formStore, "1") || {
target: props.targetHost || "",
report: false,
}
} }
footer={<Footer />} }
/> footer={<Footer />}
</Match> />
<Match when={step() === "2"}> </Match>
<DiskStep <Match when={step() === "2"}>
// @ts-expect-error: This cannot be undefined in this context. <DiskStep
machine_id={props.name} // @ts-expect-error: This cannot be undefined in this context.
// @ts-expect-error: This cannot be undefined in this context. machine_id={props.name}
dir={activeURI()} // @ts-expect-error: This cannot be undefined in this context.
footer={<Footer />} dir={activeURI()}
handleNext={(data) => { footer={<Footer />}
const prev = getValue(formStore, "2"); handleNext={(data) => {
setValue(formStore, "2", { ...prev, ...data }); const prev = getValue(formStore, "2");
handleNext(); setValue(formStore, "2", { ...prev, ...data });
}} handleNext();
// @ts-expect-error: The placeholder type is to wide }}
initial={{ // @ts-expect-error: The placeholder type is to wide
...props.machine.disk_schema, initial={{
...getValue(formStore, "2"), ...props.machine.disk_schema,
initialized: !!props.machine.disk_schema, ...getValue(formStore, "2"),
}} initialized: !!props.machine.disk_schema,
/> }}
</Match> />
<Match when={step() === "3"}> </Match>
<SummaryStep <Match when={step() === "3"}>
// @ts-expect-error: This cannot be undefined in this context. <VarsStep
machine_id={props.name} // @ts-expect-error: This cannot be undefined in this context.
// @ts-expect-error: This cannot be undefined in this context. machine_id={props.name}
dir={activeURI()} // @ts-expect-error: This cannot be undefined in this context.
handleNext={() => handleNext()} dir={activeURI()}
// @ts-expect-error: This cannot be known. footer={<Footer />}
initial={getValues(formStore)} handleNext={(data) => {
footer={ // const prev = getValue(formStore, "2");
<div class="flex justify-between"> // setValue(formStore, "2", { ...prev, ...data });
<Button handleNext();
startIcon={<Icon icon="ArrowLeft" />} }}
variant="light" initial={{
type="button" ...getValue(formStore, "3"),
onClick={handlePrev} }}
disabled={step() === "1"} />
> </Match>
Previous <Match when={step() === "4"}>
</Button> <SummaryStep
<Button startIcon={<Icon icon="Flash" />}>Install</Button> // @ts-expect-error: This cannot be undefined in this context.
</div> machine_id={props.name}
} // @ts-expect-error: This cannot be undefined in this context.
/> dir={activeURI()}
</Match> handleNext={() => handleNext()}
</Switch> // @ts-expect-error: This cannot be known.
</div> initial={getValues(formStore)}
footer={
<div class="flex justify-between p-4">
<Button
startIcon={<Icon icon="ArrowLeft" />}
variant="light"
type="button"
onClick={handlePrev}
disabled={step() === "1"}
>
Previous
</Button>
<Button startIcon={<Icon icon="Flash" />}>Install</Button>
</div>
}
/>
</Match>
</Switch>
</Form> </Form>
} }
> >

View File

@@ -11,6 +11,7 @@ import { StepProps } from "./hardware-step";
import { SelectInput } from "@/src/Form/fields/Select"; import { SelectInput } from "@/src/Form/fields/Select";
import { Typography } from "@/src/components/Typography"; import { Typography } from "@/src/components/Typography";
import { Group } from "@/src/components/group"; import { Group } from "@/src/components/group";
import { useContext } from "corvu/dialog";
export interface DiskValues extends FieldValues { export interface DiskValues extends FieldValues {
placeholders: { placeholders: {
@@ -44,67 +45,82 @@ export const DiskStep = (props: StepProps<DiskValues>) => {
}, },
})); }));
const modalContext = useContext();
return ( return (
<Form <>
onSubmit={handleSubmit} <Form
class="flex flex-col gap-6" onSubmit={handleSubmit}
noValidate={false} class="flex flex-col gap-6"
> noValidate={false}
<span class="flex flex-col gap-4"> >
<Field name="schema" validate={required("Schema must be provided")}> <div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
{(field, fieldProps) => ( <div class="flex h-full flex-col gap-6 p-4">
<> <span class="flex flex-col gap-4">
<Typography <Field
hierarchy="body" name="schema"
size="default" validate={required("Schema must be provided")}
weight="bold"
class="capitalize"
> >
{(field.value || "No schema selected").split("-").join(" ")} {(field, fieldProps) => (
</Typography> <>
<Typography <Typography
hierarchy="body" hierarchy="body"
size="xs" size="default"
weight="medium" weight="bold"
class="underline" class="capitalize"
>
{(field.value || "No schema selected")
.split("-")
.join(" ")}
</Typography>
<Typography
hierarchy="body"
size="xs"
weight="medium"
class="underline"
>
Change schema
</Typography>
</>
)}
</Field>
</span>
<Group>
{props.initial?.initialized &&
"Disk has been initialized already"}
<Field
name="placeholders.mainDisk"
validate={
!props.initial?.initialized
? required("Disk must be provided")
: undefined
}
> >
Change schema {(field, fieldProps) => (
</Typography> <SelectInput
</> loading={diskSchemaQuery.isFetching}
)} options={
</Field> diskSchemaQuery.data?.["single-disk"].placeholders[
</span> "mainDisk"
<Group> ].options?.map((o) => ({ label: o, value: o })) || [
{props.initial?.initialized && "Disk has been initialized already"} { label: "No options", value: "" },
<Field ]
name="placeholders.mainDisk" }
validate={ error={field.error}
!props.initial?.initialized label="Main Disk"
? required("Disk must be provided") value={field.value || ""}
: undefined placeholder="Select a disk"
} selectProps={fieldProps}
> required={!props.initial?.initialized}
{(field, fieldProps) => ( portalRef={modalContext.contentRef}
<SelectInput />
loading={diskSchemaQuery.isFetching} )}
options={ </Field>
diskSchemaQuery.data?.["single-disk"].placeholders[ </Group>
"mainDisk" </div>
].options?.map((o) => ({ label: o, value: o })) || [ </div>
{ label: "No options", value: "" }, {props.footer}
] </Form>
} </>
error={field.error}
label="Main Disk"
value={field.value || ""}
placeholder="Select a disk"
selectProps={fieldProps}
required={!props.initial?.initialized}
/>
)}
</Field>
</Group>
{props.footer}
</Form>
); );
}; };

View File

@@ -105,101 +105,105 @@ export const HWStep = (props: StepProps<HardwareValues>) => {
return ( return (
<Form onSubmit={handleSubmit} class="flex flex-col gap-6"> <Form onSubmit={handleSubmit} class="flex flex-col gap-6">
<Group> <div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
<Field name="target" validate={required("Target must be provided")}> <div class="flex h-full flex-col gap-6 p-4">
{(field, fieldProps) => ( <Group>
<TextInput <Field name="target" validate={required("Target must be provided")}>
error={field.error} {(field, fieldProps) => (
variant="ghost" <TextInput
label="Target ip" error={field.error}
value={field.value || ""} variant="ghost"
inputProps={fieldProps} label="Target ip"
required value={field.value || ""}
/> inputProps={fieldProps}
)}
</Field>
</Group>
<Group>
<Field
name="report"
type="boolean"
validate={required("Report must be generated")}
>
{(field, fieldProps) => (
<FieldLayout
error={field.error && <InputError error={field.error} />}
label={
<InputLabel
required required
help="Detect hardware specific drivers from target ip" />
> )}
Hardware report </Field>
</InputLabel> </Group>
} <Group>
field={ <Field
<Switch> name="report"
<Match when={hwReportQuery.isLoading}> type="boolean"
<div>Loading...</div> validate={required("Report must be generated")}
</Match> >
<Match when={hwReportQuery.error}> {(field, fieldProps) => (
<div>Error...</div> <FieldLayout
</Match> error={field.error && <InputError error={field.error} />}
<Match when={hwReportQuery.data}> label={
{(data) => ( <InputLabel
<> required
<Switch> help="Detect hardware specific drivers from target ip"
<Match when={data() === "none"}> >
<Badge color="red" icon="Attention"> Hardware report
No report </InputLabel>
</Badge> }
<Button field={
variant="ghost" <Switch>
disabled={isGenerating()} <Match when={hwReportQuery.isLoading}>
startIcon={<Icon icon="Report" />} <div>Loading...</div>
class="w-full" </Match>
onClick={generateReport} <Match when={hwReportQuery.error}>
> <div>Error...</div>
Run hardware detection </Match>
</Button> <Match when={hwReportQuery.data}>
</Match> {(data) => (
<Match when={data() === "nixos-facter"}> <>
<Badge color="primary" icon="Checkmark"> <Switch>
Report detected <Match when={data() === "none"}>
</Badge> <Badge color="red" icon="Attention">
<Button No report
variant="ghost" </Badge>
disabled={isGenerating()} <Button
startIcon={<Icon icon="Report" />} variant="ghost"
class="w-full" disabled={isGenerating()}
onClick={generateReport} startIcon={<Icon icon="Report" />}
> class="w-full"
Re-run hardware detection onClick={generateReport}
</Button> >
</Match> Run hardware detection
<Match when={data() === "nixos-generate-config"}> </Button>
<Badge color="primary" icon="Checkmark"> </Match>
Legacy Report detected <Match when={data() === "nixos-facter"}>
</Badge> <Badge color="primary" icon="Checkmark">
<Button Report detected
variant="ghost" </Badge>
disabled={isGenerating()} <Button
startIcon={<Icon icon="Report" />} variant="ghost"
class="w-full" disabled={isGenerating()}
onClick={generateReport} startIcon={<Icon icon="Report" />}
> class="w-full"
Replace hardware detection onClick={generateReport}
</Button> >
</Match> Re-run hardware detection
</Switch> </Button>
</> </Match>
)} <Match when={data() === "nixos-generate-config"}>
</Match> <Badge color="primary" icon="Checkmark">
</Switch> Legacy Report detected
} </Badge>
/> <Button
)} variant="ghost"
</Field> disabled={isGenerating()}
</Group> startIcon={<Icon icon="Report" />}
class="w-full"
onClick={generateReport}
>
Replace hardware detection
</Button>
</Match>
</Switch>
</>
)}
</Match>
</Switch>
}
/>
)}
</Field>
</Group>
</div>
</div>
{props.footer} {props.footer}
</Form> </Form>
); );

View File

@@ -12,87 +12,96 @@ export const SummaryStep = (props: StepProps<AllStepsValues>) => {
const diskValues = () => props.initial?.["2"]; const diskValues = () => props.initial?.["2"];
return ( return (
<> <>
<Section> <div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
<Typography <div class="flex h-full flex-col gap-6 p-4">
hierarchy="label" <Section>
size="xs"
weight="medium"
class="uppercase"
>
Hardware Report
</Typography>
<Group>
<FieldLayout
label={<InputLabel>Detected</InputLabel>}
field={
hwValues()?.report ? (
<Badge color="green" class="w-fit">
<Icon icon="Checkmark" color="inherit" />
</Badge>
) : (
<Badge color="red" class="w-fit">
<Icon icon="Warning" color="inherit" />
</Badge>
)
}
></FieldLayout>
<FieldLayout
label={<InputLabel>Target</InputLabel>}
field={
<Typography hierarchy="body" size="xs" weight="bold">
{hwValues()?.target}
</Typography>
}
></FieldLayout>
</Group>
</Section>
<Section>
<Typography
hierarchy="label"
size="xs"
weight="medium"
class="uppercase"
>
Disk Configuration
</Typography>
<Group>
<FieldLayout
label={<InputLabel>Disk Layout</InputLabel>}
field={
<Typography hierarchy="body" size="xs" weight="bold">
{diskValues()?.schema}
</Typography>
}
></FieldLayout>
<hr class="h-px w-full border-none bg-acc-3"></hr>
<FieldLayout
label={<InputLabel>Main Disk</InputLabel>}
field={
<Typography hierarchy="body" size="xs" weight="bold">
{diskValues()?.placeholders.mainDisk}
</Typography>
}
></FieldLayout>
</Group>
</Section>
<SectionHeader
variant="danger"
headline={
<span>
<Typography hierarchy="body" size="s" weight="bold" color="inherit">
Setup your device.
</Typography>
<Typography <Typography
hierarchy="body" hierarchy="label"
size="s" size="xs"
weight="medium" weight="medium"
color="inherit" class="uppercase"
> >
This will erase the disk and bootstrap fresh. Hardware Report
</Typography> </Typography>
</span> <Group>
} <FieldLayout
/> label={<InputLabel>Detected</InputLabel>}
field={
hwValues()?.report ? (
<Badge color="green" class="w-fit">
<Icon icon="Checkmark" color="inherit" />
</Badge>
) : (
<Badge color="red" class="w-fit">
<Icon icon="Warning" color="inherit" />
</Badge>
)
}
></FieldLayout>
<FieldLayout
label={<InputLabel>Target</InputLabel>}
field={
<Typography hierarchy="body" size="xs" weight="bold">
{hwValues()?.target}
</Typography>
}
></FieldLayout>
</Group>
</Section>
<Section>
<Typography
hierarchy="label"
size="xs"
weight="medium"
class="uppercase"
>
Disk Configuration
</Typography>
<Group>
<FieldLayout
label={<InputLabel>Disk Layout</InputLabel>}
field={
<Typography hierarchy="body" size="xs" weight="bold">
{diskValues()?.schema}
</Typography>
}
></FieldLayout>
<hr class="h-px w-full border-none bg-acc-3"></hr>
<FieldLayout
label={<InputLabel>Main Disk</InputLabel>}
field={
<Typography hierarchy="body" size="xs" weight="bold">
{diskValues()?.placeholders.mainDisk}
</Typography>
}
></FieldLayout>
</Group>
</Section>
<SectionHeader
variant="danger"
headline={
<span>
<Typography
hierarchy="body"
size="s"
weight="bold"
color="inherit"
>
Setup your device.
</Typography>
<Typography
hierarchy="body"
size="s"
weight="medium"
color="inherit"
>
This will erase the disk and bootstrap fresh.
</Typography>
</span>
}
/>
</div>
</div>
{props.footer} {props.footer}
</> </>
); );

View File

@@ -0,0 +1,86 @@
import { callApi } from "@/src/api";
import {
createForm,
SubmitHandler,
validate,
FieldValues,
} from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query";
import { StepProps } from "./hardware-step";
import { Typography } from "@/src/components/Typography";
import { Group } from "@/src/components/group";
import { For, Match, Show, Switch } from "solid-js";
export type VarsValues = FieldValues & Record<string, string>;
export const VarsStep = (props: StepProps<VarsValues>) => {
const [formStore, { Form, Field }] = createForm<VarsValues>({
initialValues: { ...props.initial, schema: "single-disk" },
});
const handleSubmit: SubmitHandler<VarsValues> = async (values, event) => {
console.log("Submit Disk", { values });
const valid = await validate(formStore);
console.log("Valid", valid);
if (!valid) return;
props.handleNext(values);
};
const generatorsQuery = createQuery(() => ({
queryKey: [props.dir, props.machine_id, "generators"],
queryFn: async () => {
const result = await callApi("get_generators", {
base_dir: props.dir,
machine_name: props.machine_id,
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
}));
return (
<Form
onSubmit={handleSubmit}
class="flex h-full flex-col gap-6"
noValidate={false}
>
<div class="max-h-[calc(100vh-20rem)] overflow-y-scroll">
<div class="flex h-full flex-col gap-6 p-4">
<Switch>
<Match when={generatorsQuery.isLoading}>Loading ...</Match>
<Match when={generatorsQuery.data}>
{(generators) => (
<For each={generators()}>
{(generator) => (
<Group>
<Typography hierarchy="label" size="default">
{generator.name}
</Typography>
<div>
Bound to module (shared):{" "}
{generator.share ? "True" : "False"}
</div>
<For each={generator.prompts}>
{(f) => (
<Group>
<Typography hierarchy="label" size="s">
{!f.previous_value ? "Required" : "Optional"}
</Typography>
<Typography hierarchy="label" size="s">
{f.name}
</Typography>
</Group>
)}
</For>
</Group>
)}
</For>
)}
</Match>
</Switch>
</div>
</div>
<Show when={generatorsQuery.isFetched}>{props.footer}</Show>
</Form>
);
};