The MCP server covered the logical canvas only. A client could create a
design whose design_type is `rack` and then had no way to put a rack, a
mount or a cable in it, and it could triage the Device Inventory but
never create, edit or delete an entry — though every route already
existed. 24 tools become 47.
Racks (mcp/app/racks.py), 13 tools: list_racks, get_rack,
list_rack_inventory, list_faceplates, create_rack, update_rack,
delete_rack, mount_device, mount_accessory, unmount_device, move_device,
set_device_faceplate, patch_cable, unpatch_cable.
`POST /api/v1/racks/save` is full-state upsert-and-prune, so every write
is load, mutate, save it all back. Two traps are encoded once, in the
helpers: the response rows carry `design_id` and the save schemas do not
(echoing one back verbatim is a 422), and the viewport has to be echoed
or the save resets the user's pan and zoom. The plate, its colour and its
ports need no special handling — /racks/save writes those through to the
inventory row itself.
Two frontend files are copied rather than imported, since the MCP server
is a separate service:
- mcp/app/faceplates.py — the 21-plate catalog from
frontend/src/rack/faceplates.ts, data only. `bank()` reproduces the
port arithmetic exactly, so a plate seeded here lands its ports where
the renderer draws them.
- mcp/app/rack_layout.py — canPlace / findSlot / freeUnits from
frontend/src/rack/layout.ts, to *choose* a slot. The backend still
polices the one it is sent.
Both are guarded against drift: test_faceplates_sync.py parses
faceplates.ts and compares every plate down to each port's coordinates,
the way test_node_types_sync.py guards NODE_TYPES, and
test_layout_parity.py runs the frontend's own layout cases against the
Python port.
Devices (mcp/app/devices.py), 10 tools: create_device, update_device,
delete_device, bulk_approve_devices, bulk_hide_devices,
bulk_restore_devices, rescan_device, list_proxmox_children,
get_scan_config, update_scan_config.
Verified against a running backend: the rack flow end to end (create,
mount, patch, move, swap a plate, shrink, unmount) plus every refusal
path, and the device tools including a byte-identical scan-config
write-back.
ha-relevant: no
108 lines
3.6 KiB
Python
108 lines
3.6 KiB
Python
"""Rack grid geometry — a port of frontend/src/rack/layout.ts.
|
|
|
|
Only the placement half: `is_in_bounds`, `can_place`, `find_slot`, `free_units`.
|
|
The pixel maths (`uToY`, `portPosition`, …) is the renderer's business.
|
|
|
|
The backend re-checks fit, the column grid and overlap when the state is saved
|
|
(backend/app/schemas/racks.py), so this is here to *choose* a slot, not to police
|
|
one — a payload that slips through still gets a 422.
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
from .faceplates import RACK_COLUMNS
|
|
|
|
# A placement is a dict with u_start, u_height, col_start, col_span — the same
|
|
# snake_case shape the API speaks, so a mount row is its own placement.
|
|
Placement = dict[str, Any]
|
|
|
|
|
|
def _overlaps(a: Placement, b: Placement) -> bool:
|
|
u_overlap = (
|
|
a["u_start"] < b["u_start"] + b["u_height"]
|
|
and b["u_start"] < a["u_start"] + a["u_height"]
|
|
)
|
|
col_overlap = (
|
|
a["col_start"] < b["col_start"] + b["col_span"]
|
|
and b["col_start"] < a["col_start"] + a["col_span"]
|
|
)
|
|
return u_overlap and col_overlap
|
|
|
|
|
|
def clamp(value: int, low: int, high: int) -> int:
|
|
return max(low, min(high, value))
|
|
|
|
|
|
def is_in_bounds(rack: dict[str, Any], p: Placement) -> bool:
|
|
"""True when the placement fits inside the rack bounds."""
|
|
return (
|
|
p["u_start"] >= 1
|
|
and p["u_height"] >= 1
|
|
and p["u_start"] + p["u_height"] - 1 <= rack["u_height"]
|
|
and p["col_start"] >= 0
|
|
and p["col_span"] >= 1
|
|
and p["col_start"] + p["col_span"] <= RACK_COLUMNS
|
|
)
|
|
|
|
|
|
def can_place(
|
|
rack: dict[str, Any],
|
|
devices: list[dict[str, Any]],
|
|
p: Placement,
|
|
ignore_id: str | None = None,
|
|
) -> bool:
|
|
"""True when the placement fits and collides with nothing.
|
|
|
|
`ignore_id` skips the mount being moved, so a no-op move stays valid.
|
|
"""
|
|
if not is_in_bounds(rack, p):
|
|
return False
|
|
return not any(
|
|
d["rack_id"] == rack["id"] and d.get("id") != ignore_id and _overlaps(p, d)
|
|
for d in devices
|
|
)
|
|
|
|
|
|
def find_slot(
|
|
rack: dict[str, Any],
|
|
devices: list[dict[str, Any]],
|
|
desired: Placement,
|
|
ignore_id: str | None = None,
|
|
) -> Placement | None:
|
|
"""Nearest valid placement for `desired`, searching the target U first and
|
|
then walking outwards. None when the rack has no room at all."""
|
|
max_u = rack["u_height"] - desired["u_height"] + 1
|
|
if max_u < 1:
|
|
return None
|
|
|
|
col_start = clamp(desired["col_start"], 0, RACK_COLUMNS - desired["col_span"])
|
|
target_u = clamp(desired["u_start"], 1, max_u)
|
|
|
|
for offset in range(rack["u_height"]):
|
|
candidates_u = [target_u] if offset == 0 else [target_u - offset, target_u + offset]
|
|
for u in candidates_u:
|
|
if u < 1 or u > max_u:
|
|
continue
|
|
candidate = {**desired, "u_start": u, "col_start": col_start}
|
|
if can_place(rack, devices, candidate, ignore_id):
|
|
return candidate
|
|
# Same U may still have room on another column run.
|
|
for c in range(RACK_COLUMNS - desired["col_span"] + 1):
|
|
shifted = {**desired, "u_start": u, "col_start": c}
|
|
if can_place(rack, devices, shifted, ignore_id):
|
|
return shifted
|
|
return None
|
|
|
|
|
|
def free_units(rack: dict[str, Any], devices: list[dict[str, Any]]) -> int:
|
|
"""Free U count, counting a U as free only when every column is free."""
|
|
free = 0
|
|
for u in range(1, rack["u_height"] + 1):
|
|
busy = any(
|
|
d["rack_id"] == rack["id"] and u >= d["u_start"] and u < d["u_start"] + d["u_height"]
|
|
for d in devices
|
|
)
|
|
if not busy:
|
|
free += 1
|
|
return free
|