fix(ui): route palette drops + design-time remove through live API on active topologies
When topoStatus is active/degraded, editor.updateDecky enqueues into
the mutator queue and returns {kind:'enqueued'}. The palette-drop
handler then short-circuits on that and never updates local state, so
a service dragged onto a deployed decky just vanishes — what ANTI saw
as 'no way to APPLY'.
Same gap on the design-time 'REMOVE SERVICE' button in the Inspector's
service detail panel: enqueue + no local update = chip stays.
Both now route through liveAddService / liveRemoveService when the
topology is active, hitting POST/DELETE /topologies/{id}/deckies/{name}/services
directly and patching local state from the response. Pending
topologies still queue through the mutator (correct: no live
containers to mutate).
Hoisted serviceRegistry / liveAddService / liveRemoveService above
the palette-drop callback so the deps array doesn't trip the const
TDZ at render time.
This commit is contained in:
@@ -109,6 +109,29 @@ const MazeNET: React.FC = () => {
|
|||||||
setTimeout(() => setActionErr(null), 4000);
|
setTimeout(() => setActionErr(null), 4000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/* ── Live service mutation (W3 endpoints) — hoisted above palette
|
||||||
|
drop so onPaletteDrop's deps can reference it without hitting the
|
||||||
|
const TDZ. Optimistic local update; SSE forwarder reconciles
|
||||||
|
cross-tab. */
|
||||||
|
const serviceRegistry = useServiceRegistry();
|
||||||
|
|
||||||
|
const liveAddService = useCallback(async (nodeName: string, slug: string) => {
|
||||||
|
const { data } = await axios.post<{ services: string[] }>(
|
||||||
|
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services`,
|
||||||
|
{ name: slug },
|
||||||
|
);
|
||||||
|
setNodes((p) => p.map((x) => x.kind === 'decky' && x.name === nodeName
|
||||||
|
? { ...x, services: data.services } : x));
|
||||||
|
}, [topologyId]);
|
||||||
|
|
||||||
|
const liveRemoveService = useCallback(async (nodeName: string, slug: string) => {
|
||||||
|
const { data } = await axios.delete<{ services: string[] }>(
|
||||||
|
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services/${encodeURIComponent(slug)}`,
|
||||||
|
);
|
||||||
|
setNodes((p) => p.map((x) => x.kind === 'decky' && x.name === nodeName
|
||||||
|
? { ...x, services: data.services } : x));
|
||||||
|
}, [topologyId]);
|
||||||
|
|
||||||
/* ── Palette drop — create LANs / deckies / services via REST ─── */
|
/* ── Palette drop — create LANs / deckies / services via REST ─── */
|
||||||
const onPaletteDrop = useCallback(
|
const onPaletteDrop = useCallback(
|
||||||
async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => {
|
async (drag: PaletteDrag, world: { x: number; y: number }, overNetId: string | null, overNodeId: string | null) => {
|
||||||
@@ -213,6 +236,21 @@ const MazeNET: React.FC = () => {
|
|||||||
const target = nodes.find((n) => n.id === overNodeId);
|
const target = nodes.find((n) => n.id === overNodeId);
|
||||||
if (!target || target.kind !== 'decky') return;
|
if (!target || target.kind !== 'decky') return;
|
||||||
if (target.services.includes(drag.slug)) return;
|
if (target.services.includes(drag.slug)) return;
|
||||||
|
// For active/degraded topologies, route through the live W3
|
||||||
|
// endpoint — the design-time mutator queue would silently
|
||||||
|
// enqueue and the dropped chip would never visibly land
|
||||||
|
// (resulting in the "no way to APPLY" feedback). liveAddService
|
||||||
|
// returns the post-mutation services list and patches local
|
||||||
|
// state so the chip appears immediately.
|
||||||
|
const live = topoStatus === 'active' || topoStatus === 'degraded';
|
||||||
|
if (live) {
|
||||||
|
try {
|
||||||
|
await liveAddService(target.name, drag.slug);
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'add service failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const nextServices = [...target.services, drag.slug];
|
const nextServices = [...target.services, drag.slug];
|
||||||
try {
|
try {
|
||||||
const r = await editor.updateDecky(topologyId, overNodeId, target.name, { services: nextServices });
|
const r = await editor.updateDecky(topologyId, overNodeId, target.name, { services: nextServices });
|
||||||
@@ -225,7 +263,7 @@ const MazeNET: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, archetypes, editor, flashErr, nets, nodes, topologyId],
|
[api, archetypes, editor, flashErr, nets, nodes, topologyId, topoStatus, liveAddService],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */
|
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */
|
||||||
@@ -403,6 +441,20 @@ const MazeNET: React.FC = () => {
|
|||||||
const removeServiceFromNode = async (id: string, slug: string) => {
|
const removeServiceFromNode = async (id: string, slug: string) => {
|
||||||
const n = nodes.find((x) => x.id === id);
|
const n = nodes.find((x) => x.id === id);
|
||||||
if (!n || n.kind !== 'decky' || !n.services.includes(slug)) return;
|
if (!n || n.kind !== 'decky' || !n.services.includes(slug)) return;
|
||||||
|
// Same routing rule as the palette drop: active/degraded topologies
|
||||||
|
// hit the live W3 endpoint so the chip disappears immediately and
|
||||||
|
// the container stops; pending topologies queue through the
|
||||||
|
// design-time mutator.
|
||||||
|
const live = topoStatus === 'active' || topoStatus === 'degraded';
|
||||||
|
if (live) {
|
||||||
|
try {
|
||||||
|
await liveRemoveService(n.name, slug);
|
||||||
|
setSelection(null);
|
||||||
|
} catch (err) {
|
||||||
|
flashErr(err, 'remove service failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const nextServices = n.services.filter((s) => s !== slug);
|
const nextServices = n.services.filter((s) => s !== slug);
|
||||||
try {
|
try {
|
||||||
const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices });
|
const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices });
|
||||||
@@ -429,33 +481,6 @@ const MazeNET: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Live service add/remove — talks to the W3 endpoints directly,
|
|
||||||
bypassing the design-time mutation queue. Used when topology
|
|
||||||
status is active/degraded; the Inspector switches between this
|
|
||||||
and the design-time path based on the topologyStatus prop.
|
|
||||||
|
|
||||||
Optimistic local update is fine: the W3 endpoint returns the
|
|
||||||
post-mutation services list, and the SSE forwarder (commit C-sse)
|
|
||||||
reconciles cross-tab. */
|
|
||||||
const serviceRegistry = useServiceRegistry();
|
|
||||||
|
|
||||||
const liveAddService = useCallback(async (nodeName: string, slug: string) => {
|
|
||||||
const { data } = await axios.post<{ services: string[] }>(
|
|
||||||
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services`,
|
|
||||||
{ name: slug },
|
|
||||||
);
|
|
||||||
setNodes((p) => p.map((x) => x.kind === 'decky' && x.name === nodeName
|
|
||||||
? { ...x, services: data.services } : x));
|
|
||||||
}, [topologyId]);
|
|
||||||
|
|
||||||
const liveRemoveService = useCallback(async (nodeName: string, slug: string) => {
|
|
||||||
const { data } = await axios.delete<{ services: string[] }>(
|
|
||||||
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/services/${encodeURIComponent(slug)}`,
|
|
||||||
);
|
|
||||||
setNodes((p) => p.map((x) => x.kind === 'decky' && x.name === nodeName
|
|
||||||
? { ...x, services: data.services } : x));
|
|
||||||
}, [topologyId]);
|
|
||||||
|
|
||||||
/* Force-mutate is a no-op against a pending topology (no live containers).
|
/* Force-mutate is a no-op against a pending topology (no live containers).
|
||||||
* Keep the menu item disabled for now; real hook lands with live-editing polish. */
|
* Keep the menu item disabled for now; real hook lands with live-editing polish. */
|
||||||
const forceMutate = (_id: string) => {
|
const forceMutate = (_id: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user