Merge pull request 'ui/install: hook up stepper store and api' (#4626) from install-ui into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4626
This commit is contained in:
hsjobeki
2025-08-07 14:21:21 +00:00
16 changed files with 485 additions and 104 deletions

View File

@@ -11,6 +11,10 @@
@apply outline outline-1 outline-inv-2
}
&[data-loading] {
@apply cursor-wait;
}
&:hover {
@apply bg-def-2;
@@ -38,6 +42,9 @@
&[data-disabled] {
@apply cursor-not-allowed;
}
&[data-loading] {
@apply bg-inherit;
}
}
.options_content {

View File

@@ -47,25 +47,28 @@ export const Default: Story = {
placeholder: "Select your pet",
},
};
// <Field name="language">
// {(field, input) => (
// <Select
// required
// label={{
// label: "Language",
// description: "Select your preferred language",
// }}
// options={[
// { value: "en", label: "English" },
// { value: "fr", label: "Français" },
// ]}
// placeholder="Language"
// onChange={(opt) => {
// setValue(formStore, "language", opt?.value || "");
// }}
// name={field.name}
// validationState={field.error ? "invalid" : "valid"}
// />
// )}
// </Field>
export const Async: Story = {
args: {
required: true,
label: {
label: "Select your pet",
description: "Choose your favorite pet from the list",
},
getOptions: async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ value: "dog", label: "Doggy" },
{ value: "cat", label: "Catty" },
{ value: "fish", label: "Fishy" },
{ value: "bird", label: "Birdy" },
{ value: "hamster", label: "Hammy" },
{ value: "snake", label: "Snakey" },
{ value: "turtle", label: "Turtly" },
]);
}, 3000);
});
},
placeholder: "Select your pet",
},
};

View File

