Merge pull request 'feat(ui): services in sidebar and sidebar pane' (#5072) from ui/list-services-sidebar into main

Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/5072
This commit is contained in:
hsjobeki
2025-09-02 19:16:49 +00:00
14 changed files with 873 additions and 446 deletions

View File

@@ -5,7 +5,7 @@ import Icon from "../Icon/Icon";
import { Typography } from "@/src/components/Typography/Typography"; import { Typography } from "@/src/components/Typography/Typography";
import { For, Show } from "solid-js"; import { For, Show } from "solid-js";
import { MachineStatus } from "@/src/components/MachineStatus/MachineStatus"; 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 { useMachineStateQuery } from "@/src/hooks/queries";
import { SidebarProps } from "./Sidebar"; import { SidebarProps } from "./Sidebar";
import { Button } from "../Button/Button"; import { Button } from "../Button/Button";
@@ -33,19 +33,19 @@ const MachineRoute = (props: MachineProps) => {
size="xs" size="xs"
weight="bold" weight="bold"
color="primary" color="primary"
inverted={true} inverted
> >
{props.name} {props.name}
</Typography> </Typography>
<MachineStatus status={status()} /> <MachineStatus status={status()} />
</div> </div>
<div class="flex w-full flex-row items-center gap-1"> <div class="flex w-full flex-row items-center gap-1">
<Icon icon="Flash" size="0.75rem" inverted={true} color="tertiary" /> <Icon icon="Flash" size="0.75rem" inverted color="tertiary" />
<Typography <Typography
hierarchy="label" hierarchy="label"
family="mono" family="mono"
size="s" size="s"
inverted={true} inverted
color="primary" color="primary"
> >
{props.serviceCount} {props.serviceCount}
@@ -56,18 +56,13 @@ const MachineRoute = (props: MachineProps) => {
); );
}; };
export const SidebarBody = (props: SidebarProps) => { const Machines = () => {
const clanURI = useClanURI();
const ctx = useClanContext(); const ctx = useClanContext();
if (!ctx) {
throw new Error("ClanContext not found");
}
const sectionLabels = (props.staticSections || []).map( const clanURI = ctx.clanURI;
(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 machines = () => { const machines = () => {
if (!ctx.machinesQuery.isSuccess) { if (!ctx.machinesQuery.isSuccess) {
@@ -78,6 +73,141 @@ export const SidebarBody = (props: SidebarProps) => {
return Object.keys(result).length > 0 ? result : undefined; return Object.keys(result).length > 0 ? result : undefined;
}; };
return (
<Accordion.Item class="item" value="machines">
<Accordion.Header class="header">
<Accordion.Trigger class="trigger">
<Typography
class="section-title"
hierarchy="label"
family="mono"
size="xs"
inverted
color="tertiary"
>
Your Machines
</Typography>
<Icon icon="CaretDown" color="tertiary" inverted size="0.75rem" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="content">
<Show
when={machines()}
fallback={
<div class="flex w-full flex-col items-center justify-center gap-2.5">
<Typography hierarchy="body" size="s" weight="medium" inverted>
No machines yet
</Typography>
<Button
hierarchy="primary"
size="s"
startIcon="Machine"
onClick={() => ctx.setShowAddMachine(true)}
>
Add machine
</Button>
</div>
}
>
<nav>
<For each={Object.entries(machines()!)}>
{([id, machine]) => (
<MachineRoute
clanURI={clanURI}
machineID={id}
name={machine.data.name || id}
serviceCount={machine?.instance_refs?.length ?? 0}
/>
)}
</For>
</nav>
</Show>
</Accordion.Content>
</Accordion.Item>
);
};
export const ServiceRoute = (props: {
clanURI: string;
machineName?: string;
label: string;
id: string;
module: { input?: string | null | undefined; name: string };
}) => (
<A href={buildServicePath(props)} replace={true}>
<div class="flex w-full flex-col gap-2">
<Typography
hierarchy="label"
size="s"
weight="bold"
color="primary"
inverted
>
{props.label}
</Typography>
</div>
</A>
);
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 (
<Accordion.Item class="item" value="services">
<Accordion.Header class="header">
<Accordion.Trigger class="trigger">
<Typography
class="section-title"
hierarchy="label"
family="mono"
size="xs"
inverted
color="tertiary"
>
Services
</Typography>
<Icon icon="CaretDown" color="tertiary" inverted size="0.75rem" />
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="content">
<nav>
<For each={serviceInstances()}>
{(instance) => <ServiceRoute clanURI={ctx.clanURI} {...instance} />}
</For>
</nav>
</Accordion.Content>
</Accordion.Item>
);
};
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 ( return (
<div class="sidebar-body"> <div class="sidebar-body">
<Accordion <Accordion
@@ -85,66 +215,8 @@ export const SidebarBody = (props: SidebarProps) => {
multiple multiple
defaultValue={defaultAccordionValues} defaultValue={defaultAccordionValues}
> >
<Accordion.Item class="item" value="your-machines"> <Machines />
<Accordion.Header class="header"> <Services />
<Accordion.Trigger class="trigger">
<Typography
class="section-title"
hierarchy="label"
family="mono"
size="xs"
inverted={true}
color="tertiary"
>
Your Machines
</Typography>
<Icon
icon="CaretDown"
color="tertiary"
inverted={true}
size="0.75rem"
/>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content class="content">
<Show
when={machines()}
fallback={
<div class="flex w-full flex-col items-center justify-center gap-2.5">
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
>
No machines yet
</Typography>
<Button
hierarchy="primary"
size="s"
startIcon="Machine"
onClick={() => ctx.setShowAddMachine(true)}
>
Add machine
</Button>
</div>
}
>
<nav>
<For each={Object.entries(machines()!)}>
{([id, machine]) => (
<MachineRoute
clanURI={clanURI}
machineID={id}
name={machine.data.name || id}
serviceCount={0}
/>
)}
</For>
</nav>
</Show>
</Accordion.Content>
</Accordion.Item>
<For each={props.staticSections}> <For each={props.staticSections}>
{(section) => ( {(section) => (
@@ -156,7 +228,7 @@ export const SidebarBody = (props: SidebarProps) => {
hierarchy="label" hierarchy="label"
family="mono" family="mono"
size="xs" size="xs"
inverted={true} inverted
color="tertiary" color="tertiary"
> >
{section.title} {section.title}
@@ -164,7 +236,7 @@ export const SidebarBody = (props: SidebarProps) => {
<Icon <Icon
icon="CaretDown" icon="CaretDown"
color="tertiary" color="tertiary"
inverted={true} inverted
size="0.75rem" size="0.75rem"
/> />
</Accordion.Trigger> </Accordion.Trigger>
@@ -179,7 +251,7 @@ export const SidebarBody = (props: SidebarProps) => {
size="xs" size="xs"
weight="bold" weight="bold"
color="primary" color="primary"
inverted={true} inverted
> >
{link.label} {link.label}
</Typography> </Typography>

View File

@@ -1,6 +1,6 @@
import { callApi } from "@/src/hooks/api"; import { callApi } from "@/src/hooks/api";
import { addClanURI, setActiveClanURI } from "@/src/stores/clan"; 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 encodeBase64 = (value: string) => window.btoa(value);
export const decodeBase64 = (value: string) => window.atob(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) => export const buildMachinePath = (clanURI: string, name: string) =>
buildClanPath(clanURI) + "/machines/" + name; 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) => { export const navigateToClan = (navigate: Navigator, clanURI: string) => {
const path = buildClanPath(clanURI); const path = buildClanPath(clanURI);
console.log("Navigating to clan", clanURI, path); console.log("Navigating to clan", clanURI, path);
@@ -64,7 +105,21 @@ export const machineNameParam = (params: Params) => {
return params.machineName; 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 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 => { export const maybeUseMachineName = (): string | null => {
const params = useParams(); const params = useParams();

View File

@@ -25,6 +25,9 @@ export type MachineStatus = MachineState["status"];
export type ListMachines = SuccessData<"list_machines">; export type ListMachines = SuccessData<"list_machines">;
export type MachineDetails = SuccessData<"get_machine_details">; export type MachineDetails = SuccessData<"get_machine_details">;
export type ListServiceModules = SuccessData<"list_service_modules">;
export type ListServiceInstances = SuccessData<"list_service_instances">;
export interface MachineDetail { export interface MachineDetail {
tags: Tags; tags: Tags;
machine: Machine; machine: Machine;
@@ -47,7 +50,7 @@ export const useMachinesQuery = (clanURI: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<ListMachines>(() => ({ return useQuery<ListMachines>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machines"], queryKey: [...clanKey(clanURI), "machines"],
queryFn: async () => { queryFn: async () => {
const api = client.fetch("list_machines", { const api = client.fetch("list_machines", {
flake: { 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) => { export const useMachineQuery = (clanURI: string, machineName: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<MachineDetail>(() => ({ return useQuery<MachineDetail>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName], queryKey: [machineKey(clanURI, machineName)],
queryFn: async () => { queryFn: async () => {
const [tagsCall, machineCall, schemaCall] = [ const [tagsCall, machineCall, schemaCall] = [
client.fetch("list_tags", { client.fetch("list_tags", {
@@ -122,7 +131,7 @@ export type TagsQuery = ReturnType<typeof useTags>;
export const useTags = (clanURI: string) => { export const useTags = (clanURI: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery(() => ({ return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanURI), "tags"], queryKey: [...clanKey(clanURI), "tags"],
queryFn: async () => { queryFn: async () => {
const apiCall = client.fetch("list_tags", { const apiCall = client.fetch("list_tags", {
flake: { flake: {
@@ -142,8 +151,9 @@ export const useTags = (clanURI: string) => {
export const useMachineStateQuery = (clanURI: string, machineName: string) => { export const useMachineStateQuery = (clanURI: string, machineName: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<MachineState>(() => ({ return useQuery<MachineState>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine", machineName, "state"], queryKey: [...machineKey(clanURI, machineName), "state"],
staleTime: 60_000, // 1 minute stale time staleTime: 60_000, // 1 minute stale time
enabled: false,
queryFn: async () => { queryFn: async () => {
const apiCall = client.fetch("get_machine_state", { const apiCall = client.fetch("get_machine_state", {
machine: { machine: {
@@ -166,13 +176,61 @@ export const useMachineStateQuery = (clanURI: string, machineName: string) => {
})); }));
}; };
export const useServiceModulesQuery = (clanURI: string) => {
const client = useApiClient();
return useQuery<ListServiceModules>(() => ({
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<ListServiceInstances>(() => ({
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 = ( export const useMachineDetailsQuery = (
clanURI: string, clanURI: string,
machineName: string, machineName: string,
) => { ) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<MachineDetails>(() => ({ return useQuery<MachineDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "machine_detail", machineName], queryKey: [machineKey(clanURI, machineName), "details"],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("get_machine_details", { const call = client.fetch("get_machine_details", {
machine: { machine: {
@@ -202,7 +260,7 @@ export const ClanDetailsPersister = experimental_createQueryPersister({
export const useClanDetailsQuery = (clanURI: string) => { export const useClanDetailsQuery = (clanURI: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery<ClanDetails>(() => ({ return useQuery<ClanDetails>(() => ({
queryKey: ["clans", encodeBase64(clanURI), "details"], queryKey: [...clanKey(clanURI), "details"],
persister: ClanDetailsPersister.persisterFn, persister: ClanDetailsPersister.persisterFn,
queryFn: async () => { queryFn: async () => {
const args = { const args = {
@@ -253,7 +311,8 @@ export const useClanListQuery = (
return useQueries(() => ({ return useQueries(() => ({
queries: clanURIs.map((clanURI) => { queries: clanURIs.map((clanURI) => {
const queryKey = ["clans", encodeBase64(clanURI), "details"]; // @BMG: Is duplicating query key intentional?
const queryKey = [...clanKey(clanURI), "details"];
return { return {
// eslint-disable-next-line @tanstack/query/exhaustive-deps // eslint-disable-next-line @tanstack/query/exhaustive-deps
@@ -322,7 +381,7 @@ export type MachineFlashOptionsQuery = UseQueryResult<MachineFlashOptions>;
export const useMachineFlashOptions = (): MachineFlashOptionsQuery => { export const useMachineFlashOptions = (): MachineFlashOptionsQuery => {
const client = useApiClient(); const client = useApiClient();
return useQuery<MachineFlashOptions>(() => ({ return useQuery<MachineFlashOptions>(() => ({
queryKey: ["clans", "machine_flash_options"], queryKey: ["flash_options"],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("get_machine_flash_options", {}); const call = client.fetch("get_machine_flash_options", {});
const result = await call.result; const result = await call.result;
@@ -486,7 +545,7 @@ export type ServiceModules = SuccessData<"list_service_modules">;
export const useServiceModules = (clanUri: string) => { export const useServiceModules = (clanUri: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery(() => ({ return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanUri), "service_modules"], queryKey: [...clanKey(clanUri), "service_modules"],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("list_service_modules", { const call = client.fetch("list_service_modules", {
flake: { flake: {
@@ -506,12 +565,14 @@ export const useServiceModules = (clanUri: string) => {
})); }));
}; };
export const clanKey = (clanUri: string) => ["clans", encodeBase64(clanUri)];
export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>; export type ServiceInstancesQuery = ReturnType<typeof useServiceInstances>;
export type ServiceInstances = SuccessData<"list_service_instances">; export type ServiceInstances = SuccessData<"list_service_instances">;
export const useServiceInstances = (clanUri: string) => { export const useServiceInstances = (clanUri: string) => {
const client = useApiClient(); const client = useApiClient();
return useQuery(() => ({ return useQuery(() => ({
queryKey: ["clans", encodeBase64(clanUri), "service_instances"], queryKey: [...clanKey(clanUri), "service_instances"],
queryFn: async () => { queryFn: async () => {
const call = client.fetch("list_service_instances", { const call = client.fetch("list_service_instances", {
flake: { flake: {

View File

@@ -1,4 +1,4 @@
import { RouteSectionProps, useNavigate } from "@solidjs/router"; import { RouteSectionProps, useLocation, useNavigate } from "@solidjs/router";
import { import {
Component, Component,
createContext, createContext,
@@ -17,13 +17,15 @@ import {
useClanURI, useClanURI,
useMachineName, useMachineName,
} from "@/src/hooks/clan"; } from "@/src/hooks/clan";
import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes"; import { CubeScene } from "@/src/scene/cubes";
import { import {
ClanDetails, ClanDetails,
ListServiceInstances,
MachinesQueryResult, MachinesQueryResult,
useClanDetailsQuery, useClanDetailsQuery,
useClanListQuery, useClanListQuery,
useMachinesQuery, useMachinesQuery,
useServiceInstancesQuery,
} from "@/src/hooks/queries"; } from "@/src/hooks/queries";
import { clanURIs, setStore, store } from "@/src/stores/clan"; import { clanURIs, setStore, store } from "@/src/stores/clan";
import { produce } from "solid-js/store"; import { produce } from "solid-js/store";
@@ -33,37 +35,27 @@ import styles from "./Clan.module.css";
import { Sidebar } from "@/src/components/Sidebar/Sidebar"; import { Sidebar } from "@/src/components/Sidebar/Sidebar";
import { UseQueryResult } from "@tanstack/solid-query"; import { UseQueryResult } from "@tanstack/solid-query";
import { ListClansModal } from "@/src/modals/ListClansModal/ListClansModal"; 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 { AddMachine } from "@/src/workflows/AddMachine/AddMachine";
import { SelectService } from "@/src/workflows/Service/SelectServiceFlyout";
interface ClanContextProps { export type WorldMode = "default" | "select" | "service" | "create" | "move";
clanURI: string;
machinesQuery: MachinesQueryResult;
activeClanQuery: UseQueryResult<ClanDetails>;
otherClanQueries: UseQueryResult<ClanDetails>[];
allClansQueries: UseQueryResult<ClanDetails>[];
isLoading(): boolean;
isError(): boolean;
showAddMachine(): boolean;
setShowAddMachine(value: boolean): void;
}
function createClanContext( function createClanContext(
clanURI: string, clanURI: string,
machinesQuery: MachinesQueryResult, machinesQuery: MachinesQueryResult,
activeClanQuery: UseQueryResult<ClanDetails>, activeClanQuery: UseQueryResult<ClanDetails>,
otherClanQueries: UseQueryResult<ClanDetails>[], otherClanQueries: UseQueryResult<ClanDetails>[],
serviceInstancesQuery: UseQueryResult<ListServiceInstances>,
) { ) {
const [worldMode, setWorldMode] = createSignal<WorldMode>("select");
const [showAddMachine, setShowAddMachine] = createSignal(false); const [showAddMachine, setShowAddMachine] = createSignal(false);
const navigate = useNavigate();
const location = useLocation();
const allClansQueries = [activeClanQuery, ...otherClanQueries]; const allClansQueries = [activeClanQuery, ...otherClanQueries];
const allQueries = [machinesQuery, ...allClansQueries]; const allQueries = [machinesQuery, ...allClansQueries, serviceInstancesQuery];
return { return {
clanURI, clanURI,
@@ -71,14 +63,23 @@ function createClanContext(
activeClanQuery, activeClanQuery,
otherClanQueries, otherClanQueries,
allClansQueries, allClansQueries,
serviceInstancesQuery,
isLoading: () => allQueries.some((q) => q.isLoading), isLoading: () => allQueries.some((q) => q.isLoading),
isError: () => activeClanQuery.isError, isError: () => activeClanQuery.isError,
showAddMachine, showAddMachine,
setShowAddMachine, setShowAddMachine,
navigateToRoot: () => {
if (location.pathname === buildClanPath(clanURI)) return;
navigate(buildClanPath(clanURI), { replace: true });
},
setWorldMode,
worldMode,
}; };
} }
const ClanContext = createContext<ClanContextProps>(); const ClanContext = createContext<
ReturnType<typeof createClanContext> | undefined
>();
export const useClanContext = () => { export const useClanContext = () => {
const ctx = useContext(ClanContext); const ctx = useContext(ClanContext);
@@ -104,12 +105,14 @@ export const Clan: Component<RouteSectionProps> = (props) => {
); );
const machinesQuery = useMachinesQuery(clanURI); const machinesQuery = useMachinesQuery(clanURI);
const serviceInstancesQuery = useServiceInstancesQuery(clanURI);
const ctx = createClanContext( const ctx = createClanContext(
clanURI, clanURI,
machinesQuery, machinesQuery,
activeClanQuery, activeClanQuery,
otherClanQueries, otherClanQueries,
serviceInstancesQuery,
); );
return ( return (
@@ -192,33 +195,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
}), }),
); );
const client = useApiClient(); const location = useLocation();
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");
};
return ( return (
<> <>
@@ -254,14 +231,19 @@ const ClanSceneController = (props: RouteSectionProps) => {
isLoading={ctx.isLoading()} isLoading={ctx.isLoading()}
cubesQuery={ctx.machinesQuery} cubesQuery={ctx.machinesQuery}
toolbarPopup={ toolbarPopup={
<Show when={worldMode() === "service"}> <Show when={ctx.worldMode() === "service"}>
<ServiceWorkflow <Show
handleSubmit={handleSubmitService} when={location.pathname.includes("/services/")}
onClose={() => { fallback={
setWorldMode("select"); <SelectService
currentPromise()?.resolve({ id: "0" }); onClose={() => {
}} ctx.setWorldMode("select");
/> }}
/>
}
>
{props.children}
</Show>
</Show> </Show>
} }
onCreate={onCreate} onCreate={onCreate}

View File

@@ -10,6 +10,7 @@ import { SidebarMachineStatus } from "@/src/components/Sidebar/SidebarMachineSta
import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall"; import { SidebarSectionInstall } from "@/src/components/Sidebar/SidebarSectionInstall";
import styles from "./Machine.module.css"; import styles from "./Machine.module.css";
import { SectionServices } from "@/src/routes/Machine/SectionServices";
export const Machine = (props: RouteSectionProps) => { export const Machine = (props: RouteSectionProps) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -62,6 +63,7 @@ export const Machine = (props: RouteSectionProps) => {
/> />
<SectionGeneral {...sectionProps} /> <SectionGeneral {...sectionProps} />
<SectionTags {...sectionProps} /> <SectionTags {...sectionProps} />
<SectionServices />
</> </>
); );
}; };

View File

@@ -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;
}
}
}

View File

@@ -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 (
<Show when={ctx.serviceInstancesQuery.isSuccess}>
<SidebarSection title="Services">
<div class={styles.sectionServices}>
<nav>
<For each={services()}>
{(instance) => (
<ServiceRoute
clanURI={ctx.clanURI}
machineName={useMachineName()}
{...instance}
/>
)}
</For>
</nav>
</div>
</SidebarSection>
</Show>
);
};

View File

@@ -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 <ServiceWorkflow handleSubmit={handleSubmit} onClose={handleClose} />;
};

View File

@@ -2,6 +2,7 @@ import type { RouteDefinition } from "@solidjs/router/dist/types";
import { Onboarding } from "@/src/routes/Onboarding/Onboarding"; import { Onboarding } from "@/src/routes/Onboarding/Onboarding";
import { Clan } from "@/src/routes/Clan/Clan"; import { Clan } from "@/src/routes/Clan/Clan";
import { Machine } from "@/src/routes/Machine/Machine"; import { Machine } from "@/src/routes/Machine/Machine";
import { Service } from "@/src/routes/Service/Service";
export const Routes: RouteDefinition[] = [ export const Routes: RouteDefinition[] = [
{ {
@@ -30,6 +31,15 @@ export const Routes: RouteDefinition[] = [
{ {
path: "/machines/:machineName", path: "/machines/:machineName",
component: Machine, component: Machine,
children: [
{
path: "/",
},
],
},
{
path: "/services/:name/:id",
component: Service,
}, },
], ],
}, },

View File

@@ -31,6 +31,7 @@ import {
setHighlightGroups, setHighlightGroups,
} from "./highlightStore"; } from "./highlightStore";
import { createMachineMesh } from "./MachineRepr"; import { createMachineMesh } from "./MachineRepr";
import { useClanContext } from "@/src/routes/Clan/Clan";
function intersectMachines( function intersectMachines(
event: MouseEvent, event: MouseEvent,
@@ -94,12 +95,6 @@ export function useMachineClick() {
return lastClickedMachine; return lastClickedMachine;
} }
/*Gloabl signal*/
const [worldMode, setWorldMode] = createSignal<
"default" | "select" | "service" | "create" | "move"
>("select");
export { worldMode, setWorldMode };
export function CubeScene(props: { export function CubeScene(props: {
cubesQuery: MachinesQueryResult; cubesQuery: MachinesQueryResult;
onCreate: () => Promise<{ id: string }>; onCreate: () => Promise<{ id: string }>;
@@ -111,6 +106,8 @@ export function CubeScene(props: {
clanURI: string; clanURI: string;
toolbarPopup?: JSX.Element; toolbarPopup?: JSX.Element;
}) { }) {
const ctx = useClanContext();
let container: HTMLDivElement; let container: HTMLDivElement;
let scene: THREE.Scene; let scene: THREE.Scene;
let camera: THREE.OrthographicCamera; let camera: THREE.OrthographicCamera;
@@ -440,7 +437,7 @@ export function CubeScene(props: {
updateCameraInfo(); updateCameraInfo();
createEffect( createEffect(
on(worldMode, (mode) => { on(ctx.worldMode, (mode) => {
if (mode === "create") { if (mode === "create") {
actionBase!.visible = true; actionBase!.visible = true;
} else { } else {
@@ -466,7 +463,7 @@ export function CubeScene(props: {
// - Select/deselects a cube in mode // - Select/deselects a cube in mode
// - Creates a new cube in "create" mode // - Creates a new cube in "create" mode
const onClick = (event: MouseEvent) => { const onClick = (event: MouseEvent) => {
if (worldMode() === "create") { if (ctx.worldMode() === "create") {
props props
.onCreate() .onCreate()
.then(({ id }) => { .then(({ id }) => {
@@ -484,16 +481,16 @@ export function CubeScene(props: {
.finally(() => { .finally(() => {
if (actionBase) actionBase.visible = false; if (actionBase) actionBase.visible = false;
setWorldMode("select"); ctx.setWorldMode("select");
}); });
} }
if (worldMode() === "move") { if (ctx.worldMode() === "move") {
const currId = menuIntersection().at(0); const currId = menuIntersection().at(0);
const pos = cursorPosition(); const pos = cursorPosition();
if (!currId || !pos) return; if (!currId || !pos) return;
props.setMachinePos(currId, pos); props.setMachinePos(currId, pos);
setWorldMode("select"); ctx.setWorldMode("select");
clearHighlight("move"); clearHighlight("move");
} }
@@ -513,13 +510,14 @@ export function CubeScene(props: {
if (!id) return; if (!id) return;
if (worldMode() === "select") props.onSelect(new Set<string>([id])); if (ctx.worldMode() === "select") props.onSelect(new Set<string>([id]));
console.log("Clicked on machine", id);
emitMachineClick(id); // notify subscribers emitMachineClick(id); // notify subscribers
} else { } else {
emitMachineClick(null); emitMachineClick(null);
if (worldMode() === "select") props.onSelect(new Set<string>()); if (ctx.worldMode() === "select") props.onSelect(new Set<string>());
} }
}; };
@@ -561,7 +559,7 @@ export function CubeScene(props: {
if (e.button === 0) { if (e.button === 0) {
// Left button // Left button
if (worldMode() === "select" && machines.length) { if (ctx.worldMode() === "select" && machines.length) {
// Disable controls to avoid conflict // Disable controls to avoid conflict
controls.enabled = false; controls.enabled = false;
@@ -571,7 +569,7 @@ export function CubeScene(props: {
// Set machine as flying // Set machine as flying
setHighlightGroups({ move: new Set(machines) }); setHighlightGroups({ move: new Set(machines) });
setWorldMode("move"); ctx.setWorldMode("move");
renderLoop.requestRender(); renderLoop.requestRender();
}, 500); }, 500);
setCancelMove(cancelMove); setCancelMove(cancelMove);
@@ -597,14 +595,14 @@ export function CubeScene(props: {
// Always re-enable controls // Always re-enable controls
controls.enabled = true; controls.enabled = true;
if (worldMode() === "move") { if (ctx.worldMode() === "move") {
// Set machine as not flying // Set machine as not flying
props.setMachinePos( props.setMachinePos(
highlightGroups["move"].values().next().value!, highlightGroups["move"].values().next().value!,
cursorPosition() || null, cursorPosition() || null,
); );
clearHighlight("move"); clearHighlight("move");
setWorldMode("select"); ctx.setWorldMode("select");
renderLoop.requestRender(); renderLoop.requestRender();
} }
} }
@@ -691,13 +689,14 @@ export function CubeScene(props: {
const onAddClick = (event: MouseEvent) => { const onAddClick = (event: MouseEvent) => {
setPositionMode("grid"); setPositionMode("grid");
setWorldMode("create"); ctx.setWorldMode("create");
renderLoop.requestRender(); renderLoop.requestRender();
}; };
const onMouseMove = (event: MouseEvent) => { 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; if (!actionRepr) return;
actionRepr.visible = true; actionRepr.visible = true;
@@ -732,7 +731,7 @@ export function CubeScene(props: {
} }
}; };
const handleMenuSelect = (mode: "move") => { const handleMenuSelect = (mode: "move") => {
setWorldMode(mode); ctx.setWorldMode(mode);
setHighlightGroups({ move: new Set(menuIntersection()) }); setHighlightGroups({ move: new Set(menuIntersection()) });
// Find the position of the first selected machine // Find the position of the first selected machine
@@ -752,7 +751,7 @@ export function CubeScene(props: {
}; };
createEffect( createEffect(
on(worldMode, (mode) => { on(ctx.worldMode, (mode) => {
console.log("World mode changed to", mode); console.log("World mode changed to", mode);
}), }),
); );
@@ -775,10 +774,10 @@ export function CubeScene(props: {
<div <div
class={cx( class={cx(
"cubes-scene-container", "cubes-scene-container",
worldMode() === "default" && "cursor-no-drop", ctx.worldMode() === "default" && "cursor-no-drop",
worldMode() === "select" && "cursor-pointer", ctx.worldMode() === "select" && "cursor-pointer",
worldMode() === "service" && "cursor-pointer", ctx.worldMode() === "service" && "cursor-pointer",
worldMode() === "create" && "cursor-cell", ctx.worldMode() === "create" && "cursor-cell",
isDragging() && "!cursor-grabbing", isDragging() && "!cursor-grabbing",
)} )}
ref={(el) => (container = el)} ref={(el) => (container = el)}
@@ -792,24 +791,25 @@ export function CubeScene(props: {
description="Select machine" description="Select machine"
name="Select" name="Select"
icon="Cursor" icon="Cursor"
onClick={() => setWorldMode("select")} onClick={() => ctx.setWorldMode("select")}
selected={worldMode() === "select"} selected={ctx.worldMode() === "select"}
/> />
<ToolbarButton <ToolbarButton
description="Create new machine" description="Create new machine"
name="new-machine" name="new-machine"
icon="NewMachine" icon="NewMachine"
onClick={onAddClick} onClick={onAddClick}
selected={worldMode() === "create"} selected={ctx.worldMode() === "create"}
/> />
<Divider orientation="vertical" /> <Divider orientation="vertical" />
<ToolbarButton <ToolbarButton
description="Add new Service" description="Add new Service"
name="modules" name="modules"
icon="Services" icon="Services"
selected={worldMode() === "service"} selected={ctx.worldMode() === "service"}
onClick={() => { onClick={() => {
setWorldMode("service"); ctx.navigateToRoot();
ctx.setWorldMode("service");
}} }}
/> />
<ToolbarButton <ToolbarButton

View File

@@ -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<Module[]>([]);
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 (
<div
ref={(e) => (ref = e)}
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
>
<div class="w-[30rem]">
<Search<Module>
loading={
serviceModulesQuery.isLoading || serviceInstancesQuery.isLoading
}
height="13rem"
onChange={handleChange}
options={moduleOptions()}
renderItem={(item, opts) => {
return (
<div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4">
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
<Icon icon="Code" />
</div>
<div class="flex w-full flex-col">
<Combobox.ItemLabel class="flex gap-1.5">
<Show when={item.raw.instance_refs.length > 0}>
<div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5">
<Typography hierarchy="label" weight="bold" size="xxs">
Added
</Typography>
</div>
</Show>
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
>
{item.label}
</Typography>
</Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xxs"
weight="normal"
color="quaternary"
inverted
class="flex justify-between"
>
<span class="inline-block max-w-80 truncate align-middle">
{item.raw.info.manifest.description}
</span>
<span class="inline-block max-w-32 truncate align-middle">
<Show when={!item.raw.native} fallback="by clan-core">
by {item.raw.usage_ref.input}
</Show>
</span>
</Typography>
</div>
</div>
);
}}
/>
</div>
</div>
);
};

View File

@@ -160,6 +160,9 @@ export const SelectRoleMembers: Story = {
handleSubmit={(instance) => { handleSubmit={(instance) => {
console.log("Submitted instance:", instance); console.log("Submitted instance:", instance);
}} }}
onClose={() => {
console.log("Closed");
}}
initialStep="select:members" initialStep="select:members"
initialStore={{ initialStore={{
currentRole: "peer", currentRole: "peer",

View File

@@ -4,10 +4,9 @@ import {
StepperProvider, StepperProvider,
useStepper, useStepper,
} from "@/src/hooks/stepper"; } from "@/src/hooks/stepper";
import { useClanURI } from "@/src/hooks/clan"; import { useClanURI, useServiceParams } from "@/src/hooks/clan";
import { import {
MachinesQuery, MachinesQuery,
ServiceModules,
TagsQuery, TagsQuery,
useMachinesQuery, useMachinesQuery,
useServiceInstances, useServiceInstances,
@@ -18,18 +17,15 @@ import {
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
For,
JSX,
Show, Show,
on, on,
onMount, onMount,
For,
} from "solid-js"; } from "solid-js";
import { Search } from "@/src/components/Search/Search";
import Icon from "@/src/components/Icon/Icon"; import Icon from "@/src/components/Icon/Icon";
import { Combobox } from "@kobalte/core/combobox"; import { Combobox } from "@kobalte/core/combobox";
import { Typography } from "@/src/components/Typography/Typography"; 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 { createForm, FieldValues } from "@modular-forms/solid";
import styles from "./Service.module.css"; import styles from "./Service.module.css";
import { TextInput } from "@/src/components/Form/TextInput"; 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 { useMachineClick } from "@/src/scene/cubes";
import { import {
clearAllHighlights, clearAllHighlights,
highlightGroups,
setHighlightGroups, setHighlightGroups,
} from "@/src/scene/highlightStore"; } from "@/src/scene/highlightStore";
import { useClickOutside } from "@/src/hooks/useClickOutside"; import {
getRoleMembers,
type ModuleItem = ServiceModules["modules"][number]; RoleType,
ServiceStoreType,
interface Module { SubmitServiceHandler,
value: string; } from "./models";
label: string; import { TagSelect } from "@/src/components/Search/TagSelect";
raw: ModuleItem; import { Tag } from "@/src/components/Tag/Tag";
}
const SelectService = () => {
const clanURI = useClanURI();
const stepper = useStepper<ServiceSteps>();
const serviceModulesQuery = useServiceModules(clanURI);
const serviceInstancesQuery = useServiceInstances(clanURI);
const machinesQuery = useMachinesQuery(clanURI);
const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]);
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<ServiceStoreType>(stepper);
return (
<Search<Module>
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 (
<div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4">
<div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
<Icon icon="Code" />
</div>
<div class="flex w-full flex-col">
<Combobox.ItemLabel class="flex gap-1.5">
<Show when={item.raw.instance_refs.length > 0}>
<div class="flex items-center rounded bg-[#76FFA4] px-1 py-0.5">
<Typography hierarchy="label" weight="bold" size="xxs">
Added
</Typography>
</div>
</Show>
<Typography hierarchy="body" size="s" weight="medium" inverted>
{item.label}
</Typography>
</Combobox.ItemLabel>
<Typography
hierarchy="body"
size="xxs"
weight="normal"
color="quaternary"
inverted
class="flex justify-between"
>
<span class="inline-block max-w-80 truncate align-middle">
{item.raw.info.manifest.description}
</span>
<span class="inline-block max-w-32 truncate align-middle">
<Show when={!item.raw.native} fallback="by clan-core">
by {item.raw.usage_ref.input}
</Show>
</span>
</Typography>
</div>
</div>
);
}}
/>
);
};
const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) => const useOptions = (tagsQuery: TagsQuery, machinesQuery: MachinesQuery) =>
createMemo<TagType[]>(() => { createMemo<TagType[]>(() => {
@@ -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 { interface RolesForm extends FieldValues {
roles: Record<string, string[]>; roles: Record<string, string[]>;
instanceName: string; instanceName: string;
} }
const ConfigureService = () => { const ConfigureService = () => {
const stepper = useStepper<ServiceSteps>(); const stepper = useStepper<ServiceSteps>();
const clanURI = useClanURI();
const machinesQuery = useMachinesQuery(clanURI);
const serviceModulesQuery = useServiceModules(clanURI);
const serviceInstancesQuery = useServiceInstances(clanURI);
const routerProps = useServiceParams();
const [store, set] = getStepStore<ServiceStoreType>(stepper); const [store, set] = getStepStore<ServiceStoreType>(stepper);
const [formStore, { Form, Field }] = createForm<RolesForm>({ const [formStore, { Form, Field }] = createForm<RolesForm>({
initialValues: { initialValues: {
// Default to the module name, until we support multiple instances // 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 tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery); const options = useOptions(tagsQuery, machinesQuery);
@@ -249,13 +165,15 @@ const ConfigureService = () => {
}, },
]), ]),
); );
store.handleSubmit( store.handleSubmit(
{ {
name: values.instanceName, name: values.instanceName,
module: { module: {
name: store.module.name, name: routerProps.name,
input: store.module.input, input: sanitizeModuleInput(
routerProps.input,
serviceModulesQuery.data?.core_input_name || "clan-core",
),
}, },
roles, roles,
}, },
@@ -271,7 +189,7 @@ const ConfigureService = () => {
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<Typography hierarchy="body" size="s" weight="medium" inverted> <Typography hierarchy="body" size="s" weight="medium" inverted>
{store.module.name} {routerProps.name}
</Typography> </Typography>
<Field name="instanceName"> <Field name="instanceName">
{(field, input) => ( {(field, input) => (
@@ -294,54 +212,71 @@ const ConfigureService = () => {
ghost ghost
size="s" size="s"
class="ml-auto" class="ml-auto"
onClick={store.close} onClick={() => store.close()}
/> />
</div> </div>
<div class={styles.content}> <div class={styles.content}>
<For each={Object.keys(store.module.raw?.info.roles || {})}> <Show
{(role) => { when={serviceModulesQuery.data && store.roles}
const values = store.roles?.[role] || []; fallback={<div>Loading...</div>}
return ( >
<TagSelect<TagType> <For each={Object.keys(selectedModule()?.info.roles || {})}>
label={role} {(role) => {
renderItem={(item: TagType) => ( const values = store.roles?.[role] || [];
<Tag return (
inverted <TagSelect<TagType>
icon={(tag) => ( label={role}
<Icon renderItem={(item: TagType) => (
icon={item.type === "machine" ? "Machine" : "Tag"} <Tag
size="0.5rem" inverted
inverted={tag.inverted} icon={(tag) => (
/> <Icon
)} icon={item.type === "machine" ? "Machine" : "Tag"}
> size="0.5rem"
{item.label} inverted={tag.inverted}
</Tag> />
)} )}
values={values} >
options={options()} {item.label}
onClick={() => { </Tag>
set("currentRole", role); )}
stepper.next(); values={values}
}} options={options()}
/> onClick={() => {
); set("currentRole", role);
}} stepper.next();
</For> }}
/>
);
}}
</For>
</Show>
</div> </div>
<div class={cx(styles.footer, styles.backgroundAlt)}> <div class={cx(styles.footer, styles.backgroundAlt)}>
<BackButton ghost hierarchy="primary" class="mr-auto" /> <Button
hierarchy="secondary"
<Button hierarchy="secondary" type="submit"> type="submit"
<Show when={store.action === "create"}>Add Service</Show> loading={!serviceInstancesQuery.data}
<Show when={store.action === "update"}>Save Changes</Show> >
<Show when={serviceInstancesQuery.data}>
{(d) => (
<>
<Show
when={Object.keys(d()).includes(routerProps.id)}
fallback={"Add Service"}
>
Save Changes
</Show>
</>
)}
</Show>
</Button> </Button>
</div> </div>
</Form> </Form>
); );
}; };
type TagType = export type TagType =
| { | {
value: string; value: string;
label: string; label: string;
@@ -362,31 +297,36 @@ const ConfigureRole = () => {
store.roles?.[store.currentRole || ""] || [], store.roles?.[store.currentRole || ""] || [],
); );
const clanUri = useClanURI();
const machinesQuery = useMachinesQuery(clanUri);
const lastClickedMachine = useMachineClick(); const lastClickedMachine = useMachineClick();
createEffect(() => { createEffect(
console.log("Current role", store.currentRole, members()); on(members, (m) => {
clearAllHighlights(); clearAllHighlights();
setHighlightGroups({ setHighlightGroups({
[store.currentRole as string]: new Set( [store.currentRole as string]: new Set(
members().flatMap((m) => { m.flatMap((m) => {
if (m.type === "machine") return m.label; if (m.type === "machine") return m.label;
return m.members; return m.members;
}), }),
), ),
}); });
}),
);
console.log("now", highlightGroups); onMount(() => {
setHighlightGroups(() => ({}));
}); });
onMount(() => setHighlightGroups(() => ({})));
createEffect( createEffect(
on(lastClickedMachine, (machine) => { on(lastClickedMachine, (machine) => {
// const machine = lastClickedMachine(); // const machine = lastClickedMachine();
const currentMembers = members(); const currentMembers = members();
console.log("Clicked machine", machine, currentMembers);
if (!machine) return; if (!machine) return;
const machineTagName = "m_" + machine; const machineTagName = "m_" + machine;
const existing = currentMembers.find((m) => m.value === machineTagName); const existing = currentMembers.find((m) => m.value === machineTagName);
@@ -403,7 +343,6 @@ const ConfigureRole = () => {
}), }),
); );
const machinesQuery = useMachinesQuery(useClanURI());
const tagsQuery = useTags(useClanURI()); const tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery); const options = useOptions(tagsQuery, machinesQuery);
@@ -428,12 +367,7 @@ const ConfigureRole = () => {
headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")} headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")}
headerChildren={ headerChildren={
<div class="flex w-full gap-2.5"> <div class="flex w-full gap-2.5">
<BackButton <BackButton ghost size="xs" hierarchy="primary" />
ghost
size="xs"
hierarchy="primary"
// onClick={() => clearAllHighlights()}
/>
<Typography <Typography
hierarchy="body" hierarchy="body"
size="s" size="s"
@@ -505,10 +439,6 @@ const ConfigureRole = () => {
}; };
const steps = [ const steps = [
{
id: "select:service",
content: SelectService,
},
{ {
id: "view:members", id: "view:members",
content: ConfigureService, content: ConfigureService,
@@ -522,79 +452,34 @@ const steps = [
export type ServiceSteps = typeof 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<string, RoleType>;
}
interface RoleType {
machines: Record<string, { settings?: unknown }>;
tags: Record<string, unknown>;
}
export interface ServiceStoreType {
module: {
name: string;
input?: string | null;
raw?: ModuleItem;
};
roles: Record<string, TagType[]>;
currentRole?: string;
close: () => void;
handleSubmit: SubmitServiceHandler;
action: "create" | "update";
}
export type SubmitServiceHandler = (
values: InventoryInstance,
action: "create" | "update",
) => void | Promise<void>;
interface ServiceWorkflowProps { interface ServiceWorkflowProps {
initialStep?: ServiceSteps[number]["id"]; initialStep?: ServiceSteps[number]["id"];
initialStore?: Partial<ServiceStoreType>; initialStore?: Partial<ServiceStoreType>;
onClose?: () => void; onClose: () => void;
handleSubmit: SubmitServiceHandler; handleSubmit: SubmitServiceHandler;
rootProps?: JSX.HTMLAttributes<HTMLDivElement>;
} }
export const ServiceWorkflow = (props: ServiceWorkflowProps) => { export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
const stepper = createStepper( const stepper = createStepper(
{ steps }, { steps },
{ {
initialStep: props.initialStep || "select:service", initialStep: props.initialStep || "view:members",
initialStoreData: { initialStoreData: {
...props.initialStore, ...props.initialStore,
close: () => props.onClose?.(), close: props.onClose,
handleSubmit: props.handleSubmit, handleSubmit: props.handleSubmit,
} satisfies Partial<ServiceStoreType>, } satisfies Partial<ServiceStoreType>,
}, },
); );
createEffect(() => { createEffect(() => {
if (stepper.currentStep().id !== "select:members") { if (stepper.currentStep().id !== "select:members") {
clearAllHighlights(); clearAllHighlights();
} }
}); });
let ref: HTMLDivElement;
useClickOutside(
() => ref,
() => {
if (stepper.currentStep().id === "select:service") props.onClose?.();
},
);
return ( return (
<div <div class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2">
ref={(e) => (ref = e)}
id="add-service"
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2"
{...props.rootProps}
>
<StepperProvider stepper={stepper}> <StepperProvider stepper={stepper}>
<div class="w-[30rem]">{stepper.currentStep().content()}</div> <div class="w-[30rem]">{stepper.currentStep().content()}</div>
</StepperProvider> </StepperProvider>

View File

@@ -0,0 +1,81 @@
import {
MachinesQuery,
ServiceInstancesQuery,
ServiceModules,
} from "@/src/hooks/queries";
import { TagType } from "./Service";
export interface ServiceStoreType {
roles: Record<string, TagType[]>;
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<string, RoleType>;
}
export interface RoleType {
machines: Record<string, { settings?: unknown }>;
tags: Record<string, unknown>;
}
export type SubmitServiceHandler = (
values: InventoryInstance,
action: "create" | "update",
) => void | Promise<void>;
export type ModuleItem = ServiceModules["modules"][number];
export interface Module {
value: string;
label: string;
raw: ModuleItem;
}
type ValueOf<T> = 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<NonNullable<ServiceInstancesQuery["data"]>>,
all_machines: NonNullable<MachinesQuery["data"]>,
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;
}