diff --git a/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx b/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx
index 9ad81d549..7665ca482 100644
--- a/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx
+++ b/pkgs/clan-app/ui/src/components/Sidebar/SidebarBody.tsx
@@ -5,7 +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, 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";
@@ -33,19 +33,19 @@ const MachineRoute = (props: MachineProps) => {
size="xs"
weight="bold"
color="primary"
- inverted={true}
+ inverted
>
{props.name}
-
+
{props.serviceCount}
@@ -56,18 +56,13 @@ const MachineRoute = (props: MachineProps) => {
);
};
-export const SidebarBody = (props: SidebarProps) => {
- const clanURI = useClanURI();
-
+const Machines = () => {
const ctx = useClanContext();
+ if (!ctx) {
+ throw new Error("ClanContext not found");
+ }
- const sectionLabels = (props.staticSections || []).map(
- (section) => section.title,
- );
-
- // controls which sections are open by default
- // we want them all to be open by default
- const defaultAccordionValues = ["your-machines", ...sectionLabels];
+ const clanURI = ctx.clanURI;
const machines = () => {
if (!ctx.machinesQuery.isSuccess) {
@@ -78,6 +73,141 @@ export const SidebarBody = (props: SidebarProps) => {
return Object.keys(result).length > 0 ? result : undefined;
};
+ return (
+
+
+
+
+
+ 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 (
+
+
+
+
+
+
+ );
+};
+
+export const SidebarBody = (props: SidebarProps) => {
+ const sectionLabels = (props.staticSections || []).map(
+ (section) => section.title,
+ );
+
+ // controls which sections are open by default
+ // we want them all to be open by default
+ const defaultAccordionValues = ["machines", "services", ...sectionLabels];
+
return (
{
multiple
defaultValue={defaultAccordionValues}
>
-
-
-
-
-
- No machines yet
-
-
-
- }
- >
-
-
-
-
+
+
{(section) => (
@@ -156,7 +228,7 @@ export const SidebarBody = (props: SidebarProps) => {
hierarchy="label"
family="mono"
size="xs"
- inverted={true}
+ inverted
color="tertiary"
>
{section.title}
@@ -164,7 +236,7 @@ export const SidebarBody = (props: SidebarProps) => {
@@ -179,7 +251,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..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);
@@ -30,6 +30,47 @@ export const buildClanPath = (clanURI: string) => {
export const buildMachinePath = (clanURI: string, name: string) =>
buildClanPath(clanURI) + "/machines/" + name;
+export const buildServicePath = (props: {
+ clanURI: string;
+ id: string;
+ module: {
+ name: string;
+ input?: string | null | undefined;
+ };
+}) => {
+ const { clanURI, id, module } = props;
+
+ const moduleName = encodeBase64(module.name);
+ const idEncoded = encodeBase64(id);
+
+ const result =
+ 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) => {
const path = buildClanPath(clanURI);
console.log("Navigating to clan", clanURI, path);
@@ -64,7 +105,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..df32c3432 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;
@@ -47,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: {
@@ -64,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", {
@@ -122,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: {
@@ -142,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: {
@@ -166,13 +176,61 @@ export const useMachineStateQuery = (clanURI: string, machineName: string) => {
}));
};
+export const useServiceModulesQuery = (clanURI: string) => {
+ const client = useApiClient();
+
+ return useQuery(() => ({
+ queryKey: [...clanKey(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: [...clanKey(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,
) => {
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: {
@@ -202,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 = {
@@ -253,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
@@ -322,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;
@@ -486,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: {
@@ -506,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 7128f95f0..6e895fd44 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,
@@ -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";
@@ -33,37 +35,27 @@ 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";
-interface ClanContextProps {
- clanURI: string;
- machinesQuery: MachinesQueryResult;
- activeClanQuery: UseQueryResult;
- otherClanQueries: UseQueryResult[];
- allClansQueries: UseQueryResult[];
-
- isLoading(): boolean;
- isError(): boolean;
-
- showAddMachine(): boolean;
- setShowAddMachine(value: boolean): void;
-}
+export type WorldMode = "default" | "select" | "service" | "create" | "move";
function createClanContext(
clanURI: string,
machinesQuery: MachinesQueryResult,
activeClanQuery: UseQueryResult,
otherClanQueries: UseQueryResult[],
+ serviceInstancesQuery: UseQueryResult,
) {
+ const [worldMode, setWorldMode] = createSignal("select");
const [showAddMachine, setShowAddMachine] = createSignal(false);
+
+ const navigate = useNavigate();
+ const location = useLocation();
+
const allClansQueries = [activeClanQuery, ...otherClanQueries];
- const allQueries = [machinesQuery, ...allClansQueries];
+ const allQueries = [machinesQuery, ...allClansQueries, serviceInstancesQuery];
return {
clanURI,
@@ -71,14 +63,23 @@ function createClanContext(
activeClanQuery,
otherClanQueries,
allClansQueries,
+ serviceInstancesQuery,
isLoading: () => allQueries.some((q) => q.isLoading),
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);
@@ -104,12 +105,14 @@ export const Clan: Component = (props) => {
);
const machinesQuery = useMachinesQuery(clanURI);
+ const serviceInstancesQuery = useServiceInstancesQuery(clanURI);
const ctx = createClanContext(
clanURI,
machinesQuery,
activeClanQuery,
otherClanQueries,
+ serviceInstancesQuery,
);
return (
@@ -192,33 +195,7 @@ 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");
- setWorldMode("select");
- };
+ const location = useLocation();
return (
<>
@@ -254,14 +231,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
isLoading={ctx.isLoading()}
cubesQuery={ctx.machinesQuery}
toolbarPopup={
-
- {
- setWorldMode("select");
- currentPromise()?.resolve({ id: "0" });
- }}
- />
+
+ {
+ ctx.setWorldMode("select");
+ }}
+ />
+ }
+ >
+ {props.children}
+
}
onCreate={onCreate}
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..c95645282
--- /dev/null
+++ b/pkgs/clan-app/ui/src/routes/Service/Service.tsx
@@ -0,0 +1,59 @@
+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";
+import { onMount } from "solid-js";
+
+export const Service = (props: RouteSectionProps) => {
+ const ctx = useClanContext();
+
+ const navigate = useNavigate();
+
+ const client = useApiClient();
+
+ const queryClient = useQueryClient();
+
+ onMount(() => {
+ ctx.setWorldMode("service");
+ });
+
+ const handleSubmit: SubmitServiceHandler = async (instance, action) => {
+ console.log("Service submitted", instance, action);
+
+ 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 c18c1fb92..8a9c7fb22 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,15 @@ export const Routes: RouteDefinition[] = [
{
path: "/machines/:machineName",
component: Machine,
+ children: [
+ {
+ path: "/",
+ },
+ ],
+ },
+ {
+ 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 5053a3099..504298955 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,14 @@ export function CubeScene(props: {
if (!id) return;
- if (worldMode() === "select") props.onSelect(new Set([id]));
+ if (ctx.worldMode() === "select") props.onSelect(new Set([id]));
+ console.log("Clicked on machine", 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 +559,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 +569,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 +595,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 +689,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 +731,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 +751,7 @@ export function CubeScene(props: {
};
createEffect(
- on(worldMode, (mode) => {
+ on(ctx.worldMode, (mode) => {
console.log("World mode changed to", mode);
}),
);
@@ -775,10 +774,10 @@ export function CubeScene(props: {
(container = el)}
@@ -792,24 +791,25 @@ 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.navigateToRoot();
+ ctx.setWorldMode("service");
}}
/>
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.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 d7f04bb96..394714f01 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,15 @@ import {
createEffect,
createMemo,
createSignal,
- For,
- JSX,
Show,
on,
onMount,
+ For,
} 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 +36,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 +75,78 @@ 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]) => {
+ // Wait for all queries to be ready
+ if (!instances || !machines) return;
+
+ const instance = instances[routerProps.id || routerProps.name];
+
+ 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 +165,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 +189,7 @@ const ConfigureService = () => {
- {store.module.name}
+ {routerProps.name}
{(field, input) => (
@@ -294,54 +212,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,31 +297,36 @@ 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) => {
// 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);
@@ -403,7 +343,6 @@ const ConfigureRole = () => {
}),
);
- const machinesQuery = useMachinesQuery(useClanURI());
const tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery);
@@ -428,12 +367,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 +452,34 @@ 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?.();
- },
- );
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;
+}