Merge pull request 'UI: Resolve some more install blockers' (#4657) from feat-ui into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4657
This commit is contained in:
hsjobeki
2025-08-09 18:12:35 +00:00
7 changed files with 301 additions and 101 deletions

View File

@@ -260,3 +260,39 @@ export const useMachineDiskSchemas = (
},
}));
};
export type MachineGenerators = SuccessData<"get_generators">;
export type MachineGeneratorsQuery = UseQueryResult<MachineGenerators>;
export const useMachineGenerators = (
clanUri: string,
machineName: string,
): MachineGeneratorsQuery => {
const client = useApiClient();
return useQuery<MachineGenerators>(() => ({
queryKey: [
"clans",
encodeBase64(clanUri),
"machines",
machineName,
"generators",
],
queryFn: async () => {
const call = client.fetch("get_generators", {
base_dir: clanUri,
machine_name: machineName,
// TODO: Make this configurable
include_previous_values: true,
});
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

@@ -33,6 +33,7 @@ import * as v from "valibot";
import { HostFileInput } from "@/src/components/Form/HostFileInput";
import { callApi } from "@/src/hooks/api";
import { Creating } from "./Creating";
import { useApiClient } from "@/src/hooks/ApiClient";
type State = "welcome" | "setup" | "creating";
@@ -208,13 +209,15 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
throw new Error("No data returned from api call");
};
const client = useApiClient();
const onSubmit: SubmitHandler<SetupForm> = async (
{ name, description, directory },
event,
) => {
const path = `${directory}/${name}`;
const req = callApi("create_clan", {
const req = client.fetch("create_clan", {
opts: {
dest: path,
// todo allow users to select a template
@@ -230,6 +233,24 @@ export const Onboarding: Component<RouteSectionProps> = (props) => {
const resp = await req.result;
// Set up default services
await client.fetch("create_service_instance", {
flake: {
identifier: path,
},
module_ref: {
name: "admin",
input: "clan-core",
},
roles: {
default: {
tags: {
all: {},
},
},
},
}).result;
if (resp.status === "error") {
setWelcomeError(resp.errors[0].message);
setState("welcome");

View File

@@ -66,8 +66,12 @@ export interface InstallStoreType {
mainDisk: string;
// ...TODO Vars
progress: ApiCall<"run_machine_install">;
promptValues: PromptValues;
};
done: () => void;
}
export type PromptValues = Record<string, Record<string, string>>;
export const InstallModal = (props: InstallModalProps) => {
const stepper = createStepper(

View File

@@ -9,10 +9,10 @@ import {
import { Fieldset } from "@/src/components/Form/Fieldset";
import * as v from "valibot";
import { getStepStore, useStepper } from "@/src/hooks/stepper";
import { InstallSteps, InstallStoreType } from "../install";
import { InstallSteps, InstallStoreType, PromptValues } from "../install";
import { TextInput } from "@/src/components/Form/TextInput";
import { Alert } from "@/src/components/Alert/Alert";
import { onMount, Show } from "solid-js";
import { For, onMount, Show } from "solid-js";
import { Divider } from "@/src/components/Divider/Divider";
import { Orienter } from "@/src/components/Form/Orienter";
import { Button } from "@/src/components/Button/Button";
@@ -20,7 +20,9 @@ import { Select } from "@/src/components/Select/Select";
import { LoadingBar } from "@/src/components/LoadingBar/LoadingBar";
import Icon from "@/src/components/Icon/Icon";
import {
MachineGenerators,
useMachineDiskSchemas,
useMachineGenerators,
useMachineHardwareSummary,
} from "@/src/hooks/queries";
import { useClanURI } from "@/src/hooks/clan";
@@ -54,8 +56,13 @@ const ConfigureAddress = () => {
},
});
const client = useApiClient();
const clanUri = useClanURI();
// TODO: push values to the parent form Store
const handleSubmit: SubmitHandler<ConfigureAdressForm> = (values, event) => {
const handleSubmit: SubmitHandler<ConfigureAdressForm> = async (
values,
event,
) => {
console.log("targetHost set", values);
set("install", (s) => ({ ...s, targetHost: values.targetHost }));
@@ -161,8 +168,8 @@ const CheckHardware = () => {
<Show when={hardwareQuery.data}>
{(d) => (
<Alert
icon="Checkmark"
type={reportExists() ? "warning" : "info"}
icon={reportExists() ? "Checkmark" : "Close"}
type={reportExists() ? "info" : "warning"}
title={
reportExists()
? "Hardware report exists"
@@ -180,7 +187,7 @@ const CheckHardware = () => {
<NextButton
type="button"
onClick={handleNext}
disabled={!reportExists()}
disabled={hardwareQuery.isLoading || !reportExists()}
>
Next
</NextButton>
@@ -274,16 +281,85 @@ const ConfigureDisk = () => {
);
};
type DynamicForm = Record<string, string>;
const ConfigureData = () => {
const [formStore, { Form, Field }] = createForm<DynamicForm>({
// TODO: Dynamically validate fields
});
const stepSignal = useStepper<InstallSteps>();
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
const handleSubmit: SubmitHandler<DynamicForm> = (values, event) => {
const generatorsQuery = useMachineGenerators(
useClanURI(),
store.install.machineName,
);
return (
<>
<Show when={generatorsQuery.isLoading}>
Checking credentials & data...
</Show>
<Show when={generatorsQuery.data}>
{(generators) => <PromptsFields generators={generators()} />}
</Show>
</>
);
};
type PromptGroup = {
name: string;
fields: {
prompt: Prompt;
generator: string;
value?: string | null;
}[];
};
type Prompt = NonNullable<MachineGenerators[number]["prompts"]>[number];
type PromptForm = {
promptValues: PromptValues;
};
interface PromptsFieldsProps {
generators: MachineGenerators;
}
const PromptsFields = (props: PromptsFieldsProps) => {
const stepSignal = useStepper<InstallSteps>();
const [store, set] = getStepStore<InstallStoreType>(stepSignal);
const groupsObj: Record<string, PromptGroup> = props.generators.reduce(
(acc, generator) => {
if (!generator.prompts) {
return acc;
}
for (const prompt of generator.prompts) {
const groupName = (
prompt.display?.group || generator.name
).toUpperCase();
if (!acc[groupName]) acc[groupName] = { fields: [], name: groupName };
acc[groupName].fields.push({
prompt,
generator: generator.name,
value: prompt.previous_value,
});
}
return acc;
},
{} as Record<string, PromptGroup>,
);
const groups = Object.values(groupsObj);
const [formStore, { Form, Field }] = createForm<PromptForm>({
initialValues: {
promptValues: store.install?.promptValues || {},
},
});
console.log(groups);
const handleSubmit: SubmitHandler<PromptForm> = (values, event) => {
console.log("vars submitted", values);
set("install", (s) => ({ ...s, promptValues: values.promptValues }));
console.log("vars preloaded");
// Here you would typically trigger the ISO creation process
stepSignal.next();
};
@@ -293,68 +369,49 @@ const ConfigureData = () => {
<StepLayout
body={
<div class="flex flex-col gap-2">
<Fieldset legend="Root password">
<Field name="root-password">
{(field, props) => (
<TextInput
{...field}
label="Root password"
description="Leave empty to generate automatically"
value={field.value}
required
orientation="horizontal"
validationState={
getError(formStore, "root-password") ? "invalid" : "valid"
}
icon="EyeClose"
input={{
...props,
type: "password",
}}
/>
)}
</Field>
</Fieldset>
<Fieldset legend="WIFI TU-YAN">
<Field name="networkSSID">
{(field, props) => (
<TextInput
{...field}
label="ssid"
description="Name of the wifi network"
value={field.value}
required
orientation="horizontal"
validationState={
getError(formStore, "wifi/password") ? "invalid" : "valid"
}
input={{
...props,
}}
/>
)}
</Field>
<Field name="password">
{(field, props) => (
<TextInput
{...field}
label="password"
description="Password for the wifi network"
value={field.value}
required
orientation="horizontal"
validationState={
getError(formStore, "wifi/password") ? "invalid" : "valid"
}
icon="EyeClose"
input={{
...props,
type: "password",
}}
/>
)}
</Field>
</Fieldset>
<For each={groups}>
{(group) => (
<Fieldset legend={group.name}>
<For each={group.fields}>
{(fieldInfo) => (
<Field
name={`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`}
>
{(f, props) => (
<TextInput
{...f}
label={
fieldInfo.prompt.display?.label ||
fieldInfo.prompt.name
}
description={fieldInfo.prompt.description}
value={f.value || fieldInfo.value || ""}
required={fieldInfo.prompt.display?.required}
orientation="horizontal"
validationState={
getError(
formStore,
`promptValues.${fieldInfo.generator}.${fieldInfo.prompt.name}`,
)
? "invalid"
: "valid"
}
input={{
type: fieldInfo.prompt.prompt_type.includes(
"hidden",
)
? "password"
: "text",
...props,
}}
/>
)}
</Field>
)}
</For>
</Fieldset>
)}
</For>
</div>
}
footer={
@@ -403,6 +460,7 @@ const InstallSummary = () => {
placeholders: {
mainDisk: store.install.mainDisk,
},
force: true,
});
const diskResult = await setDisk.result; // Wait for the disk schema to be set
@@ -411,6 +469,15 @@ const InstallSummary = () => {
return;
}
const runGenerators = client.fetch("run_generators", {
all_prompt_values: store.install.promptValues,
base_dir: clanUri,
machine_name: store.install.machineName,
});
stepSignal.setActiveStep("install:progress");
await runGenerators.result; // Wait for the generators to run
const runInstall = client.fetch("run_machine_install", {
opts: {
machine: {
@@ -429,7 +496,9 @@ const InstallSummary = () => {
progress: runInstall,
}));
stepSignal.setActiveStep("install:progress");
await runInstall.result; // Wait for the installation to finish
stepSignal.setActiveStep("install:done");
};
return (
<StepLayout
@@ -468,17 +537,6 @@ const InstallProgress = () => {
const stepSignal = useStepper<InstallSteps>();
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
onMount(async () => {
if (store.install.progress) {
const result = await store.install.progress.result;
if (result.status === "error") {
console.error("Error during installation:", result.errors);
}
stepSignal.setActiveStep("install:done");
}
});
return (
<div class="flex h-60 w-full flex-col items-center justify-end bg-inv-4">
<div class="mb-6 flex w-full max-w-md flex-col items-center gap-3 fg-inv-1">
@@ -501,6 +559,8 @@ const InstallProgress = () => {
const FlashDone = () => {
const stepSignal = useStepper<InstallSteps>();
const [store, get] = getStepStore<InstallStoreType>(stepSignal);
return (
<div class="flex w-full flex-col items-center bg-inv-4">
<div class="flex w-full max-w-md flex-col items-center gap-3 py-6 fg-inv-1">
@@ -520,7 +580,7 @@ const FlashDone = () => {
hierarchy="primary"
endIcon="Close"
size="s"
onClick={() => stepSignal.next()}
onClick={() => store.done()}
>
Close
</Button>

View File

@@ -504,9 +504,9 @@ def _generate_vars_for_machine(
@API.register
def run_generators(
machine_name: str,
generators: list[str],
all_prompt_values: dict[str, dict[str, str]],
base_dir: Path,
generators: list[str] | None = None,
no_sandbox: bool = False,
) -> bool:
"""Run the specified generators for a machine.
@@ -526,16 +526,20 @@ def run_generators(
from clan_lib.machines.machines import Machine
machine = Machine(name=machine_name, flake=Flake(str(base_dir)))
generators_set = set(generators)
generators_ = [
g
for g in Generator.get_machine_generators(machine_name, machine.flake)
if g.name in generators_set
]
if not generators:
filtered_generators = Generator.get_machine_generators(
machine_name, machine.flake
)
else:
generators_set = set(generators)
filtered_generators = [
g
for g in Generator.get_machine_generators(machine_name, machine.flake)
if g.name in generators_set
]
return _generate_vars_for_machine(
machine=machine,
generators=generators_,
generators=filtered_generators,
all_prompt_values=all_prompt_values,
no_sandbox=no_sandbox,
)

View File

@@ -3,9 +3,9 @@ import logging
import sys
import termios
import tty
from dataclasses import dataclass
from dataclasses import dataclass, field
from getpass import getpass
from typing import Any
from typing import Any, TypedDict
from clan_lib.errors import ClanError
@@ -22,6 +22,13 @@ class PromptType(enum.Enum):
MULTILINE_HIDDEN = "multiline-hidden"
class Display(TypedDict):
label: str | None
group: str | None
helperText: str | None
required: bool
@dataclass
class Prompt:
name: str
@@ -30,6 +37,16 @@ class Prompt:
persist: bool = False
previous_value: str | None = None
display: Display = field(
default_factory=lambda: Display(
{
"label": None,
"group": None,
"helperText": None,
"required": False,
}
)
)
@classmethod
def from_nix(cls: type["Prompt"], data: dict[str, Any]) -> "Prompt":
@@ -38,6 +55,7 @@ class Prompt:
description=data.get("description", data["name"]),
prompt_type=PromptType(data.get("type", "line")),
persist=data.get("persist", False),
display=data.get("display", {}),
)

View File

@@ -6,8 +6,14 @@ from typing import Any, TypedDict
from clan_lib.api import API
from clan_lib.errors import ClanError
from clan_lib.flake import Flake
from clan_lib.nix_models.clan import InventoryInstanceModuleType
from clan_lib.flake.flake import Flake
from clan_lib.nix_models.clan import (
InventoryInstance,
InventoryInstanceModuleType,
InventoryInstanceRolesType,
)
from clan_lib.persist.inventory_store import InventoryStore
from clan_lib.persist.util import set_value_by_path
class CategoryInfo(TypedDict):
@@ -246,6 +252,57 @@ def get_service_module_schema(
)
@API.register
def create_service_instance(
flake: Flake,
module_ref: InventoryInstanceModuleType,
roles: InventoryInstanceRolesType,
) -> None:
"""
Show information about a module
"""
input_name, module_name = check_service_module_ref(flake, module_ref)
inventory_store = InventoryStore(flake)
inventory = inventory_store.read()
# TODO: Multiple instances support
instance_name = module_name
curr_instances = inventory.get("instances", {})
if instance_name in curr_instances:
msg = f"Instance '{instance_name}' already exists in the inventory"
raise ClanError(msg)
# TODO: Check the roles against the schema
schema = get_service_module_schema(flake, module_ref)
for role_name, _role in roles.items():
if role_name not in schema:
msg = f"Role '{role_name}' is not defined in the module schema"
raise ClanError(msg)
# TODO: Validate roles against the schema
# Create a new instance with the given roles
new_instance: InventoryInstance = {
"module": {
"name": module_name,
"input": input_name,
},
"roles": roles,
}
set_value_by_path(inventory, f"instances.{instance_name}", new_instance)
inventory_store.write(
inventory,
message=f"Add service instance '{instance_name}' with module '{module_name} from {input_name}'",
commit=True,
)
return
@dataclass
class LegacyModuleInfo:
description: str