diff --git a/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx b/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx index 3bdfb2a06..897c4614e 100644 --- a/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx +++ b/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx @@ -16,7 +16,7 @@ import { useClanURI, useMachineName, } from "@/src/hooks/clan"; -import { CubeScene } from "@/src/scene/cubes"; +import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes"; import { ClanDetails, MachinesQueryResult, @@ -38,10 +38,11 @@ import { Sidebar } from "@/src/components/Sidebar/Sidebar"; import { UseQueryResult } from "@tanstack/solid-query"; import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal"; import { - InventoryInstance, ServiceWorkflow, + SubmitServiceHandler, } from "@/src/workflows/Service/Service"; import { useApiClient } from "@/src/hooks/ApiClient"; +import toast from "solid-toast"; interface ClanContextProps { clanURI: string; @@ -208,7 +209,7 @@ const ClanSceneController = (props: RouteSectionProps) => { const onAddService = async (): Promise<{ id: string }> => { return new Promise((resolve, reject) => { - setShowService(true); + setShowService((v) => !v); console.log("setting current promise"); setCurrentPromise({ resolve, reject }); }); @@ -287,8 +288,16 @@ const ClanSceneController = (props: RouteSectionProps) => { ); const client = useApiClient(); - const handleSubmitService = async (instance: InventoryInstance) => { - console.log("Create Instance", instance); + const handleSubmitService: SubmitServiceHandler = async ( + instance, + action, + ) => { + console.log(action, "Instance", instance); + + if (action !== "create") { + toast.error("Only creating new services is supported"); + return; + } const call = client.fetch("create_service_instance", { flake: { identifier: ctx.clanURI, @@ -299,13 +308,26 @@ const ClanSceneController = (props: RouteSectionProps) => { const result = await call.result; if (result.status === "error") { + toast.error("Error creating service instance"); console.error("Error creating service instance", result.errors); } + toast.success("Created"); // currentPromise()?.resolve({ id: "0" }); setShowService(false); }; + createEffect( + on(worldMode, (mode) => { + if (mode === "service") { + setShowService(true); + } else { + // todo: request close instead of force close + setShowService(false); + } + }), + ); + return ( <> @@ -338,7 +360,6 @@ const ClanSceneController = (props: RouteSectionProps) => { { handleSubmit={handleSubmitService} onClose={() => { setShowService(false); + setWorldMode("default"); currentPromise()?.resolve({ id: "0" }); }} /> diff --git a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx index b4a4c0277..21e6f720e 100644 --- a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx @@ -10,32 +10,50 @@ import { ServiceModules, TagsQuery, useMachinesQuery, + useServiceInstances, useServiceModules, useTags, } from "@/src/hooks/queries"; -import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; +import { + createEffect, + createMemo, + createSignal, + For, + JSX, + Show, + on, + onMount, +} 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 { TagSelect } from "@/src/components/Search/TagSelect"; import { Tag } from "@/src/components/Tag/Tag"; -import { createForm, FieldValues, setValue } from "@modular-forms/solid"; +import { createForm, FieldValues } 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"; +import { useMachineClick } from "@/src/scene/cubes"; +import { + clearAllHighlights, + highlightGroups, + setHighlightGroups, +} from "@/src/scene/highlightStore"; +import { useClickOutside } from "@/src/hooks/useClickOutside"; type ModuleItem = ServiceModules[number]; interface Module { value: string; - input: string; + input?: string; label: string; description: string; raw: ModuleItem; + instances: string[]; } const SelectService = () => { @@ -43,17 +61,27 @@ const SelectService = () => { const stepper = useStepper(); const serviceModulesQuery = useServiceModules(clanURI); + const serviceInstancesQuery = useServiceInstances(clanURI); + const machinesQuery = useMachinesQuery(clanURI); const [moduleOptions, setModuleOptions] = createSignal([]); createEffect(() => { - if (serviceModulesQuery.data) { + if (serviceModulesQuery.data && serviceInstancesQuery.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", + input: m.module.input, raw: m, + // TODO: include the instances that use this module + instances: Object.entries(serviceInstancesQuery.data) + .filter( + ([name, i]) => + i.module?.name === m.module.name && + (!i.module?.input || i.module?.input === m.module.input), + ) + .map(([name, _]) => name), })), ); } @@ -72,17 +100,77 @@ const SelectService = () => { input: module.raw.module.input, raw: module.raw, }); + // TODO: Ideally we need to ask + // - create new + // - update existing (and select which one) + + // For now: + // Create a new instance, if there are no instances yet + // Update the first instance, if there is one + if (module.instances.length === 0) { + set("action", "create"); + } else { + if (!serviceInstancesQuery.data) return; + if (!machinesQuery.data) return; + set("action", "update"); + + const instanceName = module.instances[0]; + const instance = serviceInstancesQuery.data[instanceName]; + console.log("Editing existing instance", module); + + for (const role of Object.keys(instance.roles || {})) { + const tags = Object.keys(instance.roles?.[role].tags || {}); + const machines = Object.keys(instance.roles?.[role].machines || {}); + + const machineTags = machines.map((m) => ({ + value: "m_" + m, + label: m, + type: "machine" as const, + })); + const tagsTags = tags.map((t) => { + return { + value: "t_" + t, + label: t, + type: "tag" as const, + members: Object.entries(machinesQuery.data || {}) + .filter(([_, m]) => m.tags?.includes(t)) + .map(([k]) => k), + }; + }); + console.log("Members for role", role, [ + ...machineTags, + ...tagsTags, + ]); + if (!store.roles) { + set("roles", {}); + } + const roleMembers = [...machineTags, ...tagsTags].sort((a, b) => + a.label.localeCompare(b.label), + ); + set("roles", role, roleMembers); + console.log("set", store.roles); + } + // Initialize the roles with the existing members + } + stepper.next(); }} options={moduleOptions()} - renderItem={(item) => { + renderItem={(item, opts) => { return (
-
+
- + + 0}> +
+ + Added + +
+
{item.label} @@ -95,8 +183,12 @@ const SelectService = () => { inverted class="flex justify-between" > - {item.description} - by {item.input} + + {item.description} + + + by {item.input} +
@@ -144,7 +236,8 @@ const ConfigureService = () => { const [formStore, { Form, Field }] = createForm({ initialValues: { - instanceName: "backup-instance-1", + // Default to the module name, until we support multiple instances + instanceName: store.module.name, }, }); @@ -168,14 +261,17 @@ const ConfigureService = () => { ]), ); - store.handleSubmit({ - name: values.instanceName, - module: { - name: store.module.name, - input: store.module.input, + store.handleSubmit( + { + name: values.instanceName, + module: { + name: store.module.name, + input: store.module.input, + }, + roles, }, - roles, - }); + store.action, + ); }; return ( @@ -245,8 +341,11 @@ const ConfigureService = () => {
+ +
@@ -266,116 +365,145 @@ type TagType = 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 [members, setMembers] = createSignal( + store.roles?.[store.currentRole || ""] || [], + ); + + const lastClickedMachine = useMachineClick(); + + createEffect(() => { + console.log("Current role", store.currentRole, members()); + clearAllHighlights(); + setHighlightGroups({ + [store.currentRole as string]: new Set( + members().flatMap((m) => { + if (m.type === "machine") return m.label; + + return m.members; + }), + ), + }); + + console.log("now", highlightGroups); }); + onMount(() => setHighlightGroups(() => ({}))); + + createEffect( + on(lastClickedMachine, (machine) => { + // const machine = lastClickedMachine(); + const currentMembers = members(); + console.log("Clicked machine", machine, currentMembers); + if (!machine) return; + const machineTagName = "m_" + machine; + + const existing = currentMembers.find((m) => m.value === machineTagName); + if (existing) { + // Remove + setMembers(currentMembers.filter((m) => m.value !== machineTagName)); + } else { + // Add + setMembers([ + ...currentMembers, + { value: machineTagName, label: machine, type: "machine" }, + ]); + } + }), + ); const machinesQuery = useMachinesQuery(useClanURI()); const tagsQuery = useTags(useClanURI()); const options = useOptions(tagsQuery, machinesQuery); - const handleSubmit = (values: RoleMembers) => { + const handleSubmit = () => { 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 })); + set("roles", (r) => ({ ...r, [store.currentRole as string]: members() })); stepper.setActiveStep("view:members"); }; return ( -
+ handleSubmit()}>
- - {(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) => ( -
- - } - > - - - - + + values={members()} + options={options()} + headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")} + headerChildren={ +
+ clearAllHighlights()} + /> + + Select {store.currentRole} + +
+ } + placeholder={"Search for Machine or Tags"} + renderItem={(item, opts) => ( +
+ + }> + + + + + + {item.label} + + + {(tag) => ( - {item.label} + {tag().members.length} - - {(tag) => ( - - {tag().members.length} - - )} - - - -
- )} - height="20rem" - virtualizerOptions={{ - estimateSize: () => 38, - }} - onChange={(selection) => { - const newval = selection.map((s) => s.value); - setValue(formStore, field.name, newval); - }} - /> + )} + +
+ +
)} -
+ height="20rem" + virtualizerOptions={{ + estimateSize: () => 38, + }} + onChange={(selection) => { + setMembers(selection); + }} + />
-
+ ); }; @@ -410,7 +538,7 @@ export interface InventoryInstance { name: string; module: { name: string; - input: string; + input?: string; }; roles: Record; } @@ -429,14 +557,21 @@ export interface ServiceStoreType { roles: Record; currentRole?: string; close: () => void; - handleSubmit: (values: InventoryInstance) => void; + handleSubmit: SubmitServiceHandler; + action: "create" | "update"; } +export type SubmitServiceHandler = ( + values: InventoryInstance, + action: "create" | "update", +) => void | Promise; + interface ServiceWorkflowProps { initialStep?: ServiceSteps[number]["id"]; initialStore?: Partial; onClose?: () => void; - handleSubmit: (values: InventoryInstance) => void; + handleSubmit: SubmitServiceHandler; + rootProps?: JSX.HTMLAttributes; } export const ServiceWorkflow = (props: ServiceWorkflowProps) => { @@ -451,10 +586,25 @@ export const ServiceWorkflow = (props: ServiceWorkflowProps) => { } satisfies Partial, }, ); + createEffect(() => { + if (stepper.currentStep().id !== "select:members") { + clearAllHighlights(); + } + }); + + let ref: HTMLDivElement; + useClickOutside( + () => ref, + () => { + if (stepper.currentStep().id === "select:service") props.onClose?.(); + }, + ); return (
(ref = e)} id="add-service" class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2" + {...props.rootProps} >
{stepper.currentStep().content()}