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:
@@ -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;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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", {}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user