From 56923ae2c3105e3ea2f8c90109fd884fe49237c8 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Thu, 28 Aug 2025 10:11:15 +0200 Subject: [PATCH] ui/services: workflow init --- .../src/workflows/Service/Service.module.css | 35 ++ .../src/workflows/Service/Service.stories.tsx | 73 +++- .../ui/src/workflows/Service/Service.tsx | 338 +++++++++++++++++- 3 files changed, 413 insertions(+), 33 deletions(-) create mode 100644 pkgs/clan-app/ui/src/workflows/Service/Service.module.css diff --git a/pkgs/clan-app/ui/src/workflows/Service/Service.module.css b/pkgs/clan-app/ui/src/workflows/Service/Service.module.css new file mode 100644 index 000000000..9478b954d --- /dev/null +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.module.css @@ -0,0 +1,35 @@ +.content { + @apply px-3 flex flex-col gap-5 py-6; + border: 1px solid #2e4a4b; + background: + linear-gradient(0deg, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.2) 100%), + linear-gradient( + 180deg, + 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); +} + +.header { + @apply py-2 pl-3 pr-2 flex gap-2.5 w-full items-center; + border-radius: 8px 8px 0 0; + border-bottom: 1px solid #2e4a4b; +} + +.footer { + @apply py-3 px-4 flex justify-end w-full; + border-radius: 0 0 8px 8px; + border-top: 1px solid #2e4a4b; +} + +.backgroundAlt { + background: linear-gradient( + 90deg, + theme(colors.bg.inv.3) 0%, + theme(colors.bg.inv.4) 100% + ); +} diff --git a/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx b/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx index ec534547f..0e4e132e1 100644 --- a/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx @@ -26,23 +26,37 @@ const mockFetcher: Fetcher = ( const resultData: Partial = { list_service_modules: [ { - module: { name: "Module A", input: "Input A" }, + module: { name: "Borgbackup", input: "clan-core" }, info: { manifest: { - name: "Module A", + name: "Borgbackup", description: "This is module A", }, roles: { - peer: null, + client: null, server: null, }, }, }, { - module: { name: "Module B", input: "Input B" }, + module: { name: "Zerotier", input: "clan-core" }, info: { manifest: { - name: "Module B", + name: "Zerotier", + description: "This is module B", + }, + roles: { + peer: null, + moon: null, + controller: null, + }, + }, + }, + { + module: { name: "Admin", input: "clan-core" }, + info: { + manifest: { + name: "Admin", description: "This is module B", }, roles: { @@ -51,22 +65,10 @@ const mockFetcher: Fetcher = ( }, }, { - module: { name: "Module C", input: "Input B" }, + module: { name: "Garage", input: "lo-l" }, 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", + name: "Garage", description: "This is module B", }, roles: { @@ -75,6 +77,28 @@ const mockFetcher: Fetcher = ( }, }, ], + list_machines: { + jon: { + name: "jon", + tags: ["all", "nixos", "tag1"], + }, + sara: { + name: "sara", + tags: ["all", "darwin", "tag2"], + }, + kyra: { + name: "kyra", + tags: ["all", "darwin", "tag2"], + }, + leila: { + name: "leila", + tags: ["all", "darwin", "tag2"], + }, + }, + list_tags: { + options: ["desktop", "server", "full", "only", "streaming", "backup"], + special: ["all", "nixos", "darwin"], + }, }; return { @@ -138,3 +162,14 @@ type Story = StoryObj; export const Default: Story = { args: {}, }; + +export const SelectRoleMembers: Story = { + render: () => ( + + ), +}; diff --git a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx index 0360f6b1d..944dd1e2b 100644 --- a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx @@ -4,14 +4,31 @@ import { 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 { + MachinesQuery, + ServiceModules, + TagsQuery, + useMachinesQuery, + useServiceModules, + useTags, +} from "@/src/hooks/queries"; +import { createEffect, createMemo, createSignal, For, Show } 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"; +import { Toolbar } from "@/src/components/Toolbar/Toolbar"; +import { ToolbarButton } from "@/src/components/Toolbar/ToolbarButton"; +import { TagSelect } from "@/src/components/Search/TagSelect"; +import { Tag } from "@/src/components/Tag/Tag"; +import { createForm, FieldValues, setValue } from "@modular-forms/solid"; +import styles from "./Service.module.css"; +import { TextInput } from "@/src/components/Form/TextInput"; +import { Button } from "@/src/components/Button/Button"; +import cx from "classnames"; +import { BackButton } from "../Steps"; +import { SearchMultiple } from "@/src/components/Search/MultipleSearch"; type ModuleItem = ServiceModules[number]; @@ -48,20 +65,21 @@ const SelectService = () => { return ( loading={serviceModulesQuery.isLoading} + height="13rem" onChange={(module) => { if (!module) return; - console.log("Module selected"); set("module", { name: module.raw.module.name, input: module.raw.module.input, + raw: module.raw, }); stepper.next(); }} options={moduleOptions()} renderItem={(item) => { return ( -
+
@@ -90,14 +108,273 @@ const SelectService = () => { ); }; +const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) => + createMemo(() => { + const tags = tagsQuery.data; + const machines = machinesQuery.data; + if (!tags || !machines) { + return []; + } + + const machineOptions = Object.keys(machines).map((m) => ({ + label: m, + value: "m_" + m, + type: "machine" as const, + })); + + const tagOptions = [...tags.options, ...tags.special].map((tag) => ({ + type: "tag" as const, + label: tag, + value: "t_" + tag, + members: Object.entries(machines) + .filter(([_, v]) => v.tags?.includes(tag)) + .map(([k]) => k), + })); + + return [...machineOptions, ...tagOptions].sort((a, b) => + a.label.localeCompare(b.label), + ); + }); + +interface RolesForm extends FieldValues { + roles: Record; + instanceName: string; +} +const ConfigureService = () => { + const stepper = useStepper(); + const [store, set] = getStepStore(stepper); + + const [formStore, { Form, Field }] = createForm({ + // initialValues: props.initialValues, + initialValues: { + instanceName: "backup-instance-1", + }, + }); + + const machinesQuery = useMachinesQuery(useClanURI()); + const tagsQuery = useTags(useClanURI()); + + const options = useOptions(tagsQuery, machinesQuery); + + const handleSubmit = (values: RolesForm) => { + console.log("Create service submitted with values:", values); + }; + + return ( +
+
+
+ +
+
+ + {store.module.name} + + + {(field, input) => ( + + )} + +
+
+
+ + {(role) => { + const values = store.roles?.[role] || []; + console.log("Role members:", role, values, "from", options()); + return ( + + label={role} + renderItem={(item: TagType) => ( + ( + + )} + > + {item.label} + + )} + values={values} + options={options()} + onClick={() => { + set("currentRole", role); + stepper.next(); + }} + /> + ); + }} + +
+
+ +
+
+ ); +}; + +type TagType = + | { + value: string; + label: string; + type: "machine"; + } + | { + value: string; + label: string; + type: "tag"; + members: string[]; + }; + +interface RoleMembers extends FieldValues { + members: string[]; +} +const ConfigureRole = () => { + const stepper = useStepper(); + const [store, set] = getStepStore(stepper); + + const [formStore, { Form, Field }] = createForm({ + initialValues: { + members: [], + }, + }); + + const machinesQuery = useMachinesQuery(useClanURI()); + const tagsQuery = useTags(useClanURI()); + + const options = useOptions(tagsQuery, machinesQuery); + + const handleSubmit = (values: RoleMembers) => { + if (!store.currentRole) return; + + const members: TagType[] = values.members.map( + (m) => options().find((o) => o.value === m)!, + ); + + if (!store.roles) { + set("roles", {}); + } + set("roles", (r) => ({ ...r, [store.currentRole as string]: members })); + console.log("Roles form submitted ", members); + + stepper.setActiveStep("view:members"); + }; + + return ( +
+
+
+ + {(field, input) => ( + + initialValues={store.roles?.[store.currentRole || ""] || []} + options={options()} + headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")} + headerChildren={ +
+ + + Select {store.currentRole} + +
+ } + placeholder={"Search for Machine or Tags"} + renderItem={(item, opts) => ( +
+ + } + > + + + + + + {item.label} + + + {(tag) => ( + + {tag().members.length} + + )} + + + +
+ )} + height="20rem" + virtualizerOptions={{ + estimateSize: () => 38, + }} + onChange={(selection) => { + const newval = selection.map((s) => s.value); + setValue(formStore, field.name, newval); + }} + /> + )} +
+
+
+ +
+
+
+ ); +}; + const steps = [ { id: "select:service", content: SelectService, }, + { + id: "view:members", + content: ConfigureService, + }, { id: "select:members", - content: () =>
Configure your service here.
, + content: ConfigureRole, }, { id: "settings", content: () =>
Adjust settings here.
}, ] as const; @@ -108,17 +385,50 @@ export interface ServiceStoreType { module: { name: string; input: string; + raw?: ModuleItem; }; + roles: Record; + currentRole?: string; + close: () => void; } -export const ServiceWorkflow = () => { - const stepper = createStepper({ steps }, { initialStep: "select:service" }); - +interface ServiceWorkflowProps { + initialStep?: ServiceSteps[number]["id"]; + initialStore?: Partial; +} +export const ServiceWorkflow = (props: ServiceWorkflowProps) => { + const [show, setShow] = createSignal(false); + const stepper = createStepper( + { steps }, + { + initialStep: props.initialStep || "select:service", + initialStoreData: { + ...props.initialStore, + close: () => setShow(false), + } satisfies Partial, + }, + ); return ( - - - {stepper.currentStep().content()} - stepper.next()} /> - + <> +
+ +
+ +
{stepper.currentStep().content()}
+
+
+
+
+ + setShow(!show())} + description="Add new Service" + name="modules" + icon="Modules" + /> + +
+
+ ); };