Skip to content

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)

  1. 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.
  2. 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 a location FK (shipped this session). So a floor plan is the physical layout of a location — one-to-one-ish. Scope the model to location (with site derivable), not a free-floating object.
  3. Custom SVG canvas, not React Flow. The topology map uses @xyflow/react because 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 via html-to-image (already a dep, used by topology + rack PNG), saved-state JSON like TopologyView (api/models.py:2464), image upload like DeviceType images (ImageField(upload_to=…), api/models.py:320).
  4. 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.
  5. 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 (+ FloorPlanMiniSerializer for pickers): all fields, tile_count SerializerMethodField, site read through location, background_image as the relative _img_url (the helper at api/serializers.py used for device images — returns /media/..., works with the Vite + nginx /media/ proxy set up this session).
  • FloorPlanTileSerializer: write the link by link_kind + a single link_id (resolve to the right FK in validate, tenant-checked — copy the _resolve/tenant pattern from CableSerializer), read back a compact linked object {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=) and FloorPlanTileViewSet (scope via floor_plan.tenant, filter ?floor_plan=). Router: floor-plans, floor-plan-tiles. RBAC: floorplan add/change/delete via the standard canDo.
  • 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 on RackSerializer from this session); device → status + monitoring up/down (monitoring CheckState, 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-line usePanZoom hook (no new dep). Grid = a <pattern> of cells at CELL_PX (e.g. 40px). Background image is an <image> under the grid at background_opacity.
  • Tiles: one <g> per tile, translated to x*CELL, y*CELL, sized width*CELL × height*CELL, rotated orientation about 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 from lucide-react's icon map (already a dep) — a <DynamicIcon name> wrapper does icons[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 new PowerPanelPicker/PowerFeedPicker are ~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_weight budget.

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 from docs/dcim/racks.md and 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; a link-kind tile round-trips its linked_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

  1. 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.
  2. Editor polish. Multi-select, marquee, undo/redo, background image + opacity, keyboard nudges, snap toggle.
  3. Live overlays (/state/ + overlay modes) — the differentiator. Do this before anything fancy; it's the reason to build this over buying a diagram.
  4. Rack deep-view (elevation side panel + device search).
  5. Camera FOV cones + toggle.
  6. PDF export (adds jspdf).
  7. (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)

  1. 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.
  2. Overlay data freshness — poll /state/ on an interval, or refetch on focus/button only? (Monitoring already pushes state; a 30s poll is cheap.)
  3. Editor save model — explicit Save (faceplate-builder feel, safest) vs debounced auto-save (netbox-map feel)? Recommend explicit for v1.
  4. Geo view — wanted at all given airgap (recommend deferring, §9)?
  5. 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 CustomMarkerType add-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 tilesthe 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_fov flags 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_zone flag: 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)

  1. 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.
  2. 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.
  3. "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.
  4. PDF export — netbox-map exports PDF incl. FOV cones; we ship PNG only. Plan Phase 6 (jspdf, new dep). Small-medium.
  5. Search indexing — floor plans/tiles aren't in Danbyte's global search app yet. Small.
  6. 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.
  7. Application mapping (beta in netbox-map) — out of floor-plan scope; would be its own Danbyte feature if wanted.
  8. 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.
  9. 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-mode text as a per-tile option rather than full rotation.