From 647bc4e4df7332376ab354d2381962b957ebefb7 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 26 Aug 2025 15:49:03 +0200 Subject: [PATCH 1/6] api/list_modules: return a simpler list of modules --- pkgs/clan-cli/clan_lib/services/modules.py | 47 +++++++++++++-------- pkgs/clan-cli/clan_lib/tests/test_create.py | 4 +- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/pkgs/clan-cli/clan_lib/services/modules.py b/pkgs/clan-cli/clan_lib/services/modules.py index 43db59ff2..42d10a779 100644 --- a/pkgs/clan-cli/clan_lib/services/modules.py +++ b/pkgs/clan-cli/clan_lib/services/modules.py @@ -8,6 +8,7 @@ from clan_lib.errors import ClanError from clan_lib.flake.flake import Flake from clan_lib.nix_models.clan import ( InventoryInstance, + InventoryInstanceModule, InventoryInstanceModuleType, InventoryInstanceRolesType, ) @@ -151,27 +152,32 @@ class ModuleInfo(TypedDict): roles: dict[str, None] -class ModuleList(TypedDict): - modules: dict[str, dict[str, ModuleInfo]] +class Module(TypedDict): + module: InventoryInstanceModule + info: ModuleInfo @API.register -def list_service_modules(flake: Flake) -> ModuleList: +def list_service_modules(flake: Flake) -> list[Module]: """Show information about a module""" modules = flake.select("clanInternals.inventoryClass.modulesPerSource") - res: dict[str, dict[str, ModuleInfo]] = {} + res: list[Module] = [] for input_name, module_set in modules.items(): - res[input_name] = {} - for module_name, module_info in module_set.items(): - # breakpoint() - res[input_name][module_name] = ModuleInfo( - manifest=ModuleManifest.from_dict(module_info.get("manifest")), - roles=module_info.get("roles", {}), + res.append( + Module( + module={"name": module_name, "input": input_name}, + info=ModuleInfo( + manifest=ModuleManifest.from_dict( + module_info.get("manifest"), + ), + roles=module_info.get("roles", {}), + ), + ) ) - return ModuleList(modules=res) + return res @API.register @@ -188,19 +194,21 @@ def get_service_module( input_name, module_name = check_service_module_ref(flake, module_ref) avilable_modules = list_service_modules(flake) - module_set = avilable_modules.get("modules", {}).get(input_name) + module_set: list[Module] = [ + m for m in avilable_modules if m["module"].get("input", None) == input_name + ] - if module_set is None: + if not module_set: msg = f"Module set for input '{input_name}' not found" raise ClanError(msg) - module = module_set.get(module_name) + module = next((m for m in module_set if m["module"]["name"] == module_name), None) if module is None: msg = f"Module '{module_name}' not found in input '{input_name}'" raise ClanError(msg) - return module + return module["info"] def check_service_module_ref( @@ -219,18 +227,21 @@ def check_service_module_ref( msg = "Setting module_ref.input is currently required" raise ClanError(msg) - module_set = avilable_modules.get("modules", {}).get(input_ref) + module_set = [ + m for m in avilable_modules if m["module"].get("input", None) == input_ref + ] if module_set is None: + inputs = {m["module"].get("input") for m in avilable_modules} msg = f"module set for input '{input_ref}' not found" - msg += f"\nAvilable input_refs: {avilable_modules.get('modules', {}).keys()}" + msg += f"\nAvilable input_refs: {inputs}" raise ClanError(msg) module_name = module_ref.get("name") if not module_name: msg = "Module name is required in module_ref" raise ClanError(msg) - module = module_set.get(module_name) + module = next((m for m in module_set if m["module"]["name"] == module_name), None) if module is None: msg = f"module with name '{module_name}' not found" raise ClanError(msg) diff --git a/pkgs/clan-cli/clan_lib/tests/test_create.py b/pkgs/clan-cli/clan_lib/tests/test_create.py index 227ef4fe0..61fd567b2 100644 --- a/pkgs/clan-cli/clan_lib/tests/test_create.py +++ b/pkgs/clan-cli/clan_lib/tests/test_create.py @@ -215,8 +215,10 @@ def test_clan_create_api( inventory = store.read() modules = list_service_modules(clan_dir_flake) + + admin_module = next(m for m in modules if m["module"]["name"] == "admin") assert ( - modules["modules"]["clan-core"]["admin"]["manifest"].name == "clan-core/admin" + admin_module["info"]["manifest"].name == "clan-core/admin" ) set_value_by_path(inventory, "instances", inventory_conf.instances) From dca7aa0487697e7161d07ee86fe5703e9ff13271 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 26 Aug 2025 15:49:13 +0200 Subject: [PATCH 2/6] ui/modules: hook up list modules query --- pkgs/clan-app/ui/src/hooks/queries.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pkgs/clan-app/ui/src/hooks/queries.ts b/pkgs/clan-app/ui/src/hooks/queries.ts index 1bcc135b7..464519a2a 100644 --- a/pkgs/clan-app/ui/src/hooks/queries.ts +++ b/pkgs/clan-app/ui/src/hooks/queries.ts @@ -456,3 +456,28 @@ export const useMachineGenerators = ( }, })); }; + +export type ServiceModulesQuery = ReturnType; +export type ServiceModules = SuccessData<"list_service_modules">; +export const useServiceModules = (clanUri: string) => { + const client = useApiClient(); + return useQuery(() => ({ + queryKey: ["clans", encodeBase64(clanUri), "service_modules"], + queryFn: async () => { + const call = client.fetch("list_service_modules", { + flake: { + identifier: clanUri, + }, + }); + 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; + }, + })); +}; From 24c5146763f9de3f2862adac768ad682a5abf43d Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 26 Aug 2025 16:52:15 +0200 Subject: [PATCH 3/6] ui/search: fix height calculate to avoid overlaying components --- pkgs/clan-app/ui/src/components/Search/Search.module.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/Search/Search.module.css b/pkgs/clan-app/ui/src/components/Search/Search.module.css index 11692c9e2..b631ed0a6 100644 --- a/pkgs/clan-app/ui/src/components/Search/Search.module.css +++ b/pkgs/clan-app/ui/src/components/Search/Search.module.css @@ -80,8 +80,7 @@ .searchContent { @apply px-3; - height: var(--container-height, 14.5rem); - padding-bottom: 4rem; + height: calc(var(--container-height, 14.5rem) - 4rem); } @keyframes contentHide { From 53e16242b90ab0ca9e30f91704ee98c835fc8bbb Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 26 Aug 2025 17:06:55 +0200 Subject: [PATCH 4/6] ui/search: add loading state --- .../src/components/Search/Search.stories.tsx | 21 +++++ .../ui/src/components/Search/Search.tsx | 87 +++++++++++-------- 2 files changed, 73 insertions(+), 35 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx b/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx index c466280ea..c0d9e9b2c 100644 --- a/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx +++ b/pkgs/clan-app/ui/src/components/Search/Search.stories.tsx @@ -117,6 +117,27 @@ export const Default: Story = { }, }; +export const Loading: Story = { + args: { + // Test with lots of modules + loading: true, + options: [], + renderItem: () => , + }, + render: (args: SearchProps) => { + return ( +
+ + {...args} + onChange={(module) => { + // Go to the module configuration + }} + /> +
+ ); + }, +}; + type MachineOrTag = | { value: string; diff --git a/pkgs/clan-app/ui/src/components/Search/Search.tsx b/pkgs/clan-app/ui/src/components/Search/Search.tsx index 3b09c6604..4e002d96c 100644 --- a/pkgs/clan-app/ui/src/components/Search/Search.tsx +++ b/pkgs/clan-app/ui/src/components/Search/Search.tsx @@ -2,9 +2,10 @@ import Icon from "../Icon/Icon"; import { Button } from "../Button/Button"; import styles from "./Search.module.css"; import { Combobox } from "@kobalte/core/combobox"; -import { createMemo, createSignal, For, JSX } from "solid-js"; +import { createMemo, createSignal, For, JSX, Match, Switch } from "solid-js"; import { createVirtualizer } from "@tanstack/solid-virtual"; import { CollectionNode } from "@kobalte/core/*"; +import { Loader } from "../Loader/Loader"; export interface Option { value: string; @@ -15,6 +16,8 @@ export interface SearchProps { onChange: (value: T | null) => void; options: T[]; renderItem: (item: T) => JSX.Element; + loading?: boolean; + loadingComponent?: JSX.Element; } export function Search(props: SearchProps) { // Controlled input value, to allow resetting the input itself @@ -136,41 +139,55 @@ export function Search(props: SearchProps) { setComboboxItems(arr); return ( -
- - {(virtualRow) => { - const item: CollectionNode | undefined = - items().getItem(virtualRow.key as string); + + + {props.loadingComponent ?? ( +
+ +
+ )} +
+ +
+ + {(virtualRow) => { + const item: CollectionNode | undefined = + items().getItem(virtualRow.key as string); - if (!item) { - console.warn("Item not found for key:", virtualRow.key); - return null; - } - return ( - - {props.renderItem(item.rawValue)} - - ); - }} - -
+ if (!item) { + console.warn( + "Item not found for key:", + virtualRow.key, + ); + return null; + } + return ( + + {props.renderItem(item.rawValue)} + + ); + }} +
+
+ + ); }} From 602879c9e48014b87625d476193997d8f6593144 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 26 Aug 2025 17:14:21 +0200 Subject: [PATCH 5/6] ui/services: workflow select service --- .../src/workflows/Service/Service.stories.tsx | 140 ++++++++++++++++++ .../ui/src/workflows/Service/Service.tsx | 124 ++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx create mode 100644 pkgs/clan-app/ui/src/workflows/Service/Service.tsx diff --git a/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx b/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx new file mode 100644 index 000000000..ec534547f --- /dev/null +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx @@ -0,0 +1,140 @@ +import type { Meta, StoryContext, StoryObj } from "@kachurun/storybook-solid"; +import { ServiceWorkflow } from "./Service"; +import { + createMemoryHistory, + MemoryRouter, + RouteDefinition, +} from "@solidjs/router"; +import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"; +import { ApiClientProvider, Fetcher } from "@/src/hooks/ApiClient"; +import { + ApiCall, + OperationNames, + OperationResponse, + SuccessQuery, +} from "@/src/hooks/api"; + +type ResultDataMap = { + [K in OperationNames]: SuccessQuery["data"]; +}; + +const mockFetcher: Fetcher = ( + name: K, + _args: unknown, +): ApiCall => { + // TODO: Make this configurable for every story + const resultData: Partial = { + list_service_modules: [ + { + module: { name: "Module A", input: "Input A" }, + info: { + manifest: { + name: "Module A", + description: "This is module A", + }, + roles: { + peer: null, + server: null, + }, + }, + }, + { + module: { name: "Module B", input: "Input B" }, + info: { + manifest: { + name: "Module B", + description: "This is module B", + }, + roles: { + default: null, + }, + }, + }, + { + module: { name: "Module C", input: "Input B" }, + info: { + manifest: { + name: "Module B", + description: "This is module B", + }, + roles: { + default: null, + }, + }, + }, + { + module: { name: "Module B", input: "Input A" }, + info: { + manifest: { + name: "Module B", + description: "This is module B", + }, + roles: { + default: null, + }, + }, + }, + ], + }; + + return { + uuid: "mock", + cancel: () => Promise.resolve(), + result: new Promise((resolve) => { + setTimeout(() => { + resolve({ + op_key: "1", + status: "success", + data: resultData[name], + } as OperationResponse); + }, 1500); + }), + }; +}; + +const meta: Meta = { + title: "workflows/service", + component: ServiceWorkflow, + decorators: [ + (Story: StoryObj, context: StoryContext) => { + const Routes: RouteDefinition[] = [ + { + path: "/clans/:clanURI", + component: () => ( +
+ +
+ ), + }, + ]; + const history = createMemoryHistory(); + history.set({ value: "/clans/dGVzdA==", replace: true }); + + const queryClient = new QueryClient(); + + return ( + + + { + console.debug("Rendering MemoryRouter root with props:", props); + return props.children; + }} + history={history} + > + {Routes} + + + + ); + }, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx new file mode 100644 index 000000000..0360f6b1d --- /dev/null +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx @@ -0,0 +1,124 @@ +import { + createStepper, + getStepStore, + StepperProvider, + useStepper, +} from "@/src/hooks/stepper"; +import { BackButton, NextButton } from "../Steps"; +import { useClanURI } from "@/src/hooks/clan"; +import { ServiceModules, useServiceModules } from "@/src/hooks/queries"; +import { createEffect, createSignal } from "solid-js"; +import { Search } from "@/src/components/Search/Search"; +import Icon from "@/src/components/Icon/Icon"; +import { Combobox } from "@kobalte/core/combobox"; +import { Typography } from "@/src/components/Typography/Typography"; + +type ModuleItem = ServiceModules[number]; + +interface Module { + value: string; + input: string; + label: string; + description: string; + raw: ModuleItem; +} + +const SelectService = () => { + const clanURI = useClanURI(); + const stepper = useStepper(); + + const serviceModulesQuery = useServiceModules(clanURI); + + const [moduleOptions, setModuleOptions] = createSignal([]); + createEffect(() => { + if (serviceModulesQuery.data) { + setModuleOptions( + serviceModulesQuery.data.map((m) => ({ + value: `${m.module.name}:${m.module.input}`, + label: m.module.name, + description: m.info.manifest.description, + input: m.module.input || "clan-core", + raw: m, + })), + ); + } + }); + const [store, set] = getStepStore(stepper); + + return ( + + loading={serviceModulesQuery.isLoading} + onChange={(module) => { + if (!module) return; + + console.log("Module selected"); + set("module", { + name: module.raw.module.name, + input: module.raw.module.input, + }); + stepper.next(); + }} + options={moduleOptions()} + renderItem={(item) => { + return ( +
+
+ +
+
+ + + {item.label} + + + + {item.description} + by {item.input} + +
+
+ ); + }} + /> + ); +}; + +const steps = [ + { + id: "select:service", + content: SelectService, + }, + { + id: "select:members", + content: () =>
Configure your service here.
, + }, + { id: "settings", content: () =>
Adjust settings here.
}, +] as const; + +export type ServiceSteps = typeof steps; + +export interface ServiceStoreType { + module: { + name: string; + input: string; + }; +} + +export const ServiceWorkflow = () => { + const stepper = createStepper({ steps }, { initialStep: "select:service" }); + + return ( + + + {stepper.currentStep().content()} + stepper.next()} /> + + ); +}; From b7639b1d81eb58700e75c7a8fb5433ad07cd51a5 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 26 Aug 2025 18:14:35 +0200 Subject: [PATCH 6/6] ui/services: fix some background colors --- pkgs/clan-app/ui/src/components/Search/Search.module.css | 6 +++--- .../clan-app/ui/src/components/Search/TagSelect.module.css | 7 ++++--- pkgs/clan-cli/clan_lib/tests/test_create.py | 4 +--- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/Search/Search.module.css b/pkgs/clan-app/ui/src/components/Search/Search.module.css index b631ed0a6..8bbff59fb 100644 --- a/pkgs/clan-app/ui/src/components/Search/Search.module.css +++ b/pkgs/clan-app/ui/src/components/Search/Search.module.css @@ -66,11 +66,11 @@ border: 1px solid #2b4647; background: - linear-gradient(0deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.18) 100%), + linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%), linear-gradient( 180deg, - var(--clr-bg-inv-3, rgba(43, 70, 71, 0.79)) 0%, - var(--clr-bg-inv-4, rgba(32, 54, 55, 0.79)) 100% + theme(colors.bg.inv.2) 0%, + theme(colors.bg.inv.3) 100% ); box-shadow: diff --git a/pkgs/clan-app/ui/src/components/Search/TagSelect.module.css b/pkgs/clan-app/ui/src/components/Search/TagSelect.module.css index 67a29f6a9..9c80c90c0 100644 --- a/pkgs/clan-app/ui/src/components/Search/TagSelect.module.css +++ b/pkgs/clan-app/ui/src/components/Search/TagSelect.module.css @@ -5,12 +5,13 @@ border-radius: 8px; border: 1px solid #2e4a4b; background: - linear-gradient(0deg, rgba(0, 0, 0, 0.18) 0%, rgba(0, 0, 0, 0.18) 100%), + linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%), linear-gradient( 180deg, - var(--clr-bg-inv-3, rgba(46, 74, 75, 0.79)) 0%, - var(--clr-bg-inv-4, rgba(32, 54, 55, 0.79)) 100% + theme(colors.bg.inv.2) 0%, + theme(colors.bg.inv.3) 100% ); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); diff --git a/pkgs/clan-cli/clan_lib/tests/test_create.py b/pkgs/clan-cli/clan_lib/tests/test_create.py index 61fd567b2..61e4add10 100644 --- a/pkgs/clan-cli/clan_lib/tests/test_create.py +++ b/pkgs/clan-cli/clan_lib/tests/test_create.py @@ -217,9 +217,7 @@ def test_clan_create_api( modules = list_service_modules(clan_dir_flake) admin_module = next(m for m in modules if m["module"]["name"] == "admin") - assert ( - admin_module["info"]["manifest"].name == "clan-core/admin" - ) + assert admin_module["info"]["manifest"].name == "clan-core/admin" set_value_by_path(inventory, "instances", inventory_conf.instances) store.write(