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:
@@ -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',
|
||||
|
||||
@@ -195,7 +195,7 @@ export interface CreateDeckyBody {
|
||||
|
||||
export type MutationOp =
|
||||
| 'add_lan' | 'remove_lan' | 'update_lan'
|
||||
| 'attach_decky' | 'detach_decky' | 'remove_decky' | 'update_decky';
|
||||
| 'add_decky' | 'attach_decky' | 'detach_decky' | 'remove_decky' | 'update_decky';
|
||||
|
||||
export interface EnqueueMutationResponse {
|
||||
mutation_id: string;
|
||||
|
||||
@@ -52,6 +52,17 @@ export interface UseTopologyEditor {
|
||||
): Promise<PrimitiveResult<void>>;
|
||||
|
||||
createDecky(topologyId: string, body: CreateDeckyBody): Promise<PrimitiveResult<DeckyRow>>;
|
||||
/** Composite: create a decky and attach it to its home LAN. On pending
|
||||
* this is two CRUD calls; on active it's one ``add_decky`` enqueue.
|
||||
* Callers should prefer this over ``createDecky`` + ``attachEdge`` so
|
||||
* the active path doesn't 409 on the CRUD half. */
|
||||
addDeckyToLan(
|
||||
topologyId: string,
|
||||
body: CreateDeckyBody,
|
||||
lanId: string,
|
||||
lanName: string,
|
||||
opts?: { is_bridge?: boolean; forwards_l3?: boolean },
|
||||
): Promise<PrimitiveResult<DeckyRow>>;
|
||||
updateDecky(
|
||||
topologyId: string,
|
||||
uuid: string,
|
||||
@@ -128,14 +139,36 @@ export function useTopologyEditor(
|
||||
|
||||
// ── Decky ──────────────────────────────────────────────────────────
|
||||
async createDecky(topologyId, body) {
|
||||
// No add_decky mutation op — decky creation on active topologies
|
||||
// is a composite (attach_decky with the create implicit). Phase B
|
||||
// step 3 handles that; for now creation stays direct-CRUD so the
|
||||
// pending path keeps working. On active this will 409 today until
|
||||
// step 3 lands a combined flow.
|
||||
// Bare create — only valid on pending. On active callers should use
|
||||
// addDeckyToLan() instead; the backend guard will 409 here.
|
||||
const data = await api.createDecky(topologyId, body);
|
||||
return { kind: 'applied', data };
|
||||
},
|
||||
async addDeckyToLan(topologyId, body, lanId, lanName, opts) {
|
||||
if (!live) {
|
||||
const data = await api.createDecky(topologyId, body);
|
||||
await api.attachEdge(topologyId, {
|
||||
decky_uuid: data.uuid,
|
||||
lan_id: lanId,
|
||||
is_bridge: opts?.is_bridge,
|
||||
forwards_l3: opts?.forwards_l3,
|
||||
});
|
||||
return { kind: 'applied', data };
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
name: body.name,
|
||||
lan: lanName,
|
||||
services: body.services,
|
||||
};
|
||||
const cfg = body.decky_config ?? {};
|
||||
if (cfg.archetype !== undefined) payload.archetype = cfg.archetype;
|
||||
const fwd = opts?.forwards_l3 ?? cfg.forwards_l3;
|
||||
if (fwd !== undefined) payload.forwards_l3 = fwd;
|
||||
if (body.x !== undefined) payload.x = body.x;
|
||||
if (body.y !== undefined) payload.y = body.y;
|
||||
const res = await api.enqueueMutation(topologyId, 'add_decky', payload, topoVersion);
|
||||
return { kind: 'enqueued', mutationId: res.mutation_id };
|
||||
},
|
||||
async updateDecky(topologyId, uuid, deckyName, patch) {
|
||||
if (!live) {
|
||||
const data = await api.updateDecky(topologyId, uuid, patch);
|
||||
|
||||
Reference in New Issue
Block a user