ui/service: rewire to allow external selection

This commit is contained in:
Johannes Kirschbauer
2025-08-28 22:39:49 +02:00
parent 8877f2d451
commit a4277ad312
2 changed files with 282 additions and 110 deletions

View File

@@ -16,7 +16,7 @@ import {
useClanURI, useClanURI,
useMachineName, useMachineName,
} from "@/src/hooks/clan"; } from "@/src/hooks/clan";
import { CubeScene } from "@/src/scene/cubes"; import { CubeScene, setWorldMode, worldMode } from "@/src/scene/cubes";
import { import {
ClanDetails, ClanDetails,
MachinesQueryResult, MachinesQueryResult,
@@ -38,10 +38,11 @@ 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 { import {
InventoryInstance,
ServiceWorkflow, ServiceWorkflow,
SubmitServiceHandler,
} from "@/src/workflows/Service/Service"; } from "@/src/workflows/Service/Service";
import { useApiClient } from "@/src/hooks/ApiClient"; import { useApiClient } from "@/src/hooks/ApiClient";
import toast from "solid-toast";
interface ClanContextProps { interface ClanContextProps {
clanURI: string; clanURI: string;
@@ -208,7 +209,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
const onAddService = async (): Promise<{ id: string }> => { const onAddService = async (): Promise<{ id: string }> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
setShowService(true); setShowService((v) => !v);
console.log("setting current promise"); console.log("setting current promise");
setCurrentPromise({ resolve, reject }); setCurrentPromise({ resolve, reject });
}); });
@@ -287,8 +288,16 @@ const ClanSceneController = (props: RouteSectionProps) => {
); );
const client = useApiClient(); const client = useApiClient();
const handleSubmitService = async (instance: InventoryInstance) => { const handleSubmitService: SubmitServiceHandler = async (
console.log("Create Instance", instance); 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", { const call = client.fetch("create_service_instance", {
flake: { flake: {
identifier: ctx.clanURI, identifier: ctx.clanURI,
@@ -299,13 +308,26 @@ const ClanSceneController = (props: RouteSectionProps) => {
const result = await call.result; const result = await call.result;
if (result.status === "error") { if (result.status === "error") {
toast.error("Error creating service instance");
console.error("Error creating service instance", result.errors); console.error("Error creating service instance", result.errors);
} }
toast.success("Created");
// //
currentPromise()?.resolve({ id: "0" }); currentPromise()?.resolve({ id: "0" });
setShowService(false); setShowService(false);
}; };
createEffect(
on(worldMode, (mode) => {
if (mode === "service") {
setShowService(true);
} else {
// todo: request close instead of force close
setShowService(false);
}
}),
);
return ( return (
<> <>
<Show when={loadingError()}> <Show when={loadingError()}>
@@ -338,7 +360,6 @@ const ClanSceneController = (props: RouteSectionProps) => {
</div> </div>
<CubeScene <CubeScene
onAddService={onAddService}
selectedIds={selectedIds} selectedIds={selectedIds}
onSelect={onMachineSelect} onSelect={onMachineSelect}
isLoading={ctx.isLoading()} isLoading={ctx.isLoading()}
@@ -349,6 +370,7 @@ const ClanSceneController = (props: RouteSectionProps) => {
handleSubmit={handleSubmitService} handleSubmit={handleSubmitService}
onClose={() => { onClose={() => {
setShowService(false); setShowService(false);
setWorldMode("default");
currentPromise()?.resolve({ id: "0" }); currentPromise()?.resolve({ id: "0" });
}} }}
/> />

View File

@@ -10,32 +10,50 @@ import {
ServiceModules, ServiceModules,
TagsQuery, TagsQuery,
useMachinesQuery, useMachinesQuery,
useServiceInstances,
useServiceModules, useServiceModules,
useTags, useTags,
} from "@/src/hooks/queries"; } from "@/src/hooks/queries";
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"; import {
createEffect,
createMemo,
createSignal,
For,
JSX,
Show,
on,
onMount,
} from "solid-js";
import { Search } from "@/src/components/Search/Search"; import { 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 { TagSelect } from "@/src/components/Search/TagSelect";
import { Tag } from "@/src/components/Tag/Tag"; import { Tag } from "@/src/components/Tag/Tag";
import { createForm, FieldValues, setValue } from "@modular-forms/solid"; import { createForm, FieldValues } from "@modular-forms/solid";
import styles from "./Service.module.css"; import styles from "./Service.module.css";
import { TextInput } from "@/src/components/Form/TextInput"; import { TextInput } from "@/src/components/Form/TextInput";
import { Button } from "@/src/components/Button/Button"; import { Button } from "@/src/components/Button/Button";
import cx from "classnames"; import cx from "classnames";
import { BackButton } from "../Steps"; import { BackButton } from "../Steps";
import { SearchMultiple } from "@/src/components/Search/MultipleSearch"; import { SearchMultiple } from "@/src/components/Search/MultipleSearch";
import { useMachineClick } from "@/src/scene/cubes";
import {
clearAllHighlights,
highlightGroups,
setHighlightGroups,
} from "@/src/scene/highlightStore";
import { useClickOutside } from "@/src/hooks/useClickOutside";
type ModuleItem = ServiceModules[number]; type ModuleItem = ServiceModules[number];
interface Module { interface Module {
value: string; value: string;
input: string; input?: string;
label: string; label: string;
description: string; description: string;
raw: ModuleItem; raw: ModuleItem;
instances: string[];
} }
const SelectService = () => { const SelectService = () => {
@@ -43,17 +61,27 @@ const SelectService = () => {
const stepper = useStepper<ServiceSteps>(); const stepper = useStepper<ServiceSteps>();
const serviceModulesQuery = useServiceModules(clanURI); const serviceModulesQuery = useServiceModules(clanURI);
const serviceInstancesQuery = useServiceInstances(clanURI);
const machinesQuery = useMachinesQuery(clanURI);
const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]); const [moduleOptions, setModuleOptions] = createSignal<Module[]>([]);
createEffect(() => { createEffect(() => {
if (serviceModulesQuery.data) { if (serviceModulesQuery.data && serviceInstancesQuery.data) {
setModuleOptions( setModuleOptions(
serviceModulesQuery.data.map((m) => ({ serviceModulesQuery.data.map((m) => ({
value: `${m.module.name}:${m.module.input}`, value: `${m.module.name}:${m.module.input}`,
label: m.module.name, label: m.module.name,
description: m.info.manifest.description, description: m.info.manifest.description,
input: m.module.input || "clan-core", input: m.module.input,
raw: m, raw: m,
// TODO: include the instances that use this module
instances: Object.entries(serviceInstancesQuery.data)
.filter(
([name, i]) =>
i.module?.name === m.module.name &&
(!i.module?.input || i.module?.input === m.module.input),
)
.map(([name, _]) => name),
})), })),
); );
} }
@@ -72,17 +100,77 @@ const SelectService = () => {
input: module.raw.module.input, input: module.raw.module.input,
raw: module.raw, raw: module.raw,
}); });
// TODO: Ideally we need to ask
// - create new
// - update existing (and select which one)
// For now:
// Create a new instance, if there are no instances yet
// Update the first instance, if there is one
if (module.instances.length === 0) {
set("action", "create");
} else {
if (!serviceInstancesQuery.data) return;
if (!machinesQuery.data) return;
set("action", "update");
const instanceName = module.instances[0];
const instance = serviceInstancesQuery.data[instanceName];
console.log("Editing existing instance", module);
for (const role of Object.keys(instance.roles || {})) {
const tags = Object.keys(instance.roles?.[role].tags || {});
const machines = Object.keys(instance.roles?.[role].machines || {});
const machineTags = machines.map((m) => ({
value: "m_" + m,
label: m,
type: "machine" as const,
}));
const tagsTags = tags.map((t) => {
return {
value: "t_" + t,
label: t,
type: "tag" as const,
members: Object.entries(machinesQuery.data || {})
.filter(([_, m]) => m.tags?.includes(t))
.map(([k]) => k),
};
});
console.log("Members for role", role, [
...machineTags,
...tagsTags,
]);
if (!store.roles) {
set("roles", {});
}
const roleMembers = [...machineTags, ...tagsTags].sort((a, b) =>
a.label.localeCompare(b.label),
);
set("roles", role, roleMembers);
console.log("set", store.roles);
}
// Initialize the roles with the existing members
}
stepper.next(); stepper.next();
}} }}
options={moduleOptions()} options={moduleOptions()}
renderItem={(item) => { renderItem={(item, opts) => {
return ( return (
<div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4"> <div class="flex items-center justify-between gap-2 overflow-hidden rounded-md px-2 py-1 pr-4">
<div class="flex size-8 items-center justify-center rounded-md bg-white"> <div class="flex size-8 shrink-0 items-center justify-center rounded-md bg-white">
<Icon icon="Code" /> <Icon icon="Code" />
</div> </div>
<div class="flex w-full flex-col"> <div class="flex w-full flex-col">
<Combobox.ItemLabel class="flex"> <Combobox.ItemLabel class="flex gap-1.5">
<Show when={item.instances.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> <Typography hierarchy="body" size="s" weight="medium" inverted>
{item.label} {item.label}
</Typography> </Typography>
@@ -95,8 +183,12 @@ const SelectService = () => {
inverted inverted
class="flex justify-between" class="flex justify-between"
> >
<span>{item.description}</span> <span class="inline-block max-w-32 truncate align-middle">
<span>by {item.input}</span> {item.description}
</span>
<span class="inline-block max-w-8 truncate align-middle">
by {item.input}
</span>
</Typography> </Typography>
</div> </div>
</div> </div>
@@ -144,7 +236,8 @@ const ConfigureService = () => {
const [formStore, { Form, Field }] = createForm<RolesForm>({ const [formStore, { Form, Field }] = createForm<RolesForm>({
initialValues: { initialValues: {
instanceName: "backup-instance-1", // Default to the module name, until we support multiple instances
instanceName: store.module.name,
}, },
}); });
@@ -168,14 +261,17 @@ const ConfigureService = () => {
]), ]),
); );
store.handleSubmit({ store.handleSubmit(
name: values.instanceName, {
module: { name: values.instanceName,
name: store.module.name, module: {
input: store.module.input, name: store.module.name,
input: store.module.input,
},
roles,
}, },
roles, store.action,
}); );
}; };
return ( return (
@@ -245,8 +341,11 @@ const ConfigureService = () => {
</For> </For>
</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" type="submit"> <Button hierarchy="secondary" type="submit">
Add Service <Show when={store.action === "create"}>Add Service</Show>
<Show when={store.action === "update"}>Save Changes</Show>
</Button> </Button>
</div> </div>
</Form> </Form>
@@ -266,116 +365,145 @@ type TagType =
members: string[]; members: string[];
}; };
interface RoleMembers extends FieldValues {
members: string[];
}
const ConfigureRole = () => { const ConfigureRole = () => {
const stepper = useStepper<ServiceSteps>(); const stepper = useStepper<ServiceSteps>();
const [store, set] = getStepStore<ServiceStoreType>(stepper); const [store, set] = getStepStore<ServiceStoreType>(stepper);
const [formStore, { Form, Field }] = createForm<RoleMembers>({ const [members, setMembers] = createSignal<TagType[]>(
initialValues: { store.roles?.[store.currentRole || ""] || [],
members: [], );
},
const lastClickedMachine = useMachineClick();
createEffect(() => {
console.log("Current role", store.currentRole, members());
clearAllHighlights();
setHighlightGroups({
[store.currentRole as string]: new Set(
members().flatMap((m) => {
if (m.type === "machine") return m.label;
return m.members;
}),
),
});
console.log("now", highlightGroups);
}); });
onMount(() => setHighlightGroups(() => ({})));
createEffect(
on(lastClickedMachine, (machine) => {
// const machine = lastClickedMachine();
const currentMembers = members();
console.log("Clicked machine", machine, currentMembers);
if (!machine) return;
const machineTagName = "m_" + machine;
const existing = currentMembers.find((m) => m.value === machineTagName);
if (existing) {
// Remove
setMembers(currentMembers.filter((m) => m.value !== machineTagName));
} else {
// Add
setMembers([
...currentMembers,
{ value: machineTagName, label: machine, type: "machine" },
]);
}
}),
);
const machinesQuery = useMachinesQuery(useClanURI()); const machinesQuery = useMachinesQuery(useClanURI());
const tagsQuery = useTags(useClanURI()); const tagsQuery = useTags(useClanURI());
const options = useOptions(tagsQuery, machinesQuery); const options = useOptions(tagsQuery, machinesQuery);
const handleSubmit = (values: RoleMembers) => { const handleSubmit = () => {
if (!store.currentRole) return; if (!store.currentRole) return;
const members: TagType[] = values.members.map(
(m) => options().find((o) => o.value === m)!,
);
if (!store.roles) { if (!store.roles) {
set("roles", {}); set("roles", {});
} }
set("roles", (r) => ({ ...r, [store.currentRole as string]: members })); set("roles", (r) => ({ ...r, [store.currentRole as string]: members() }));
stepper.setActiveStep("view:members"); stepper.setActiveStep("view:members");
}; };
return ( return (
<Form onSubmit={handleSubmit}> <form onSubmit={() => handleSubmit()}>
<div class={cx(styles.backgroundAlt, "rounded-md")}> <div class={cx(styles.backgroundAlt, "rounded-md")}>
<div class="flex w-full flex-col "> <div class="flex w-full flex-col ">
<Field name="members" type="string[]"> <SearchMultiple<TagType>
{(field, input) => ( values={members()}
<SearchMultiple<TagType> options={options()}
initialValues={store.roles?.[store.currentRole || ""] || []} headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")}
options={options()} headerChildren={
headerClass={cx(styles.backgroundAlt, "flex flex-col gap-2.5")} <div class="flex w-full gap-2.5">
headerChildren={ <BackButton
<div class="flex w-full gap-2.5"> ghost
<BackButton ghost size="xs" hierarchy="primary" /> size="xs"
<Typography hierarchy="primary"
hierarchy="body" // onClick={() => clearAllHighlights()}
size="s" />
weight="medium" <Typography
inverted hierarchy="body"
class="capitalize" size="s"
> weight="medium"
Select {store.currentRole} inverted
</Typography> class="capitalize"
</div> >
} Select {store.currentRole}
placeholder={"Search for Machine or Tags"} </Typography>
renderItem={(item, opts) => ( </div>
<div class={cx("flex w-full items-center gap-2 px-3 py-2")}> }
<Combobox.ItemIndicator> placeholder={"Search for Machine or Tags"}
<Show renderItem={(item, opts) => (
when={opts.selected} <div class={cx("flex w-full items-center gap-2 px-3 py-2")}>
fallback={<Icon icon="Code" />} <Combobox.ItemIndicator>
> <Show when={opts.selected} fallback={<Icon icon="Code" />}>
<Icon icon="Checkmark" color="primary" inverted /> <Icon icon="Checkmark" color="primary" inverted />
</Show> </Show>
</Combobox.ItemIndicator> </Combobox.ItemIndicator>
<Combobox.ItemLabel class="flex items-center gap-2"> <Combobox.ItemLabel class="flex items-center gap-2">
<Typography
hierarchy="body"
size="s"
weight="medium"
inverted
>
{item.label}
</Typography>
<Show when={item.type === "tag" && item}>
{(tag) => (
<Typography <Typography
hierarchy="body" hierarchy="body"
size="s" size="xs"
weight="medium" weight="medium"
inverted inverted
color="secondary"
tag="div"
> >
{item.label} {tag().members.length}
</Typography> </Typography>
<Show when={item.type === "tag" && item}> )}
{(tag) => ( </Show>
<Typography </Combobox.ItemLabel>
hierarchy="body" <Icon
size="xs" class="ml-auto"
weight="medium" icon={item.type === "machine" ? "Machine" : "Tag"}
inverted color="quaternary"
color="secondary" inverted
tag="div" />
> </div>
{tag().members.length}
</Typography>
)}
</Show>
</Combobox.ItemLabel>
<Icon
class="ml-auto"
icon={item.type === "machine" ? "Machine" : "Tag"}
color="quaternary"
inverted
/>
</div>
)}
height="20rem"
virtualizerOptions={{
estimateSize: () => 38,
}}
onChange={(selection) => {
const newval = selection.map((s) => s.value);
setValue(formStore, field.name, newval);
}}
/>
)} )}
</Field> height="20rem"
virtualizerOptions={{
estimateSize: () => 38,
}}
onChange={(selection) => {
setMembers(selection);
}}
/>
</div> </div>
<div class={cx(styles.footer, styles.backgroundAlt)}> <div class={cx(styles.footer, styles.backgroundAlt)}>
<Button hierarchy="secondary" type="submit"> <Button hierarchy="secondary" type="submit">
@@ -383,7 +511,7 @@ const ConfigureRole = () => {
</Button> </Button>
</div> </div>
</div> </div>
</Form> </form>
); );
}; };
@@ -410,7 +538,7 @@ export interface InventoryInstance {
name: string; name: string;
module: { module: {
name: string; name: string;
input: string; input?: string;
}; };
roles: Record<string, RoleType>; roles: Record<string, RoleType>;
} }
@@ -429,14 +557,21 @@ export interface ServiceStoreType {
roles: Record<string, TagType[]>; roles: Record<string, TagType[]>;
currentRole?: string; currentRole?: string;
close: () => void; close: () => void;
handleSubmit: (values: InventoryInstance) => 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: (values: InventoryInstance) => void; handleSubmit: SubmitServiceHandler;
rootProps?: JSX.HTMLAttributes<HTMLDivElement>;
} }
export const ServiceWorkflow = (props: ServiceWorkflowProps) => { export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
@@ -451,10 +586,25 @@ export const ServiceWorkflow = (props: ServiceWorkflowProps) => {
} satisfies Partial<ServiceStoreType>, } satisfies Partial<ServiceStoreType>,
}, },
); );
createEffect(() => {
if (stepper.currentStep().id !== "select:members") {
clearAllHighlights();
}
});
let ref: HTMLDivElement;
useClickOutside(
() => ref,
() => {
if (stepper.currentStep().id === "select:service") props.onClose?.();
},
);
return ( return (
<div <div
ref={(e) => (ref = e)}
id="add-service" id="add-service"
class="absolute bottom-full left-1/2 mb-2 -translate-x-1/2" 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>