diff --git a/pkgs/clan-cli/clan_cli/api/modules.py b/pkgs/clan-cli/clan_cli/api/modules.py index 0decdde15..2cf51abac 100644 --- a/pkgs/clan-cli/clan_cli/api/modules.py +++ b/pkgs/clan-cli/clan_cli/api/modules.py @@ -7,7 +7,7 @@ from typing import Any, get_args, get_type_hints from clan_cli.cmd import run_no_stdout from clan_cli.errors import ClanCmdError, ClanError -from clan_cli.inventory import Inventory, load_inventory_json, save_inventory +from clan_cli.inventory import Inventory, load_inventory_json, set_inventory from clan_cli.inventory.classes import Service from clan_cli.nix import nix_eval @@ -187,7 +187,7 @@ def set_service_instance( setattr(inventory.services, module_name, module_instance_map) - save_inventory( + set_inventory( inventory, base_path, f"Update {module_name} instance {instance_name}" ) diff --git a/pkgs/clan-cli/clan_cli/api/serde.py b/pkgs/clan-cli/clan_cli/api/serde.py index a43b415e8..b69f57d3f 100644 --- a/pkgs/clan-cli/clan_cli/api/serde.py +++ b/pkgs/clan-cli/clan_cli/api/serde.py @@ -202,6 +202,8 @@ def construct_value( return construct_value(base_type, field_value) # elif get_origin(t) is Union: + if t is Any: + return field_value # Unhandled msg = f"Unhandled field type {t} with value {field_value}" diff --git a/pkgs/clan-cli/clan_cli/clan/update.py b/pkgs/clan-cli/clan_cli/clan/update.py index 65ff096f6..822246615 100644 --- a/pkgs/clan-cli/clan_cli/clan/update.py +++ b/pkgs/clan-cli/clan_cli/clan/update.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from clan_cli.api import API -from clan_cli.inventory import Meta, load_inventory_json, save_inventory +from clan_cli.inventory import Meta, load_inventory_json, set_inventory @dataclass @@ -15,6 +15,6 @@ def update_clan_meta(options: UpdateOptions) -> Meta: inventory = load_inventory_json(options.directory) inventory.meta = options.meta - save_inventory(inventory, options.directory, "Update clan metadata") + set_inventory(inventory, options.directory, "Update clan metadata") return inventory.meta diff --git a/pkgs/clan-cli/clan_cli/inventory/__init__.py b/pkgs/clan-cli/clan_cli/inventory/__init__.py index 019be0713..006036d5a 100644 --- a/pkgs/clan-cli/clan_cli/inventory/__init__.py +++ b/pkgs/clan-cli/clan_cli/inventory/__init__.py @@ -58,6 +58,7 @@ default_inventory = Inventory( ) +@API.register def load_inventory_eval(flake_dir: str | Path) -> Inventory: """ Loads the actual inventory. @@ -89,6 +90,7 @@ def load_inventory_eval(flake_dir: str | Path) -> Inventory: return inventory +@API.register def load_inventory_json( flake_dir: str | Path, default: Inventory = default_inventory ) -> Inventory: @@ -116,7 +118,8 @@ def load_inventory_json( return inventory -def save_inventory(inventory: Inventory, flake_dir: str | Path, message: str) -> None: +@API.register +def set_inventory(inventory: Inventory, flake_dir: str | Path, message: str) -> None: """ " Write the inventory to the flake directory and commit it to git with the given message @@ -143,4 +146,4 @@ def init_inventory(directory: str, init: Inventory | None = None) -> None: # Write inventory.json file if inventory is not None: # Persist creates a commit message for each change - save_inventory(inventory, directory, "Init inventory") + set_inventory(inventory, directory, "Init inventory") diff --git a/pkgs/clan-cli/clan_cli/machines/create.py b/pkgs/clan-cli/clan_cli/machines/create.py index a5319c7fe..814d46416 100644 --- a/pkgs/clan-cli/clan_cli/machines/create.py +++ b/pkgs/clan-cli/clan_cli/machines/create.py @@ -10,7 +10,7 @@ from clan_cli.inventory import ( MachineDeploy, load_inventory_eval, load_inventory_json, - save_inventory, + set_inventory, ) log = logging.getLogger(__name__) @@ -34,7 +34,7 @@ def create_machine(flake: FlakeId, machine: Machine) -> None: print(f"Define machine {machine.name}", machine) inventory.machines.update({machine.name: machine}) - save_inventory(inventory, flake.path, f"Create machine {machine.name}") + set_inventory(inventory, flake.path, f"Create machine {machine.name}") def create_command(args: argparse.Namespace) -> None: diff --git a/pkgs/clan-cli/clan_cli/machines/delete.py b/pkgs/clan-cli/clan_cli/machines/delete.py index 1da35a616..09f13147e 100644 --- a/pkgs/clan-cli/clan_cli/machines/delete.py +++ b/pkgs/clan-cli/clan_cli/machines/delete.py @@ -6,7 +6,7 @@ from clan_cli.clan_uri import FlakeId from clan_cli.completions import add_dynamic_completer, complete_machines from clan_cli.dirs import specific_machine_dir from clan_cli.errors import ClanError -from clan_cli.inventory import load_inventory_json, save_inventory +from clan_cli.inventory import load_inventory_json, set_inventory @API.register @@ -18,7 +18,7 @@ def delete_machine(flake: FlakeId, name: str) -> None: msg = f"Machine {name} does not exist" raise ClanError(msg) - save_inventory(inventory, flake.path, f"Delete machine {name}") + set_inventory(inventory, flake.path, f"Delete machine {name}") folder = specific_machine_dir(flake.path, name) if folder.exists(): diff --git a/pkgs/clan-cli/clan_cli/machines/list.py b/pkgs/clan-cli/clan_cli/machines/list.py index 5ccb66cb5..98c1e3b42 100644 --- a/pkgs/clan-cli/clan_cli/machines/list.py +++ b/pkgs/clan-cli/clan_cli/machines/list.py @@ -8,7 +8,7 @@ from typing import Literal from clan_cli.api import API from clan_cli.cmd import run_no_stdout from clan_cli.errors import ClanCmdError, ClanError -from clan_cli.inventory import Machine, load_inventory_eval, save_inventory +from clan_cli.inventory import Machine, load_inventory_eval, set_inventory from clan_cli.nix import nix_eval, nix_shell log = logging.getLogger(__name__) @@ -20,7 +20,7 @@ def set_machine(flake_url: str | Path, machine_name: str, machine: Machine) -> N inventory.machines[machine_name] = machine - save_inventory(inventory, flake_url, "machines: edit '{machine_name}'") + set_inventory(inventory, flake_url, "machines: edit '{machine_name}'") @API.register diff --git a/pkgs/clan-cli/tests/test_modules.py b/pkgs/clan-cli/tests/test_modules.py index 45c507d8b..a8c9889f1 100644 --- a/pkgs/clan-cli/tests/test_modules.py +++ b/pkgs/clan-cli/tests/test_modules.py @@ -8,7 +8,7 @@ from clan_cli.inventory import ( Machine, MachineDeploy, load_inventory_json, - save_inventory, + set_inventory, ) from clan_cli.machines.create import create_machine from clan_cli.nix import nix_eval, run_no_stdout @@ -74,7 +74,7 @@ def test_add_module_to_inventory( } } - save_inventory(inventory, base_path, "Add borgbackup service") + set_inventory(inventory, base_path, "Add borgbackup service") cmd = ["facts", "generate", "--flake", str(test_flake_with_core.path), "machine1"] cli.run(cmd) diff --git a/pkgs/webview-ui/app/src/api/disk.ts b/pkgs/webview-ui/app/src/api/disk.ts index 592451ff1..37ad908fb 100644 --- a/pkgs/webview-ui/app/src/api/disk.ts +++ b/pkgs/webview-ui/app/src/api/disk.ts @@ -1,20 +1,26 @@ +import { QueryClient } from "@tanstack/query-core"; import { get_inventory } from "./inventory"; export const instance_name = (machine_name: string) => `${machine_name}-single-disk` as const; export async function set_single_disk_id( + client: QueryClient, base_path: string, machine_name: string, disk_id: string, ) { - const inventory = await get_inventory(base_path); - if (!inventory.services) { + const r = await get_inventory(client, base_path); + if (r.status === "error") { + return r; + } + if (!r.data.services) { return new Error("No services found in inventory"); } - if (!inventory.services["single-disk"]) { - inventory.services["single-disk"] = {}; - } + const inventory = r.data; + inventory.services = inventory.services || {}; + inventory.services["single-disk"] = inventory.services["single-disk"] || {}; + inventory.services["single-disk"][instance_name(machine_name)] = { meta: { name: instance_name(machine_name), diff --git a/pkgs/webview-ui/app/src/api/index.ts b/pkgs/webview-ui/app/src/api/index.ts index 9bf104c68..2ad32591b 100644 --- a/pkgs/webview-ui/app/src/api/index.ts +++ b/pkgs/webview-ui/app/src/api/index.ts @@ -1,5 +1,5 @@ import schema from "@/api/API.json" assert { type: "json" }; -import { API } from "@/api/API"; +import { API, Error } from "@/api/API"; import { nanoid } from "nanoid"; import { Schema as Inventory } from "@/api/Inventory"; @@ -7,6 +7,14 @@ export type OperationNames = keyof API; export type OperationArgs = API[T]["arguments"]; export type OperationResponse = API[T]["return"]; +export type ApiEnvelope = + | { + status: "success"; + data: T; + op_key: string; + } + | Error; + export type Services = NonNullable; export type ServiceNames = keyof Services; export type ClanService = Services[T]; diff --git a/pkgs/webview-ui/app/src/api/inventory.ts b/pkgs/webview-ui/app/src/api/inventory.ts index 9ad4aa468..d85985ffb 100644 --- a/pkgs/webview-ui/app/src/api/inventory.ts +++ b/pkgs/webview-ui/app/src/api/inventory.ts @@ -1,40 +1,112 @@ -import { callApi, ClanService, ServiceNames, Services } from "."; +import { QueryClient } from "@tanstack/solid-query"; +import { + ApiEnvelope, + callApi, + ClanServiceInstance, + ServiceNames, + Services, +} from "."; import { Schema as Inventory } from "@/api/Inventory"; -export async function get_inventory(base_path: string) { - const r = await callApi("get_inventory", { - base_path, +export async function get_inventory(client: QueryClient, base_path: string) { + const data = await client.ensureQueryData({ + queryKey: [base_path, "inventory"], + queryFn: () => { + console.log("Refreshing inventory"); + return callApi("get_inventory", { base_path }) as Promise< + ApiEnvelope + >; + }, + revalidateIfStale: true, + staleTime: 60 * 1000, }); - if (r.status == "error") { - throw new Error("Failed to get inventory"); - } - const inventory: Inventory = r.data; - return inventory; + + return data; } -export const single_instance_name = ( +export const generate_instance_name = ( machine_name: string, service_name: T, -) => `${machine_name}_${service_name}_0` as const; +) => [machine_name, service_name, 1].filter(Boolean).join("_"); -function get_service(base_path: string, service: T) { - return callApi("get_inventory", { base_path }).then((r) => { - if (r.status == "error") { - return null; - } - const inventory: Inventory = r.data; +export const get_first_instance_name = async ( + client: QueryClient, + base_path: string, + service_name: T, +): Promise => { + const r = await get_inventory(client, base_path); + if (r.status === "success") { + const service = r.data.services?.[service_name]; + if (!service) return null; + return Object.keys(service)[0] || null; + } + return null; +}; - const serviceInstance = inventory.services?.[service]; - return serviceInstance; - }); +async function get_service( + client: QueryClient, + base_path: string, + service_name: T, +) { + const r = await get_inventory(client, base_path); + if (r.status === "success") { + const service = r.data.services?.[service_name]; + return service as Services[T]; + } + return null; } export async function get_single_service( + client: QueryClient, + base_path: string, + service_name: T, +): Promise> { + const instance_key = await get_first_instance_name( + client, + base_path, + service_name, + ); + + if (!instance_key) { + throw new Error("No instance found"); + } + const service: Services[T] | null = await get_service( + client, + base_path, + service_name, + ); + if (service) { + const clanServiceInstance = service[instance_key] as ClanServiceInstance; + return clanServiceInstance; + } + throw new Error("No service found"); +} + +export async function set_single_service( + client: QueryClient, base_path: string, machine_name: string, service_name: T, + service_config: ClanServiceInstance, ) { - const instance_key = single_instance_name(machine_name, service_name); - const service = await get_service(base_path, "admin"); - return service?.[instance_key]; + const instance_key = + (await get_first_instance_name(client, base_path, service_name)) || + generate_instance_name(machine_name, service_name); + const r = await get_inventory(client, base_path); + if (r.status === "success") { + const inventory = r.data; + inventory.services = inventory.services || {}; + inventory.services[service_name] = inventory.services[service_name] || {}; + + // @ts-expect-error: This cannot be undefined, because of the line above + inventory.services[service_name][instance_key] = service_config; + console.log("saving inventory", inventory); + return callApi("set_inventory", { + // @ts-expect-error: This doesn't check + inventory, + message: `update_single_service ${service_name}`, + flake_dir: base_path, + }); + } + return r; } diff --git a/pkgs/webview-ui/app/src/index.tsx b/pkgs/webview-ui/app/src/index.tsx index ab94b097a..abbc4137e 100644 --- a/pkgs/webview-ui/app/src/index.tsx +++ b/pkgs/webview-ui/app/src/index.tsx @@ -16,7 +16,7 @@ import { HostList } from "./routes/hosts/view"; import { Welcome } from "./routes/welcome"; import { Toaster } from "solid-toast"; -const client = new QueryClient(); +export const client = new QueryClient(); const root = document.getElementById("app"); diff --git a/pkgs/webview-ui/app/src/routes/clans/details.tsx b/pkgs/webview-ui/app/src/routes/clans/details.tsx index 62e73726e..6f4c4596f 100644 --- a/pkgs/webview-ui/app/src/routes/clans/details.tsx +++ b/pkgs/webview-ui/app/src/routes/clans/details.tsx @@ -24,7 +24,7 @@ import { } from "@modular-forms/solid"; import { TextInput } from "@/src/components/TextInput"; import toast from "solid-toast"; -import { get_single_service } from "@/src/api/inventory"; +import { get_single_service, set_single_service } from "@/src/api/inventory"; interface AdminModuleFormProps { admin: AdminData; @@ -190,20 +190,34 @@ const AdminModuleForm = (props: AdminModuleFormProps) => { const handleSubmit = async (values: AdminSettings) => { console.log("submitting", values, getValues(formStore)); - // const r = await callApi("set_admin_service", { - // base_url: props.base_url, - // allowed_keys: values.allowedKeys.reduce( - // (acc, curr) => ({ ...acc, [curr.name]: curr.value }), - // {} - // ), - // }); - // if (r.status === "success") { - // toast.success("Successfully updated admin settings"); - // } - // if (r.status === "error") { - // toast.error(`Failed to update admin settings: ${r.errors[0].message}`); - toast.error(`Failed to update admin settings: feature disabled`); - // } + const r = await set_single_service( + queryClient, + props.base_url, + "", + "admin", + { + meta: { + name: "admin", + }, + roles: { + default: { + tags: ["all"], + }, + }, + config: { + allowedKeys: values.allowedKeys.reduce( + (acc, curr) => ({ ...acc, [curr.name]: curr.value }), + {}, + ), + }, + }, + ); + if (r.status === "success") { + toast.success("Successfully updated admin settings"); + } + if (r.status === "error") { + toast.error(`Failed to update admin settings: ${r.errors[0].message}`); + } queryClient.invalidateQueries({ queryKey: [props.base_url, "get_admin_service"], }); @@ -340,10 +354,11 @@ type AdminData = ClanServiceInstance<"admin">; export const ClanDetails = () => { const params = useParams(); + const queryClient = useQueryClient(); const clan_dir = window.atob(params.id); // Fetch general meta data const clanQuery = createQuery(() => ({ - queryKey: [clan_dir, "meta"], + queryKey: [clan_dir, "inventory", "meta"], queryFn: async () => { const result = await callApi("show_clan_meta", { uri: clan_dir }); if (result.status === "error") throw new Error("Failed to fetch data"); @@ -352,11 +367,11 @@ export const ClanDetails = () => { })); // Fetch admin settings const adminQuery = createQuery(() => ({ - queryKey: [clan_dir, "get_admin_service"], + queryKey: [clan_dir, "inventory", "services", "admin"], queryFn: async () => { - const result = await get_single_service(clan_dir, "", "admin"); + const result = await get_single_service(queryClient, clan_dir, "admin"); if (!result) throw new Error("Failed to fetch data"); - return result || null; + return result; }, })); @@ -371,7 +386,7 @@ export const ClanDetails = () => { } > - + General data not available}> {(d) => } @@ -386,7 +401,7 @@ export const ClanDetails = () => { } > - + Admin data not available}> {(d) => } diff --git a/pkgs/webview-ui/app/src/routes/machines/details.tsx b/pkgs/webview-ui/app/src/routes/machines/details.tsx index 6e5499735..c9af7b02a 100644 --- a/pkgs/webview-ui/app/src/routes/machines/details.tsx +++ b/pkgs/webview-ui/app/src/routes/machines/details.tsx @@ -21,7 +21,11 @@ import { setValue, } from "@modular-forms/solid"; import { useParams } from "@solidjs/router"; -import { createQuery, QueryObserver } from "@tanstack/solid-query"; +import { + createQuery, + QueryObserver, + useQueryClient, +} from "@tanstack/solid-query"; import { createSignal, For, @@ -115,6 +119,7 @@ const InstallMachine = (props: InstallMachineProps) => { toast.success("Machine installed successfully"); } }; + const queryClient = useQueryClient(); const handleDiskConfirm = async (e: Event) => { e.preventDefault(); @@ -125,7 +130,12 @@ const InstallMachine = (props: InstallMachineProps) => { return; } - const r = await set_single_disk_id(curr_uri, props.name, disk_id); + const r = await set_single_disk_id( + queryClient, + curr_uri, + props.name, + disk_id, + ); if (!r) { toast.success("Disk set successfully"); setConfirmDisk(true);