From 6d8ea1f2c5766fb0523c9fd890debbc1d6083118 Mon Sep 17 00:00:00 2001 From: Brian McGee Date: Thu, 28 Aug 2025 18:37:05 +0100 Subject: [PATCH 1/3] feat(ui): services in sidebar and sidebar pane --- .../ui/src/components/Sidebar/SidebarBody.tsx | 234 ++++++++++++------ pkgs/clan-app/ui/src/hooks/clan.ts | 32 +++ pkgs/clan-app/ui/src/hooks/queries.ts | 51 ++++ pkgs/clan-app/ui/src/routes/Clan/Clan.tsx | 26 +- .../ui/src/routes/Machine/Machine.tsx | 2 + .../routes/Machine/SectionServices.module.css | 41 +++ .../ui/src/routes/Machine/SectionServices.tsx | 56 +++++ .../ui/src/routes/Service/Service.tsx | 22 ++ pkgs/clan-app/ui/src/routes/index.tsx | 14 ++ pkgs/clan-app/ui/src/scene/cubes.tsx | 60 +++-- 10 files changed, 425 insertions(+), 113 deletions(-) create mode 100644 pkgs/clan-app/ui/src/routes/Machine/SectionServices.module.css create mode 100644 pkgs/clan-app/ui/src/routes/Machine/SectionServices.tsx create mode 100644 pkgs/clan-app/ui/src/routes/Service/Service.tsx diff --git a/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx b/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx index 9ad81d549..a4a9ae13d 100644 --- a/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx +++ b/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx @@ -5,7 +5,11 @@ import Icon from "../Icon/Icon"; import { Typography } from "@/src/components/Typography/Typography"; import { For, Show } from "solid-js"; import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus"; -import { buildMachinePath, useClanURI } from "@/src/hooks/clan"; +import { + buildMachinePath, + buildServicePath, + useClanURI, +} from "@/src/hooks/clan"; import { useMachineStateQuery } from "@/src/hooks/queries"; import { SidebarProps } from "./Sidebar"; import { Button } from "../Button/Button"; @@ -33,19 +37,19 @@ const MachineRoute = (props: MachineProps) => { size="xs" weight="bold" color="primary" - inverted={true} + inverted > {props.name}
- + {props.serviceCount} @@ -56,6 +60,149 @@ const MachineRoute = (props: MachineProps) => { ); }; +const Machines = () => { + const ctx = useClanContext(); + if (!ctx) { + throw new Error("ClanContext not found"); + } + + const clanURI = ctx.clanURI; + + const machines = () => { + if (!ctx.machinesQuery.isSuccess) { + return {}; + } + + const result = ctx.machinesQuery.data; + return Object.keys(result).length > 0 ? result : undefined; + }; + + return ( + + + + + Your Machines + + + + + + + + No machines yet + + +
+ } + > + + + + + ); +}; + +export const ServiceRoute = (props: { + clanURI: string; + machineName?: string; + label: string; + id: string; + module: { input?: string | null | undefined; name: string }; +}) => ( + +
+ + {props.label} + +
+
+); + +const Services = () => { + const ctx = useClanContext(); + if (!ctx) { + throw new Error("ClanContext not found"); + } + + const serviceInstances = () => { + if (!ctx.serviceInstancesQuery.isSuccess) { + return []; + } + + return Object.entries(ctx.serviceInstancesQuery.data).map( + ([id, instance]) => { + const moduleName = instance.module.name; + + const label = moduleName == id ? moduleName : `${moduleName} (${id})`; + + return { id, label, module: instance.module }; + }, + ); + }; + + return ( + + + + + Services + + + + + + + + + ); +}; + export const SidebarBody = (props: SidebarProps) => { const clanURI = useClanURI(); @@ -67,16 +214,7 @@ export const SidebarBody = (props: SidebarProps) => { // controls which sections are open by default // we want them all to be open by default - const defaultAccordionValues = ["your-machines", ...sectionLabels]; - - const machines = () => { - if (!ctx.machinesQuery.isSuccess) { - return {}; - } - - const result = ctx.machinesQuery.data; - return Object.keys(result).length > 0 ? result : undefined; - }; + const defaultAccordionValues = ["machines", "services", ...sectionLabels]; return ( - } - > - - - - + + {(section) => ( @@ -156,7 +236,7 @@ export const SidebarBody = (props: SidebarProps) => { hierarchy="label" family="mono" size="xs" - inverted={true} + inverted color="tertiary" > {section.title} @@ -164,7 +244,7 @@ export const SidebarBody = (props: SidebarProps) => { @@ -179,7 +259,7 @@ export const SidebarBody = (props: SidebarProps) => { size="xs" weight="bold" color="primary" - inverted={true} + inverted > {link.label} diff --git a/pkgs/clan-app/ui/src/hooks/clan.ts b/pkgs/clan-app/ui/src/hooks/clan.ts index f3a63a7ca..44cd3246c 100644 --- a/pkgs/clan-app/ui/src/hooks/clan.ts +++ b/pkgs/clan-app/ui/src/hooks/clan.ts @@ -30,6 +30,24 @@ export const buildClanPath = (clanURI: string) => { export const buildMachinePath = (clanURI: string, name: string) => buildClanPath(clanURI) + "/machines/" + name; +export const buildServicePath = (props: { + clanURI: string; + machineName?: string; + id: string; + module: { + input?: string | null | undefined; + name: string; + }; +}) => { + const { clanURI, machineName, id, module } = props; + const result = + (machineName + ? buildMachinePath(clanURI, machineName) + : buildClanPath(clanURI)) + + `/services/${module.input ?? "clan"}/${module.name}`; + return id == module.name ? result : result + "/" + id; +}; + export const navigateToClan = (navigate: Navigator, clanURI: string) => { const path = buildClanPath(clanURI); console.log("Navigating to clan", clanURI, path); @@ -64,7 +82,21 @@ export const machineNameParam = (params: Params) => { return params.machineName; }; +export const inputParam = (params: Params) => params.input; +export const nameParam = (params: Params) => params.name; +export const idParam = (params: Params) => params.id; + export const useMachineName = (): string => machineNameParam(useParams()); +export const useInputParam = (): string => inputParam(useParams()); +export const useNameParam = (): string => nameParam(useParams()); + +export const maybeUseIdParam = (): string | null => { + const params = useParams(); + if (params.id === undefined) { + return null; + } + return idParam(params); +}; export const maybeUseMachineName = (): string | null => { const params = useParams(); diff --git a/pkgs/clan-app/ui/src/hooks/queries.ts b/pkgs/clan-app/ui/src/hooks/queries.ts index da81a5785..c07059a86 100644 --- a/pkgs/clan-app/ui/src/hooks/queries.ts +++ b/pkgs/clan-app/ui/src/hooks/queries.ts @@ -25,6 +25,9 @@ export type MachineStatus = MachineState["status"]; export type ListMachines = SuccessData<"list_machines">; export type MachineDetails = SuccessData<"get_machine_details">; +export type ListServiceModules = SuccessData<"list_service_modules">; +export type ListServiceInstances = SuccessData<"list_service_instances">; + export interface MachineDetail { tags: Tags; machine: Machine; @@ -166,6 +169,54 @@ export const useMachineStateQuery = (clanURI: string, machineName: string) => { })); }; +export const useServiceModulesQuery = (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") { + throw new Error( + "Error fetching service modules: " + result.errors[0].message, + ); + } + + return result.data; + }, + })); +}; + +export const useServiceInstancesQuery = (clanURI: string) => { + const client = useApiClient(); + + return useQuery(() => ({ + queryKey: ["clans", encodeBase64(clanURI), "service_instances"], + queryFn: async () => { + const call = client.fetch("list_service_instances", { + flake: { + identifier: clanURI, + }, + }); + + const result = await call.result; + if (result.status === "error") { + throw new Error( + "Error fetching service instances: " + result.errors[0].message, + ); + } + + return result.data; + }, + })); +}; + export const useMachineDetailsQuery = ( clanURI: string, machineName: string, diff --git a/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx b/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx index 7128f95f0..351535bcc 100644 --- a/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx +++ b/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx @@ -17,13 +17,15 @@ import { useClanURI, useMachineName, } from "@/src/hooks/clan"; -import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes"; +import { CubeScene } from "@/src/scene/cubes"; import { ClanDetails, + ListServiceInstances, MachinesQueryResult, useClanDetailsQuery, useClanListQuery, useMachinesQuery, + useServiceInstancesQuery, } from "@/src/hooks/queries"; import { clanURIs, setStore, store } from "@/src/stores/clan"; import { produce } from "solid-js/store"; @@ -41,18 +43,24 @@ import { useApiClient } from "@/src/hooks/ApiClient"; import toast from "solid-toast"; import { AddMachine } from "@/src/workflows/AddMachine/AddMachine"; +export type WorldMode = "default" | "select" | "service" | "create" | "move"; + interface ClanContextProps { clanURI: string; machinesQuery: MachinesQueryResult; activeClanQuery: UseQueryResult; otherClanQueries: UseQueryResult[]; allClansQueries: UseQueryResult[]; + serviceInstancesQuery: UseQueryResult; isLoading(): boolean; isError(): boolean; showAddMachine(): boolean; setShowAddMachine(value: boolean): void; + + worldMode(): WorldMode; + setWorldMode(mode: WorldMode): void; } function createClanContext( @@ -60,10 +68,13 @@ function createClanContext( machinesQuery: MachinesQueryResult, activeClanQuery: UseQueryResult, otherClanQueries: UseQueryResult[], + serviceInstancesQuery: UseQueryResult, ) { + const [worldMode, setWorldMode] = createSignal("select"); const [showAddMachine, setShowAddMachine] = createSignal(false); + const allClansQueries = [activeClanQuery, ...otherClanQueries]; - const allQueries = [machinesQuery, ...allClansQueries]; + const allQueries = [machinesQuery, ...allClansQueries, serviceInstancesQuery]; return { clanURI, @@ -71,10 +82,13 @@ function createClanContext( activeClanQuery, otherClanQueries, allClansQueries, + serviceInstancesQuery, isLoading: () => allQueries.some((q) => q.isLoading), isError: () => activeClanQuery.isError, showAddMachine, setShowAddMachine, + setWorldMode, + worldMode, }; } @@ -104,12 +118,14 @@ export const Clan: Component = (props) => { ); const machinesQuery = useMachinesQuery(clanURI); + const serviceInstancesQuery = useServiceInstancesQuery(clanURI); const ctx = createClanContext( clanURI, machinesQuery, activeClanQuery, otherClanQueries, + serviceInstancesQuery, ); return ( @@ -217,7 +233,7 @@ const ClanSceneController = (props: RouteSectionProps) => { console.error("Error creating service instance", result.errors); } toast.success("Created"); - setWorldMode("select"); + ctx.setWorldMode("select"); }; return ( @@ -254,11 +270,11 @@ const ClanSceneController = (props: RouteSectionProps) => { isLoading={ctx.isLoading()} cubesQuery={ctx.machinesQuery} toolbarPopup={ - + { - setWorldMode("select"); + ctx.setWorldMode("select"); currentPromise()?.resolve({ id: "0" }); }} /> diff --git a/pkgs/clan-app/ui/src/routes/Machine/Machine.tsx b/pkgs/clan-app/ui/src/routes/Machine/Machine.tsx index 5d3c0e84e..00e1774bf 100644 --- a/pkgs/clan-app/ui/src/routes/Machine/Machine.tsx +++ b/pkgs/clan-app/ui/src/routes/Machine/Machine.tsx @@ -10,6 +10,7 @@ import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineSta import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall"; import styles from "./Machine.module.css"; +import { SectionServices } from "@/src/routes/Machine/SectionServices"; export const Machine = (props: RouteSectionProps) => { const navigate = useNavigate(); @@ -62,6 +63,7 @@ export const Machine = (props: RouteSectionProps) => { /> + ); }; diff --git a/pkgs/clan-app/ui/src/routes/Machine/SectionServices.module.css b/pkgs/clan-app/ui/src/routes/Machine/SectionServices.module.css new file mode 100644 index 000000000..566f57ff9 --- /dev/null +++ b/pkgs/clan-app/ui/src/routes/Machine/SectionServices.module.css @@ -0,0 +1,41 @@ +.sectionServices { + @apply overflow-hidden flex flex-col; + @apply bg-inv-4 rounded-md; + + nav * { + @apply outline-none; + } + + nav > a { + @apply block w-full px-2 py-1.5 min-h-7 my-2 rounded-md; + + &:first-child { + @apply mt-0; + } + + &:last-child { + @apply mb-0; + } + + &:focus-visible { + background: linear-gradient( + 90deg, + theme(colors.secondary.900), + 60%, + theme(colors.secondary.600) 100% + ); + } + + &:hover { + @apply bg-inv-acc-2; + } + + &:active { + @apply bg-inv-acc-3; + } + + &.active { + @apply bg-inv-acc-2; + } + } +} diff --git a/pkgs/clan-app/ui/src/routes/Machine/SectionServices.tsx b/pkgs/clan-app/ui/src/routes/Machine/SectionServices.tsx new file mode 100644 index 000000000..64d82f146 --- /dev/null +++ b/pkgs/clan-app/ui/src/routes/Machine/SectionServices.tsx @@ -0,0 +1,56 @@ +import { SidebarSection } from "@/src/components/Sidebar/SidebarSection"; +import { useClanContext } from "@/src/routes/Clan/Clan"; +import { For, Show } from "solid-js"; +import { useMachineName } from "@/src/hooks/clan"; +import { ServiceRoute } from "@/src/components/Sidebar/SidebarBody"; +import styles from "./SectionServices.module.css"; + +export const SectionServices = () => { + const ctx = useClanContext(); + + const services = () => { + if (!(ctx.machinesQuery.isSuccess && ctx.serviceInstancesQuery.isSuccess)) { + return []; + } + + const machineName = useMachineName(); + if (!ctx.machinesQuery.data[machineName]) { + return []; + } + + return (ctx.machinesQuery.data[machineName].instance_refs ?? []).map( + (id) => { + const module = ctx.serviceInstancesQuery.data?.[id].module; + if (!module) { + throw new Error(`Service instance ${id} has no module`); + } + + return { + id, + module, + label: module.name == id ? module.name : `${module.name} (${id})`, + }; + }, + ); + }; + + return ( + + +
+ +
+
+
+ ); +}; diff --git a/pkgs/clan-app/ui/src/routes/Service/Service.tsx b/pkgs/clan-app/ui/src/routes/Service/Service.tsx new file mode 100644 index 000000000..9aae7d853 --- /dev/null +++ b/pkgs/clan-app/ui/src/routes/Service/Service.tsx @@ -0,0 +1,22 @@ +import { RouteSectionProps } from "@solidjs/router"; +import { maybeUseIdParam, useInputParam, useNameParam } from "@/src/hooks/clan"; +import { createEffect } from "solid-js"; +import { useClanContext } from "@/src/routes/Clan/Clan"; + +export const Service = (props: RouteSectionProps) => { + const ctx = useClanContext(); + + console.log("service route"); + + createEffect(() => { + const input = useInputParam(); + const name = useNameParam(); + const id = maybeUseIdParam(); + + ctx.setWorldMode("service"); + + console.log("service", input, name, id); + }); + + return <>h1; +}; diff --git a/pkgs/clan-app/ui/src/routes/index.tsx b/pkgs/clan-app/ui/src/routes/index.tsx index c18c1fb92..6ce503385 100644 --- a/pkgs/clan-app/ui/src/routes/index.tsx +++ b/pkgs/clan-app/ui/src/routes/index.tsx @@ -2,6 +2,7 @@ import type { RouteDefinition } from "@solidjs/router/dist/types"; import { Onboarding } from "@/src/routes/Onboarding/Onboarding"; import { Clan } from "@/src/routes/Clan/Clan"; import { Machine } from "@/src/routes/Machine/Machine"; +import { Service } from "@/src/routes/Service/Service"; export const Routes: RouteDefinition[] = [ { @@ -30,6 +31,19 @@ export const Routes: RouteDefinition[] = [ { path: "/machines/:machineName", component: Machine, + children: [ + { + path: "/", + }, + { + path: "/services/:input/:name/:id?", + component: Service, + }, + ], + }, + { + path: "/services/:input/:name/:id?", + component: Service, }, ], }, diff --git a/pkgs/clan-app/ui/src/scene/cubes.tsx b/pkgs/clan-app/ui/src/scene/cubes.tsx index 5053a3099..14e3b4cec 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.tsx +++ b/pkgs/clan-app/ui/src/scene/cubes.tsx @@ -31,6 +31,7 @@ import { setHighlightGroups, } from "./highlightStore"; import { createMachineMesh } from "./MachineRepr"; +import { useClanContext } from "@/src/routes/Clan/Clan"; function intersectMachines( event: MouseEvent, @@ -94,12 +95,6 @@ export function useMachineClick() { return lastClickedMachine; } -/*Gloabl signal*/ -const [worldMode, setWorldMode] = createSignal< - "default" | "select" | "service" | "create" | "move" ->("select"); -export { worldMode, setWorldMode }; - export function CubeScene(props: { cubesQuery: MachinesQueryResult; onCreate: () => Promise<{ id: string }>; @@ -111,6 +106,8 @@ export function CubeScene(props: { clanURI: string; toolbarPopup?: JSX.Element; }) { + const ctx = useClanContext(); + let container: HTMLDivElement; let scene: THREE.Scene; let camera: THREE.OrthographicCamera; @@ -440,7 +437,7 @@ export function CubeScene(props: { updateCameraInfo(); createEffect( - on(worldMode, (mode) => { + on(ctx.worldMode, (mode) => { if (mode === "create") { actionBase!.visible = true; } else { @@ -466,7 +463,7 @@ export function CubeScene(props: { // - Select/deselects a cube in mode // - Creates a new cube in "create" mode const onClick = (event: MouseEvent) => { - if (worldMode() === "create") { + if (ctx.worldMode() === "create") { props .onCreate() .then(({ id }) => { @@ -484,16 +481,16 @@ export function CubeScene(props: { .finally(() => { if (actionBase) actionBase.visible = false; - setWorldMode("select"); + ctx.setWorldMode("select"); }); } - if (worldMode() === "move") { + if (ctx.worldMode() === "move") { const currId = menuIntersection().at(0); const pos = cursorPosition(); if (!currId || !pos) return; props.setMachinePos(currId, pos); - setWorldMode("select"); + ctx.setWorldMode("select"); clearHighlight("move"); } @@ -513,13 +510,13 @@ export function CubeScene(props: { if (!id) return; - if (worldMode() === "select") props.onSelect(new Set([id])); + if (ctx.worldMode() === "select") props.onSelect(new Set([id])); emitMachineClick(id); // notify subscribers } else { emitMachineClick(null); - if (worldMode() === "select") props.onSelect(new Set()); + if (ctx.worldMode() === "select") props.onSelect(new Set()); } }; @@ -561,7 +558,7 @@ export function CubeScene(props: { if (e.button === 0) { // Left button - if (worldMode() === "select" && machines.length) { + if (ctx.worldMode() === "select" && machines.length) { // Disable controls to avoid conflict controls.enabled = false; @@ -571,7 +568,7 @@ export function CubeScene(props: { // Set machine as flying setHighlightGroups({ move: new Set(machines) }); - setWorldMode("move"); + ctx.setWorldMode("move"); renderLoop.requestRender(); }, 500); setCancelMove(cancelMove); @@ -597,14 +594,14 @@ export function CubeScene(props: { // Always re-enable controls controls.enabled = true; - if (worldMode() === "move") { + if (ctx.worldMode() === "move") { // Set machine as not flying props.setMachinePos( highlightGroups["move"].values().next().value!, cursorPosition() || null, ); clearHighlight("move"); - setWorldMode("select"); + ctx.setWorldMode("select"); renderLoop.requestRender(); } } @@ -691,13 +688,14 @@ export function CubeScene(props: { const onAddClick = (event: MouseEvent) => { setPositionMode("grid"); - setWorldMode("create"); + ctx.setWorldMode("create"); renderLoop.requestRender(); }; const onMouseMove = (event: MouseEvent) => { - if (!(worldMode() === "create" || worldMode() === "move")) return; + if (!(ctx.worldMode() === "create" || ctx.worldMode() === "move")) return; - const actionRepr = worldMode() === "create" ? actionBase : actionMachine; + const actionRepr = + ctx.worldMode() === "create" ? actionBase : actionMachine; if (!actionRepr) return; actionRepr.visible = true; @@ -732,7 +730,7 @@ export function CubeScene(props: { } }; const handleMenuSelect = (mode: "move") => { - setWorldMode(mode); + ctx.setWorldMode(mode); setHighlightGroups({ move: new Set(menuIntersection()) }); // Find the position of the first selected machine @@ -752,7 +750,7 @@ export function CubeScene(props: { }; createEffect( - on(worldMode, (mode) => { + on(ctx.worldMode, (mode) => { console.log("World mode changed to", mode); }), ); @@ -775,10 +773,10 @@ export function CubeScene(props: {
(container = el)} @@ -792,24 +790,24 @@ export function CubeScene(props: { description="Select machine" name="Select" icon="Cursor" - onClick={() => setWorldMode("select")} - selected={worldMode() === "select"} + onClick={() => ctx.setWorldMode("select")} + selected={ctx.worldMode() === "select"} /> { - setWorldMode("service"); + ctx.setWorldMode("service"); }} /> Date: Tue, 2 Sep 2025 20:41:51 +0200 Subject: [PATCH 2/3] ui/services: refactor services --- pkgs/clan-app/ui/src/hooks/clan.ts | 41 +- pkgs/clan-app/ui/src/hooks/queries.ts | 34 +- pkgs/clan-app/ui/src/routes/Clan/Clan.tsx | 86 ++-- .../ui/src/routes/Service/Service.tsx | 56 ++- pkgs/clan-app/ui/src/routes/index.tsx | 6 +- pkgs/clan-app/ui/src/scene/cubes.tsx | 1 + .../workflows/Service/SelectServiceFlyout.tsx | 120 +++++ .../ui/src/workflows/Service/Service.tsx | 429 +++++++----------- .../ui/src/workflows/Service/models.ts | 81 ++++ 9 files changed, 489 insertions(+), 365 deletions(-) create mode 100644 pkgs/clan-app/ui/src/workflows/Service/SelectServiceFlyout.tsx create mode 100644 pkgs/clan-app/ui/src/workflows/Service/models.ts diff --git a/pkgs/clan-app/ui/src/hooks/clan.ts b/pkgs/clan-app/ui/src/hooks/clan.ts index 44cd3246c..685cb027c 100644 --- a/pkgs/clan-app/ui/src/hooks/clan.ts +++ b/pkgs/clan-app/ui/src/hooks/clan.ts @@ -1,6 +1,6 @@ import { callApi } from "@/src/hooks/api"; import { addClanURI, setActiveClanURI } from "@/src/stores/clan"; -import { Params, Navigator, useParams } from "@solidjs/router"; +import { Params, Navigator, useParams, useSearchParams } from "@solidjs/router"; export const encodeBase64 = (value: string) => window.btoa(value); export const decodeBase64 = (value: string) => window.atob(value); @@ -32,20 +32,43 @@ export const buildMachinePath = (clanURI: string, name: string) => export const buildServicePath = (props: { clanURI: string; - machineName?: string; id: string; module: { - input?: string | null | undefined; name: string; + input?: string | null | undefined; }; }) => { - const { clanURI, machineName, id, module } = props; + const { clanURI, id, module } = props; + + const moduleName = encodeBase64(module.name); + const idEncoded = encodeBase64(id); + const result = - (machineName - ? buildMachinePath(clanURI, machineName) - : buildClanPath(clanURI)) + - `/services/${module.input ?? "clan"}/${module.name}`; - return id == module.name ? result : result + "/" + id; + buildClanPath(clanURI) + + `/services/${moduleName}/${idEncoded}` + + (module.input ? `?input=${module.input}` : ""); + + return result; +}; + +export const useServiceParams = () => { + const params = useParams<{ + name?: string; + id?: string; + }>(); + + const [search] = useSearchParams<{ input?: string }>(); + + if (!params.name || !params.id) { + console.error("Service params not found", params, window.location.pathname); + throw new Error("Service params not found"); + } + + return { + name: decodeBase64(params.name), + id: decodeBase64(params.id), + input: search.input, + }; }; export const navigateToClan = (navigate: Navigator, clanURI: string) => { diff --git a/pkgs/clan-app/ui/src/hooks/queries.ts b/pkgs/clan-app/ui/src/hooks/queries.ts index c07059a86..df32c3432 100644 --- a/pkgs/clan-app/ui/src/hooks/queries.ts +++ b/pkgs/clan-app/ui/src/hooks/queries.ts @@ -50,7 +50,7 @@ export const useMachinesQuery = (clanURI: string) => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", encodeBase64(clanURI), "machines"], + queryKey: [...clanKey(clanURI), "machines"], queryFn: async () => { const api = client.fetch("list_machines", { flake: { @@ -67,10 +67,16 @@ export const useMachinesQuery = (clanURI: string) => { })); }; +export const machineKey = (clanUri: string, machineName: string) => [ + ...clanKey(clanUri), + "machine", + encodeBase64(machineName), +]; + export const useMachineQuery = (clanURI: string, machineName: string) => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", encodeBase64(clanURI), "machine", machineName], + queryKey: [machineKey(clanURI, machineName)], queryFn: async () => { const [tagsCall, machineCall, schemaCall] = [ client.fetch("list_tags", { @@ -125,7 +131,7 @@ export type TagsQuery = ReturnType; export const useTags = (clanURI: string) => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", encodeBase64(clanURI), "tags"], + queryKey: [...clanKey(clanURI), "tags"], queryFn: async () => { const apiCall = client.fetch("list_tags", { flake: { @@ -145,8 +151,9 @@ export const useTags = (clanURI: string) => { export const useMachineStateQuery = (clanURI: string, machineName: string) => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"], + queryKey: [...machineKey(clanURI, machineName), "state"], staleTime: 60_000, // 1 minute stale time + enabled: false, queryFn: async () => { const apiCall = client.fetch("get_machine_state", { machine: { @@ -173,7 +180,7 @@ export const useServiceModulesQuery = (clanURI: string) => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", encodeBase64(clanURI), "service_modules"], + queryKey: [...clanKey(clanURI), "service_modules"], queryFn: async () => { const call = client.fetch("list_service_modules", { flake: { @@ -197,7 +204,7 @@ export const useServiceInstancesQuery = (clanURI: string) => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", encodeBase64(clanURI), "service_instances"], + queryKey: [...clanKey(clanURI), "service_instances"], queryFn: async () => { const call = client.fetch("list_service_instances", { flake: { @@ -223,7 +230,7 @@ export const useMachineDetailsQuery = ( ) => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName], + queryKey: [machineKey(clanURI, machineName), "details"], queryFn: async () => { const call = client.fetch("get_machine_details", { machine: { @@ -253,7 +260,7 @@ export const ClanDetailsPersister = experimental_createQueryPersister({ export const useClanDetailsQuery = (clanURI: string) => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", encodeBase64(clanURI), "details"], + queryKey: [...clanKey(clanURI), "details"], persister: ClanDetailsPersister.persisterFn, queryFn: async () => { const args = { @@ -304,7 +311,8 @@ export const useClanListQuery = ( return useQueries(() => ({ queries: clanURIs.map((clanURI) => { - const queryKey = ["clans", encodeBase64(clanURI), "details"]; + // @BMG: Is duplicating query key intentional? + const queryKey = [...clanKey(clanURI), "details"]; return { // eslint-disable-next-line @tanstack/query/exhaustive-deps @@ -373,7 +381,7 @@ export type MachineFlashOptionsQuery = UseQueryResult; export const useMachineFlashOptions = (): MachineFlashOptionsQuery => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", "machine_flash_options"], + queryKey: ["flash_options"], queryFn: async () => { const call = client.fetch("get_machine_flash_options", {}); const result = await call.result; @@ -537,7 +545,7 @@ export type ServiceModules = SuccessData<"list_service_modules">; export const useServiceModules = (clanUri: string) => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", encodeBase64(clanUri), "service_modules"], + queryKey: [...clanKey(clanUri), "service_modules"], queryFn: async () => { const call = client.fetch("list_service_modules", { flake: { @@ -557,12 +565,14 @@ export const useServiceModules = (clanUri: string) => { })); }; +export const clanKey = (clanUri: string) => ["clans", encodeBase64(clanUri)]; + export type ServiceInstancesQuery = ReturnType; export type ServiceInstances = SuccessData<"list_service_instances">; export const useServiceInstances = (clanUri: string) => { const client = useApiClient(); return useQuery(() => ({ - queryKey: ["clans", encodeBase64(clanUri), "service_instances"], + queryKey: [...clanKey(clanUri), "service_instances"], queryFn: async () => { const call = client.fetch("list_service_instances", { flake: { diff --git a/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx b/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx index 351535bcc..0bd50fbcb 100644 --- a/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx +++ b/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx @@ -1,4 +1,4 @@ -import { RouteSectionProps, useNavigate } from "@solidjs/router"; +import { RouteSectionProps, useLocation, useNavigate } from "@solidjs/router"; import { Component, createContext, @@ -35,34 +35,15 @@ import styles from "./Clan.module.css"; import { Sidebar } from "@/src/components/Sidebar/Sidebar"; import { UseQueryResult } from "@tanstack/solid-query"; import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal"; -import { - ServiceWorkflow, - SubmitServiceHandler, -} from "@/src/workflows/Service/Service"; + import { useApiClient } from "@/src/hooks/ApiClient"; import toast from "solid-toast"; import { AddMachine } from "@/src/workflows/AddMachine/AddMachine"; +import { SelectService } from "@/src/workflows/Service/SelectServiceFlyout"; +import { SubmitServiceHandler } from "@/src/workflows/Service/models"; export type WorldMode = "default" | "select" | "service" | "create" | "move"; -interface ClanContextProps { - clanURI: string; - machinesQuery: MachinesQueryResult; - activeClanQuery: UseQueryResult; - otherClanQueries: UseQueryResult[]; - allClansQueries: UseQueryResult[]; - serviceInstancesQuery: UseQueryResult; - - isLoading(): boolean; - isError(): boolean; - - showAddMachine(): boolean; - setShowAddMachine(value: boolean): void; - - worldMode(): WorldMode; - setWorldMode(mode: WorldMode): void; -} - function createClanContext( clanURI: string, machinesQuery: MachinesQueryResult, @@ -73,6 +54,9 @@ function createClanContext( const [worldMode, setWorldMode] = createSignal("select"); const [showAddMachine, setShowAddMachine] = createSignal(false); + const navigate = useNavigate(); + const location = useLocation(); + const allClansQueries = [activeClanQuery, ...otherClanQueries]; const allQueries = [machinesQuery, ...allClansQueries, serviceInstancesQuery]; @@ -87,12 +71,18 @@ function createClanContext( isError: () => activeClanQuery.isError, showAddMachine, setShowAddMachine, + navigateToRoot: () => { + if (location.pathname === buildClanPath(clanURI)) return; + navigate(buildClanPath(clanURI), { replace: true }); + }, setWorldMode, worldMode, }; } -const ClanContext = createContext(); +const ClanContext = createContext< + ReturnType | undefined +>(); export const useClanContext = () => { const ctx = useContext(ClanContext); @@ -208,34 +198,6 @@ const ClanSceneController = (props: RouteSectionProps) => { }), ); - const client = useApiClient(); - 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, - }, - module_ref: instance.module, - roles: instance.roles, - }); - 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"); - ctx.setWorldMode("select"); - }; - return ( <> @@ -271,13 +233,19 @@ const ClanSceneController = (props: RouteSectionProps) => { cubesQuery={ctx.machinesQuery} toolbarPopup={ - { - ctx.setWorldMode("select"); - currentPromise()?.resolve({ id: "0" }); - }} - /> + { + ctx.setWorldMode("select"); + }} + /> + } + > + {props.children} + } onCreate={onCreate} diff --git a/pkgs/clan-app/ui/src/routes/Service/Service.tsx b/pkgs/clan-app/ui/src/routes/Service/Service.tsx index 9aae7d853..27e2f76be 100644 --- a/pkgs/clan-app/ui/src/routes/Service/Service.tsx +++ b/pkgs/clan-app/ui/src/routes/Service/Service.tsx @@ -1,22 +1,54 @@ -import { RouteSectionProps } from "@solidjs/router"; -import { maybeUseIdParam, useInputParam, useNameParam } from "@/src/hooks/clan"; -import { createEffect } from "solid-js"; +import { RouteSectionProps, useNavigate } from "@solidjs/router"; import { useClanContext } from "@/src/routes/Clan/Clan"; +import { ServiceWorkflow } from "@/src/workflows/Service/Service"; +import { SubmitServiceHandler } from "@/src/workflows/Service/models"; +import { buildClanPath } from "@/src/hooks/clan"; +import { useApiClient } from "@/src/hooks/ApiClient"; +import { useQueryClient } from "@tanstack/solid-query"; +import { clanKey } from "@/src/hooks/queries"; export const Service = (props: RouteSectionProps) => { const ctx = useClanContext(); - console.log("service route"); + const navigate = useNavigate(); - createEffect(() => { - const input = useInputParam(); - const name = useNameParam(); - const id = maybeUseIdParam(); + const client = useApiClient(); - ctx.setWorldMode("service"); + const queryClient = useQueryClient(); - console.log("service", input, name, id); - }); + const handleSubmit: SubmitServiceHandler = async (instance, action) => { + console.log("Service submitted", instance, action); - return <>h1; + if (action !== "create") { + console.warn("Updating service instances is not supported yet"); + return; + } + + const call = client.fetch("create_service_instance", { + flake: { + identifier: ctx.clanURI, + }, + module_ref: instance.module, + roles: instance.roles, + }); + const result = await call.result; + + if (result.status === "error") { + console.error("Error creating service instance", result.errors); + } + + queryClient.invalidateQueries({ + queryKey: clanKey(ctx.clanURI), + }); + + ctx.setWorldMode("select"); + }; + + const handleClose = () => { + console.log("Service closed, navigating back"); + navigate(buildClanPath(ctx.clanURI), { replace: true }); + ctx.setWorldMode("select"); + }; + + return ; }; diff --git a/pkgs/clan-app/ui/src/routes/index.tsx b/pkgs/clan-app/ui/src/routes/index.tsx index 6ce503385..8a9c7fb22 100644 --- a/pkgs/clan-app/ui/src/routes/index.tsx +++ b/pkgs/clan-app/ui/src/routes/index.tsx @@ -35,14 +35,10 @@ export const Routes: RouteDefinition[] = [ { path: "/", }, - { - path: "/services/:input/:name/:id?", - component: Service, - }, ], }, { - path: "/services/:input/:name/:id?", + path: "/services/:name/:id", component: Service, }, ], diff --git a/pkgs/clan-app/ui/src/scene/cubes.tsx b/pkgs/clan-app/ui/src/scene/cubes.tsx index 14e3b4cec..f3f22c04a 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.tsx +++ b/pkgs/clan-app/ui/src/scene/cubes.tsx @@ -807,6 +807,7 @@ export function CubeScene(props: { icon="Services" selected={ctx.worldMode() === "service"} onClick={() => { + ctx.navigateToRoot(); ctx.setWorldMode("service"); }} /> diff --git a/pkgs/clan-app/ui/src/workflows/Service/SelectServiceFlyout.tsx b/pkgs/clan-app/ui/src/workflows/Service/SelectServiceFlyout.tsx new file mode 100644 index 000000000..d57c97ae2 --- /dev/null +++ b/pkgs/clan-app/ui/src/workflows/Service/SelectServiceFlyout.tsx @@ -0,0 +1,120 @@ +import { Search } from "@/src/components/Search/Search"; +import { Typography } from "@/src/components/Typography/Typography"; +import { buildServicePath, useClanURI } from "@/src/hooks/clan"; +import { useServiceInstances, useServiceModules } from "@/src/hooks/queries"; +import { useNavigate } from "@solidjs/router"; +import { createEffect, createSignal, Show } from "solid-js"; +import { Module } from "./models"; +import Icon from "@/src/components/Icon/Icon"; +import { Combobox } from "@kobalte/core/combobox"; +import { useClickOutside } from "@/src/hooks/useClickOutside"; + +interface FlyoutProps { + onClose: () => void; +} +export const SelectService = (props: FlyoutProps) => { + const clanURI = useClanURI(); + + const serviceModulesQuery = useServiceModules(clanURI); + const serviceInstancesQuery = useServiceInstances(clanURI); + + const [moduleOptions, setModuleOptions] = createSignal([]); + + createEffect(() => { + if (serviceModulesQuery.data && serviceInstancesQuery.data) { + setModuleOptions( + serviceModulesQuery.data.modules.map((currService) => ({ + value: `${currService.usage_ref.name}:${currService.usage_ref.input}`, + label: currService.usage_ref.name, + raw: currService, + })), + ); + } + }); + + const handleChange = (module: Module | null) => { + if (!module) return; + + const serviceURL = buildServicePath({ + clanURI, + id: module.raw.instance_refs[0] || module.raw.usage_ref.name, + module: { + name: module.raw.usage_ref.name, + input: module.raw.usage_ref.input, + }, + }); + navigate(serviceURL); + }; + const navigate = useNavigate(); + + let ref: HTMLDivElement; + + useClickOutside( + () => ref, + () => { + props.onClose(); + }, + ); + return ( +
(ref = e)} + class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2" + > +
+ + loading={ + serviceModulesQuery.isLoading || serviceInstancesQuery.isLoading + } + height="13rem" + onChange={handleChange} + options={moduleOptions()} + renderItem={(item, opts) => { + return ( +
+
+ +
+
+ + 0}> +
+ + Added + +
+
+ + {item.label} + +
+ + + {item.raw.info.manifest.description} + + + + by {item.raw.usage_ref.input} + + + +
+
+ ); + }} + /> +
+
+ ); +}; diff --git a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx index d7f04bb96..b30f02036 100644 --- a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx @@ -4,10 +4,9 @@ import { StepperProvider, useStepper, } from "@/src/hooks/stepper"; -import { useClanURI } from "@/src/hooks/clan"; +import { useClanURI, useServiceParams } from "@/src/hooks/clan"; import { MachinesQuery, - ServiceModules, TagsQuery, useMachinesQuery, useServiceInstances, @@ -18,18 +17,16 @@ import { createEffect, createMemo, createSignal, - For, - JSX, Show, on, onMount, + For, + onCleanup, } 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 } from "@modular-forms/solid"; import styles from "./Service.module.css"; import { TextInput } from "@/src/components/Form/TextInput"; @@ -40,152 +37,16 @@ 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["modules"][number]; - -interface Module { - value: string; - label: string; - raw: ModuleItem; -} - -const SelectService = () => { - const clanURI = useClanURI(); - const stepper = useStepper(); - - const serviceModulesQuery = useServiceModules(clanURI); - const serviceInstancesQuery = useServiceInstances(clanURI); - const machinesQuery = useMachinesQuery(clanURI); - - const [moduleOptions, setModuleOptions] = createSignal([]); - createEffect(() => { - if (serviceModulesQuery.data && serviceInstancesQuery.data) { - setModuleOptions( - serviceModulesQuery.data.modules.map((currService) => ({ - value: `${currService.usage_ref.name}:${currService.usage_ref.input}`, - label: currService.usage_ref.name, - raw: currService, - })), - ); - } - }); - const [store, set] = getStepStore(stepper); - - return ( - - loading={serviceModulesQuery.isLoading} - height="13rem" - onChange={(module) => { - if (!module) return; - - set("module", { - name: module.raw.usage_ref.name, - input: module.raw.usage_ref.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.raw.instance_refs.length === 0) { - set("action", "create"); - } else { - if (!serviceInstancesQuery.data) return; - if (!machinesQuery.data) return; - set("action", "update"); - - const instanceName = module.raw.instance_refs[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.data.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, opts) => { - return ( -
-
- -
-
- - 0}> -
- - Added - -
-
- - {item.label} - -
- - - {item.raw.info.manifest.description} - - - - by {item.raw.usage_ref.input} - - - -
-
- ); - }} - /> - ); -}; +import { + getRoleMembers, + RoleType, + ServiceStoreType, + SubmitServiceHandler, +} from "./models"; +import { TagSelect } from "@/src/components/Search/TagSelect"; +import { Tag } from "@/src/components/Tag/Tag"; const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) => createMemo(() => { @@ -215,22 +76,81 @@ const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) => ); }); +const sanitizeModuleInput = ( + input: string | undefined, + core_input_name: string, +) => { + if (!input) return null; + + if (input === core_input_name) return null; + + return input; +}; + interface RolesForm extends FieldValues { roles: Record; instanceName: string; } const ConfigureService = () => { const stepper = useStepper(); + const clanURI = useClanURI(); + const machinesQuery = useMachinesQuery(clanURI); + const serviceModulesQuery = useServiceModules(clanURI); + const serviceInstancesQuery = useServiceInstances(clanURI); + const routerProps = useServiceParams(); + const [store, set] = getStepStore(stepper); const [formStore, { Form, Field }] = createForm({ initialValues: { // Default to the module name, until we support multiple instances - instanceName: store.module.name, + instanceName: routerProps.id, }, }); - const machinesQuery = useMachinesQuery(useClanURI()); + const selectedModule = createMemo(() => { + if (!serviceModulesQuery.data) return undefined; + return serviceModulesQuery.data.modules.find( + (m) => + m.usage_ref.name === routerProps.name && + // left side is string | null + // right side is string | undefined + m.usage_ref.input === + sanitizeModuleInput( + routerProps.input, + serviceModulesQuery.data.core_input_name, + ), + ); + }); + + createEffect( + on( + () => [serviceInstancesQuery.data, machinesQuery.data] as const, + ([instances, machines]) => { + console.log("Effect RUNNING"); + // Wait for all queries to be ready + if (!instances || !machines) return; + + const instance = instances[routerProps.id || routerProps.name]; + + console.log("Data ready, instance", instance ?? "NEW"); + + set("roles", {}); + if (!instance) { + set("action", "create"); + return; + } + + for (const role of Object.keys(instance.roles || {})) { + // Get Role members + const roleMembers = getRoleMembers(instance, machines, role); + set("roles", role, roleMembers); + } + set("action", "update"); + }, + ), + ); + const tagsQuery = useTags(useClanURI()); const options = useOptions(tagsQuery, machinesQuery); @@ -249,13 +169,15 @@ const ConfigureService = () => { }, ]), ); - store.handleSubmit( { name: values.instanceName, module: { - name: store.module.name, - input: store.module.input, + name: routerProps.name, + input: sanitizeModuleInput( + routerProps.input, + serviceModulesQuery.data?.core_input_name || "clan-core", + ), }, roles, }, @@ -271,7 +193,7 @@ const ConfigureService = () => {
- {store.module.name} + {routerProps.name} {(field, input) => ( @@ -294,54 +216,71 @@ const ConfigureService = () => { ghost size="s" class="ml-auto" - onClick={store.close} + onClick={() => store.close()} />
- - {(role) => { - const values = store.roles?.[role] || []; - return ( - - label={role} - renderItem={(item: TagType) => ( - ( - - )} - > - {item.label} - - )} - values={values} - options={options()} - onClick={() => { - set("currentRole", role); - stepper.next(); - }} - /> - ); - }} - + Loading...
} + > + + {(role) => { + const values = store.roles?.[role] || []; + return ( + + label={role} + renderItem={(item: TagType) => ( + ( + + )} + > + {item.label} + + )} + values={values} + options={options()} + onClick={() => { + set("currentRole", role); + stepper.next(); + }} + /> + ); + }} + +
- - -
); }; -type TagType = +export type TagType = | { value: string; label: string; @@ -362,24 +301,29 @@ const ConfigureRole = () => { store.roles?.[store.currentRole || ""] || [], ); + const clanUri = useClanURI(); + const machinesQuery = useMachinesQuery(clanUri); + 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; + createEffect( + on(members, (m) => { + clearAllHighlights(); + setHighlightGroups({ + [store.currentRole as string]: new Set( + m.flatMap((m) => { + if (m.type === "machine") return m.label; - return m.members; - }), - ), - }); + return m.members; + }), + ), + }); + }), + ); - console.log("now", highlightGroups); + onMount(() => { + setHighlightGroups(() => ({})); }); - onMount(() => setHighlightGroups(() => ({}))); createEffect( on(lastClickedMachine, (machine) => { @@ -403,7 +347,6 @@ const ConfigureRole = () => { }), ); - const machinesQuery = useMachinesQuery(useClanURI()); const tagsQuery = useTags(useClanURI()); const options = useOptions(tagsQuery, machinesQuery); @@ -428,12 +371,7 @@ const ConfigureRole = () => { headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")} headerChildren={
- clearAllHighlights()} - /> + { }; const steps = [ - { - id: "select:service", - content: SelectService, - }, { id: "view:members", content: ConfigureService, @@ -522,79 +456,38 @@ const steps = [ export type ServiceSteps = typeof steps; -// TODO: Ideally we would impot this from a backend model package -export interface InventoryInstance { - name: string; - module: { - name: string; - input?: string | null; - }; - roles: Record; -} - -interface RoleType { - machines: Record; - tags: Record; -} - -export interface ServiceStoreType { - module: { - name: string; - input?: string | null; - raw?: ModuleItem; - }; - roles: Record; - currentRole?: string; - close: () => 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; + onClose: () => void; handleSubmit: SubmitServiceHandler; - rootProps?: JSX.HTMLAttributes; } export const ServiceWorkflow = (props: ServiceWorkflowProps) => { const stepper = createStepper( { steps }, { - initialStep: props.initialStep || "select:service", + initialStep: props.initialStep || "view:members", initialStoreData: { ...props.initialStore, - close: () => props.onClose?.(), + close: props.onClose, handleSubmit: props.handleSubmit, } satisfies Partial, }, ); + createEffect(() => { if (stepper.currentStep().id !== "select:members") { clearAllHighlights(); } }); - let ref: HTMLDivElement; - useClickOutside( - () => ref, - () => { - if (stepper.currentStep().id === "select:service") props.onClose?.(); - }, - ); + onCleanup(() => { + console.log("cleanup"); + }); + return ( -
(ref = e)} - id="add-service" - class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2" - {...props.rootProps} - > +
{stepper.currentStep().content()}
diff --git a/pkgs/clan-app/ui/src/workflows/Service/models.ts b/pkgs/clan-app/ui/src/workflows/Service/models.ts new file mode 100644 index 000000000..4a399f826 --- /dev/null +++ b/pkgs/clan-app/ui/src/workflows/Service/models.ts @@ -0,0 +1,81 @@ +import { + MachinesQuery, + ServiceInstancesQuery, + ServiceModules, +} from "@/src/hooks/queries"; +import { TagType } from "./Service"; + +export interface ServiceStoreType { + roles: Record; + currentRole?: string; + close: () => void; + handleSubmit: SubmitServiceHandler; + action: "create" | "update"; +} + +// TODO: Ideally we would impot this from a backend model package +export interface InventoryInstance { + name: string; + module: { + name: string; + input?: string | null; + }; + roles: Record; +} + +export interface RoleType { + machines: Record; + tags: Record; +} + +export type SubmitServiceHandler = ( + values: InventoryInstance, + action: "create" | "update", +) => void | Promise; + +export type ModuleItem = ServiceModules["modules"][number]; + +export interface Module { + value: string; + label: string; + raw: ModuleItem; +} + +type ValueOf = T[keyof T]; + +/** + * Collect all members (machines and tags) for a given role in a service instance + * + * TODO: Make this native feature of the API + * + */ +export function getRoleMembers( + instance: ValueOf>, + all_machines: NonNullable, + role: string, +) { + 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(all_machines) + .filter(([_, m]) => m.data.tags?.includes(t)) + .map(([k]) => k), + }; + }); + console.log("Members for role", role, [...machineTags, ...tagsTags]); + + const roleMembers = [...machineTags, ...tagsTags].sort((a, b) => + a.label.localeCompare(b.label), + ); + return roleMembers; +} From c61a0f0712557034b988db24db2198fc4eba4238 Mon Sep 17 00:00:00 2001 From: Johannes Kirschbauer Date: Tue, 2 Sep 2025 21:12:13 +0200 Subject: [PATCH 3/3] ui/services: wire up with sidebar and router --- .../clan-app/ui/src/components/Sidebar/SidebarBody.tsx | 10 +--------- pkgs/clan-app/ui/src/routes/Clan/Clan.tsx | 6 ++---- pkgs/clan-app/ui/src/routes/Service/Service.tsx | 5 +++++ pkgs/clan-app/ui/src/scene/cubes.tsx | 1 + .../ui/src/workflows/Service/Service.stories.tsx | 3 +++ pkgs/clan-app/ui/src/workflows/Service/Service.tsx | 10 +--------- 6 files changed, 13 insertions(+), 22 deletions(-) diff --git a/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx b/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx index a4a9ae13d..7665ca482 100644 --- a/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx +++ b/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx @@ -5,11 +5,7 @@ import Icon from "../Icon/Icon"; import { Typography } from "@/src/components/Typography/Typography"; import { For, Show } from "solid-js"; import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus"; -import { - buildMachinePath, - buildServicePath, - useClanURI, -} from "@/src/hooks/clan"; +import { buildMachinePath, buildServicePath } from "@/src/hooks/clan"; import { useMachineStateQuery } from "@/src/hooks/queries"; import { SidebarProps } from "./Sidebar"; import { Button } from "../Button/Button"; @@ -204,10 +200,6 @@ const Services = () => { }; export const SidebarBody = (props: SidebarProps) => { - const clanURI = useClanURI(); - - const ctx = useClanContext(); - const sectionLabels = (props.staticSections || []).map( (section) => section.title, ); diff --git a/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx b/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx index 0bd50fbcb..6e895fd44 100644 --- a/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx +++ b/pkgs/clan-app/ui/src/routes/Clan/Clan.tsx @@ -36,11 +36,8 @@ import { Sidebar } from "@/src/components/Sidebar/Sidebar"; import { UseQueryResult } from "@tanstack/solid-query"; import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal"; -import { useApiClient } from "@/src/hooks/ApiClient"; -import toast from "solid-toast"; import { AddMachine } from "@/src/workflows/AddMachine/AddMachine"; import { SelectService } from "@/src/workflows/Service/SelectServiceFlyout"; -import { SubmitServiceHandler } from "@/src/workflows/Service/models"; export type WorldMode = "default" | "select" | "service" | "create" | "move"; @@ -198,6 +195,8 @@ const ClanSceneController = (props: RouteSectionProps) => { }), ); + const location = useLocation(); + return ( <> @@ -237,7 +236,6 @@ const ClanSceneController = (props: RouteSectionProps) => { when={location.pathname.includes("/services/")} fallback={ { ctx.setWorldMode("select"); }} diff --git a/pkgs/clan-app/ui/src/routes/Service/Service.tsx b/pkgs/clan-app/ui/src/routes/Service/Service.tsx index 27e2f76be..c95645282 100644 --- a/pkgs/clan-app/ui/src/routes/Service/Service.tsx +++ b/pkgs/clan-app/ui/src/routes/Service/Service.tsx @@ -6,6 +6,7 @@ import { buildClanPath } from "@/src/hooks/clan"; import { useApiClient } from "@/src/hooks/ApiClient"; import { useQueryClient } from "@tanstack/solid-query"; import { clanKey } from "@/src/hooks/queries"; +import { onMount } from "solid-js"; export const Service = (props: RouteSectionProps) => { const ctx = useClanContext(); @@ -16,6 +17,10 @@ export const Service = (props: RouteSectionProps) => { const queryClient = useQueryClient(); + onMount(() => { + ctx.setWorldMode("service"); + }); + const handleSubmit: SubmitServiceHandler = async (instance, action) => { console.log("Service submitted", instance, action); diff --git a/pkgs/clan-app/ui/src/scene/cubes.tsx b/pkgs/clan-app/ui/src/scene/cubes.tsx index f3f22c04a..504298955 100644 --- a/pkgs/clan-app/ui/src/scene/cubes.tsx +++ b/pkgs/clan-app/ui/src/scene/cubes.tsx @@ -512,6 +512,7 @@ export function CubeScene(props: { if (ctx.worldMode() === "select") props.onSelect(new Set([id])); + console.log("Clicked on machine", id); emitMachineClick(id); // notify subscribers } else { emitMachineClick(null); 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 663a158c8..843a97b0b 100644 --- a/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.stories.tsx @@ -160,6 +160,9 @@ export const SelectRoleMembers: Story = { handleSubmit={(instance) => { console.log("Submitted instance:", instance); }} + onClose={() => { + console.log("Closed"); + }} initialStep="select:members" initialStore={{ currentRole: "peer", diff --git a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx index b30f02036..394714f01 100644 --- a/pkgs/clan-app/ui/src/workflows/Service/Service.tsx +++ b/pkgs/clan-app/ui/src/workflows/Service/Service.tsx @@ -21,7 +21,6 @@ import { on, onMount, For, - onCleanup, } from "solid-js"; import Icon from "@/src/components/Icon/Icon"; import { Combobox } from "@kobalte/core/combobox"; @@ -127,14 +126,11 @@ const ConfigureService = () => { on( () => [serviceInstancesQuery.data, machinesQuery.data] as const, ([instances, machines]) => { - console.log("Effect RUNNING"); // Wait for all queries to be ready if (!instances || !machines) return; const instance = instances[routerProps.id || routerProps.name]; - console.log("Data ready, instance", instance ?? "NEW"); - set("roles", {}); if (!instance) { set("action", "create"); @@ -329,8 +325,8 @@ const ConfigureRole = () => { 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); @@ -482,10 +478,6 @@ export const ServiceWorkflow = (props: ServiceWorkflowProps) => { } }); - onCleanup(() => { - console.log("cleanup"); - }); - return (