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:
@@ -267,8 +267,8 @@ def _mutate(
|
|||||||
op: str = typer.Argument(
|
op: str = typer.Argument(
|
||||||
...,
|
...,
|
||||||
help=(
|
help=(
|
||||||
"One of: add_lan, remove_lan, attach_decky, detach_decky, "
|
"One of: add_lan, remove_lan, add_decky, attach_decky, "
|
||||||
"remove_decky, update_decky, update_lan"
|
"detach_decky, remove_decky, update_decky, update_lan"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
payload_json: str = typer.Option(
|
payload_json: str = typer.Option(
|
||||||
|
|||||||
@@ -154,6 +154,78 @@ async def apply_remove_lan(
|
|||||||
await _assert_valid_after(repo, topology_id)
|
await _assert_valid_after(repo, topology_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_add_decky(
|
||||||
|
repo: Any, topology_id: str, payload: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Create a brand-new decky and attach it to its home LAN.
|
||||||
|
|
||||||
|
Used when the editor drags an archetype onto an active topology.
|
||||||
|
``apply_attach_decky`` requires an existing decky, so without this
|
||||||
|
op there is no way to grow a live topology from the UI.
|
||||||
|
|
||||||
|
``payload`` keys:
|
||||||
|
``name`` — decky name (required, unique in topology).
|
||||||
|
``lan`` — home LAN name (required).
|
||||||
|
``services`` — list of service slugs (optional).
|
||||||
|
``archetype`` — slug string; stored in ``decky_config`` (optional).
|
||||||
|
``forwards_l3`` — bool; stored in ``decky_config`` (optional).
|
||||||
|
``ip`` — pinned IP inside the LAN; else auto-allocated.
|
||||||
|
``x``,``y`` — layout coords (optional).
|
||||||
|
"""
|
||||||
|
name = payload["name"]
|
||||||
|
hydrated = await _hydrated(repo, topology_id)
|
||||||
|
if _decky_by_name(hydrated, name) is not None:
|
||||||
|
raise MutationError(f"decky {name!r} already exists")
|
||||||
|
lan = _lan_by_name(hydrated, payload["lan"])
|
||||||
|
if lan is None:
|
||||||
|
raise MutationError(f"LAN {payload['lan']!r} not found")
|
||||||
|
|
||||||
|
ip = payload.get("ip")
|
||||||
|
if ip is None:
|
||||||
|
taken = {
|
||||||
|
d["decky_config"]["ips_by_lan"].get(lan["name"])
|
||||||
|
for d in hydrated["deckies"]
|
||||||
|
if lan["name"] in d["decky_config"].get("ips_by_lan", {})
|
||||||
|
}
|
||||||
|
taken.discard(None)
|
||||||
|
alloc = IPAllocator(subnet=lan["subnet"])
|
||||||
|
for t in taken:
|
||||||
|
if t:
|
||||||
|
alloc.reserve(t)
|
||||||
|
ip = alloc.next_free()
|
||||||
|
|
||||||
|
decky_config: dict[str, Any] = {
|
||||||
|
"name": name,
|
||||||
|
"ips_by_lan": {lan["name"]: ip},
|
||||||
|
}
|
||||||
|
if "archetype" in payload:
|
||||||
|
decky_config["archetype"] = payload["archetype"]
|
||||||
|
forwards_l3 = bool(payload.get("forwards_l3", False))
|
||||||
|
if forwards_l3:
|
||||||
|
decky_config["forwards_l3"] = True
|
||||||
|
|
||||||
|
decky_uuid = await repo.add_topology_decky(
|
||||||
|
{
|
||||||
|
"topology_id": topology_id,
|
||||||
|
"name": name,
|
||||||
|
"services": list(payload.get("services", [])),
|
||||||
|
"decky_config": decky_config,
|
||||||
|
"x": payload.get("x"),
|
||||||
|
"y": payload.get("y"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await repo.add_topology_edge(
|
||||||
|
{
|
||||||
|
"topology_id": topology_id,
|
||||||
|
"decky_uuid": decky_uuid,
|
||||||
|
"lan_id": lan["id"],
|
||||||
|
"is_bridge": False,
|
||||||
|
"forwards_l3": forwards_l3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await _assert_valid_after(repo, topology_id)
|
||||||
|
|
||||||
|
|
||||||
async def apply_attach_decky(
|
async def apply_attach_decky(
|
||||||
repo: Any, topology_id: str, payload: dict[str, Any]
|
repo: Any, topology_id: str, payload: dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -326,6 +398,7 @@ async def apply_update_lan(
|
|||||||
DISPATCH: dict[str, OpFunc] = {
|
DISPATCH: dict[str, OpFunc] = {
|
||||||
"add_lan": apply_add_lan,
|
"add_lan": apply_add_lan,
|
||||||
"remove_lan": apply_remove_lan,
|
"remove_lan": apply_remove_lan,
|
||||||
|
"add_decky": apply_add_decky,
|
||||||
"attach_decky": apply_attach_decky,
|
"attach_decky": apply_attach_decky,
|
||||||
"detach_decky": apply_detach_decky,
|
"detach_decky": apply_detach_decky,
|
||||||
"remove_decky": apply_remove_decky,
|
"remove_decky": apply_remove_decky,
|
||||||
@@ -358,6 +431,7 @@ __all__ = [
|
|||||||
"dispatch",
|
"dispatch",
|
||||||
"apply_add_lan",
|
"apply_add_lan",
|
||||||
"apply_remove_lan",
|
"apply_remove_lan",
|
||||||
|
"apply_add_decky",
|
||||||
"apply_attach_decky",
|
"apply_attach_decky",
|
||||||
"apply_detach_decky",
|
"apply_detach_decky",
|
||||||
"apply_remove_decky",
|
"apply_remove_decky",
|
||||||
|
|||||||
@@ -339,8 +339,8 @@ class TopologyMutation(SQLModel, table=True):
|
|||||||
)
|
)
|
||||||
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
id: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||||
topology_id: str = Field(foreign_key="topologies.id", index=True)
|
topology_id: str = Field(foreign_key="topologies.id", index=True)
|
||||||
# add_lan|remove_lan|attach_decky|detach_decky|remove_decky|
|
# add_lan|remove_lan|add_decky|attach_decky|detach_decky|
|
||||||
# update_decky|update_lan
|
# remove_decky|update_decky|update_lan
|
||||||
op: str = Field(index=True)
|
op: str = Field(index=True)
|
||||||
# JSON-serialised op payload (keys depend on ``op``).
|
# JSON-serialised op payload (keys depend on ``op``).
|
||||||
payload: str = Field(
|
payload: str = Field(
|
||||||
@@ -805,6 +805,7 @@ class EdgeCreateRequest(BaseModel):
|
|||||||
_MUTATION_OPS = Literal[
|
_MUTATION_OPS = Literal[
|
||||||
"add_lan",
|
"add_lan",
|
||||||
"remove_lan",
|
"remove_lan",
|
||||||
|
"add_decky",
|
||||||
"attach_decky",
|
"attach_decky",
|
||||||
"detach_decky",
|
"detach_decky",
|
||||||
"remove_decky",
|
"remove_decky",
|
||||||
|
|||||||
@@ -96,16 +96,15 @@ const MazeNET: React.FC = () => {
|
|||||||
|
|
||||||
if (isDmz) {
|
if (isDmz) {
|
||||||
const gwName = `dmz-gateway-${hex4()}`;
|
const gwName = `dmz-gateway-${hex4()}`;
|
||||||
const gwRes = await editor.createDecky(topologyId, {
|
const gwRes = await editor.addDeckyToLan(
|
||||||
name: gwName, services: ['ssh'], x: 20, y: 40,
|
topologyId,
|
||||||
decky_config: { archetype: 'deaddeck', forwards_l3: true },
|
{ 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;
|
if (gwRes.kind !== 'applied') return;
|
||||||
const gw = gwRes.data;
|
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 = {
|
const gwNode: DeckyNode = {
|
||||||
kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name,
|
kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name,
|
||||||
archetype: 'deaddeck', services: ['ssh'], status: 'idle',
|
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 ny = Math.max(28, Math.round(world.y - net.y - 24));
|
||||||
const name = `decky-${hex4()}`;
|
const name = `decky-${hex4()}`;
|
||||||
try {
|
try {
|
||||||
const dRes = await editor.createDecky(topologyId, {
|
const dRes = await editor.addDeckyToLan(
|
||||||
name, services: dServices, x: nx, y: ny,
|
topologyId,
|
||||||
decky_config: { archetype: archSlug },
|
{ name, services: dServices, x: nx, y: ny,
|
||||||
});
|
decky_config: { archetype: archSlug } },
|
||||||
|
overNetId, net.label,
|
||||||
|
);
|
||||||
if (dRes.kind !== 'applied') return;
|
if (dRes.kind !== 'applied') return;
|
||||||
const decky = dRes.data;
|
const decky = dRes.data;
|
||||||
await editor.attachEdge(topologyId,
|
|
||||||
{ decky_uuid: decky.uuid, lan_id: overNetId },
|
|
||||||
decky.name, net.label);
|
|
||||||
const node: DeckyNode = {
|
const node: DeckyNode = {
|
||||||
kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name,
|
kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name,
|
||||||
archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny,
|
archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny,
|
||||||
@@ -256,16 +254,15 @@ const MazeNET: React.FC = () => {
|
|||||||
if (!n || n.kind !== 'decky') return;
|
if (!n || n.kind !== 'decky') return;
|
||||||
const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`;
|
const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`;
|
||||||
try {
|
try {
|
||||||
const dRes = await editor.createDecky(topologyId, {
|
const parentNet = nets.find((net) => net.id === n.netId);
|
||||||
name, services: [...n.services], x: n.x + 24, y: n.y + 24,
|
const dRes = await editor.addDeckyToLan(
|
||||||
decky_config: { archetype: n.archetype },
|
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;
|
if (dRes.kind !== 'applied') return;
|
||||||
const decky = dRes.data;
|
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 = {
|
const copy: DeckyNode = {
|
||||||
kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name,
|
kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name,
|
||||||
archetype: n.archetype, services: [...n.services], status: 'idle',
|
archetype: n.archetype, services: [...n.services], status: 'idle',
|
||||||
@@ -351,15 +348,14 @@ const MazeNET: React.FC = () => {
|
|||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const name = `decky-${hex4()}`;
|
const name = `decky-${hex4()}`;
|
||||||
try {
|
try {
|
||||||
const dRes = await editor.createDecky(topologyId, {
|
const dRes = await editor.addDeckyToLan(
|
||||||
name, services: [...a.services], x: 20, y: 40,
|
topologyId,
|
||||||
decky_config: { archetype: a.slug },
|
{ name, services: [...a.services], x: 20, y: 40,
|
||||||
});
|
decky_config: { archetype: a.slug } },
|
||||||
|
id, net.label,
|
||||||
|
);
|
||||||
if (dRes.kind !== 'applied') return;
|
if (dRes.kind !== 'applied') return;
|
||||||
const decky = dRes.data;
|
const decky = dRes.data;
|
||||||
await editor.attachEdge(topologyId,
|
|
||||||
{ decky_uuid: decky.uuid, lan_id: id },
|
|
||||||
decky.name, net.label);
|
|
||||||
const node: DeckyNode = {
|
const node: DeckyNode = {
|
||||||
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
|
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
|
||||||
archetype: a.slug, services: [...a.services], status: 'idle',
|
archetype: a.slug, services: [...a.services], status: 'idle',
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export interface CreateDeckyBody {
|
|||||||
|
|
||||||
export type MutationOp =
|
export type MutationOp =
|
||||||
| 'add_lan' | 'remove_lan' | 'update_lan'
|
| '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 {
|
export interface EnqueueMutationResponse {
|
||||||
mutation_id: string;
|
mutation_id: string;
|
||||||
|
|||||||
@@ -52,6 +52,17 @@ export interface UseTopologyEditor {
|
|||||||
): Promise<PrimitiveResult<void>>;
|
): Promise<PrimitiveResult<void>>;
|
||||||
|
|
||||||
createDecky(topologyId: string, body: CreateDeckyBody): Promise<PrimitiveResult<DeckyRow>>;
|
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(
|
updateDecky(
|
||||||
topologyId: string,
|
topologyId: string,
|
||||||
uuid: string,
|
uuid: string,
|
||||||
@@ -128,14 +139,36 @@ export function useTopologyEditor(
|
|||||||
|
|
||||||
// ── Decky ──────────────────────────────────────────────────────────
|
// ── Decky ──────────────────────────────────────────────────────────
|
||||||
async createDecky(topologyId, body) {
|
async createDecky(topologyId, body) {
|
||||||
// No add_decky mutation op — decky creation on active topologies
|
// Bare create — only valid on pending. On active callers should use
|
||||||
// is a composite (attach_decky with the create implicit). Phase B
|
// addDeckyToLan() instead; the backend guard will 409 here.
|
||||||
// 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.
|
|
||||||
const data = await api.createDecky(topologyId, body);
|
const data = await api.createDecky(topologyId, body);
|
||||||
return { kind: 'applied', data };
|
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) {
|
async updateDecky(topologyId, uuid, deckyName, patch) {
|
||||||
if (!live) {
|
if (!live) {
|
||||||
const data = await api.updateDecky(topologyId, uuid, patch);
|
const data = await api.updateDecky(topologyId, uuid, patch);
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import pytest
|
|||||||
from decnet.bus import topics as _topics
|
from decnet.bus import topics as _topics
|
||||||
from decnet.bus.fake import FakeBus
|
from decnet.bus.fake import FakeBus
|
||||||
from decnet.mutator import engine as _engine
|
from decnet.mutator import engine as _engine
|
||||||
from decnet.mutator.ops import MutationError, apply_add_lan, apply_update_decky
|
from decnet.mutator.ops import (
|
||||||
|
MutationError,
|
||||||
|
apply_add_decky,
|
||||||
|
apply_add_lan,
|
||||||
|
apply_update_decky,
|
||||||
|
)
|
||||||
from decnet.topology.config import TopologyConfig
|
from decnet.topology.config import TopologyConfig
|
||||||
from decnet.topology.generator import generate
|
from decnet.topology.generator import generate
|
||||||
from decnet.topology.persistence import persist, transition_status
|
from decnet.topology.persistence import persist, transition_status
|
||||||
@@ -158,6 +163,55 @@ async def test_apply_add_lan_persists(repo):
|
|||||||
assert "LAN-MUT" in names
|
assert "LAN-MUT" in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_apply_add_decky_creates_and_attaches(repo):
|
||||||
|
"""add_decky creates a new decky row + home-LAN edge in one op."""
|
||||||
|
tid = await _make_active(repo)
|
||||||
|
lans = await repo.list_lans_for_topology(tid)
|
||||||
|
home_lan = lans[0]
|
||||||
|
|
||||||
|
await apply_add_decky(
|
||||||
|
repo, tid,
|
||||||
|
{
|
||||||
|
"name": "new-decky-mut",
|
||||||
|
"lan": home_lan["name"],
|
||||||
|
"services": ["ssh"],
|
||||||
|
"archetype": "deaddeck",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
deckies = await repo.list_topology_deckies(tid)
|
||||||
|
new = next((d for d in deckies if d["decky_config"]["name"] == "new-decky-mut"), None)
|
||||||
|
assert new is not None
|
||||||
|
assert new["services"] == ["ssh"]
|
||||||
|
assert new["decky_config"]["archetype"] == "deaddeck"
|
||||||
|
assert home_lan["name"] in new["decky_config"]["ips_by_lan"]
|
||||||
|
|
||||||
|
edges = await repo.list_topology_edges(tid)
|
||||||
|
assert any(e["decky_uuid"] == new["uuid"] and e["lan_id"] == home_lan["id"] for e in edges)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_apply_add_decky_rejects_duplicate_name(repo):
|
||||||
|
tid = await _make_active(repo)
|
||||||
|
lans = await repo.list_lans_for_topology(tid)
|
||||||
|
existing = (await repo.list_topology_deckies(tid))[0]
|
||||||
|
with pytest.raises(MutationError, match="already exists"):
|
||||||
|
await apply_add_decky(
|
||||||
|
repo, tid,
|
||||||
|
{"name": existing["decky_config"]["name"], "lan": lans[0]["name"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_apply_add_decky_rejects_missing_lan(repo):
|
||||||
|
tid = await _make_active(repo)
|
||||||
|
with pytest.raises(MutationError, match="not found"):
|
||||||
|
await apply_add_decky(
|
||||||
|
repo, tid, {"name": "orphan-decky", "lan": "nonexistent-lan"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_apply_update_decky_replaces_services(repo):
|
async def test_apply_update_decky_replaces_services(repo):
|
||||||
"""Top-level ``services`` payload key replaces the decky's services list."""
|
"""Top-level ``services`` payload key replaces the decky's services list."""
|
||||||
@@ -287,8 +341,9 @@ def test_ops_payload_shape_docstring_present():
|
|||||||
from decnet.mutator.ops import DISPATCH
|
from decnet.mutator.ops import DISPATCH
|
||||||
|
|
||||||
assert set(DISPATCH) == {
|
assert set(DISPATCH) == {
|
||||||
"add_lan", "remove_lan", "attach_decky", "detach_decky",
|
"add_lan", "remove_lan",
|
||||||
"remove_decky", "update_decky", "update_lan",
|
"add_decky", "attach_decky", "detach_decky", "remove_decky",
|
||||||
|
"update_decky", "update_lan",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user