Plan: a Maps section with a floor-plan (floortile) editor¶
Build a Maps sidebar group whose flagship is a floor-plan editor: a
grid canvas where a user lays out tiles — racks, aisles, walls, cooling, power,
cameras, doors — that represent the physical layout of a room/floor, links
those tiles to real Danbyte objects, and reads live state off them (rack
utilisation, power draw, device up/down from monitoring). It draws its concept
from netbox-map's floor plans but is a clean reimplementation on Danbyte's
stack, design system, and — crucially — its live data (monitoring, rack
capacity rollups, topology), which is where it should beat the inspiration.
This is a handoff plan. Verify line numbers before editing; they drift.
0. Guiding decisions (read first)¶
- Airgap-safe, always.
netbox-map's global site map is Leaflet + OSM tiles — that needs the internet and is out of scope here (see §9). Everything in this plan is self-contained: an SVG grid canvas, uploaded background images served from our own/media/, no external JS/CSS/fonts. - A floor plan belongs to a Location. Danbyte already models
Location(api/models.py:3866, tenant + site + self-nesting parent) as "a building / floor / room within a site," and racks now carry alocationFK (shipped this session). So a floor plan is the physical layout of a location — one-to-one-ish. Scope the model tolocation(withsitederivable), not a free-floating object. - Custom SVG canvas, not React Flow. The topology map uses
@xyflow/reactbecause it's a free-form graph. A floor plan is a grid — tiles snap to integer cells, resize by cells, rotate in 90° steps. That's a spreadsheet, not a graph. A hand-rolled SVG canvas (<g transform>for pan/zoom,<rect>per tile, grid-snapped pointer math) is simpler and cleaner than bending React Flow to a grid, and it's a React island exactly like topology (frontend/src/routes/floorplans.*+frontend/src/components/floorplan/). Reuse patterns, not the library: PNG export viahtml-to-image(already a dep, used by topology + rack PNG), saved-state JSON likeTopologyView(api/models.py:2464), image upload like DeviceType images (ImageField(upload_to=…),api/models.py:320). - Zero-pre-filled-data holds — no built-in tile types. We ship the
mechanism, never the tiles. There is no fixed tile-kind enum. A tile's
type is either a tenant-created
FloorTileType(name + colour + a user-picked icon) or an existing device role (reuse its colour) — see §2/§7, which are now core, not a later phase. A fresh tenant has an empty palette and builds "Rack", "Wall", "Cooling"… themselves. No seeded floor plans, tile types, or icons, ever. Behaviour derives from a tile's link, not its type (a tile linked to a rack gets the rack heatmap whatever its type is called), so user-defined types stay purely visual/semantic and nothing special-cases a hardcoded "rack" string. - This is a multi-phase feature. Ship the MVP (view + edit a grid of linked tiles) before the advanced overlays. Phase order in §8.
1. Where it lives — the Maps sidebar group¶
frontend/src/components/app-sidebar.tsx groups nav by section (DCIM at the
"Racks" entry ~line 341; "Governance" label ~line 526). Add a Maps group,
placed after DCIM / before Governance:
- Floor plans (
/floorplans) — list of every floor plan in the tenant (columns: name, location, site, tile count, last edited); an Add creates one bound to a location. - (Phase 4) Site overview (
/maps/sites) — an offline geo/relative placement of sites + locations (see §9), not Leaflet.
Also surface entry points where users already are (the FK-linkage pattern used
everywhere): a Floor plan button/tab on the Location detail page
(routes/locations.$id.tsx — it already has Devices/Racks/Prefixes tabs from
this session) that opens/creates the location's plan, and a "Show on floor
plan" link on a Rack detail page when that rack is placed on a tile.
2. Data model¶
Three models (FloorTileType is now core, not optional). No seeded data.
All audited (add to AUDITED_MODELS in audit/apps.py).
FloorTileType(NumIdMixin, TimestampedModel) — user-created tile palette¶
The whole tile vocabulary is tenant data. There are no built-ins.
| field | type | notes |
|---|---|---|
tenant |
FK Tenant | scope |
name |
CharField(64) | "Rack", "Wall", "Cooling unit", "UPS" |
slug |
SlugField(64) | stable ref, unique per tenant |
color |
CharField(7) | hex; the tile's fill/tint |
icon |
CharField(48) | a Lucide icon name the user picks (e.g. server, door-closed, wind, cctv) — rendered via lucide-react's dynamic icon map, which is already a dep |
default_width/default_height |
PositiveSmallInteger, default 1 | new tiles of this type start this size (a rack is 1×1, an aisle 1×4…) |
unique_together = ("tenant", "slug"). This is the palette CRUD (a small
settings page under Customize, alongside Tags / Custom fields — the
zero-data section).
Device roles double as tile types. A device role already carries a colour
(and is_patch_panel, added this session). Let a tile's type be either a
FloorTileType or a DeviceRole — so a shop that has already defined
"Firewall/Access/Server" roles gets tiles for free, coloured to match the rack
elevations + topology. Model this as two nullable FKs on the tile
(tile_type → FloorTileType, role_type → DeviceRole), exactly one set, with
a check constraint — the same "exactly one of N" shape as
CableTermination (api/models.py:2088).
FloorPlan(NumIdMixin, TimestampedModel, CustomFieldsMixin, TaggableMixin)¶
| field | type | notes |
|---|---|---|
tenant |
FK Tenant | scope, like every domain model |
location |
FK Location (related_name="floor_plans") |
the room/floor this lays out; site read through location.site |
name |
CharField(128) | "DC1 · Hall A" |
grid_width / grid_height |
PositiveSmallInteger, default 24 / 16 | cells; validators 1–512 |
background_image |
ImageField(upload_to="floorplans/", null) |
blueprint/photo, scaled under the grid |
background_opacity |
PositiveSmallInteger, default 60 | % |
state |
JSONField(default=dict) | view prefs: default zoom/pan, overlay mode, grid on/off. Same free-schema trick as TopologyView.state — no migrations to evolve it. |
unique_together = ("tenant", "location", "name"). Ordering by name.
FloorPlanTile(NumIdMixin, TimestampedModel)¶
One row per placed tile. Tenant scope via floor_plan.tenant.
| field | type | notes |
|---|---|---|
floor_plan |
FK FloorPlan (related_name="tiles", CASCADE) |
|
x, y |
PositiveSmallInteger | top-left cell, 0-indexed |
width, height |
PositiveSmallInteger, default 1 | in cells |
tile_type |
FK FloorTileType, null | the user's palette entry… |
role_type |
FK DeviceRole, null | …or a device role — exactly one of the two (check constraint) |
orientation |
PositiveSmallInteger, default 0 | 0/90/180/270 |
label |
CharField(64, blank) | overrides the linked object's name |
color |
CharField(7, blank) | optional hex override |
status |
CharField(16, choices, blank) | active/planned/reserved/decommissioning (reuse the semantic vocabulary) |
link_kind |
CharField(16, blank) | "" / rack / device / powerpanel / powerfeed / location (nested plan) — mirror the topology reference-kind approach |
| generic link | one nullable FK per link kind (rack/device/power_panel/power_feed) or a single linked_floor_plan FK for nesting |
prefer explicit FKs over GenericFK — tenant validation is cleaner, matches how CableTermination does its 8 point FKs (api/models.py:2088) |
fov_deg, fov_distance, fov_direction |
PositiveSmallInteger, null | camera cone (Phase 5) |
unique_together is not on (x,y) — tiles can overlap intentionally
(a device tile on an aisle). The frontend prevents accidental overlap by
snap + occupancy hints, not the DB.
No kind enum. Colour + icon come from the tile's tile_type/role_type
(overridable per tile via color/label). Special behaviours are not keyed
off a type string — they come from what the tile links to: a tile whose
link_kind == "rack" gets the rack heatmap + elevation deep-view; a tile with a
linked_floor_plan navigates on click; any tile may carry the optional camera
fov_* fields. So the palette stays 100% user-defined and no code special-cases
a magic tile name.
Migration 00NN_floorplan (three models). Register all three in
AUDITED_MODELS.
3. Backend API¶
Mirror the module-types / topology-views slices built this session.
FloorPlanSerializer(+FloorPlanMiniSerializerfor pickers): all fields,tile_countSerializerMethodField,siteread through location,background_imageas the relative_img_url(the helper atapi/serializers.pyused for device images — returns/media/..., works with the Vite + nginx/media/proxy set up this session).FloorPlanTileSerializer: write the link bylink_kind+ a singlelink_id(resolve to the right FK invalidate, tenant-checked — copy the_resolve/tenant pattern fromCableSerializer), read back a compactlinkedobject{kind, id, name, route}for the canvas to render + deep-link.- Bulk tile save: the editor mutates many tiles at once (drag several,
paint a wall run). Add a
POST /api/floor-plans/<id>/tiles/bulk/action taking{create:[], update:[], delete:[]}in one transaction, returning the fresh tile list — avoids N requests per edit. (Precedent: the cable form's_sync, the interface bulk endpoints.) - Viewsets
FloorPlanViewSet(TenantScopedViewSet)(filter?location=,?site=) andFloorPlanTileViewSet(scope via floor_plan.tenant, filter?floor_plan=). Router:floor-plans,floor-plan-tiles. RBAC:floorplanadd/change/delete via the standardcanDo. - Live-state endpoint (Phase 3, the differentiator):
GET /api/floor-plans/<id>/state/→ per-tile live metrics for the linked objects in one query: rack →{used_units, u_height, power:{…}, weight:{…}}(all already onRackSerializerfrom this session); device → status + monitoring up/down (monitoringCheckState,monitoring/models.py:324); power feed → utilisation. Keyed by tile id. The canvas polls or refetches this to paint overlays without re-reading the whole plan.
4. Frontend — the canvas¶
frontend/src/components/floorplan/ (island) + routes/floorplans.*.
4a. Rendering core (floor-canvas.tsx)¶
- One
<svg>with a<g transform="translate(px,py) scale(z)">. Pan = pointer drag on empty grid; zoom = wheel about the cursor. A ~60-lineusePanZoomhook (no new dep). Grid = a<pattern>of cells atCELL_PX(e.g. 40px). Background image is an<image>under the grid atbackground_opacity. - Tiles: one
<g>per tile, translated tox*CELL, y*CELL, sizedwidth*CELL × height*CELL, rotatedorientationabout its centre. Inside: a<rect>(kind colour or override), an inline SVG icon (Lucide-style, the topology stencil already uses inline SVGs — no icon lib), and a<text>label that counter-rotates so it stays upright (netbox's nice touch). - No static tile registry. A tile's colour/icon come from its
tile_type/role_type(fetched with the palette), overridable per tile. Icons render by name fromlucide-react's icon map (already a dep) — a<DynamicIcon name>wrapper doesicons[toPascalCase(name)], falling back to a neutral square for an unknown/removed icon. Colour conveys the tile's type; live overlays (§4d) convey state — the design rule that they never fight still holds.
4a-bis. Palette CRUD + icon picker¶
A small Customize → Floor tiles page (list + form, like Tags): create a
tile type with name, colour (the existing FormColor), and an icon picker.
The picker is a searchable popover (Popover + a filtered grid, the pattern from
the Level organiser / object pickers) over the Lucide icon-name list — type
"cam", see the camera icons, click to set. Because the whole palette is user
data, this page is where a tenant builds "Rack / Wall / Cooling / Camera / UPS"
once; device roles appear in the tile palette automatically (no CRUD needed).
4b. Editor UX (floor-editor.tsx)¶
- Palette rail (left, like the faceplate builder / filter rails): the tile kinds as draggable chips. Drag onto the grid to place (snap to cell), or select a kind then click-drag to paint a rectangle (walls, aisles).
- Selected tile toolbar: resize (drag corner handles, cell-snapped),
rotate (90° button), set label/color/status, link to an object (uses the
advanced ObjectPicker family built this session —
DevicePicker,RackPicker, and a newPowerPanelPicker/PowerFeedPickerare ~60-line specs), delete (Del key). Multi-select + move (marquee drag). - Grid controls: grid size inputs, toggle grid lines, background upload + opacity slider, snap on/off.
- Edits batch into the bulk endpoint on a short debounce (or explicit Save — match the faceplate builder's explicit-Save feel; auto-save is riskier). Undo/redo stack in the island (Phase 2).
4c. Viewer mode (floor-viewer.tsx)¶
Read-only render for the location page / dashboards / anyone without
floorplan.change. Click a tile → its detail popover (linked object summary +
"Open rack/device", or navigate a link tile to its child plan). This is the
default for most users; editor is gated on the permission.
4d. Live overlays — the Danbyte differentiator¶
An overlay mode select (like topology's colour-mode): None · Utilisation
· Power · Status · Weight. The canvas fetches /state/ and tints each linked
tile:
- Utilisation — rack tiles get the green→amber→red heatmap
(
used_units/u_height) — the netbox headline feature, but we already compute used-units server-side (half-width aware), so it's exact. - Power — rack
power.allocated_w / available_w(the rollup shipped this session), red when over budget. - Status — device/rack tiles pulse by live monitoring up/down
(
CheckState) + object status. This is the thing netbox-map can't do — a floor plan that shows a rack going red the moment a device in it drops. - Weight — rack load vs
max_weightbudget.
Overlay legend + a timestamp ("live as of …"), refetch button, optional poll.
4e. Rack tile deep-view¶
Clicking a rack tile opens a side panel with the actual rack elevation
— reuse <RackElevation rack mode="names" /> (built + hardened this session,
front/rear, role-coloured blocks) — plus its capacity triad (space/power/weight)
and a search over its devices. This is netbox-map's "expand rack" feature but
using our real elevation renderer, not a re-draw.
4f. Export¶
PNG via html-to-image on the SVG wrapper (theme-aware background), exactly
like the topology + rack PNG buttons. PDF (Phase 6): render PNG → embed via
jspdf (would be a new dep; netbox uses it) at the plan's real proportions,
with a title block (location, date, legend). MVP ships PNG only.
5. Reuse map (what to build on — all in-repo, this session or earlier)¶
| Need | Reuse |
|---|---|
| Saved view / free JSON state | TopologyView.state pattern (api/models.py:2464) |
Image upload + /media/ serving |
DeviceType front_image (api/models.py:320) + the Vite/nginx /media/ wiring |
| Object linking pickers | ObjectPicker + DevicePicker/RackPicker (this session) |
| Rack render in the deep-view | RackElevation component |
| Rack capacity numbers | RackSerializer used_units / power / max_weight_kg (this session) |
| Live up/down | monitoring CheckState (monitoring/models.py:324) |
| PNG export | html-to-image (already a dep) |
| Palette drag-drop | @dnd-kit (already a dep, used by faceplate builder + level organiser) |
| Bulk transactional save | cable form _sync; interface bulk endpoints |
| Tenant-checked link resolution | CableSerializer._resolve |
Net new deps: none for MVP (jspdf only if/when PDF lands).
6. Docs & tests (per project convention — same change, not after)¶
docs/features/floor-plans.md(new) — user guide: tile kinds, linking, overlays, export. Link it fromdocs/dcim/racks.mdand the Location docs.docs/development/roadmap.md— add to "Shipped" per phase.- Tests
api/tests_floorplan.py: CRUD + tenant isolation; bulk-tile create/update/delete in one call; link resolution + cross-tenant link rejected;/state/returns rack utilisation/power for linked racks; alink-kind tile round-trips itslinked_floor_plan. - Frontend: no topology test suite exists to extend; a pan/zoom + snap unit test would be net-new (flag, don't assume).
7. Custom tile types — core, not a phase¶
Folded into §2/§4a-bis and Phase 1: FloorTileType (tenant, slug, name,
color, icon) is the only source of tile kinds (plus device roles). There is
no built-in enum to "add to" — the palette is empty until the user fills it,
matching how Danbyte ships statuses/tags/custom-fields. This is the direct
analogue of netbox-map's CustomMarkerType, but made the default and only
path rather than an override on top of built-ins.
8. Phase order¶
- MVP — palette + model + grid + linked tiles. All three models (FloorTileType included) + API + bulk save; the Floor tiles palette CRUD with the icon picker; device roles surfaced as tile types; the SVG canvas with pan/zoom/grid; place/move/resize/rotate/label/link/delete; PNG export; the Floor plans list + the Location-page entry. Viewer mode. This alone is a shippable, useful feature — and the palette-first order is deliberate: there are no tiles to place until the user defines some.
- Editor polish. Multi-select, marquee, undo/redo, background image + opacity, keyboard nudges, snap toggle.
- Live overlays (
/state/+ overlay modes) — the differentiator. Do this before anything fancy; it's the reason to build this over buying a diagram. - Rack deep-view (elevation side panel + device search).
- Camera FOV cones + toggle.
- PDF export (adds
jspdf). - (Optional) Offline site overview — see §9.
9. The geographic map — deliberately deferred / re-scoped¶
netbox-map's global map is Leaflet + OpenStreetMap raster tiles + GPS sync.
That violates Danbyte's airgap constraint (external tile servers) and is
the one part not to copy as-is. Options, none in MVP:
- (Recommended, if wanted) Offline relative map: sites/locations placed on a plain pannable SVG board (optionally over an uploaded region/campus image), storing relative x/y — no tiles, no internet. Reuses the same canvas core.
- Self-hosted tiles: only if the deployment ships an offline tile pack; a config-gated opt-in, never a hard dependency.
Confirm with the product owner whether the geo view is wanted at all before building it — the floor plan is the high-value, airgap-clean 90%.
10. Open questions (surface, don't guess)¶
- Floor plan ↔ Location cardinality — one plan per location (simplest, recommended) or many (a location with several layout revisions)? The model above allows many via the name uniqueness; the UI can enforce one.
- Overlay data freshness — poll
/state/on an interval, or refetch on focus/button only? (Monitoring already pushes state; a 30s poll is cheap.) - Editor save model — explicit Save (faceplate-builder feel, safest) vs debounced auto-save (netbox-map feel)? Recommend explicit for v1.
- Geo view — wanted at all given airgap (recommend deferring, §9)?
- Tile link breadth — MVP links rack/device/power; do users need to link interfaces/ports (netbox's "drop" tiles for cable tracing to a wall plate)? That ties into our trace engine and could be a strong Phase-5+ add.
11. Gap analysis vs netbox-map (2026-07-04, task #13)¶
Feature-by-feature against DenDanskeMine/netbox-map (README, current main).
"✅" = Danbyte has it (often better); "🔶" = partially; "❌" = missing.
Where Danbyte already matches or beats the plugin¶
- ✅ Floor-plan editor — grid canvas, pan/zoom, drag placement, resize, 0/90/180/270 orientation, background image upload, configurable grid. Danbyte adds: explicit-Save transactional bulk endpoint, tenant scoping, RBAC, audit on all three models.
- ✅ Tile palette — netbox-map ships 12 hardcoded tile types + a
CustomMarkerTypeadd-on. Danbyte made the user palette the only path (zero pre-filled data) and lets device roles double as tile types. - ✅ Object links — rack / device / power panel / power feed, plus Danbyte-only nested floor plans (cage inside hall).
- ✅ Rack utilization heatmap —
/state/computes exact half-width-aware used-units server-side; canvas paints the bar/tier colors. - ✅ Live monitoring on tiles — the differentiator netbox-map lacks: worst CheckState across a rack's devices' IPs tints the tile.
- ✅ Camera FOV cones — direction/angle/distance fields + cone render +
has_fovflags on tile types and device roles (netbox-map gates it on the camera tile kind; ours is any type/role). - ✅ Zones — netbox-map's aisle/reserved/empty tiles as a first-class
is_zoneflag: render under tiles, exempt from collision. - ✅ Topology — Danbyte's own topology map already covers stencil cards, saved views, pass-through collapse, PNG.
- ✅ Dark mode, REST CRUD, change logging — all standard here.
Real gaps (ranked, with a home)¶
- Port-level tile links (
tile-port-assignments) — wall plates / "drop" tiles that link ports, feeding cable tracing to an office jack. Open question §10.5; ties into the trace engine. Medium effort, high networking value. - Cable tracing from the plan — netbox-map traces device/front/rear
ports (through patch panels) from the map sidebar and has "Show on map"
to zoom to a device's tile. Task #11 (deep-view panel) covers the
rack-expand + elevation half; add trace links into the existing trace
pages (cheap — routes exist) and a
?tile=deep-link/zoom-to-tile param. - "Show on floor plan" backlinks — rack/device detail pages should link
to the plan(s) whose tiles reference them (reverse FK exists:
rack.floor_tiles). Location page already has the button; racks/devices don't. Small. - PDF export — netbox-map exports PDF incl. FOV cones; we ship PNG only.
Plan Phase 6 (
jspdf, new dep). Small-medium. - Search indexing — floor plans/tiles aren't in Danbyte's global search app yet. Small.
- Geographic site map + GPS sync + map markers + fiber paths — deliberately not copied (Leaflet + OSM tiles violate airgap, §9). If demand appears, build the §9 offline relative board on the same canvas.
- Application mapping (beta in netbox-map) — out of floor-plan scope; would be its own Danbyte feature if wanted.
- Per-object detail-panel field settings UI — netbox-map has a web settings page for what its detail panels show; Danbyte's equivalent would ride the future User/Display preferences store, not a floor-plan-specific page.
- Rotated labels on narrow vertical tiles — netbox-map rotates text
with the tile; Danbyte keeps labels upright by design (grid-honest
rotate). If 1-wide tall tiles prove unreadable, add vertical
writing-modetext as a per-tile option rather than full rotation.