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:
@@ -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) {
|
||||||
@@ -79,13 +74,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="sidebar-body">
|
<Accordion.Item class="item" value="machines">
|
||||||
<Accordion
|
|
||||||
class="accordion"
|
|
||||||
multiple
|
|
||||||
defaultValue={defaultAccordionValues}
|
|
||||||
>
|
|
||||||
<Accordion.Item class="item" value="your-machines">
|
|
||||||
<Accordion.Header class="header">
|
<Accordion.Header class="header">
|
||||||
<Accordion.Trigger class="trigger">
|
<Accordion.Trigger class="trigger">
|
||||||
<Typography
|
<Typography
|
||||||
@@ -93,17 +82,12 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
hierarchy="label"
|
hierarchy="label"
|
||||||
family="mono"
|
family="mono"
|
||||||
size="xs"
|
size="xs"
|
||||||
inverted={true}
|
inverted
|
||||||
color="tertiary"
|
color="tertiary"
|
||||||
>
|
>
|
||||||
Your Machines
|
Your Machines
|
||||||
</Typography>
|
</Typography>
|
||||||
<Icon
|
<Icon icon="CaretDown" color="tertiary" inverted size="0.75rem" />
|
||||||
icon="CaretDown"
|
|
||||||
color="tertiary"
|
|
||||||
inverted={true}
|
|
||||||
size="0.75rem"
|
|
||||||
/>
|
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
</Accordion.Header>
|
</Accordion.Header>
|
||||||
<Accordion.Content class="content">
|
<Accordion.Content class="content">
|
||||||
@@ -111,12 +95,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
when={machines()}
|
when={machines()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="flex w-full flex-col items-center justify-center gap-2.5">
|
<div class="flex w-full flex-col items-center justify-center gap-2.5">
|
||||||
<Typography
|
<Typography hierarchy="body" size="s" weight="medium" inverted>
|
||||||
hierarchy="body"
|
|
||||||
size="s"
|
|
||||||
weight="medium"
|
|
||||||
inverted
|
|
||||||
>
|
|
||||||
No machines yet
|
No machines yet
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button
|
<Button
|
||||||
@@ -137,7 +116,7 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
clanURI={clanURI}
|
clanURI={clanURI}
|
||||||
machineID={id}
|
machineID={id}
|
||||||
name={machine.data.name || id}
|
name={machine.data.name || id}
|
||||||
serviceCount={0}
|
serviceCount={machine?.instance_refs?.length ?? 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -145,6 +124,99 @@ export const SidebarBody = (props: SidebarProps) => {
|
|||||||
</Show>
|
</Show>
|
||||||
</Accordion.Content>
|
</Accordion.Content>
|
||||||
</Accordion.Item>
|
</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 (
|
||||||
|
<div class="sidebar-body">
|
||||||
|
<Accordion
|
||||||
|
class="accordion"
|
||||||
|
multiple
|
||||||
|
defaultValue={defaultAccordionValues}
|
||||||
|
>
|
||||||
|
<Machines />
|
||||||
|
<Services />
|
||||||
|
|
||||||
<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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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/")}
|
||||||
|
fallback={
|
||||||
|
<SelectService
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setWorldMode("select");
|
ctx.setWorldMode("select");
|
||||||
currentPromise()?.resolve({ id: "0" });
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
}
|
}
|
||||||
onCreate={onCreate}
|
onCreate={onCreate}
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
pkgs/clan-app/ui/src/routes/Machine/SectionServices.tsx
Normal file
56
pkgs/clan-app/ui/src/routes/Machine/SectionServices.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
59
pkgs/clan-app/ui/src/routes/Service/Service.tsx
Normal file
59
pkgs/clan-app/ui/src/routes/Service/Service.tsx
Normal 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} />;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
120
pkgs/clan-app/ui/src/workflows/Service/SelectServiceFlyout.tsx
Normal file
120
pkgs/clan-app/ui/src/workflows/Service/SelectServiceFlyout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,11 +212,15 @@ 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
|
||||||
|
when={serviceModulesQuery.data && store.roles}
|
||||||
|
fallback={<div>Loading...</div>}
|
||||||
|
>
|
||||||
|
<For each={Object.keys(selectedModule()?.info.roles || {})}>
|
||||||
{(role) => {
|
{(role) => {
|
||||||
const values = store.roles?.[role] || [];
|
const values = store.roles?.[role] || [];
|
||||||
return (
|
return (
|
||||||
@@ -328,20 +250,33 @@ const ConfigureService = () => {
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</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>
|
||||||
|
|||||||
81
pkgs/clan-app/ui/src/workflows/Service/models.ts
Normal file
81
pkgs/clan-app/ui/src/workflows/Service/models.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user