fix(web/mazenet): split Net.name (canonical) from Net.label (display)
Two bugs sharing the same root cause: Net only carried a label string, set to lan.name.toUpperCase() everywhere. Backend mutator ops look up LANs by canonical lowercase name, so passing the uppercase label through attachEdge / detachEdge / addDeckyToLan / deleteLan failed with 'LAN \\'SUBNET-XXXX\\' not found'. Add Net.name (canonical, lowercase) alongside Net.label (display). Every backend call site now passes name; toasts and drag ghosts keep label. Second bug — new LANs stacking on top of each other on live topologies — fell out of the same UX path: createLan returns 'enqueued' when the topology is active/degraded, the existing early-return skipped local-state insertion, so the next drop recomputed the same grid index. Now we drop a placeholder Net with id 'pending-lan-<name>' immediately on enqueue. Grid index advances and the user gets a visual ack right away; SSE replaces the placeholder by canonical id when the mutator applies it.
This commit is contained in:
@@ -115,9 +115,10 @@ const MazeNET: React.FC = () => {
|
|||||||
flashErr(null, 'topology already has a DMZ');
|
flashErr(null, 'topology already has a DMZ');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Append to the 3-col grid matching adaptTopology so new drops
|
// Append to the 3-col grid matching adaptTopology. Counting
|
||||||
// never land on top of existing LANs. The raw drop point is
|
// existing nets PLUS any pending placeholders (live-topology
|
||||||
// ignored — cleaner than trying to resolve collisions after.
|
// enqueued mutations that haven't echoed through SSE yet)
|
||||||
|
// keeps successive drops from stacking on the same cell.
|
||||||
const w = 300, h = 240;
|
const w = 300, h = 240;
|
||||||
const GAP = 40, COLS = 3;
|
const GAP = 40, COLS = 3;
|
||||||
const i = nets.filter((n) => n.kind !== 'internet').length;
|
const i = nets.filter((n) => n.kind !== 'internet').length;
|
||||||
@@ -127,10 +128,23 @@ const MazeNET: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const subnet = await api.getNextSubnet().catch(() => undefined);
|
const subnet = await api.getNextSubnet().catch(() => undefined);
|
||||||
const lanRes = await editor.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) });
|
const lanRes = await editor.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) });
|
||||||
if (lanRes.kind !== 'applied') return;
|
if (lanRes.kind !== 'applied') {
|
||||||
|
// Live topology: mutator will materialise the LAN. Drop
|
||||||
|
// a placeholder net so the grid index advances and the
|
||||||
|
// user gets an immediate visual ack. Real LAN arriving
|
||||||
|
// via SSE replaces the placeholder by id when its
|
||||||
|
// canonical id lands; until then, the temp id is unique.
|
||||||
|
const tempId = `pending-lan-${name}`;
|
||||||
|
setNets((p) => [...p, {
|
||||||
|
id: tempId, name, label: name.toUpperCase(),
|
||||||
|
cidr: subnet ?? '', kind: isDmz ? 'dmz' : 'subnet',
|
||||||
|
x, y, w, h,
|
||||||
|
}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const lan = lanRes.data;
|
const lan = lanRes.data;
|
||||||
const net: Net = {
|
const net: Net = {
|
||||||
id: lan.id, label: lan.name.toUpperCase(), cidr: lan.subnet,
|
id: lan.id, name: lan.name, label: lan.name.toUpperCase(), cidr: lan.subnet,
|
||||||
kind: isDmz ? 'dmz' : 'subnet', x, y, w, h,
|
kind: isDmz ? 'dmz' : 'subnet', x, y, w, h,
|
||||||
};
|
};
|
||||||
setNets((p) => [...p, net]);
|
setNets((p) => [...p, net]);
|
||||||
@@ -174,7 +188,7 @@ const MazeNET: React.FC = () => {
|
|||||||
topologyId,
|
topologyId,
|
||||||
{ name, services: dServices, x: nx, y: ny,
|
{ name, services: dServices, x: nx, y: ny,
|
||||||
decky_config: { archetype: archSlug } },
|
decky_config: { archetype: archSlug } },
|
||||||
overNetId, net.label,
|
overNetId, net.name,
|
||||||
);
|
);
|
||||||
if (dRes.kind !== 'applied') return;
|
if (dRes.kind !== 'applied') return;
|
||||||
const decky = dRes.data;
|
const decky = dRes.data;
|
||||||
@@ -223,9 +237,9 @@ const MazeNET: React.FC = () => {
|
|||||||
const toNet = nets.find((n) => n.id === toNetId);
|
const toNet = nets.find((n) => n.id === toNetId);
|
||||||
const nodeName = node?.kind === 'decky' ? node.name : '';
|
const nodeName = node?.kind === 'decky' ? node.name : '';
|
||||||
if (existingEdge) {
|
if (existingEdge) {
|
||||||
await editor.detachEdge(topologyId, existingEdge.id, nodeName, fromNet?.label ?? '');
|
await editor.detachEdge(topologyId, existingEdge.id, nodeName, fromNet?.name ?? '');
|
||||||
}
|
}
|
||||||
await editor.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId }, nodeName, toNet?.label ?? '');
|
await editor.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId }, nodeName, toNet?.name ?? '');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
flashErr(err, 'reparent failed');
|
flashErr(err, 'reparent failed');
|
||||||
}
|
}
|
||||||
@@ -264,7 +278,7 @@ const MazeNET: React.FC = () => {
|
|||||||
topologyId,
|
topologyId,
|
||||||
{ decky_uuid: fromId, lan_id: toNode.netId, is_bridge: true },
|
{ decky_uuid: fromId, lan_id: toNode.netId, is_bridge: true },
|
||||||
fromName,
|
fromName,
|
||||||
targetNet.label,
|
targetNet.name,
|
||||||
);
|
);
|
||||||
const backendEdgeId = res.kind === 'applied' ? res.data.id : `enqueued:${res.mutationId}`;
|
const backendEdgeId = res.kind === 'applied' ? res.data.id : `enqueued:${res.mutationId}`;
|
||||||
const id = `viz-${fromId}-${toId}-${Date.now()}`;
|
const id = `viz-${fromId}-${toId}-${Date.now()}`;
|
||||||
@@ -299,7 +313,7 @@ const MazeNET: React.FC = () => {
|
|||||||
const mName = m.kind === 'decky' ? m.name : '';
|
const mName = m.kind === 'decky' ? m.name : '';
|
||||||
await editor.deleteDecky(topologyId, m.id, mName);
|
await editor.deleteDecky(topologyId, m.id, mName);
|
||||||
}
|
}
|
||||||
await editor.deleteLan(topologyId, id, net.label);
|
await editor.deleteLan(topologyId, id, net.name);
|
||||||
setNets((p) => p.filter((n) => n.id !== id));
|
setNets((p) => p.filter((n) => n.id !== id));
|
||||||
setNodes((p) => p.filter((n) => n.netId !== id));
|
setNodes((p) => p.filter((n) => n.netId !== id));
|
||||||
setEdges((p) => p.filter((e) => {
|
setEdges((p) => p.filter((e) => {
|
||||||
@@ -346,7 +360,7 @@ const MazeNET: React.FC = () => {
|
|||||||
const toNode = nodes.find((n) => n.id === edge.to);
|
const toNode = nodes.find((n) => n.id === edge.to);
|
||||||
const targetNet = toNode ? nets.find((n) => n.id === toNode.netId) : undefined;
|
const targetNet = toNode ? nets.find((n) => n.id === toNode.netId) : undefined;
|
||||||
const fromName = fromNode?.kind === 'decky' ? fromNode.name : '';
|
const fromName = fromNode?.kind === 'decky' ? fromNode.name : '';
|
||||||
const lanName = targetNet?.label ?? '';
|
const lanName = targetNet?.name ?? '';
|
||||||
try {
|
try {
|
||||||
await editor.detachEdge(topologyId, edge.backendEdgeId, fromName, lanName);
|
await editor.detachEdge(topologyId, edge.backendEdgeId, fromName, lanName);
|
||||||
setEdges((p) => p.filter((e) => e.id !== id));
|
setEdges((p) => p.filter((e) => e.id !== id));
|
||||||
@@ -366,7 +380,7 @@ const MazeNET: React.FC = () => {
|
|||||||
topologyId,
|
topologyId,
|
||||||
{ name, services: [...n.services], x: n.x + 24, y: n.y + 24,
|
{ name, services: [...n.services], x: n.x + 24, y: n.y + 24,
|
||||||
decky_config: { archetype: n.archetype } },
|
decky_config: { archetype: n.archetype } },
|
||||||
n.netId, parentNet?.label ?? '',
|
n.netId, parentNet?.name ?? '',
|
||||||
);
|
);
|
||||||
if (dRes.kind !== 'applied') return;
|
if (dRes.kind !== 'applied') return;
|
||||||
const decky = dRes.data;
|
const decky = dRes.data;
|
||||||
@@ -474,7 +488,7 @@ const MazeNET: React.FC = () => {
|
|||||||
topologyId,
|
topologyId,
|
||||||
{ name, services: [...a.services], x: 20, y: 40,
|
{ name, services: [...a.services], x: 20, y: 40,
|
||||||
decky_config: { archetype: a.slug } },
|
decky_config: { archetype: a.slug } },
|
||||||
id, net.label,
|
id, net.name,
|
||||||
);
|
);
|
||||||
if (dRes.kind !== 'applied') return;
|
if (dRes.kind !== 'applied') return;
|
||||||
const decky = dRes.data;
|
const decky = dRes.data;
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ export type NetKind = 'internet' | 'subnet' | 'dmz';
|
|||||||
|
|
||||||
export interface Net {
|
export interface Net {
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Display string (uppercased for the canvas chrome). */
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Canonical LAN name as stored on the backend — lowercase. Use
|
||||||
|
* this (not ``label``) for any API call that identifies a LAN by
|
||||||
|
* name (mutator attach/detach, delete, etc.); the mutator looks
|
||||||
|
* up case-sensitively and will 404 on the uppercased form. */
|
||||||
|
name: string;
|
||||||
cidr: string;
|
cidr: string;
|
||||||
kind: NetKind;
|
kind: NetKind;
|
||||||
x: number;
|
x: number;
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function adaptTopology(detail: TopologyDetail): HydratedTopology {
|
|||||||
const ordered = [...dmzs, ...subnets];
|
const ordered = [...dmzs, ...subnets];
|
||||||
const nets: Net[] = ordered.map((lan, i) => ({
|
const nets: Net[] = ordered.map((lan, i) => ({
|
||||||
id: lan.id,
|
id: lan.id,
|
||||||
|
name: lan.name,
|
||||||
label: lan.name.toUpperCase(),
|
label: lan.name.toUpperCase(),
|
||||||
cidr: lan.subnet,
|
cidr: lan.subnet,
|
||||||
kind: lan.is_dmz ? 'dmz' : 'subnet',
|
kind: lan.is_dmz ? 'dmz' : 'subnet',
|
||||||
|
|||||||
Reference in New Issue
Block a user