feat(mutator,web): add_decky op — create-and-attach in one mutation

apply_attach_decky requires an existing decky, so the MazeNET editor
had no way to grow a live topology: creating a new decky on active
topologies 409'd on the direct-CRUD createDecky call.

- Backend: new apply_add_decky that creates the decky row + its
  home-LAN edge atomically, auto-allocating an IP if none pinned.
  Post-apply validation still runs. Added to DISPATCH + _MUTATION_OPS
  Literal + CLI help text.
- Tests: 3 new ops tests (happy path, duplicate-name rejection,
  missing-LAN rejection) plus dispatch coverage update.
- Frontend: useTopologyEditor gains addDeckyToLan() composite. Pending
  routes through createDecky + attachEdge as before; active routes
  through a single add_decky enqueue. MazeNET.tsx drag-archetype,
  duplicate, DMZ-gateway, and ctx-menu add-decky paths all use the
  composite so active topologies stop 409'ing on new-decky drops.
This commit is contained in:
2026-04-21 20:13:39 -04:00
parent 8fd166470f
commit c266d1b6e3
7 changed files with 202 additions and 43 deletions

View File

@@ -96,16 +96,15 @@ const MazeNET: React.FC = () => {
if (isDmz) {
const gwName = `dmz-gateway-${hex4()}`;
const gwRes = await editor.createDecky(topologyId, {
name: gwName, services: ['ssh'], x: 20, y: 40,
decky_config: { archetype: 'deaddeck', forwards_l3: true },
});
const gwRes = await editor.addDeckyToLan(
topologyId,
{ name: gwName, services: ['ssh'], x: 20, y: 40,
decky_config: { archetype: 'deaddeck', forwards_l3: true } },
lan.id, lan.name,
{ is_bridge: true, forwards_l3: true },
);
if (gwRes.kind !== 'applied') return;
const gw = gwRes.data;
await editor.attachEdge(topologyId, {
decky_uuid: gw.uuid, lan_id: lan.id,
is_bridge: true, forwards_l3: true,
}, gw.name, lan.name);
const gwNode: DeckyNode = {
kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name,
archetype: 'deaddeck', services: ['ssh'], status: 'idle',
@@ -130,15 +129,14 @@ const MazeNET: React.FC = () => {
const ny = Math.max(28, Math.round(world.y - net.y - 24));
const name = `decky-${hex4()}`;
try {
const dRes = await editor.createDecky(topologyId, {
name, services: dServices, x: nx, y: ny,
decky_config: { archetype: archSlug },
});
const dRes = await editor.addDeckyToLan(
topologyId,
{ name, services: dServices, x: nx, y: ny,
decky_config: { archetype: archSlug } },
overNetId, net.label,
);
if (dRes.kind !== 'applied') return;
const decky = dRes.data;
await editor.attachEdge(topologyId,
{ decky_uuid: decky.uuid, lan_id: overNetId },
decky.name, net.label);
const node: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name,
archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny,
@@ -256,16 +254,15 @@ const MazeNET: React.FC = () => {
if (!n || n.kind !== 'decky') return;
const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`;
try {
const dRes = await editor.createDecky(topologyId, {
name, services: [...n.services], x: n.x + 24, y: n.y + 24,
decky_config: { archetype: n.archetype },
});
const parentNet = nets.find((net) => net.id === n.netId);
const dRes = await editor.addDeckyToLan(
topologyId,
{ name, services: [...n.services], x: n.x + 24, y: n.y + 24,
decky_config: { archetype: n.archetype } },
n.netId, parentNet?.label ?? '',
);
if (dRes.kind !== 'applied') return;
const decky = dRes.data;
const parentNet = nets.find((net) => net.id === n.netId);
await editor.attachEdge(topologyId,
{ decky_uuid: decky.uuid, lan_id: n.netId },
decky.name, parentNet?.label ?? '');
const copy: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name,
archetype: n.archetype, services: [...n.services], status: 'idle',
@@ -351,15 +348,14 @@ const MazeNET: React.FC = () => {
onClick: async () => {
const name = `decky-${hex4()}`;
try {
const dRes = await editor.createDecky(topologyId, {
name, services: [...a.services], x: 20, y: 40,
decky_config: { archetype: a.slug },
});
const dRes = await editor.addDeckyToLan(
topologyId,
{ name, services: [...a.services], x: 20, y: 40,
decky_config: { archetype: a.slug } },
id, net.label,
);
if (dRes.kind !== 'applied') return;
const decky = dRes.data;
await editor.attachEdge(topologyId,
{ decky_uuid: decky.uuid, lan_id: id },
decky.name, net.label);
const node: DeckyNode = {
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
archetype: a.slug, services: [...a.services], status: 'idle',