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

View File

@@ -294,10 +294,28 @@ def _ask_prompts(
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(
machine: "Machine",
generator_name: str | None,
regenerate: bool,
include_previous_values: bool = False,
) -> list[Generator]:
from .graph import all_missing_closure, full_closure
@@ -310,14 +328,24 @@ def get_closure(
for generator in vars_generators:
generator.machine(machine)
result_closure = []
if generator_name is None: # all generators selected
if regenerate:
return full_closure(generators)
return all_missing_closure(generators)
result_closure = full_closure(generators)
else:
result_closure = all_missing_closure(generators)
# specific generator selected
if regenerate:
return requested_closure([generator_name], generators)
return minimal_closure([generator_name], generators)
elif regenerate:
result_closure = requested_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
@@ -325,6 +353,7 @@ def get_generators_closure(
machine_name: str,
base_dir: Path,
regenerate: bool = False,
include_previous_values: bool = False,
) -> list[Generator]:
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))),
generator_name=None,
regenerate=regenerate,
include_previous_values=include_previous_values,
)
def _generate_vars_for_machine(
machine: "Machine",
generators: list[Generator],
all_prompt_values: dict[str, dict],
all_prompt_values: dict[str, dict[str, str]],
no_sandbox: bool = False,
) -> bool:
for generator in generators:
@@ -350,7 +380,7 @@ def _generate_vars_for_machine(
generator=generator,
secret_vars_store=machine.secret_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,
)
return True

View File

@@ -21,6 +21,8 @@ import { ModuleDetails as AddModule } from "./routes/modules/add";
import { ApiTester } from "./api_test";
import { IconVariant } from "./components/icon";
import { Components } from "./routes/components";
import { activeURI } from "./App";
import { VarsForMachine, VarsStep } from "./routes/machines/install/vars-step";
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?",
);
}
if (import.meta.env.DEV) {
console.log("Development mode");
// Load the debugger in development mode
@@ -73,6 +74,12 @@ export const routes: AppRoute[] = [
hidden: true,
component: () => <MachineDetails />,
},
{
path: "/:id/vars",
label: "Vars",
hidden: true,
component: () => <VarsForMachine />,
},
],
},
{

View File

@@ -11,9 +11,9 @@ import {
getValues,
setValue,
} from "@modular-forms/solid";
import { useParams } from "@solidjs/router";
import { createQuery } from "@tanstack/solid-query";
import { createSignal, For, Match, Show, Switch } from "solid-js";
import { useNavigate, useParams, useSearchParams } from "@solidjs/router";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { createEffect, createSignal, For, Match, Show, Switch } from "solid-js";
import toast from "solid-toast";
import { MachineAvatar } from "./avatar";
import { Header } from "@/src/layout/header";
@@ -316,21 +316,7 @@ const InstallMachine = (props: InstallMachineProps) => {
/>
</Match>
<Match when={step() === "3"}>
<VarsStep
// @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"),
}}
/>
<div>TODO: vars</div>
</Match>
<Match when={step() === "4"}>
<SummaryStep
@@ -416,6 +402,10 @@ const MachineForm = (props: MachineDetailsProps) => {
const [installModalOpen, setInstallModalOpen] = createSignal(false);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const handleSubmit = async (values: MachineFormInterface) => {
console.log("submitting", values);
@@ -447,7 +437,40 @@ const MachineForm = (props: MachineDetailsProps) => {
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 () => {
if (isUpdating()) {
return;
}
const curr_uri = activeURI();
if (!curr_uri) {
return;
@@ -461,6 +484,7 @@ const MachineForm = (props: MachineDetailsProps) => {
const target = targetHost();
const loading_toast = toast.loading("Updating machine...");
setIsUpdating(true);
const r = await callApi("update_machines", {
base_path: curr_uri,
machines: [
@@ -472,6 +496,7 @@ const MachineForm = (props: MachineDetailsProps) => {
},
],
});
setIsUpdating(false);
toast.dismiss(loading_toast);
if (r.status === "error") {
@@ -481,6 +506,15 @@ const MachineForm = (props: MachineDetailsProps) => {
toast.success("Machine updated successfully");
}
};
createEffect(() => {
const action = searchParams.action;
if (action === "update") {
setSearchParams({ action: undefined });
handleUpdate();
}
});
return (
<>
<div class="flex flex-col gap-6 p-4">
@@ -626,7 +660,7 @@ const MachineForm = (props: MachineDetailsProps) => {
<Button
class="w-full"
// disabled={!online()}
onClick={() => handleUpdate()}
onClick={() => handleUpdateButton()}
endIcon={<Icon icon="Update" />}
>
Update

View File

@@ -5,31 +5,73 @@ import {
validate,
FieldValues,
} from "@modular-forms/solid";
import { createQuery } from "@tanstack/solid-query";
import { StepProps } from "./hardware-step";
import { createQuery, useQueryClient } from "@tanstack/solid-query";
import { Typography } from "@/src/components/Typography";
import { Group } from "@/src/components/group";
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>) => {
const [formStore, { Form, Field }] = createForm<VarsValues>({
initialValues: { ...props.initial, schema: "single-disk" },
});
export interface VarsStepProps {
machine_id: string;
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) => {
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);
console.log("Valid", valid);
if (!valid) return;
props.handleNext(values);
if (generatorsQuery.data === undefined) {
toast.error("Error fetching data");
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(() => ({
queryKey: [props.dir, props.machine_id, "generators"],
queryFn: async () => {
const result = await callApi("get_generators", {
const result = await callApi("get_generators_closure", {
base_dir: props.dir,
machine_name: props.machine_id,
});
@@ -61,14 +103,33 @@ export const VarsStep = (props: StepProps<VarsValues>) => {
{generator.share ? "True" : "False"}
</div>
<For each={generator.prompts}>
{(f) => (
{(prompt) => (
<Group>
<Typography hierarchy="label" size="s">
{!f.previous_value ? "Required" : "Optional"}
{!prompt.previous_value ? "Required" : "Optional"}
</Typography>
<Typography hierarchy="label" size="s">
{f.name}
{prompt.name}
</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>
)}
</For>
@@ -80,7 +141,17 @@ export const VarsStep = (props: StepProps<VarsValues>) => {
</Switch>
</div>
</div>
<Show when={generatorsQuery.isFetched}>{props.footer}</Show>
<button type="submit">Submit</button>
</Form>
);
};
export const VarsForMachine = () => {
const params = useParams();
return (
<Show when={activeURI()}>
{(uri) => <VarsStep machine_id={params.id} dir={uri()} />}
</Show>
);
};