Merge pull request 'GUI: initialize support for vars prompts' (#3529) from DavHau/clan-core:gui-prompts into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/3529
This commit is contained in:
DavHau
2025-05-07 11:15:32 +00:00
5 changed files with 214 additions and 64 deletions

View File

@@ -12,7 +12,12 @@ from clan_cli.tests.age_keys import SopsSetup
from clan_cli.tests.fixtures_flakes import ClanFlake from clan_cli.tests.fixtures_flakes import ClanFlake
from clan_cli.tests.helpers import cli from clan_cli.tests.helpers import cli
from clan_cli.vars.check import check_vars from clan_cli.vars.check import check_vars
from clan_cli.vars.generate import Generator, generate_vars_for_machine_interactive from clan_cli.vars.generate import (
Generator,
generate_vars_for_machine,
generate_vars_for_machine_interactive,
get_generators_closure,
)
from clan_cli.vars.get import get_var from clan_cli.vars.get import get_var
from clan_cli.vars.graph import all_missing_closure, requested_closure from clan_cli.vars.graph import all_missing_closure, requested_closure
from clan_cli.vars.list import stringify_all_vars from clan_cli.vars.list import stringify_all_vars
@@ -640,9 +645,6 @@ def test_api_set_prompts(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
flake: ClanFlake, flake: ClanFlake,
) -> None: ) -> None:
from clan_cli.vars._types import GeneratorUpdate
from clan_cli.vars.list import get_generators, set_prompts
config = flake.machines["my_machine"] config = flake.machines["my_machine"]
config["nixpkgs"]["hostPlatform"] = "x86_64-linux" config["nixpkgs"]["hostPlatform"] = "x86_64-linux"
my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"] my_generator = config["clan"]["core"]["vars"]["generators"]["my_generator"]
@@ -652,33 +654,39 @@ def test_api_set_prompts(
flake.refresh() flake.refresh()
monkeypatch.chdir(flake.path) monkeypatch.chdir(flake.path)
params = {"machine_name": "my_machine", "base_dir": str(flake.path)}
set_prompts( generate_vars_for_machine(
**params, machine_name="my_machine",
updates=[ base_dir=flake.path,
GeneratorUpdate( generators=["my_generator"],
generator="my_generator", all_prompt_values={
prompt_values={"prompt1": "input1"}, "my_generator": {
) "prompt1": "input1",
], }
},
) )
machine = Machine(name="my_machine", flake=Flake(str(flake.path))) machine = Machine(name="my_machine", flake=Flake(str(flake.path)))
store = in_repo.FactStore(machine) store = in_repo.FactStore(machine)
assert store.exists(Generator("my_generator"), "prompt1") assert store.exists(Generator("my_generator"), "prompt1")
assert store.get(Generator("my_generator"), "prompt1").decode() == "input1" assert store.get(Generator("my_generator"), "prompt1").decode() == "input1"
set_prompts( generate_vars_for_machine(
**params, machine_name="my_machine",
updates=[ base_dir=flake.path,
GeneratorUpdate( generators=["my_generator"],
generator="my_generator", all_prompt_values={
prompt_values={"prompt1": "input2"}, "my_generator": {
) "prompt1": "input2",
], }
},
) )
assert store.get(Generator("my_generator"), "prompt1").decode() == "input2" assert store.get(Generator("my_generator"), "prompt1").decode() == "input2"
generators = get_generators(**params) generators = get_generators_closure(
machine_name="my_machine",
base_dir=flake.path,
regenerate=True,
include_previous_values=True,
)
assert len(generators) == 1 assert len(generators) == 1
assert generators[0].name == "my_generator" assert generators[0].name == "my_generator"
assert generators[0].prompts[0].name == "prompt1" assert generators[0].prompts[0].name == "prompt1"

View File

@@ -294,10 +294,28 @@ def _ask_prompts(
return prompt_values return prompt_values
def _get_previous_value(
machine: "Machine",
generator: Generator,
prompt: Prompt,
) -> str | None:
if not prompt.persist:
return None
pub_store = machine.public_vars_store
if pub_store.exists(generator, prompt.name):
return pub_store.get(generator, prompt.name).decode()
sec_store = machine.secret_vars_store
if sec_store.exists(generator, prompt.name):
return sec_store.get(generator, prompt.name).decode()
return None
def get_closure( def get_closure(
machine: "Machine", machine: "Machine",
generator_name: str | None, generator_name: str | None,
regenerate: bool, regenerate: bool,
include_previous_values: bool = False,
) -> list[Generator]: ) -> list[Generator]:
from .graph import all_missing_closure, full_closure from .graph import all_missing_closure, full_closure
@@ -310,14 +328,24 @@ def get_closure(
for generator in vars_generators: for generator in vars_generators:
generator.machine(machine) generator.machine(machine)
result_closure = []
if generator_name is None: # all generators selected if generator_name is None: # all generators selected
if regenerate: if regenerate:
return full_closure(generators) result_closure = full_closure(generators)
return all_missing_closure(generators) else:
result_closure = all_missing_closure(generators)
# specific generator selected # specific generator selected
if regenerate: elif regenerate:
return requested_closure([generator_name], generators) result_closure = requested_closure([generator_name], generators)
return minimal_closure([generator_name], generators) else:
result_closure = minimal_closure([generator_name], generators)
if include_previous_values:
for generator in result_closure:
for prompt in generator.prompts:
prompt.previous_value = _get_previous_value(machine, generator, prompt)
return result_closure
@API.register @API.register
@@ -325,6 +353,7 @@ def get_generators_closure(
machine_name: str, machine_name: str,
base_dir: Path, base_dir: Path,
regenerate: bool = False, regenerate: bool = False,
include_previous_values: bool = False,
) -> list[Generator]: ) -> list[Generator]:
from clan_cli.machines.machines import Machine from clan_cli.machines.machines import Machine
@@ -332,13 +361,14 @@ def get_generators_closure(
machine=Machine(name=machine_name, flake=Flake(str(base_dir))), machine=Machine(name=machine_name, flake=Flake(str(base_dir))),
generator_name=None, generator_name=None,
regenerate=regenerate, regenerate=regenerate,
include_previous_values=include_previous_values,
) )
def _generate_vars_for_machine( def _generate_vars_for_machine(
machine: "Machine", machine: "Machine",
generators: list[Generator], generators: list[Generator],
all_prompt_values: dict[str, dict], all_prompt_values: dict[str, dict[str, str]],
no_sandbox: bool = False, no_sandbox: bool = False,
) -> bool: ) -> bool:
for generator in generators: for generator in generators:
@@ -350,7 +380,7 @@ def _generate_vars_for_machine(
generator=generator, generator=generator,
secret_vars_store=machine.secret_vars_store, secret_vars_store=machine.secret_vars_store,
public_vars_store=machine.public_vars_store, public_vars_store=machine.public_vars_store,
prompt_values=all_prompt_values[generator.name], prompt_values=all_prompt_values.get(generator.name, {}),
no_sandbox=no_sandbox, no_sandbox=no_sandbox,
) )
return True return True

View File

@@ -21,6 +21,8 @@ import { ModuleDetails as AddModule } from "./routes/modules/add";
import { ApiTester } from "./api_test"; import { ApiTester } from "./api_test";
import { IconVariant } from "./components/icon"; import { IconVariant } from "./components/icon";
import { Components } from "./routes/components"; import { Components } from "./routes/components";
import { activeURI } from "./App";
import { VarsForMachine, VarsStep } from "./routes/machines/install/vars-step";
export const client = new QueryClient(); export const client = new QueryClient();
@@ -31,7 +33,6 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
); );
} }
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.log("Development mode"); console.log("Development mode");
// Load the debugger in development mode // Load the debugger in development mode
@@ -73,6 +74,12 @@ export const routes: AppRoute[] = [
hidden: true, hidden: true,
component: () => <MachineDetails />, component: () => <MachineDetails />,
}, },
{
path: "/:id/vars",
label: "Vars",
hidden: true,
component: () => <VarsForMachine />,
},
], ],
}, },
{ {

View File

@@ -11,9 +11,9 @@ import {
getValues, getValues,
setValue, setValue,
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import { useParams } from "@solidjs/router"; import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query"; import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { createSignal, For, Match, Show, Switch } from "solid-js"; import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js";
import toast from "solid-toast"; import toast from "solid-toast";
import { MachineAvatar } from "./avatar"; import { MachineAvatar } from "./avatar";
import { Header } from "@/src/layout/header"; import { Header } from "@/src/layout/header";
@@ -316,21 +316,7 @@ const InstallMachine = (props: InstallMachineProps) => {
/> />
</Match> </Match>
<Match when={step() === "3"}> <Match when={step() === "3"}>
<VarsStep <div>TODO: vars</div>
// @ts-expect-error: This cannot be undefined in this context.
machine_id={props.name}
// @ts-expect-error: This cannot be undefined in this context.
dir={activeURI()}
footer={<Footer />}
handleNext={(data) => {
// const prev = getValue(formStore, "2");
// setValue(formStore, "2", { ...prev, ...data });
handleNext();
}}
initial={{
...getValue(formStore, "3"),
}}
/>
</Match> </Match>
<Match when={step() === "4"}> <Match when={step() === "4"}>
<SummaryStep <SummaryStep
@@ -416,6 +402,10 @@ const MachineForm = (props: MachineDetailsProps) => {
const [installModalOpen, setInstallModalOpen] = createSignal(false); const [installModalOpen, setInstallModalOpen] = createSignal(false);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const handleSubmit = async (values: MachineFormInterface) => { const handleSubmit = async (values: MachineFormInterface) => {
console.log("submitting", values); console.log("submitting", values);
@@ -447,7 +437,40 @@ const MachineForm = (props: MachineDetailsProps) => {
return null; return null;
}; };
const generatorsQuery = createQuery(() => ({
queryKey: [activeURI(), machineName(), "generators"],
queryFn: async () => {
const machine_name = machineName();
const base_dir = activeURI();
if (!machine_name || !base_dir) {
return [];
}
const result = await callApi("get_generators_closure", {
base_dir: base_dir,
machine_name: machine_name,
});
if (result.status === "error") throw new Error("Failed to fetch data");
return result.data;
},
}));
const handleUpdateButton = async () => {
const t = toast.loading("Checking for generators...");
await generatorsQuery.refetch();
toast.dismiss(t);
if (generatorsQuery.data?.length !== 0) {
navigate(`/machines/${machineName()}/vars`);
} else {
handleUpdate();
}
};
const [isUpdating, setIsUpdating] = createSignal(false);
const handleUpdate = async () => { const handleUpdate = async () => {
if (isUpdating()) {
return;
}
const curr_uri = activeURI(); const curr_uri = activeURI();
if (!curr_uri) { if (!curr_uri) {
return; return;
@@ -461,6 +484,7 @@ const MachineForm = (props: MachineDetailsProps) => {
const target = targetHost(); const target = targetHost();
const loading_toast = toast.loading("Updating machine..."); const loading_toast = toast.loading("Updating machine...");
setIsUpdating(true);
const r = await callApi("update_machines", { const r = await callApi("update_machines", {
base_path: curr_uri, base_path: curr_uri,
machines: [ machines: [
@@ -472,6 +496,7 @@ const MachineForm = (props: MachineDetailsProps) => {
}, },
], ],
}); });
setIsUpdating(false);
toast.dismiss(loading_toast); toast.dismiss(loading_toast);
if (r.status === "error") { if (r.status === "error") {
@@ -481,6 +506,15 @@ const MachineForm = (props: MachineDetailsProps) => {
toast.success("Machine updated successfully"); toast.success("Machine updated successfully");
} }
}; };
createEffect(() => {
const action = searchParams.action;
if (action === "update") {
setSearchParams({ action: undefined });
handleUpdate();
}
});
return ( return (
<> <>
<div class="flex flex-col gap-6 p-4"> <div class="flex flex-col gap-6 p-4">
@@ -626,7 +660,7 @@ const MachineForm = (props: MachineDetailsProps) => {
<Button <Button
class="w-full" class="w-full"
// disabled={!online()} // disabled={!online()}
onClick={() => handleUpdate()} onClick={() => handleUpdateButton()}
endIcon={<Icon icon="Update" />} endIcon={<Icon icon="Update" />}
> >
Update Update

View File

@@ -5,31 +5,73 @@ import {
validate, validate,
FieldValues, FieldValues,
} from "@modular-forms/solid"; } from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query"; import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { StepProps } from "./hardware-step";
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 { For, Match, Show, Switch } from "solid-js"; import { For, Match, Show, Switch } from "solid-js";
import { TextInput } from "@/src/Form/fields";
import toast from "solid-toast";
import { useNavigate, useParams } from "@solidjs/router";
import { activeURI } from "@/src/App";
export type VarsValues = FieldValues & Record<string, string>; export type VarsValues = FieldValues & Record<string, Record<string, string>>;
export const VarsStep = (props: StepProps<VarsValues>) => { export interface VarsStepProps {
const [formStore, { Form, Field }] = createForm<VarsValues>({ machine_id: string;
initialValues: { ...props.initial, schema: "single-disk" }, dir: string;
}); }
export const VarsStep = (props: VarsStepProps) => {
const [formStore, { Form, Field }] = createForm<VarsValues>({});
const navigate = useNavigate();
const queryClient = useQueryClient();
const handleSubmit: SubmitHandler<VarsValues> = async (values, event) => { const handleSubmit: SubmitHandler<VarsValues> = async (values, event) => {
console.log("Submit Disk", { values }); console.log("Submit Disk", { values });
// sanitize the values back (replace __dot__)
// This hack is needed because we are using "." in the keys of the form
const sanitizedValues = Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key.replaceAll("__dot__", "."),
Object.fromEntries(
Object.entries(value).map(([k, v]) => [
k.replaceAll("__dot__", "."),
v,
]),
),
]),
) as VarsValues;
const valid = await validate(formStore); const valid = await validate(formStore);
console.log("Valid", valid); if (generatorsQuery.data === undefined) {
if (!valid) return; toast.error("Error fetching data");
props.handleNext(values); return;
}
const loading_toast = toast.loading("Generating vars...");
const result = await callApi("generate_vars_for_machine", {
machine_name: props.machine_id,
base_dir: props.dir,
generators: generatorsQuery.data.map((generator) => generator.name),
all_prompt_values: sanitizedValues,
});
queryClient.invalidateQueries({
queryKey: [props.dir, props.machine_id, "generators"],
});
toast.dismiss(loading_toast);
if (result.status === "error") {
toast.error(result.errors[0].message);
return;
}
if (result.status === "success") {
toast.success("Vars saved successfully");
navigate(`/machines/${props.machine_id}?action=update`);
}
}; };
const generatorsQuery = createQuery(() => ({ const generatorsQuery = createQuery(() => ({
queryKey: [props.dir, props.machine_id, "generators"], queryKey: [props.dir, props.machine_id, "generators"],
queryFn: async () => { queryFn: async () => {
const result = await callApi("get_generators", { const result = await callApi("get_generators_closure", {
base_dir: props.dir, base_dir: props.dir,
machine_name: props.machine_id, machine_name: props.machine_id,
}); });
@@ -61,14 +103,33 @@ export const VarsStep = (props: StepProps<VarsValues>) => {
{generator.share ? "True" : "False"} {generator.share ? "True" : "False"}
</div> </div>
<For each={generator.prompts}> <For each={generator.prompts}>
{(f) => ( {(prompt) => (
<Group> <Group>
<Typography hierarchy="label" size="s"> <Typography hierarchy="label" size="s">
{!f.previous_value ? "Required" : "Optional"} {!prompt.previous_value ? "Required" : "Optional"}
</Typography> </Typography>
<Typography hierarchy="label" size="s"> <Typography hierarchy="label" size="s">
{f.name} {prompt.name}
</Typography> </Typography>
{/* Avoid nesting issue in case of a "." */}
<Field
name={`${generator.name.replaceAll(".", "__dot__")}.${prompt.name.replaceAll(".", "__dot__")}`}
>
{(field, props) => (
<TextInput
inputProps={{
...props,
type:
prompt.prompt_type === "hidden"
? "password"
: "text",
}}
label={prompt.description}
value={prompt.previous_value ?? ""}
error={field.error}
/>
)}
</Field>
</Group> </Group>
)} )}
</For> </For>
@@ -80,7 +141,17 @@ export const VarsStep = (props: StepProps<VarsValues>) => {
</Switch> </Switch>
</div> </div>
</div> </div>
<Show when={generatorsQuery.isFetched}>{props.footer}</Show> <button type="submit">Submit</button>
</Form> </Form>
); );
}; };
export const VarsForMachine = () => {
const params = useParams();
return (
<Show when={activeURI()}>
{(uri) => <VarsStep machine_id={params.id} dir={uri()} />}
</Show>
);
};