@@ -2,18 +2,21 @@ import { Select as KSelect } from "@kobalte/core/select";
import Icon from "../Icon/Icon";
import { Orienter } from "../Form/Orienter";
import { Label, LabelProps } from "../Form/Label";
import { createEffect, createSignal, JSX, splitProps } from "solid-js";
import { createEffect, createSignal, JSX, Show, splitProps } from "solid-js";
import styles from "./Select.module.css";
import { Typography } from "../Typography/Typography";
import cx from "classnames";
export interface Option { value: string; label: string; disabled?: boolean }
export interface Option {
value: string;
label: string;
disabled?: boolean;
}
export interface SelectProps {
export type SelectProps = {
// Kobalte Select props, for modular forms
name: string;
placeholder?: string | undefined;
options: Option[];
value: string | undefined;
error: string;
required?: boolean | undefined;
@@ -25,24 +28,57 @@ export interface SelectProps {
// Custom props
orientation?: "horizontal" | "vertical";
label?: Omit<LabelProps, "labelComponent" | "descriptionComponent">;
}
} & (
| {
// Sync options
options: Option[];
getOptions?: never;
}
| {
// Async options
getOptions: () => Promise<Option[]>;
options?: never;
}
);
export const Select = (props: SelectProps) => {
const [root, selectProps] = splitProps(
props,
["name", "placeholder", "options", "required", "disabled"],
["name", "placeholder", "required", "disabled"],
["placeholder", "ref", "onInput", "onChange", "onBlur"],
);
const [getValue, setValue] = createSignal<Option>();
const [resolvedOptions, setResolvedOptions] = createSignal<Option[]>([]);
// Internal loading state for async options
const [loading, setLoading] = createSignal(false);
createEffect(async () => {
if (props.getOptions) {
setLoading(true);
try {
const options = await props.getOptions();
setResolvedOptions(options);
} finally {
setLoading(false);
}
} else if (props.options) {
setResolvedOptions(props.options);
}
});
const options = () => props.options ?? resolvedOptions();
createEffect(() => {
setValue(props.options.find((option) => props.value === option.value));
console.log("options,", options());
setValue(options().find((option) => props.value === option.value));
});
return (
<KSelect
{...root}
options={options()}
sameWidth={true}
gutter={0}
multiple={false}
@@ -70,14 +106,29 @@ export const Select = (props: SelectProps) => {
</KSelect.Item>
)}
placeholder={
<Typography
hierarchy="body"
size="xs"
weight="bold"
class="flex w-full items-center"
<Show
when={!loading()}
fallback={
<Typography
hierarchy="body"
size="xs"
weight="bold"
class="flex w-full items-center"
color="secondary"
>
Loading...
</Typography>
}
>
{props.placeholder}
</Typography>
<Typography
hierarchy="body"
size="xs"
weight="bold"
class="flex w-full items-center"
>
{props.placeholder}
</Typography>
</Show>
}
>
<Orienter orientation={props.orientation || "horizontal"}>
@@ -88,7 +139,10 @@ export const Select = (props: SelectProps) => {
validationState={props.error ? "invalid" : "valid"}
/>
<KSelect.HiddenSelect {...selectProps} />
<KSelect.Trigger class={cx(styles.trigger)}>
<KSelect.Trigger
class={cx(styles.trigger)}
data-loading={loading() || undefined}
>
<KSelect.Value<Option>>
{(state) => (
<Typography
@@ -101,7 +155,11 @@ export const Select = (props: SelectProps) => {
</Typography>
)}
</KSelect.Value>
<KSelect.Icon as="button" class={styles.icon}>
<KSelect.Icon
as="button"
class={styles.icon}
data-loading={loading() || undefined}
>
<Icon icon="Expand" color="inherit" />
</KSelect.Icon>
</KSelect.Trigger>

View File

@@ -0,0 +1,33 @@
import { createContext, JSX, useContext } from "solid-js";
import { ApiCall, OperationArgs, OperationNames } from "./api";
export interface ApiClient {
fetch: Fetcher;
}
export type Fetcher = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,
) => ApiCall<K>;
const ApiClientContext = createContext<ApiClient>();
interface ApiClientProviderProps {
client: ApiClient;
children: JSX.Element;
}
export const ApiClientProvider = (props: ApiClientProviderProps) => {
return (
<ApiClientContext.Provider value={props.client}>
{props.children}
</ApiClientContext.Provider>
);
};
export const useApiClient = () => {
const client = useContext(ApiClientContext);
if (!client) {
throw new Error("useApiClient must be used within an ApiClientProvider");
}
return client;
};

View File

@@ -49,6 +49,11 @@ export interface ApiCall<K extends OperationNames> {
cancel: () => Promise<void>;
}
/**
* Do not use this function directly, use `useApiClient` function instead.
* This allows mocking the result in tests.
* Or switch to different client implementations.
*/
export const callApi = <K extends OperationNames>(
method: K,
args: OperationArgs<K>,

View File

@@ -47,7 +47,12 @@ export const navigateToMachine = (
};
export const clanURIParam = (params: Params) => {
return decodeBase64(params.clanURI);
try {
return decodeBase64(params.clanURI);
} catch (e) {
console.error("Failed to decode clan URI:", params.clanURI, e);
throw new Error("Invalid clan URI");
}
};
export const useClanURI = () => clanURIParam(useParams());

View File

@@ -1,6 +1,7 @@
import { useQueries, useQuery, UseQueryResult } from "@tanstack/solid-query";
import { callApi, SuccessData } from "../hooks/api";
import { SuccessData } from "../hooks/api";
import { encodeBase64 } from "@/src/hooks/clan";
import { useApiClient } from "./ApiClient";
export type ClanDetails = SuccessData<"get_clan_details">;
export type ClanDetailsWithURI = ClanDetails & { uri: string };
@@ -12,11 +13,12 @@ export type MachineDetails = SuccessData<"get_machine_details">;
export type MachinesQueryResult = UseQueryResult<ListMachines>;
export type ClanListQueryResult = UseQueryResult<ClanDetailsWithURI>[];
export const useMachinesQuery = (clanURI: string) =>
useQuery<ListMachines>(() => ({
export const useMachinesQuery = (clanURI: string) => {
const client = useApiClient();
return useQuery<ListMachines>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machines"],
queryFn: async () => {
const api = callApi("list_machines", {
const api = client.fetch("list_machines", {
flake: {
identifier: clanURI,
},
@@ -29,12 +31,14 @@ export const useMachinesQuery = (clanURI: string) =>
return result.data;
},
}));
};
export const useMachineQuery = (clanURI: string, machineName: string) =>
useQuery<Machine>(() => ({
export const useMachineQuery = (clanURI: string, machineName: string) => {
const client = useApiClient();
return useQuery<Machine>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName],
queryFn: async () => {
const call = callApi("get_machine", {
const call = client.fetch("get_machine", {
name: machineName,
flake: {
identifier: clanURI,
@@ -49,12 +53,17 @@ export const useMachineQuery = (clanURI: string, machineName: string) =>
return result.data;
},
}));
};
export const useMachineDetailsQuery = (clanURI: string, machineName: string) =>
useQuery<MachineDetails>(() => ({
export const useMachineDetailsQuery = (
clanURI: string,
machineName: string,
) => {
const client = useApiClient();
return useQuery<MachineDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName],
queryFn: async () => {
const call = callApi("get_machine_details", {
const call = client.fetch("get_machine_details", {
machine: {
name: machineName,
flake: {
@@ -73,12 +82,14 @@ export const useMachineDetailsQuery = (clanURI: string, machineName: string) =>
return result.data;
},
}));
};
export const useClanDetailsQuery = (clanURI: string) =>
useQuery<ClanDetails>(() => ({
export const useClanDetailsQuery = (clanURI: string) => {
const client = useApiClient();
return useQuery<ClanDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "details"],
queryFn: async () => {
const call = callApi("get_clan_details", {
const call = client.fetch("get_clan_details", {
flake: {
identifier: clanURI,
},
@@ -97,14 +108,16 @@ export const useClanDetailsQuery = (clanURI: string) =>
};
},
}));
};
export const useClanListQuery = (clanURIs: string[]): ClanListQueryResult =>
useQueries(() => ({
export const useClanListQuery = (clanURIs: string[]): ClanListQueryResult => {
const client = useApiClient();
return useQueries(() => ({
queries: clanURIs.map((clanURI) => ({
queryKey: ["clans", encodeBase64(clanURI), "details"],
enabled: !!clanURI,
queryFn: async () => {
const call = callApi("get_clan_details", {
const call = client.fetch("get_clan_details", {
flake: {
identifier: clanURI,
},
@@ -124,3 +137,54 @@ export const useClanListQuery = (clanURIs: string[]): ClanListQueryResult =>
},
})),
}));
};
export type MachineFlashOptions = SuccessData<"get_machine_flash_options">;
export type MachineFlashOptionsQuery = UseQueryResult<MachineFlashOptions>;
export const useMachineFlashOptions = (
clanURI: string,
): MachineFlashOptionsQuery => {
const client = useApiClient();
return useQuery<MachineFlashOptions>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine_flash_options"],
queryFn: async () => {
const call = client.fetch("get_machine_flash_options", {
flake: {
identifier: clanURI,
},
});
const result = await call.result;
if (result.status === "error") {
// todo should we create some specific error types?
console.error("Error fetching clan details:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data;
},
}));
};
export type SystemStorageOptions = SuccessData<"list_system_storage_devices">;
export type SystemStorageOptionsQuery = UseQueryResult<SystemStorageOptions>;
export const useSystemStorageOptions = (): SystemStorageOptionsQuery => {
const client = useApiClient();
return useQuery<SystemStorageOptions>(() => ({
queryKey: ["system", "storage_devices"],
queryFn: async () => {
const call = client.fetch("list_system_storage_devices", {});
const result = await call.result;
if (result.status === "error") {
// todo should we create some specific error types?
console.error("Error fetching clan details:", result.errors);
throw new Error(result.errors[0].message);
}
return result.data;
},
}));
};

View File

@@ -6,6 +6,7 @@ import {
Setter,
useContext,
} from "solid-js";
import { createStore, SetStoreFunction, Store } from "solid-js/store";
export interface StepBase {
id: string;
@@ -13,24 +14,39 @@ export interface StepBase {
export type Step<ExtraFields = unknown> = StepBase & ExtraFields;
export interface StepOptions<Id> {
export interface StepOptions<Id, StoreType> {
initialStep: Id;
initialStoreData?: StoreType;
}
export function createStepper<
T extends readonly Step<Extra>[],
StepId extends T[number]["id"],
Extra = unknown,
>(s: { steps: T }, stepOpts: StepOptions<StepId>): StepperReturn<T> {
StoreType extends Record<string, unknown> = Record<string, unknown>,
>(
s: { steps: T },
stepOpts: StepOptions<StepId, StoreType>,
): StepperReturn<T, T[number]["id"]> {
const [activeStep, setActiveStep] = createSignal<T[number]["id"]>(
stepOpts.initialStep,
);
const store: StoreTuple<StoreType> = createStore<StoreType>(
stepOpts.initialStoreData ?? ({} as StoreType),
);
/**
* Hooks to manage the current step in the workflow.
* It provides the active step and a function to set the active step.
*/
return {
/**
* Usage store = getStepStore<MyStoreType>(stepper);
*
* TODO: Getting type inference working is tricky. Might fix this later.
*/
_store: store as unknown as never,
activeStep,
setActiveStep,
currentStep: () => {
@@ -73,10 +89,12 @@ export function createStepper<
};
}
type StoreTuple<T> = [get: Store<T>, set: SetStoreFunction<T>];
export interface StepperReturn<
T extends readonly Step[],
StepId = T[number]["id"],
> {
_store: never;
activeStep: Accessor<StepId>;
setActiveStep: Setter<StepId>;
currentStep: () => T[number];
@@ -125,3 +143,10 @@ export function StepperProvider<
export function defineSteps<T extends readonly StepBase[]>(steps: T) {
return steps;
}
interface getStepStoreArg {
_store: never;
}
export function getStepStore<StoreType>(stepper: getStepStoreArg) {
return stepper._store as StoreTuple<StoreType>;
}

View File

@@ -7,6 +7,8 @@ import { Routes } from "@/src/routes";
import { Router } from "@solidjs/router";
import { Layout } from "@/src/routes/Layout";
import { SolidQueryDevtools } from "@tanstack/solid-query-devtools";
import { ApiClientProvider } from "./hooks/ApiClient";
import { callApi } from "./hooks/api";
export const client = new QueryClient();
@@ -23,10 +25,12 @@ if (import.meta.env.DEV) {
render(
() => (
<QueryClientProvider client={client}>
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
<Router root={Layout}>{Routes}</Router>
</QueryClientProvider>
<ApiClientProvider client={{ fetch: callApi }}>
<QueryClientProvider client={client}>
{import.meta.env.DEV && <SolidQueryDevtools initialIsOpen={true} />}
<Router root={Layout}>{Routes}</Router>
</QueryClientProvider>
</ApiClientProvider>
),
root!,
);

View File

@@ -1,14 +1,105 @@
import type { Meta, StoryObj } from "@kachurun/storybook-solid";
import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid";
import { InstallModal } from "./install";
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";
const meta: Meta = {
type ResultDataMap = {
[K in OperationNames]: SuccessQuery<K>["data"];
};
export const mockFetcher: Fetcher = <K extends OperationNames>(
name: K,
_args: unknown,
): ApiCall<K> => {
// TODO: Make this configurable for every story
const resultData: Partial<ResultDataMap> = {
get_machine_flash_options: {
keymaps: ["DE_de", "US_en"],
languages: ["en", "de"],
},
get_system_file: ["id_rsa.pub"],
list_system_storage_devices: {
blockdevices: [
{
name: "sda_bla_bla",
path: "/dev/sda",
},
{
name: "sdb_foo_foo",
path: "/dev/sdb",
},
] as SuccessQuery<"list_system_storage_devices">["data"]["blockdevices"],
},
};
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 InstallModal> = {
title: "workflows/install",
component: InstallModal,
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;
type Story = StoryObj<typeof InstallModal>;
export const Init: Story = {
description: "Welcome step for the installation workflow",

View File

@@ -50,6 +50,14 @@ const steps = [
] as const;
export type InstallSteps = typeof steps;
export interface InstallStoreType {
flash: {
language: string;
keymap: string;
ssh_file: string;
device: string;
};
}
export const InstallModal = (props: InstallModalProps) => {
const stepper = createStepper(

View File

@@ -1,8 +1,7 @@
import { defineSteps, Step, StepBase, useStepper } from "@/src/hooks/stepper";
import { defineSteps, useStepper } from "@/src/hooks/stepper";
import { InstallSteps } from "../install";
import { Typography } from "@/src/components/Typography/Typography";
import { Button } from "@/src/components/Button/Button";
import { Divider } from "@/src/components/Divider/Divider";
import { StepLayout } from "../../Steps";
const ChoiceLocalOrRemote = () => {
@@ -10,8 +9,8 @@ const ChoiceLocalOrRemote = () => {
return (
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2">
<div class="flex gap-2 justify-between">
<div class="flex flex-col gap-1 px-1 justify-center">
<div class="flex justify-between gap-2">
<div class="flex flex-col justify-center gap-1 px-1">
<Typography
hierarchy="label"
size="xs"
@@ -31,8 +30,8 @@ const ChoiceLocalOrRemote = () => {
</div>
</div>
<div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2">
<div class="flex gap-2 justify-between">
<div class="flex flex-col gap-1 px-1 justify-center">
<div class="flex justify-between gap-2">
<div class="flex flex-col justify-center gap-1 px-1">
<Typography
hierarchy="label"
size="xs"
@@ -62,8 +61,8 @@ const ChoiceLocalInstaller = () => {
body={
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2">
<div class="flex gap-2 justify-between">
<div class="flex flex-col gap-1 px-1 justify-center">
<div class="flex justify-between gap-2">
<div class="flex flex-col justify-center gap-1 px-1">
<Typography
hierarchy="label"
size="xs"
@@ -83,8 +82,8 @@ const ChoiceLocalInstaller = () => {
</div>
</div>
<div class="flex flex-col gap-6 rounded-md px-4 py-6 text-fg-def-1 bg-def-2">
<div class="flex gap-2 justify-between">
<div class="flex flex-col gap-1 px-1 justify-center">
<div class="flex justify-between gap-2">
<div class="flex flex-col justify-center gap-1 px-1">
<Typography
hierarchy="label"
size="xs"

View File

@@ -1,4 +1,4 @@
import { useStepper } from "@/src/hooks/stepper";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import {
createForm,
getError,
@@ -6,8 +6,7 @@ import {
valiForm,
} from "@modular-forms/solid";
import * as v from "valibot";
import { InstallSteps } from "../install";
import { callApi } from "@/src/hooks/api";
import { InstallSteps, InstallStoreType } from "../install";
import { Fieldset } from "@/src/components/Form/Fieldset";
import { HostFileInput } from "@/src/components/Form/HostFileInput";
import { Select } from "@/src/components/Select/Select";
@@ -17,6 +16,12 @@ import { Alert } from "@/src/components/Alert/Alert";
import { LoadingBar } from "@/src/components/LoadingBar/LoadingBar";
import { Button } from "@/src/components/Button/Button";
import Icon from "@/src/components/Icon/Icon";
import {
useMachineFlashOptions,
useSystemStorageOptions,
} from "@/src/hooks/queries";
import { useClanURI } from "@/src/hooks/clan";
import { useApiClient } from "@/src/hooks/ApiClient";
const Prose = () => (
<StepLayout
@@ -47,7 +52,7 @@ const Prose = () => (
</Typography>
</div>
</div>
<div class="flex flex-col px-4 gap-4">
<div class="flex flex-col gap-4 px-4">
<div class="flex flex-col gap-1">
<Typography hierarchy="body" size="default" weight="bold">
Let's walk through it.
@@ -101,15 +106,23 @@ const ConfigureImage = () => {
});
const stepSignal = useStepper<InstallSteps>();
// TODO: push values to the parent form Store
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
const handleSubmit: SubmitHandler<ConfigureImageForm> = (values, event) => {
console.log("ISO creation submitted", values);
// Here you would typically trigger the ISO creation process
// Push values to the store
set("flash", (s) => ({
...s,
language: values.language,
keymap: values.keymap,
ssh_file: values.ssh_key,
}));
stepSignal.next();
};
const client = useApiClient();
const onSelectFile = async () => {
const req = callApi("get_system_file", {
const req = client.fetch("get_system_file", {
file_request: {
mode: "select_folder",
title: "Select a folder for you new Clan",
@@ -131,6 +144,9 @@ const ConfigureImage = () => {
throw new Error("No data returned from api call");
};
const currClan = useClanURI();
const optionsQuery = useMachineFlashOptions(currClan);
return (
<Form onSubmit={handleSubmit}>
<StepLayout
@@ -168,10 +184,19 @@ const ConfigureImage = () => {
label: "Language",
description: "Select your preferred language",
}}
options={[
{ value: "en", label: "English" },
{ value: "fr", label: "Français" },
]}
getOptions={async () => {
if (!optionsQuery.data) {
await optionsQuery.refetch();
}
return (optionsQuery.data?.languages ?? []).map(
(lang) => ({
// TODO: Pretty label ?
value: lang,
label: lang,
}),
);
}}
placeholder="Language"
name={field.name}
/>
@@ -188,10 +213,19 @@ const ConfigureImage = () => {
label: "Keymap",
description: "Select your keyboard layout",
}}
options={[
{ value: "EN_US", label: "QWERTY" },
{ value: "DE_DE", label: "QWERTZ" },
]}
getOptions={async () => {
if (!optionsQuery.data) {
await optionsQuery.refetch();
}
return (optionsQuery.data?.keymaps ?? []).map(
(keymap) => ({
// TODO: Pretty label ?
value: keymap,
label: keymap,
}),
);
}}
placeholder="Keymap"
name={field.name}
/>
@@ -226,9 +260,34 @@ const ChooseDisk = () => {
const [formStore, { Form, Field }] = createForm<ChooseDiskForm>({
validate: valiForm(ChooseDiskSchema),
});
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
const client = useApiClient();
const systemStorageQuery = useSystemStorageOptions();
const handleSubmit: SubmitHandler<ChooseDiskForm> = (values, event) => {
console.log("Disk selected", values);
// Just for completeness, set the disk in the store
console.log("Flashing", store.flash);
set("flash", (s) => ({
...s,
device: values.disk,
}));
const call = client.fetch("run_machine_flash", {
system_config: {
keymap: store.flash.keymap,
language: store.flash.language,
ssh_keys_path: [store.flash.ssh_file],
},
disks: [
{
name: "main",
device: values.disk,
},
],
});
// TOOD: Pass the "call" Promise to the "progress step"
console.log("Flashing", store.flash);
// Here you would typically trigger the disk selection process
stepSignal.next();
};
@@ -249,10 +308,18 @@ const ChooseDisk = () => {
label: "USB Stick",
description: "Select the usb stick",
}}
options={[
{ value: "1", label: "sda1" },
{ value: "2", label: "sdb2" },
]}
getOptions={async () => {
if (!systemStorageQuery.data) {
await systemStorageQuery.refetch();
}
return (systemStorageQuery.data?.blockdevices ?? []).map(
(dev) => ({
value: dev.path,
label: dev.name,
}),
);
}}
placeholder="Choose Device"
name={field.name}
/>

View File

@@ -84,8 +84,8 @@ def flash_command(args: argparse.Namespace) -> None:
return
run_machine_flash(
machine,
mode=opts.mode,
machine=machine,
mode=opts.mode, # type: ignore
disks=opts.disks,
system_config=opts.system_config,
dry_run=opts.dry_run,

View File

@@ -56,6 +56,13 @@ def find_toplevel(top_level_files: list[str]) -> Path | None:
return None
def clan_core_flake() -> Path:
"""
Returns the path to the clan core flake.
"""
return module_root().parent.parent.parent
class TemplateType(Enum):
CLAN = "clan"
DISK = "disk"

View File

@@ -4,7 +4,7 @@ import os
from dataclasses import dataclass, field
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any
from typing import Any, Literal
from clan_cli.facts.generate import generate_facts
from clan_cli.vars.generate import generate_vars
@@ -12,7 +12,9 @@ from clan_cli.vars.upload import populate_secret_vars
from clan_lib.api import API
from clan_lib.cmd import Log, RunOpts, cmd_with_root, run
from clan_lib.dirs import clan_core_flake
from clan_lib.errors import ClanError
from clan_lib.flake.flake import Flake
from clan_lib.machines.machines import Machine
from clan_lib.nix import nix_shell
@@ -35,17 +37,20 @@ class Disk:
device: str
installer_machine = Machine(name="flash-installer", flake=Flake(str(clan_core_flake())))
# TODO: unify this with machine install
@API.register
def run_machine_flash(
machine: Machine,
*,
mode: str,
disks: list[Disk],
system_config: SystemConfig,
dry_run: bool,
write_efi_boot_entries: bool,
debug: bool,
# Optional parameters
machine: Machine = installer_machine,
mode: Literal["format", "mount"] = "format",
dry_run: bool = False,
write_efi_boot_entries: bool = False,
debug: bool = False,
extra_args: list[str] | None = None,
graphical: bool = False,
) -> None: