Compare commits

...
Sign in to create a new pull request.

87 commits

Author SHA1 Message Date
sjat
4153c8d1d9 feat(rack): draw shelf-mounted tower heights in the elevation
All checks were successful
Build docs site / build (push) Successful in 50s
Build slides / build (push) Successful in 1m11s
Add an optional `chassis_u` field for shelf-mounted devices (their height
in U where they stand on the shelf) and render it:
- gen_rack draws each tower chassis_u U's tall, rising above the 1U shelf
  line; rail-mounted devices now paint on top so a PDU within a tower's
  span (e.g. pdu03 over srv05/06) stays visible
- occupancy table shows each tower's real U-span (e.g. srv01 U37-U46)
- validate_item checks chassis_u is a positive integer; absent chassis_u
  renders byte-identically to before
- set chassis_u for srv01-07 (10/8/6/6/7/7/6U); document the field in the
  editing guide; regenerate rack01 artifacts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 22:11:19 +02:00
sjat
4dc975062a docs(hardware): reconcile rack01 to as-mounted layout and document cabling
All checks were successful
Build docs site / build (push) Successful in 49s
Build slides / build (push) Successful in 1m11s
Rebuild rack01 from the physically remounted hardware:
- Correct stale positions/ports/outlets for pp01, pp02, sw01, pdu01-04
- Model shelves as 1U trays (towers stand above without consuming rack U's);
  add shf02 and empty half-depth shf03/shf04
- Add ups01/ups02; reseat nas01/02 and sw02-05; move srv04-07 onto shf02
- Add `wan` hardware kind; add WAN demarcation hosts wan01 (active) and
  wan02 (staging)
- Document full live network wiring: srv01-07 -> pp02 -> sw01 (LAN) and
  srv01 eth0 -> pp02 -> pp01 -> wan01 (WAN); keep non-active lines
  (wan2, working-table patches, sw01 mgmt) in notes only
- Regenerate hardware index + rack01 elevation/network/power artifacts

Also includes the in-progress generator updates (gen_rack.py, gen_overview.py,
Makefile, tests) that the regenerated artifacts depend on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:31:48 +02:00
sjat
8f4047cdd7 fix(hardware): add missing source files for 9 racked devices
All checks were successful
Build docs site / build (push) Successful in 48s
Build slides / build (push) Successful in 1m10s
Camilla's "added all devices to rack" commit committed the regenerated
index/rack artifacts but not the new per-device source files, so CI's
drift check failed (regeneration dropped 9 devices that had no source).

Recreate the source files for sw02-sw05, pp02, srv06-srv07 and nas01-nas02
from the committed artifacts (placement, power feeds, network links, and
cluster:tappaas on the servers; specs left as '?' placeholders, optional
port counts omitted). Regeneration is now drift-clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 22:41:30 +02:00
Camila Baruĥ
4a6f8f07a8 added all devices to rack
Some checks failed
Build slides / build (push) Successful in 1m15s
Build docs site / build (push) Failing after 42s
2026-06-29 22:27:52 +02:00
Camila Baruĥ
dac2ad51ae added Rear/Front rack_u for pdu*
All checks were successful
Build docs site / build (push) Successful in 48s
Build slides / build (push) Successful in 1m10s
2026-06-29 21:54:07 +02:00
Camila Baruĥ
271e24c389 test
All checks were successful
Build docs site / build (push) Successful in 49s
Build slides / build (push) Successful in 1m11s
2026-06-29 21:48:44 +02:00
Camila Baruĥ
fbd99be414 test 2026-06-29 21:48:09 +02:00
sjat
9d7b4684c4 docs: publish hardware naming scheme and link it from the editing guide
All checks were successful
Build docs site / build (push) Successful in 48s
Build slides / build (push) Successful in 1m11s
Move the naming-scheme spec from notes/dev/specs/ into docs/guides/ so it
publishes to docs.makerfloss.eu, add it under the Hardware nav, and link it
from the editing guide. Repoint the stale references in CLAUDE.md and the
migration plan to the new path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:40:09 +02:00
sjat
95399640d1 docs: add hardware-docs editing guide and link it in nav
All checks were successful
Build docs site / build (push) Successful in 48s
Build slides / build (push) Successful in 1m9s
Add docs/guides/editing-hardware-docs.md covering the frontmatter schema,
rack/shelf/PDU/power/network fields, the `make docs-index` workflow and
CI drift gate, plus dos and don'ts. Kept under docs/guides/ so the
hardware generators don't parse it as a host file. Linked under the
Hardware nav section so it publishes to docs.makerfloss.eu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:32:32 +02:00
sjat
ebe193d9d8 docs(hardware): update srv01-03 notes
All checks were successful
Build docs site / build (push) Successful in 48s
Build slides / build (push) Successful in 1m12s
Mark srv02/srv03 as TaPPaaS nodes 2/3 and clear the completed
BIOS/UEFI todo items on srv01.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:19:24 +02:00
sjat
5b3251f848 fix(hardware): set pdu03/pdu04 to rack01 and regenerate indices
Camilla's "Changed PDU values" commit added pdu03/pdu04 frontmatter but
left the auto-generated docs/hardware/index.md stale, failing the CI
drift check. Correct both PDUs from the placeholder racks rack33/rack11
to rack01 (where all current hardware lives) and regenerate the hardware
index and rack01 elevation via `make docs-index`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:19:20 +02:00
Camila Baruĥ
7cad66b466 Changed PDU values
Some checks failed
Build docs site / build (push) Failing after 53s
Build slides / build (push) Successful in 1m14s
2026-06-29 21:09:20 +02:00
sjat
3a0f062f37 slides: fix TaPPaaS deck overflow + stray tag
All checks were successful
Build docs site / build (push) Successful in 53s
Build slides / build (push) Successful in 1m12s
Shrink global font and tighten spacing so dense slides fit; split the
phasing slide into VPS-edge (1-3) and internal/later (4-5). Remove a
stray </content> line that leaked into the source. Verified all 13
slides fit via per-slide PNG render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:59:46 +02:00
sjat
125d1d67e9 slides: TaPPaaS VPS publishing (technical review)
All checks were successful
Build docs site / build (push) Successful in 54s
Build slides / build (push) Successful in 1m12s
Marp deck for the split-horizon DNS + public exposure design: reuses the
mf01 pattern (TLS at VPS, plain HTTP over wg1 to TaPPaaS Caddy). Two
Mermaid request-flow diagrams, decisions, phasing, isolation, risks.
Publishes to slides.makerfloss.eu on CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:11:53 +02:00
sjat
a6033e2038 fix(rack): centre 0U rail labels across the bar (dominant-baseline)
All checks were successful
Build docs site / build (push) Successful in 52s
Build slides / build (push) Successful in 1m11s
2026-06-24 19:07:10 +02:00
sjat
e1c05a6c88 test(rack): cover off-rack peer (styled, not clickable) in network graph
All checks were successful
Build docs site / build (push) Successful in 49s
Build slides / build (push) Successful in 1m8s
2026-06-24 18:48:13 +02:00
sjat
d3d5e9c69e feat(rack): colour and link mermaid power/network nodes by kind 2026-06-24 18:43:52 +02:00
sjat
08862fde51 feat(rack): add elevation legend, both-gutter U-numbers, column frames
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:39:56 +02:00
sjat
8d39fbcdf5 feat(rack): inline interactive elevation with links, tooltips, status borders 2026-06-24 18:34:06 +02:00
sjat
d5cfe9665c docs(rack): graphical presentation improvements implementation plan 2026-06-24 18:29:36 +02:00
sjat
aad5672a6b docs(rack): graphical presentation improvements design spec 2026-06-24 18:22:41 +02:00
sjat
c24978436d feat(rack): place TaPPaaS nodes on shelf shf01 (provisional)
All checks were successful
Build docs site / build (push) Successful in 50s
Build slides / build (push) Successful in 1m9s
2026-06-24 17:51:52 +02:00
sjat
e08862b81d fix(rack): draw shelves after rails (match spec placement) 2026-06-24 17:49:49 +02:00
sjat
aab58e3692 feat(rack): render shelf strip, occupant boxes, and mounted occupancy rows 2026-06-24 17:45:39 +02:00
sjat
b85479b9a0 feat(rack): validate shelf-mounted devices (mounted_on/shelf_face/shelf_slot) 2026-06-24 17:42:07 +02:00
sjat
4961a748d4 docs(rack): shelf-mounted devices implementation plan 2026-06-24 17:39:34 +02:00
sjat
d8b1fd3272 docs(rack): shelf-mounted devices design spec 2026-06-24 17:27:02 +02:00
sjat
613a5c3cab docs(hardware): clarify provisional cluster note in naming spec
All checks were successful
Build docs site / build (push) Successful in 50s
Build slides / build (push) Successful in 1m10s
2026-06-24 16:29:28 +02:00
sjat
34243bbf6f docs: record hardware naming scheme, refresh stale mf0x hints 2026-06-24 16:26:11 +02:00
sjat
fd21d4807d refactor(hardware): rename mf00-mf04 to srv01-srv05, add cluster field
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 16:23:23 +02:00
sjat
9869da5c6b docs(hardware): naming migration implementation plan 2026-06-24 16:16:25 +02:00
sjat
bcd5748d28 docs(hardware): hardware naming scheme design 2026-06-24 15:55:43 +02:00
sjat
773fec952f test(rack): cover peer_port below 1 in validate_links
All checks were successful
Build docs site / build (push) Successful in 52s
Build slides / build (push) Successful in 1m10s
2026-06-24 15:13:15 +02:00
sjat
e54cbb3f0f feat(rack): populate provisional network topology (sw01, pp01, links)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:09:02 +02:00
sjat
39644541f1 feat(rack): render mermaid network graph into the rack page 2026-06-24 15:05:42 +02:00
sjat
ed5bda83e0 feat(rack): validate network links against peer files and ports 2026-06-24 15:02:34 +02:00
sjat
734a6522c1 docs(rack): Phase 3 network implementation plan 2026-06-24 15:00:07 +02:00
sjat
8b137291c7 docs(rack): Phase 3 network design spec 2026-06-24 14:56:28 +02:00
sjat
1b5e8316ea docs(rack): note render_power precondition (validate_power first)
All checks were successful
Build docs site / build (push) Successful in 49s
Build slides / build (push) Successful in 1m9s
2026-06-24 14:46:29 +02:00
sjat
5c3dab55a4 feat(rack): enable mermaid, populate provisional power data
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:41:46 +02:00
sjat
d2744db4ee feat(rack): render mermaid power graph into the rack page 2026-06-24 14:38:23 +02:00
sjat
ed4e7c751a feat(rack): validate power feeds against PDU outlets 2026-06-24 14:35:19 +02:00
sjat
a45d6d0266 docs(rack): Phase 2 power implementation plan 2026-06-24 14:33:18 +02:00
sjat
f4022edf3b docs(rack): Phase 2 power design spec 2026-06-24 14:28:48 +02:00
sjat
9253d1ca0d docs(services): regenerate index after moving coursemanagement out of docs
All checks were successful
Build docs site / build (push) Successful in 51s
Build slides / build (push) Successful in 1m9s
The service note was moved to notes/coursemanagement.md but the generated
index still listed it, which fails the CI drift check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 14:19:11 +02:00
sjat
4f13962812 Moved a note out of docs
Some checks failed
Build docs site / build (push) Failing after 51s
Build slides / build (push) Successful in 1m13s
2026-06-24 14:15:27 +02:00
sjat
74b43ed5af test(rack): guard empty rack value and cover 0U/both/multi-rack rendering
All checks were successful
Build docs site / build (push) Successful in 54s
Build slides / build (push) Successful in 1m13s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:05:56 +02:00
sjat
b7fb69cf9a ci(rack): generate rack artifacts, run tests, add nav entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 14:00:48 +02:00
sjat
b0c933011b fix(services): commit regenerated services index after coursemanagement fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:56:15 +02:00
sjat
15666e0470 feat(rack): populate rack01 and wire gen_rack into make targets
Add rack placement frontmatter to mf00–mf04, fix pre-existing schema
error in docs/services/coursemanagement.md (missing kind/name fields),
extend Makefile docs-index and docs-check targets to also run gen_rack,
add test target, generate rack01.md and rack01-elevation.svg.

Positions are provisional placeholders to prove the pipeline; correct when mounting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:56:09 +02:00
sjat
039b1212b9 feat(rack): render page and orchestrate generation 2026-06-24 13:51:52 +02:00
sjat
2fd0df1597 feat(rack): render SVG elevation (U1 at top, front/rear columns) 2026-06-24 13:48:19 +02:00
sjat
a1b889209a feat(rack): detect U overlaps within a rack face
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:45:08 +02:00
sjat
3324c01810 feat(rack): gen_rack placement parsing and validation 2026-06-24 13:42:21 +02:00
sjat
717de70eca feat(hardware): allow rack item kinds (pdu, patch-panel, shelf, blank, ups, kvm) 2026-06-24 13:40:01 +02:00
sjat
f8bcd7ec7f docs(plan): rack elevation Phase 1 implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:32:52 +02:00
sjat
c362c93f65 docs(spec): rack documentation design (md → CI → SVG/mermaid)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:25:28 +02:00
sjat
6b06550447 docs(hardware): record DDR4-2666 RAM type for mf01/mf02, fix mf00 status
Some checks failed
Build docs site / build (push) Failing after 45s
Build slides / build (push) Successful in 1m13s
Add ram_type (DDR4-2666 non-ECC UDIMM) to mf01 and mf02 frontmatter, and
set mf00's placeholder status to staging so the overview index regenerates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 20:42:13 +02:00
22749de762 first cut of a VPN presentation
Some checks failed
Build docs site / build (push) Failing after 40s
Build slides / build (push) Successful in 1m6s
2026-06-01 16:10:26 +02:00
sjat
1f9b203cb4 test: re-trigger ntfy alert after phone settings update
Some checks failed
Build docs site / build (push) Failing after 40s
Build slides / build (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:58:29 +02:00
sjat
87d4b66bd0 test: re-trigger ntfy alert to verify phone delivery
Some checks failed
Build docs site / build (push) Failing after 44s
Build slides / build (push) Successful in 1m5s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:52:31 +02:00
sjat
df4637e269 test: deliberately break CI to verify ntfy alert (will revert)
Some checks failed
Build docs site / build (push) Failing after 39s
Build slides / build (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:46:44 +02:00
sjat
baf1992b0e ci(docs): notify ntfy when main build fails
All checks were successful
Build docs site / build (push) Successful in 44s
Build slides / build (push) Successful in 1m4s
Adds an `if: failure()` step that POSTs to ntfy.sh on a failed push-to-main
run. Topic comes from the NTFY_TOPIC secret so it stays out of the public
repo; missing secret silently no-ops via `|| true`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:24:29 +02:00
sjat
7b59a9c1b8 docs(hardware): drop the model field
All checks were successful
Build docs site / build (push) Successful in 43s
Build slides / build (push) Successful in 1m6s
Removed from per-host frontmatter, the index column, and the Specs table.
Model values kept churning (case manufacturer vs. "custom") without adding
useful information.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:37:04 +02:00
sjat
c0b6986984 Updated notes on mf00
All checks were successful
Build docs site / build (push) Successful in 43s
Build slides / build (push) Successful in 1m1s
2026-05-27 10:35:04 +02:00
sjat
41c4834dbd fix(docs-ci): unblock build by tolerating "?" placeholders in hardware frontmatter
All checks were successful
Build docs site / build (push) Successful in 46s
Build slides / build (push) Successful in 1m7s
mf00.md used bare ? in YAML (a complex-key indicator), breaking gen_overview.py.
Quoted the placeholders and taught fmt_cpu/fmt_ram/fmt_storage/fmt_nic to render
string values literally so unknown specs show as "?" in both the per-host Specs
table and the hardware index. Also regenerates docs/hardware/index.md to clear
the accumulated drift (mf02 16 GB, mf04 entry, mf00 custom).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:31:46 +02:00
sjat
c50a3b5acf Updated hardware descriptions to custom instead of case manufactorers
Some checks failed
Build docs site / build (push) Failing after 28s
Build slides / build (push) Successful in 48s
2026-05-20 09:18:48 +02:00
sjat
69d1f16a69 Added mf04 - an old stationary. Details needs updating.
Some checks failed
Build docs site / build (push) Failing after 28s
Build slides / build (push) Successful in 48s
2026-05-19 17:43:18 +02:00
sjat
10d37f6ad2 Upgraded mf02 to 16gb ram
Some checks failed
Build docs site / build (push) Failing after 34s
Build slides / build (push) Successful in 57s
2026-05-19 17:29:39 +02:00
sjat
9017a91ae2 docs(index): list Services alongside Hardware on the home page
All checks were successful
Build docs site / build (push) Successful in 30s
Build slides / build (push) Successful in 49s
Sidebar nav had Services after the previous commit, but the home page
body still only mentioned Hardware and House rules. Add a matching
bullet so visitors land somewhere obvious.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:54:24 +02:00
sjat
1288f3972b Updated forgejo text
All checks were successful
Build docs site / build (push) Successful in 31s
Build slides / build (push) Successful in 49s
2026-05-18 17:52:31 +02:00
sjat
c743416ded feat(docs): add services category alongside hardware
All checks were successful
Build docs site / build (push) Successful in 32s
Build slides / build (push) Successful in 52s
Mirror the auto-indexed per-host pattern for a new docs/services/
category, seeded with the six things currently deployed on or around
makerfloss.eu: docs, slides, forgejo, gandi-dns, marp, mermaid.

Generator/hook generalisation:
- scripts/gen_overview.py: replace the hardcoded `hostname` check
  with a configurable `key_field` (default: hostname). Add a generic
  `key-link` column kind (replaces the old `hostname-link`) and a
  `url-link` kind that renders the value as a clickable link.
- scripts/overview_config.yml: declare hardware's key_field, then add
  a `services` block (key_field=name, its own kind/status enums,
  grouped by kind for the index table).
- scripts/mkdocs_hooks.py: route by `page.file.src_uri` so each
  hardware/* page gets a "Specs" table and each services/* page gets
  a "Service" table; both share the helpers in gen_overview.

Wiring:
- Makefile: docs-index and docs-check now regenerate and drift-check
  both indices.
- .forgejo/workflows/docs.yml: same on the CI runner.
- mkdocs.yml: add Services to nav.
- README.md, CLAUDE.md: list services/ in the repo-layout block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:48:15 +02:00
sjat
96d2efc75c docs(dev): relocate MARP-MERMAID-Readme.md under notes/dev/
All checks were successful
Build docs site / build (push) Successful in 30s
Build slides / build (push) Successful in 48s
This file is pre-implementation reference material for the Marp +
Mermaid pipeline, not site content, so it belongs under notes/dev/
alongside the existing plans/ and specs/. Renamed to kebab-case to
match the filename convention.

  MARP-MERMAID-Readme.md -> notes/dev/marp-mermaid-setup.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:18:20 +02:00
sjat
62fcd8ac27 docs: add proper README at repo root and per-tree READMEs
All checks were successful
Build docs site / build (push) Successful in 29s
Build slides / build (push) Successful in 48s
- Rewrite the 84-byte Danish placeholder README.md into a proper
  English entry point: what this repo is, live URLs, layout, build
  instructions (make targets), and conventions, with pointers to
  CLAUDE.md for the long form.
- Add short READMEs to notes/, sandbox/, and scripts/ so each tree
  documents its own purpose at the boundary where someone is likely
  to land on it via the Forgejo web UI or a clone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:13:50 +02:00
sjat
16f56fd961 docs: standardise filenames to ASCII kebab-case + ISO date prefix
All checks were successful
Build docs site / build (push) Successful in 30s
Build slides / build (push) Successful in 48s
Adopt one filename convention everywhere except hardware host pages
(those are pinned to their hostnames by the index validator):

  - ASCII lowercase
  - kebab-case word separator
  - dated docs: YYYY-MM-DD-label.md (hyphen between date and label)
  - English regardless of content language

Renames:
  docs/makerFLOSS_house_rules.md                        -> docs/house-rules.md
  docs/infrastruktur/                                   -> docs/infrastructure/
  docs/infrastructure/vps-og-dns.md                     -> docs/infrastructure/vps-and-dns.md
  docs/presentations/2026-05-11_messaging.md            -> docs/presentations/2026-05-11-messaging.md
  docs/presentations/2026-05-11_SoMe-taxonomi.md        -> docs/presentations/2026-05-11-social-media-taxonomy.md
  notes/todo/2026-04-14_todo.md                         -> notes/todo/2026-04-14-todo.md
  notes/todo/OM_services.md                             -> notes/todo/om-services.md
  notes/todo/indkøbsliste.md                            -> notes/todo/wishlist.md
  notes/communications/2026-03-16_facebookpost.md       -> notes/communications/2026-03-16-facebook-post.md

Other touches:
- Document the convention in CLAUDE.md under Working Norms.
- Refresh the stale Repository Structure block in CLAUDE.md to reflect
  the docs/ vs notes/ split introduced in f6d589e.
- Update the House rules link in docs/index.md and the nav entry in
  mkdocs.yml to the new house-rules.md path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:07:26 +02:00
sjat
f6d589edec docs: split published site (docs/) from internal notes (notes/)
All checks were successful
Build docs site / build (push) Successful in 31s
Build slides / build (push) Successful in 49s
The docs/ tree previously conflated published-site content (3 pages
were in mkdocs.yml nav) with working notes (~18 files that just sat
in the repo). Restructure so each tree means one thing:

- docs/         everything here is built and shipped to docs.makerfloss.eu.
                Adds docs/presentations/ for the two Marp decks
                previously living under docs/møder/.
- notes/        repo-only working material, not built. Contains
                meetings/, todo/, dev/ (was docs/superpowers/), and
                communications/ (the launch Facebook post).
- sandbox/      test-mermaid.md, the Marp/Mermaid pipeline sandbox.

Other touches:
- Drop "_noter" suffix on meeting filenames; drop "_presentation"
  from the messaging deck's basename for symmetry with SoMe-taxonomi.
- Update CLAUDE.md and docs/index.md path references.
- Drop the now-redundant --exclude-dir=superpowers from
  build-slides.sh since superpowers/ is no longer under docs/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:53:27 +02:00
sjat
f401f98d47 docs(todo): refresh OM_services and indkøbsliste
All checks were successful
Build docs site / build (push) Successful in 31s
Build slides / build (push) Successful in 51s
- Add 'touch display med kalender etc.' to the OM services wishlist.
- Reset indkøbsliste into an empty wish/get list ('Ønske/skaffe-liste').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:21:31 +02:00
sjat
9310396fac docs: streamline todos and remove fit-test sandboxes
- Delete two stale single-meeting todos: 2026-03-16_todo.md and 2026-05-05.md.
- Rename `2026-04-14 TODO.md` -> `2026-04-14_todo.md` to match the
  underscore convention used by the other dated files, and update the
  CLAUDE.md reference.
- Remove the two Marp/CSS fit-test sandboxes (labdesign-fit-test.md
  and 2026-05-11_messaging-presentation-fit-test.md); the responsive
  experiments were never folded back into the canonical decks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:21:26 +02:00
sjat
b9722f082f feat(docs): render Specs table on each hardware host page
All checks were successful
Build docs site / build (push) Successful in 30s
Build slides / build (push) Successful in 51s
- Add scripts/mkdocs_hooks.py: on_page_markdown hook that prepends
  `# {hostname}` + a Specs table built from the YAML frontmatter.
  Reuses fmt_cpu/fmt_ram/fmt_storage/fmt_nic from gen_overview.py so
  the host page and the index table stay in sync.
- Wire the hook into mkdocs.yml.
- Demote `# Notes` / `# ToDo` to `##` in the five host source files so
  each rendered page has a single H1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:24:56 +02:00
sjat
03b2430e7a fix(docs-ci): unblock hardware index regeneration
All checks were successful
Build docs site / build (push) Successful in 31s
Build slides / build (push) Successful in 54s
- Add `staging` to allowed status enum so mfXX hosts validate.
- fmt_nic and fmt_storage now accept lists (mf00 has 3 NICs and 4 drives).
- Rename makerfloss.md -> makerfloss.eu.md so filename matches the FQDN.
- Regenerate docs/hardware/index.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:14:46 +02:00
sjat
70cb6759bd Added hardware docs.
Some checks failed
Build docs site / build (push) Failing after 32s
Build slides / build (push) Successful in 56s
2026-05-18 15:07:30 +02:00
Claude
50775186fb fix(docs-ci): install nodejs in python image — actions/checkout needs node
All checks were successful
Build docs site / build (push) Successful in 31s
Build slides / build (push) Successful in 52s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:17:59 +02:00
sjat
da19ce1bef Revert "fix(docs-ci): install nodejs in python image — actions/checkout needs node"
This reverts commit 3fe690edf0.
2026-05-17 21:17:41 +02:00
Claude
3fe690edf0 fix(docs-ci): install nodejs in python image — actions/checkout needs node
Some checks failed
Build docs site / build (push) Successful in 30s
Build slides / build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:17:06 +02:00
sjat
e5c8e86708 Revert "fix(docs-ci): install nodejs in python image — actions/checkout needs node"
This reverts commit d51d68abca.
2026-05-17 21:17:06 +02:00
Claude
d51d68abca fix(docs-ci): install nodejs in python image — actions/checkout needs node
All checks were successful
Build docs site / build (push) Successful in 31s
Build slides / build (push) Successful in 53s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:12:42 +02:00
sjat
3287df35c5 Merge branch 'feat/hardware-overview'
Some checks failed
Build docs site / build (push) Failing after 43s
Build slides / build (push) Successful in 59s
2026-05-17 21:11:19 +02:00
96 changed files with 9144 additions and 503 deletions

View file

@ -15,10 +15,10 @@ jobs:
volumes:
- /srv/docs-makerfloss/html:/output
steps:
- name: Install git for actions/checkout
- name: Install git, rsync, nodejs for actions/checkout
run: |
apt-get update -qq
apt-get install -y --no-install-recommends git rsync
apt-get install -y --no-install-recommends git rsync nodejs
- name: Checkout repository
uses: actions/checkout@v4
@ -26,14 +26,22 @@ jobs:
- name: Install Python dependencies
run: pip install --quiet -r requirements.txt
- name: Regenerate hardware index
run: python3 scripts/gen_overview.py --category hardware
- name: Fail on drift in docs/hardware/index.md
- name: Install dev dependencies and run tests
run: |
if ! git diff --exit-code docs/hardware/index.md; then
pip install --quiet -r requirements-dev.txt
pytest -q
- name: Regenerate hardware and services indices
run: |
python3 scripts/gen_overview.py --category hardware
python3 scripts/gen_overview.py --category services
python3 scripts/gen_rack.py
- name: Fail on drift in generated indices
run: |
if ! git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/; then
echo
echo "::error::docs/hardware/index.md is stale."
echo "::error::A generated index is stale."
echo "Regenerate locally via 'make docs-index' and commit the result."
exit 1
fi
@ -44,3 +52,16 @@ jobs:
- name: Publish to /output (main only)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: rsync -a --delete site/ /output/
- name: Notify ntfy on failure
if: failure() && github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
curl -sf \
-H "Title: docs.makerfloss.eu build failed" \
-H "Priority: high" \
-H "Tags: warning,rotating_light" \
-d "Run #${{ github.run_number }} on ${{ github.ref_name }}@${{ github.sha }}
${{ github.event.head_commit.message }}
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
"https://ntfy.sh/${{ secrets.NTFY_TOPIC }}" || true

View file

@ -8,7 +8,7 @@ This is a **documentation-only repository** for the MakerFLOSS initiative at Ora
## Working Norms
From `docs/todo/2026-04-14 TODO.md`:
From `notes/todo/2026-04-14-todo.md`:
- **Language**: English for code, docs, commits (meeting notes may be in Danish)
- **Git**: Trunk-based development, feature branches, simple commit messages
@ -17,15 +17,23 @@ From `docs/todo/2026-04-14 TODO.md`:
- **AI**: Allowed but reviewed; no secrets in commits
- **Decisions**: Lightweight markdown decision logs
- **License**: FLOSS by default
- **Filenames**: ASCII lowercase, kebab-case (`like-this.md`), English regardless of content language. Dated docs use an ISO prefix joined with a hyphen: `YYYY-MM-DD-label.md`. Hostnames are the exception — `docs/hardware/*.md` must equal the host's `hostname` (enforced by `scripts/gen_overview.py`).
- **Hardware identifiers**: `<kind-abbrev><NN>` — 2-digit, unique per kind (`srv`, `sw`, `pp`, `pdu`, `ups`, `shf`). Grouping (`cluster:`) lives in frontmatter, not the name. Full scheme: `docs/guides/hardware-naming-scheme.md`.
## Repository Structure
```
docs/
infrastruktur/ # Infrastructure documentation (VPS, DNS, etc.)
kommunikation/ # Communication materials (Facebook posts, announcements)
møder/ # Meeting notes
todo/ # Task lists and working norms
docs/ # everything here is built and shipped to docs.makerfloss.eu
hardware/ # auto-indexed per-host frontmatter (srv01..srv05, makerfloss.eu)
services/ # auto-indexed per-service frontmatter (docs, forgejo, …)
infrastructure/ # labdesign, VPS/DNS, etc.
presentations/ # Marp decks (build-slides.sh)
notes/ # repo-only working material, not built
meetings/ # meeting notes (Danish allowed)
todo/ # task lists, working norms, wishlist, services
dev/ # internal plans/ and specs/
communications/ # community comms artifacts (Facebook posts, etc.)
sandbox/ # scratch / pipeline fixtures (e.g. test-mermaid.md)
```
## Infrastructure

View file

@ -1,15 +1,18 @@
.PHONY: help docs-index docs-build docs-serve docs-check slides
.PHONY: help docs-index docs-build docs-serve docs-check slides test
help:
@echo "Targets:"
@echo " docs-index Regenerate docs/hardware/index.md from per-host frontmatter"
@echo " docs-index Regenerate docs/{hardware,services}/index.md from per-item frontmatter"
@echo " docs-build Build the static MkDocs site into ./site (strict)"
@echo " docs-serve Run a live-reload local preview server"
@echo " docs-check Drift-check: regenerate index, fail if it differs from the committed copy"
@echo " docs-check Drift-check: regenerate indices, fail if they differ from the committed copies"
@echo " slides Run build-slides.sh (Marp slides)"
@echo " test Run the Python unit tests (pytest)"
docs-index:
python3 scripts/gen_overview.py --category hardware
python3 scripts/gen_overview.py --category services
python3 scripts/gen_rack.py
docs-build:
mkdocs build --strict
@ -19,7 +22,22 @@ docs-serve:
docs-check:
python3 scripts/gen_overview.py --category hardware
git diff --exit-code docs/hardware/index.md
python3 scripts/gen_overview.py --category services
python3 scripts/gen_rack.py
@git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/ \
|| { \
echo; \
echo "✗ The generated docs are out of date with the source files."; \
echo " The diff above is what 'make docs-index' just regenerated."; \
echo " This is what CI checks on push. To fix it:"; \
echo " 1. run 'make docs-index'"; \
echo " 2. commit the changed files (including the generated ones)"; \
echo " Guide: https://docs.makerfloss.eu/guides/editing-hardware-docs/"; \
exit 1; \
}
slides:
./build-slides.sh
test:
pytest -q

View file

@ -1,3 +1,57 @@
# MakerFLOSS
Første midlertidige repo til Orange Makerspaces MakerFLOSS initiativ.
Documentation and working notes for **MakerFLOSS**, an Orange Makerspace initiative — a bi-weekly FLOSS jam-session community focused on self-hosted, open-source infrastructure.
- Live docs: <https://docs.makerfloss.eu>
- Slides: <https://slides.makerfloss.eu>
- Source: <https://forgejo.makerfloss.eu/sjat/MakerFLOSS>
## Repository layout
```
docs/ # built into the public site (docs.makerfloss.eu)
hardware/ # auto-indexed per-host frontmatter (srv01..srv05, makerfloss.eu)
services/ # auto-indexed per-service frontmatter (docs, forgejo, …)
infrastructure/ # labdesign, VPS/DNS, etc.
presentations/ # Marp decks (also published to slides.makerfloss.eu)
notes/ # repo-only working material — not part of the site
meetings/ # meeting notes (Danish allowed)
todo/ # task lists, working norms, wishlist, services
dev/ # internal plans/ and specs/
communications/ # community comms artefacts
sandbox/ # scratch / pipeline fixtures
scripts/ # build helpers (hardware index, mkdocs hook)
```
## Building locally
Requires Python 3.10+ and, for slide builds, either `marp` on `$PATH` or Docker.
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
```
Common targets:
```bash
make docs-serve # live-reload preview at http://127.0.0.1:8000
make docs-build # strict build into ./site
make docs-index # regenerate docs/hardware/index.md from per-host frontmatter
make docs-check # CI drift-check for the hardware index
make slides # build Marp decks (docs/presentations/*.md) into ./slides
```
CI runs on the self-hosted Forgejo Actions runner (`.forgejo/workflows/docs.yml`) and rsyncs the built site to the VPS.
## Conventions
- **Filenames**: ASCII lowercase, kebab-case (`like-this.md`). Dated docs use `YYYY-MM-DD-label.md`. Hardware host pages are the exception — their filename stem must equal the `hostname` field, enforced by `scripts/gen_overview.py`.
- **Languages**: English for code and docs; Danish allowed in meeting notes and community communications.
- **Git**: Trunk-based, feature branches, simple commit messages, no secrets in commits.
See [`CLAUDE.md`](CLAUDE.md) for the full working norms and AI-collaboration guidance.
## License
MIT — see [`LICENSE`](LICENSE).

View file

@ -15,7 +15,7 @@ find "$OUTPUT_DIR" -maxdepth 1 -name "*.html" -delete
SLIDES=()
while IFS= read -r f; do
SLIDES+=("$f")
done < <(grep -rl "^marp: true" "$REPO_ROOT/docs" --include="*.md" --exclude-dir=superpowers 2>/dev/null || true)
done < <(grep -rl "^marp: true" "$REPO_ROOT/docs" --include="*.md" 2>/dev/null || true)
if [ ${#SLIDES[@]} -eq 0 ]; then
echo "No marp presentations found in docs/."

View file

@ -0,0 +1,204 @@
---
title: Editing the hardware docs
---
# Editing the hardware docs
The hardware inventory is **one Markdown file per device** under
`docs/hardware/`. The YAML frontmatter at the top of each file is the single
source of truth. Everything you see rendered — the
[Hardware Overview](../hardware/index.md) table, the per-device **Specs** box,
the [rack elevation](../infrastructure/racks/rack01.md) SVG, and the network
graph — is **generated** from that frontmatter.
!!! warning "The golden rule"
After editing any file in `docs/hardware/`, run **`make docs-index`** and
commit the regenerated files. CI rebuilds the indices and fails the build if
they differ from what you committed. This is the single most common reason a
push goes red.
## Quick start: add a device
1. Pick a hostname following the [naming scheme](hardware-naming-scheme.md):
`<kind-abbrev><NN>` — a 2-digit number, unique per kind (`srv`, `sw`, `pp`,
`pdu`, `ups`, `shf`, …). Example: `srv06`.
2. Create `docs/hardware/<hostname>.md`. **The filename stem must equal the
`hostname` field** — `srv06.md` must contain `hostname: srv06`.
3. Fill in the frontmatter (see the reference below). Write any free-text under
`## Notes`.
4. Run `make docs-index` and commit **both** your new file and the regenerated
index / rack files.
```markdown
---
hostname: srv06
kind: server
status: staging
location: The pile
cpu: Intel Core i5-3570K @ 3.40GHz
cpu_cores: 4
cpu_threads: 4
ram_gb: 8
storage_gb: 500
storage_type: hdd
nic_gbps: 1
rack: rack01
rack_u: 7
u_height: 2
rack_face: front
power:
- { pdu: pdu01, outlet: 5 }
links:
- { local: eth0, peer: sw01, peer_port: 6, speed_gbps: 1 }
---
## Notes
Donated tower; PSU replaced 2026-06.
```
## Frontmatter reference
### Required on every device
| Field | Notes |
|---|---|
| `hostname` | Must equal the filename stem. |
| `kind` | One of the enum below. |
| `status` | One of the enum below. |
**`kind`** — `server`, `laptop`, `sbc`, `switch`, `ap`, `desktop`, `pdu`,
`patch-panel`, `shelf`, `blank`, `ups`, `kvm`.
**`status`** — `in-use`, `staging`, `spare`, `broken`, `donated`.
### Specs (optional, shown in the table and Specs box)
| Field | Example | Notes |
|---|---|---|
| `location` | `The pile` | Free text. |
| `cpu` | `Intel Core i5-3570K @ 3.40GHz` | Model string. |
| `cpu_cores` / `cpu_threads` | `4` / `8` | Integers; threads shown only if they differ from cores. |
| `ram_gb` | `8` | Integer (rendered as `8 GB`). |
| `storage_gb` + `storage_type` | `500` + `hdd` | `storage_type``nvme`, `ssd`, `hdd`, `mixed`. |
| `storage` | `[{gb: 500, type: ssd}, {gb: 2000, type: hdd}]` | List form for multiple drives (alternative to the two fields above). |
| `nic_gbps` | `1` or `[1, 10]` | Number or list (rendered as `GbE`). |
### Placement in a rack
Only files that declare a `rack:` appear in a rack elevation. The rack is 48U.
=== "Front / rear (U-mounted)"
```yaml
rack: rack01
rack_face: front # front | rear | both
rack_u: 7 # starting U (148)
u_height: 2 # height in U (≥1)
```
`both` occupies the same U range on **front and rear**. Two U-mounted
devices may not overlap on the same face.
=== "0U side rail (e.g. vertical PDU)"
```yaml
rack: rack01
rack_face: left # left | right
# no rack_u / u_height
```
Side-rail (`left`/`right`) items are 0U and **must omit** `rack_u` and
`u_height`.
=== "Shelf-mounted"
```yaml
rack: rack01
mounted_on: shf01 # an existing kind:shelf in the same rack
shelf_face: front # front | rear
shelf_slot: 2 # integer ≥1
chassis_u: 6 # optional: device height in U (how tall it stands)
# no rack_u / u_height / rack_face
```
The shelf itself must be placed (have `rack_u` + `u_height`). Two devices
can't share the same `(shelf, face, slot)`.
`chassis_u` is optional. Shelves are typically 1U trays; a device sitting on
one (e.g. a tower PC) stands `chassis_u` U's tall, rising above the shelf
line in the elevation without consuming those rack U's (so rail-mounted gear
may still occupy them). Omit it and the device just fills the shelf block.
### Power feeds
A device draws power by listing feeds. Each feed must point at a real `kind:pdu`
file, and the outlet must be within that PDU's `outlets` count.
```yaml
power:
- { pdu: pdu01, outlet: 5 }
```
A `pdu` device must declare a positive integer `outlets:` (e.g. `outlets: 8`).
### Network links
Links feed the network graph. `peer` is the hostname of a switch / patch-panel
/ peer device.
```yaml
links:
- { local: eth0, peer: sw01, peer_port: 6, speed_gbps: 1 }
```
## The make process
| Command | What it does |
|---|---|
| `make docs-index` | Regenerate the hardware/services indices and rack elevations from frontmatter. **Run this after every edit.** |
| `make docs-check` | Regenerate, then fail if the result differs from the committed copies — exactly what CI runs. |
| `make docs-build` | Build the static site with `mkdocs build --strict`. |
| `make docs-serve` | Live-reload local preview at `http://127.0.0.1:8000`. |
| `make test` | Run the Python unit tests (`pytest`). |
A typical edit loop:
```bash
$EDITOR docs/hardware/srv06.md
make docs-index # regenerate
make docs-check # confirm no drift (optional sanity check)
git add docs/hardware/srv06.md docs/hardware/index.md docs/infrastructure/racks/
git commit -m "hardware: add srv06"
git push # CI builds and publishes to docs.makerfloss.eu
```
On push to `main`, CI regenerates the indices, runs the drift check, builds the
site strictly, and publishes it. If you forgot `make docs-index`, the drift
check fails and nothing is published.
## Dos and don'ts
!!! success "Do"
- **Do** run `make docs-index` and commit the regenerated files with your change.
- **Do** keep the filename equal to the `hostname`.
- **Do** use the [`<kind-abbrev><NN>` naming scheme](hardware-naming-scheme.md),
2 digits, unique per kind.
- **Do** mark unknown values as provisional placeholders and ask before
inventing rack/power/network numbers.
- **Do** preview locally with `make docs-serve` before pushing.
!!! danger "Don't"
- **Don't** hand-edit generated files — `docs/hardware/index.md`,
`docs/services/index.md`, `docs/infrastructure/racks/*.md`, and the
`*-elevation.svg` files all carry a *"do not edit by hand"* banner and will
be overwritten.
- **Don't** put non-device Markdown into `docs/hardware/` — the generators
scan that folder and expect host frontmatter. Guides like this one live in
`docs/guides/`.
- **Don't** give a 0U side-rail item (`left`/`right`) a `rack_u`/`u_height`,
and don't give a U-mounted item a side-rail face — the generator rejects
both.
- **Don't** point `power` at a non-PDU, or use an outlet number beyond the
PDU's `outlets` count.
- **Don't** rename a file without renaming the `hostname` to match.

View file

@ -0,0 +1,118 @@
# Hardware Naming Scheme Design
**Date:** 2026-06-24
**Status:** Approved
## Goal
Settle a single, clear hardware naming scheme for the MakerFLOSS rack that the
team can grow within, before more devices are documented. The scheme assigns
every physical device a stable identifier and pushes everything that changes
over time (which cluster a node serves, its role) into frontmatter, so the
identifier never has to be renamed when hardware is repurposed.
## Context
- Hardware is documented one-file-per-device under `docs/hardware/*.md`, with the
file name equal to the device identifier (enforced by `scripts/gen_overview.py`).
- Current names are inconsistent: compute uses an org/brand prefix
(`mf00``mf04`), infrastructure uses kind prefixes (`pdu01/02`, `sw01`, `pp01`).
- The inventory to be documented is a mix of rack and home-office gear, grouped
into clusters (TaPPaaS production + four test clusters) plus crosscutting
power/network gear. Stationary PCs will sit on rack shelves.
- Hardware moves between test clusters over time, so cluster membership is a
changeable attribute, not an intrinsic property of a device.
## Decisions
### 1. Identifier nature (mixed)
- **Compute nodes (stationary PCs) and managed switches:** the identifier *is*
the device's real OS/DNS hostname. It must be DNS-safe (lowercase letters,
digits, hyphens) and stable — renaming costs OS/DNS/Ansible churn.
- **Passive gear (UPS, PDU/power strip, patch panel, unmanaged switch):** the
identifier is a documentation/physical-label id only; the device has no OS.
### 2. Format
`<kind-abbrev><NN>` — a kind abbreviation followed by a **2-digit zero-padded
sequence**, **globally unique within a kind**, starting at `01`. The number is
just "next free"; it encodes nothing — not cluster, rack, role, or port count.
A 10-port and a 24-port switch are still `sw01` and `sw02`.
### 3. Kind abbreviation table
The prefix equals the `kind` field, so the name is self-describing.
| Device | `kind` | abbrev | example |
|--------|--------|--------|---------|
| Stationary PC / server | `server` | `srv` | `srv01` |
| Switch (managed or dumb) | `switch` | `sw` | `sw01` |
| Patch / network panel | `patch-panel` | `pp` | `pp01` |
| PDU / power strip | `pdu` | `pdu` | `pdu01` |
| UPS | `ups` | `ups` | `ups01` |
| Shelf | `shelf` | `shf` | `shf01` |
| WAN uplink / ISP demarcation | `wan` | `wan` | `wan01` |
Existing enum kinds not yet in the rack reuse their natural short form when
first used (`ap``ap`, `kvm``kvm`, `sbc``sbc`, `laptop``lt`,
`desktop``dt`, `blank``blank`); register the chosen abbrev in this table
at that time.
Distinguishing attributes (port/outlet count, managed vs dumb, speed) live in
frontmatter (`ports:`, `outlets:`, …), never in the name.
### 4. Grouping in frontmatter, not the name
- `cluster:` — one of `tappaas`, `test-1`, `test-2`, `test-3`, `test-4`, or
`shared` (crosscutting gear that spans clusters: power strips, shared patch
panels). Editing this field is the entire cost of moving a node between
clusters.
- `role:` — optional, free-form (e.g. `control`, `worker`); added only where it
earns its keep.
A future generator step may group/table hardware by `cluster`, exactly as the
hardware index groups by `kind` today. That is **not** part of this scheme — the
scheme only fixes the field and its allowed values.
### 5. Exceptions
Cloud / externally-named hosts keep their real FQDN as the identifier (e.g.
`makerfloss.eu`) instead of `srvNN`. They are not racked and DNS owns the name.
Such hosts carry no `rack:` field and need no `cluster:`.
### 6. Growth rules
- **New device class:** add the `kind` to the `hardware` enum in
`scripts/overview_config.yml` and register a short abbrev in the table above.
- **New grouping:** add a `cluster:` value.
- **More than 99 of one kind:** widen that kind's sequence to 3 digits.
- **More racks:** already supported via the `rack:` field (Phase 1).
- **Multiple sites:** deferred until real (YAGNI); a site prefix or field can be
added without disturbing existing names.
## Migration of current names
| Current | New | Notes |
|---------|-----|-------|
| `mf00` | `srv01` | "TaPPaaS node 1" → `cluster: tappaas` |
| `mf01` | `srv02` | cluster omitted until a real assignment is given |
| `mf02` | `srv03` | |
| `mf03` | `srv04` | |
| `mf04` | `srv05` | |
| `pdu01`, `pdu02` | unchanged | already conform |
| `sw01`, `pp01` | unchanged | already conform |
| `makerfloss.eu` | unchanged | cloud FQDN exception |
The `mf0x` machines are staging placeholders, so renaming is cheap now. The
rename touches their files, the `power:`/`links:` references to them in
`pdu*`/`sw*`/`pp*` and sibling host files, and the regenerated artifacts under
`docs/hardware/index.md` and `docs/infrastructure/racks/`. The cluster
assignments for `srv02``srv05` are provisional until real values are given;
`srv01` = `tappaas` is the one known mapping.
## Out of scope
- Choosing the *best distribution* of hardware across clusters.
- Building a generator view that groups by `cluster` (possible future work).
- Real cluster assignments beyond `srv01` = TaPPaaS.

View file

@ -1,25 +0,0 @@
---
hostname: fisi
kind: server
status: in-use
model: HP MicroServer Gen10 Plus
location: home rack
cpu: Xeon E-2226G
cpu_cores: 6
cpu_threads: 12
ram_gb: 64
storage_gb: 8000
storage_type: hdd
storage_notes: ZFS mirror 2×8 TB HDD + 1 TB NVMe cache
nic_gbps: 1
---
# fisi
Primary home server in the baobab.band homelab. Hosts the bulk of
self-hosted services: Nextcloud, Jellyfin + *arr stack, Technitium DNS,
PhotoPrism, Matrix (conduwuit + Element), Forgejo (internal), Vaultwarden,
and more.
Not part of the MakerFLOSS infrastructure proper, listed here for
Proxmox-style placement planning when we eventually share workloads.

View file

@ -2,15 +2,66 @@
_Auto-generated from `docs/hardware/*.md` — do not edit by hand. Run `make docs-index` after changing a file._
## Laptops
## Patch panels
| Hostname | Model | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|---|
| [tembo](tembo.md) | ThinkPad T480 | Orange Makerspace (kiosk) | Intel Core i5-8350U · 4c/8t | 16 GB | 512 GB NVME | 1 GbE | in-use |
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [pp01](pp01.md) | | | | | | in-use |
| [pp02](pp02.md) | | | | | | in-use |
## PDUs
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [pdu01](pdu01.md) | | | | | | in-use |
| [pdu02](pdu02.md) | | | | | | in-use |
| [pdu03](pdu03.md) | | | | | | in-use |
| [pdu04](pdu04.md) | | | | | | in-use |
## Servers
| Hostname | Model | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|---|
| [fisi](fisi.md) | HP MicroServer Gen10 Plus | home rack | Xeon E-2226G · 6c/12t | 64 GB | 8 TB HDD | 1 GbE | in-use |
| [makerfloss](makerfloss.md) | Hetzner CX22 | Hetzner HEL1 (cloud) | AMD EPYC (shared vCPU) · 2c | 4 GB | 40 GB NVME | 1 GbE | in-use |
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [makerfloss.eu](makerfloss.eu.md) | Hetzner HEL1 (cloud) | AMD EPYC (shared vCPU) · 2c | 4 GB | 40 GB NVME | 1 GbE | in-use |
| [nas01](nas01.md) | The pile | ? | ? | ? | ? | staging |
| [nas02](nas02.md) | The pile | ? | ? | ? | ? | staging |
| [srv01](srv01.md) | The pile | ? | ? | ? | ? | in-use |
| [srv02](srv02.md) | The pile | Intel Core i5-8500 @ 3.00GHz · 6c | 8 GB | 40 GB NVME | 1 GbE | staging |
| [srv03](srv03.md) | The pile | Intel Core i5-8500 @ 3.00GHz · 6c | 16 GB | 40 GB NVME | 1 GbE | staging |
| [srv04](srv04.md) | The pile | Intel Core i5-3570K @ 3.40GHz · 4c | 8 GB | 500 GB HDD | 1 GbE | staging |
| [srv05](srv05.md) | The pile | Intel Core i5-3570K @ 3.40GHz · 4c | 8 GB | 500 GB HDD | 1 GbE | staging |
| [srv06](srv06.md) | The pile | ? | ? | ? | ? | staging |
| [srv07](srv07.md) | The pile | ? | ? | ? | ? | staging |
## Shelves
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [shf01](shf01.md) | | | | | | in-use |
| [shf02](shf02.md) | | | | | | in-use |
| [shf03](shf03.md) | | | | | | in-use |
| [shf04](shf04.md) | | | | | | in-use |
## Switches
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [sw01](sw01.md) | | | | | | in-use |
| [sw02](sw02.md) | | | | | | staging |
| [sw03](sw03.md) | | | | | | staging |
| [sw04](sw04.md) | | | | | | staging |
| [sw05](sw05.md) | | | | | | staging |
## UPS
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [ups01](ups01.md) | | | | | | staging |
| [ups02](ups02.md) | | | | | | staging |
## WAN uplinks
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [wan01](wan01.md) | ISP demarcation | | | | | in-use |
| [wan02](wan02.md) | ISP demarcation | | | | | staging |

View file

@ -1,8 +1,7 @@
---
hostname: makerfloss
hostname: makerfloss.eu
kind: server
status: in-use
model: Hetzner CX22
location: Hetzner HEL1 (cloud)
cpu: AMD EPYC (shared vCPU)
cpu_cores: 2
@ -13,12 +12,9 @@ storage_type: nvme
nic_gbps: 1
---
# makerfloss
## Notes
Hetzner Cloud VPS running the public-facing MakerFLOSS stack: Forgejo
(self-hosted git forge), Traefik with Let's Encrypt, poste.io mail
server, a Forgejo Actions runner, and the nginx services that serve
`slides.makerfloss.eu` and `docs.makerfloss.eu`.
Managed via the [`AnsibleBaobabV4`](https://forgejo.nyumbani.baobab.band/sjat/AnsibleBaobabV4)
Ansible project. SSH on port 7576.

25
docs/hardware/nas01.md Normal file
View file

@ -0,0 +1,25 @@
---
hostname: nas01
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: "?"
cpu_cores: "?"
cpu_threads: "?"
ram_gb: "?"
storage: "?"
nic_gbps: "?"
rack: rack01
rack_u: 6
u_height: 1
rack_face: front
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
# links: cabling TBD — to be documented during network wiring
---
## Notes
- Reconstructed from committed rack artifacts; specs are placeholders.

25
docs/hardware/nas02.md Normal file
View file

@ -0,0 +1,25 @@
---
hostname: nas02
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: "?"
cpu_cores: "?"
cpu_threads: "?"
ram_gb: "?"
storage: "?"
nic_gbps: "?"
rack: rack01
rack_u: 7
u_height: 1
rack_face: front
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
# links: cabling TBD — to be documented during network wiring
---
## Notes
- Reconstructed from committed rack artifacts; specs are placeholders.

14
docs/hardware/pdu01.md Normal file
View file

@ -0,0 +1,14 @@
---
hostname: pdu01
kind: pdu
status: in-use
rack: rack01
rack_face: rear
outlets: 9
rack_u: 1
u_height: 1
---
## Notes
-

14
docs/hardware/pdu02.md Normal file
View file

@ -0,0 +1,14 @@
---
hostname: pdu02
kind: pdu
status: in-use
rack: rack01
rack_face: rear
outlets: 5
rack_u: 12
u_height: 1
---
## Notes
-

13
docs/hardware/pdu03.md Normal file
View file

@ -0,0 +1,13 @@
---
hostname: pdu03
kind: pdu
status: in-use
rack: rack01
rack_face: rear
outlets: 11
rack_u: 34
u_height: 1
---
## Notes

13
docs/hardware/pdu04.md Normal file
View file

@ -0,0 +1,13 @@
---
hostname: pdu04
kind: pdu
status: in-use
rack: rack01
rack_face: front
outlets: 5
rack_u: 12
u_height: 1
---
## Notes

20
docs/hardware/pp01.md Normal file
View file

@ -0,0 +1,20 @@
---
hostname: pp01
kind: patch-panel
status: in-use
rack: rack01
rack_u: 24
u_height: 1
rack_face: front
ports: 9
links:
- { local: "1", peer: wan01, peer_port: 1 }
---
## Notes
- Port 1 → wan01 (WAN line 1, active). Fed from pp02:1 (srv01 eth0).
- Port 2 → wan02 (WAN line 2), set up but non-active.
- Port 3 → working table (white cable), non-active.
- Port 4 ← sw01:8 (switch management), patched out to the working table
(black cable), non-active.

26
docs/hardware/pp02.md Normal file
View file

@ -0,0 +1,26 @@
---
hostname: pp02
kind: patch-panel
status: in-use
rack: rack01
rack_u: 25
u_height: 1
rack_face: front
ports: 24
links:
- { local: "1", peer: pp01, peer_port: 1 }
- { local: "2", peer: sw01, peer_port: 1 }
- { local: "3", peer: sw01, peer_port: 2 }
- { local: "4", peer: sw01, peer_port: 3 }
- { local: "5", peer: sw01, peer_port: 4 }
- { local: "6", peer: sw01, peer_port: 5 }
- { local: "7", peer: sw01, peer_port: 6 }
- { local: "8", peer: sw01, peer_port: 7 }
---
## Notes
- 24-port patch panel; ports 18 are the live feeds.
- Port 1 → pp01:1 → wan01 (srv01 eth0, WAN).
- Ports 28 → sw01:17 (LAN): srv01 eth1 (p2), srv02 (p3), srv03 (p4),
srv04 (p5), srv05 (p6), srv06 (p7), srv07 (p8).

17
docs/hardware/shf01.md Normal file
View file

@ -0,0 +1,17 @@
---
hostname: shf01
kind: shelf
status: in-use
rack: rack01
rack_u: 46
u_height: 1
rack_face: both
cluster: tappaas
---
## Notes
- 1U full-depth tray at U46. Tower PCs stand on it and rise above U46; they are
not rail-mounted, so the U's above are not consumed in the rack model.
- Front: srv01 (10U, U37U46), srv02 (8U, U39U46).
- Rear: srv03 (6U, U41U46).

17
docs/hardware/shf02.md Normal file
View file

@ -0,0 +1,17 @@
---
hostname: shf02
kind: shelf
status: in-use
rack: rack01
rack_u: 35
u_height: 1
rack_face: both
---
## Notes
- 1U full-depth tray at U35. Tower PCs stand on it and rise above U35; they are
not rail-mounted, so the U's above are not consumed in the rack model
(e.g. pdu03 sits at U34, just above this shelf).
- Front: srv07 (6U, U30U35), srv04 (6U, U30U35).
- Rear: srv05 (7U, U29U35), srv06 (7U, U29U35).

14
docs/hardware/shf03.md Normal file
View file

@ -0,0 +1,14 @@
---
hostname: shf03
kind: shelf
status: in-use
rack: rack01
rack_u: 21
u_height: 1
rack_face: front
---
## Notes
- Half-depth shelf at U21 (front), currently empty.
- Paired with shf04 (rear half-depth at the same U).

14
docs/hardware/shf04.md Normal file
View file

@ -0,0 +1,14 @@
---
hostname: shf04
kind: shelf
status: in-use
rack: rack01
rack_u: 21
u_height: 1
rack_face: rear
---
## Notes
- Half-depth shelf at U21 (rear), currently empty.
- Paired with shf03 (front half-depth at the same U).

30
docs/hardware/srv01.md Normal file
View file

@ -0,0 +1,30 @@
---
hostname: srv01
kind: server
status: in-use
cluster: tappaas
location: The pile
cpu: "?"
cpu_cores: "?"
cpu_threads: "?"
ram_gb: "?"
storage: "?"
nic_gbps: "?"
rack: rack01
mounted_on: shf01
shelf_face: front
shelf_slot: 1
chassis_u: 10
power:
- { pdu: pdu01, outlet: 1 }
links:
- { local: eth0, peer: pp02, peer_port: 1, speed_gbps: 1 }
- { local: eth1, peer: pp02, peer_port: 2, speed_gbps: 1 }
---
## Notes
- MakerFLOSS production stack
- TaPPaaS node 1
## ToDo

34
docs/hardware/srv02.md Normal file
View file

@ -0,0 +1,34 @@
---
hostname: srv02
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: Intel Core i5-8500 @ 3.00GHz
cpu_cores: 6
cpu_threads: 6
ram_gb: 8
ram_type: DDR4-2666 non-ECC UDIMM
storage_gb: 40
storage_type: nvme
nic_gbps: 1
rack: rack01
mounted_on: shf01
shelf_face: front
shelf_slot: 2
chassis_u: 8
power:
- { pdu: pdu01, outlet: 2 }
links:
- { local: eth0, peer: pp02, peer_port: 3, speed_gbps: 1 }
---
## Notes
- TaPPaaS node 2
## ToDo
1. Update BIOS/UEFI firmware
2. Update BIOS/UEFI settings
3. New CMOS battery

34
docs/hardware/srv03.md Normal file
View file

@ -0,0 +1,34 @@
---
hostname: srv03
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: Intel Core i5-8500 @ 3.00GHz
cpu_cores: 6
cpu_threads: 6
ram_gb: 16
ram_type: DDR4-2666 non-ECC UDIMM
storage_gb: 40
storage_type: nvme
nic_gbps: 1
rack: rack01
mounted_on: shf01
shelf_face: rear
shelf_slot: 1
chassis_u: 6
power:
- { pdu: pdu01, outlet: 3 }
links:
- { local: eth0, peer: pp02, peer_port: 4, speed_gbps: 1 }
---
## Notes
- TaPPaaS node 3
## ToDo
1. Update BIOS/UEFI firmware
2. Update BIOS/UEFI settings
3. New CMOS battery

31
docs/hardware/srv04.md Normal file
View file

@ -0,0 +1,31 @@
---
hostname: srv04
kind: server
status: staging
location: The pile
cpu: Intel Core i5-3570K @ 3.40GHz
cpu_cores: 4
cpu_threads: 4
ram_gb: 8
storage_gb: 500
storage_type: hdd
nic_gbps: 1
rack: rack01
mounted_on: shf02
shelf_face: front
shelf_slot: 2
chassis_u: 6
power:
- { pdu: pdu01, outlet: 4 }
links:
- { local: eth0, peer: pp02, peer_port: 5, speed_gbps: 1 }
---
## Notes
Old Gamer style PC
## ToDo
1. Update BIOS/UEFI firmware
2. Update BIOS/UEFI settings

31
docs/hardware/srv05.md Normal file
View file

@ -0,0 +1,31 @@
---
hostname: srv05
kind: server
status: staging
location: The pile
cpu: Intel Core i5-3570K @ 3.40GHz
cpu_cores: 4
cpu_threads: 4
ram_gb: 8
storage_gb: 500
storage_type: hdd
nic_gbps: 1
rack: rack01
mounted_on: shf02
shelf_face: rear
shelf_slot: 1
chassis_u: 7
power:
- { pdu: pdu01, outlet: 5 }
links:
- { local: eth0, peer: pp02, peer_port: 6, speed_gbps: 1 }
---
## Notes
Old Gamer style PC
## ToDo
1. Update BIOS/UEFI firmware
2. Update BIOS/UEFI settings

27
docs/hardware/srv06.md Normal file
View file

@ -0,0 +1,27 @@
---
hostname: srv06
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: "?"
cpu_cores: "?"
cpu_threads: "?"
ram_gb: "?"
storage: "?"
nic_gbps: "?"
rack: rack01
mounted_on: shf02
shelf_face: rear
shelf_slot: 2
chassis_u: 7
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
links:
- { local: eth0, peer: pp02, peer_port: 7, speed_gbps: 1 }
---
## Notes
- Reconstructed from committed rack artifacts; specs are placeholders.

27
docs/hardware/srv07.md Normal file
View file

@ -0,0 +1,27 @@
---
hostname: srv07
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: "?"
cpu_cores: "?"
cpu_threads: "?"
ram_gb: "?"
storage: "?"
nic_gbps: "?"
rack: rack01
mounted_on: shf02
shelf_face: front
shelf_slot: 1
chassis_u: 6
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
links:
- { local: eth0, peer: pp02, peer_port: 8, speed_gbps: 1 }
---
## Notes
- Reconstructed from committed rack artifacts; specs are placeholders.

17
docs/hardware/sw01.md Normal file
View file

@ -0,0 +1,17 @@
---
hostname: sw01
kind: switch
status: in-use
rack: rack01
rack_u: 23
u_height: 1
rack_face: front
ports: 10
---
## Notes
- 10 ports: p1p8 are 1 GbE; sfp1sfp2 are 2.5 GbE SFP+ (unused today).
- p1p7 carry server uplinks via pp02 (pp02:28 → sw01:17).
- p8 is the management port, patched out via pp01:4 to the working table
(black cable) — non-active.

13
docs/hardware/sw02.md Normal file
View file

@ -0,0 +1,13 @@
---
hostname: sw02
kind: switch
status: staging
rack: rack01
rack_u: 9
u_height: 1
rack_face: front
---
## Notes
- Reconstructed from committed rack artifacts; placeholder values.

13
docs/hardware/sw03.md Normal file
View file

@ -0,0 +1,13 @@
---
hostname: sw03
kind: switch
status: staging
rack: rack01
rack_u: 10
u_height: 1
rack_face: front
---
## Notes
- Reconstructed from committed rack artifacts; placeholder values.

13
docs/hardware/sw04.md Normal file
View file

@ -0,0 +1,13 @@
---
hostname: sw04
kind: switch
status: staging
rack: rack01
rack_u: 5
u_height: 1
rack_face: front
---
## Notes
- Reconstructed from committed rack artifacts; placeholder values.

13
docs/hardware/sw05.md Normal file
View file

@ -0,0 +1,13 @@
---
hostname: sw05
kind: switch
status: staging
rack: rack01
rack_u: 8
u_height: 1
rack_face: front
---
## Notes
- Reconstructed from committed rack artifacts; placeholder values.

View file

@ -1,24 +0,0 @@
---
hostname: tembo
kind: laptop
status: in-use
model: ThinkPad T480
location: Orange Makerspace (kiosk)
cpu: Intel Core i5-8350U
cpu_cores: 4
cpu_threads: 8
ram_gb: 16
storage_gb: 512
storage_type: nvme
nic_gbps: 1
---
# tembo
XFCE-based touchscreen kiosk laptop at Orange Makerspace. Runs a
rotation of dashboards (Grafana, the Marp slides at
`slides.makerfloss.eu`, the radio page) and serves as the local
display for the room. Also runs a Grafana/Loki/Prometheus stack.
Acts as an example of a re-purposed laptop being treated as a fixed
piece of infrastructure rather than a personal device.

13
docs/hardware/ups01.md Normal file
View file

@ -0,0 +1,13 @@
---
hostname: ups01
kind: ups
status: staging
rack: rack01
rack_u: 4
u_height: 1
rack_face: front
---
## Notes
-

13
docs/hardware/ups02.md Normal file
View file

@ -0,0 +1,13 @@
---
hostname: ups02
kind: ups
status: staging
rack: rack01
rack_u: 3
u_height: 1
rack_face: front
---
## Notes
-

19
docs/hardware/wan01.md Normal file
View file

@ -0,0 +1,19 @@
---
hostname: wan01
kind: wan
status: in-use
location: ISP demarcation
ports: 1
---
## Notes
- External WAN uplink — the upstream/ISP side of the WAN cable, where the
MakerFLOSS network meets the provider.
- Not racked (no `rack:`), like the cloud-FQDN exception in the naming scheme.
It exists so the WAN cable has a real peer to terminate on.
- Path into the rack: `wan01:1 ← pp01:1 ← pp02:1 ← srv01 eth0`.
## ToDo
- Confirm provider / circuit details for this handoff.

13
docs/hardware/wan02.md Normal file
View file

@ -0,0 +1,13 @@
---
hostname: wan02
kind: wan
status: staging
location: ISP demarcation
ports: 1
---
## Notes
- Second WAN uplink line ("wan 2"), set up but not yet active.
- Patched from pp01:2 (non-active), so it does not yet appear in the live
network diagram.

View file

@ -8,7 +8,10 @@ jam-session community focused on self-hosted, open-source infrastructure.
- [Hardware](hardware/index.md) — every machine in the lab, auto-indexed from per-host
frontmatter blocks. Use this when planning where to deploy a new service.
- [House rules](makerFLOSS_house_rules.md) — working norms, governance, and
- [Services](services/index.md) — the things we run on (or around) that
hardware: the docs site, slides, Forgejo, DNS, the slide-builder
toolchain, and so on.
- [House rules](house-rules.md) — working norms, governance, and
what we do (and don't) do.
## Working norms (summary)
@ -18,5 +21,5 @@ jam-session community focused on self-hosted, open-source infrastructure.
- **Environments:** containerised and reproducible.
- **Hardware:** all setups documented (this site) and physically labelled.
- **Decisions:** lightweight markdown decision logs under
[`docs/superpowers/`](https://forgejo.makerfloss.eu/sjat/MakerFLOSS/src/branch/main/docs/superpowers).
[`notes/dev/`](https://forgejo.makerfloss.eu/sjat/MakerFLOSS/src/branch/main/notes/dev).
- **License:** FLOSS by default; MIT for what we build.

View file

@ -0,0 +1,355 @@
<svg xmlns="http://www.w3.org/2000/svg" width="614" height="1068" viewBox="0 0 614 1068" style="max-width:100%;height:auto" font-family="sans-serif" font-size="11">
<rect width="614" height="1068" fill="#ffffff"/>
<text x="12" y="28" font-size="16" font-weight="bold">Rack rack01</text>
<text x="162" y="34" text-anchor="middle" font-weight="bold">front</text>
<rect x="42" y="40" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="60" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="80" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="100" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="120" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="140" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="160" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="180" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="200" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="220" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="240" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="260" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="280" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="300" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="320" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="340" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="360" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="380" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="400" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="420" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="440" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="460" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="480" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="500" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="520" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="540" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="560" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="580" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="600" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="620" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="640" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="660" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="680" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="700" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="720" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="740" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="760" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="780" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="800" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="820" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="840" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="860" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="880" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="900" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="920" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="940" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="960" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="980" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<text x="452" y="34" text-anchor="middle" font-weight="bold">rear</text>
<rect x="332" y="40" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="60" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="80" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="100" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="120" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="140" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="160" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="180" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="200" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="220" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="240" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="260" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="280" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="300" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="320" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="340" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="360" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="380" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="400" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="420" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="440" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="460" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="480" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="500" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="520" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="540" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="560" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="580" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="600" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="620" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="640" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="660" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="680" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="700" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="720" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="740" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="760" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="780" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="800" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="820" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="840" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="860" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="880" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="900" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="920" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="940" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="960" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="980" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<text x="38" y="54" text-anchor="end" fill="#999">1</text>
<text x="38" y="74" text-anchor="end" fill="#999">2</text>
<text x="38" y="94" text-anchor="end" fill="#999">3</text>
<text x="38" y="114" text-anchor="end" fill="#999">4</text>
<text x="38" y="134" text-anchor="end" fill="#999">5</text>
<text x="38" y="154" text-anchor="end" fill="#999">6</text>
<text x="38" y="174" text-anchor="end" fill="#999">7</text>
<text x="38" y="194" text-anchor="end" fill="#999">8</text>
<text x="38" y="214" text-anchor="end" fill="#999">9</text>
<text x="38" y="234" text-anchor="end" fill="#999">10</text>
<text x="38" y="254" text-anchor="end" fill="#999">11</text>
<text x="38" y="274" text-anchor="end" fill="#999">12</text>
<text x="38" y="294" text-anchor="end" fill="#999">13</text>
<text x="38" y="314" text-anchor="end" fill="#999">14</text>
<text x="38" y="334" text-anchor="end" fill="#999">15</text>
<text x="38" y="354" text-anchor="end" fill="#999">16</text>
<text x="38" y="374" text-anchor="end" fill="#999">17</text>
<text x="38" y="394" text-anchor="end" fill="#999">18</text>
<text x="38" y="414" text-anchor="end" fill="#999">19</text>
<text x="38" y="434" text-anchor="end" fill="#999">20</text>
<text x="38" y="454" text-anchor="end" fill="#999">21</text>
<text x="38" y="474" text-anchor="end" fill="#999">22</text>
<text x="38" y="494" text-anchor="end" fill="#999">23</text>
<text x="38" y="514" text-anchor="end" fill="#999">24</text>
<text x="38" y="534" text-anchor="end" fill="#999">25</text>
<text x="38" y="554" text-anchor="end" fill="#999">26</text>
<text x="38" y="574" text-anchor="end" fill="#999">27</text>
<text x="38" y="594" text-anchor="end" fill="#999">28</text>
<text x="38" y="614" text-anchor="end" fill="#999">29</text>
<text x="38" y="634" text-anchor="end" fill="#999">30</text>
<text x="38" y="654" text-anchor="end" fill="#999">31</text>
<text x="38" y="674" text-anchor="end" fill="#999">32</text>
<text x="38" y="694" text-anchor="end" fill="#999">33</text>
<text x="38" y="714" text-anchor="end" fill="#999">34</text>
<text x="38" y="734" text-anchor="end" fill="#999">35</text>
<text x="38" y="754" text-anchor="end" fill="#999">36</text>
<text x="38" y="774" text-anchor="end" fill="#999">37</text>
<text x="38" y="794" text-anchor="end" fill="#999">38</text>
<text x="38" y="814" text-anchor="end" fill="#999">39</text>
<text x="38" y="834" text-anchor="end" fill="#999">40</text>
<text x="38" y="854" text-anchor="end" fill="#999">41</text>
<text x="38" y="874" text-anchor="end" fill="#999">42</text>
<text x="38" y="894" text-anchor="end" fill="#999">43</text>
<text x="38" y="914" text-anchor="end" fill="#999">44</text>
<text x="38" y="934" text-anchor="end" fill="#999">45</text>
<text x="38" y="954" text-anchor="end" fill="#999">46</text>
<text x="38" y="974" text-anchor="end" fill="#999">47</text>
<text x="38" y="994" text-anchor="end" fill="#999">48</text>
<text x="576" y="54" text-anchor="start" fill="#999">1</text>
<text x="576" y="74" text-anchor="start" fill="#999">2</text>
<text x="576" y="94" text-anchor="start" fill="#999">3</text>
<text x="576" y="114" text-anchor="start" fill="#999">4</text>
<text x="576" y="134" text-anchor="start" fill="#999">5</text>
<text x="576" y="154" text-anchor="start" fill="#999">6</text>
<text x="576" y="174" text-anchor="start" fill="#999">7</text>
<text x="576" y="194" text-anchor="start" fill="#999">8</text>
<text x="576" y="214" text-anchor="start" fill="#999">9</text>
<text x="576" y="234" text-anchor="start" fill="#999">10</text>
<text x="576" y="254" text-anchor="start" fill="#999">11</text>
<text x="576" y="274" text-anchor="start" fill="#999">12</text>
<text x="576" y="294" text-anchor="start" fill="#999">13</text>
<text x="576" y="314" text-anchor="start" fill="#999">14</text>
<text x="576" y="334" text-anchor="start" fill="#999">15</text>
<text x="576" y="354" text-anchor="start" fill="#999">16</text>
<text x="576" y="374" text-anchor="start" fill="#999">17</text>
<text x="576" y="394" text-anchor="start" fill="#999">18</text>
<text x="576" y="414" text-anchor="start" fill="#999">19</text>
<text x="576" y="434" text-anchor="start" fill="#999">20</text>
<text x="576" y="454" text-anchor="start" fill="#999">21</text>
<text x="576" y="474" text-anchor="start" fill="#999">22</text>
<text x="576" y="494" text-anchor="start" fill="#999">23</text>
<text x="576" y="514" text-anchor="start" fill="#999">24</text>
<text x="576" y="534" text-anchor="start" fill="#999">25</text>
<text x="576" y="554" text-anchor="start" fill="#999">26</text>
<text x="576" y="574" text-anchor="start" fill="#999">27</text>
<text x="576" y="594" text-anchor="start" fill="#999">28</text>
<text x="576" y="614" text-anchor="start" fill="#999">29</text>
<text x="576" y="634" text-anchor="start" fill="#999">30</text>
<text x="576" y="654" text-anchor="start" fill="#999">31</text>
<text x="576" y="674" text-anchor="start" fill="#999">32</text>
<text x="576" y="694" text-anchor="start" fill="#999">33</text>
<text x="576" y="714" text-anchor="start" fill="#999">34</text>
<text x="576" y="734" text-anchor="start" fill="#999">35</text>
<text x="576" y="754" text-anchor="start" fill="#999">36</text>
<text x="576" y="774" text-anchor="start" fill="#999">37</text>
<text x="576" y="794" text-anchor="start" fill="#999">38</text>
<text x="576" y="814" text-anchor="start" fill="#999">39</text>
<text x="576" y="834" text-anchor="start" fill="#999">40</text>
<text x="576" y="854" text-anchor="start" fill="#999">41</text>
<text x="576" y="874" text-anchor="start" fill="#999">42</text>
<text x="576" y="894" text-anchor="start" fill="#999">43</text>
<text x="576" y="914" text-anchor="start" fill="#999">44</text>
<text x="576" y="934" text-anchor="start" fill="#999">45</text>
<text x="576" y="954" text-anchor="start" fill="#999">46</text>
<text x="576" y="974" text-anchor="start" fill="#999">47</text>
<text x="576" y="994" text-anchor="start" fill="#999">48</text>
<rect x="42" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<rect x="332" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<a href="/hardware/srv01/">
<title>srv01 · server · in-use · cluster: tappaas · shf01/front/slot 1</title>
<rect x="43" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5"/>
<text x="102" y="861" text-anchor="middle" fill="#ffffff">srv01</text>
</a>
<a href="/hardware/srv02/">
<title>srv02 · server · staging · cluster: tappaas · shf01/front/slot 2</title>
<rect x="163" y="801" width="118" height="152" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="222" y="881" text-anchor="middle" fill="#ffffff">srv02</text>
</a>
<a href="/hardware/srv03/">
<title>srv03 · server · staging · cluster: tappaas · shf01/rear/slot 1</title>
<rect x="333" y="841" width="238" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="452" y="901" text-anchor="middle" fill="#ffffff">srv03</text>
</a>
<a href="/hardware/shf01/">
<title>shf01 · shelf · in-use · cluster: tappaas · U46</title>
<rect x="42" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="959" text-anchor="middle" fill="#333" font-size="9">shf01</text>
</a>
<a href="/hardware/srv07/">
<title>srv07 · server · staging · cluster: tappaas · shf02/front/slot 1</title>
<rect x="43" y="621" width="118" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="102" y="681" text-anchor="middle" fill="#ffffff">srv07</text>
</a>
<a href="/hardware/srv04/">
<title>srv04 · server · staging · cluster: — · shf02/front/slot 2</title>
<rect x="163" y="621" width="118" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="222" y="681" text-anchor="middle" fill="#ffffff">srv04</text>
</a>
<a href="/hardware/srv05/">
<title>srv05 · server · staging · cluster: — · shf02/rear/slot 1</title>
<rect x="333" y="601" width="118" height="132" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="392" y="671" text-anchor="middle" fill="#ffffff">srv05</text>
</a>
<a href="/hardware/srv06/">
<title>srv06 · server · staging · cluster: tappaas · shf02/rear/slot 2</title>
<rect x="453" y="601" width="118" height="132" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="512" y="671" text-anchor="middle" fill="#ffffff">srv06</text>
</a>
<a href="/hardware/shf02/">
<title>shf02 · shelf · in-use · cluster: — · U35</title>
<rect x="42" y="734" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="734" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="739" text-anchor="middle" fill="#333" font-size="9">shf02</text>
</a>
<a href="/hardware/shf03/">
<title>shf03 · shelf · in-use · cluster: — · U21</title>
<rect x="42" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="459" text-anchor="middle" fill="#333" font-size="9">shf03</text>
</a>
<a href="/hardware/shf04/">
<title>shf04 · shelf · in-use · cluster: — · U21</title>
<rect x="42" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="459" text-anchor="middle" fill="#333" font-size="9">shf04</text>
</a>
<a href="/hardware/pdu01/">
<title>pdu01 · pdu · in-use · cluster: — · U1</title>
<rect x="333" y="41" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="54" text-anchor="middle" fill="#ffffff">pdu01 (U1)</text>
</a>
<a href="/hardware/ups02/">
<title>ups02 · ups · staging · cluster: — · U3</title>
<rect x="43" y="81" width="238" height="18" rx="3" fill="#edc948" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="94" text-anchor="middle" fill="#ffffff">ups02 (U3)</text>
</a>
<a href="/hardware/ups01/">
<title>ups01 · ups · staging · cluster: — · U4</title>
<rect x="43" y="101" width="238" height="18" rx="3" fill="#edc948" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="114" text-anchor="middle" fill="#ffffff">ups01 (U4)</text>
</a>
<a href="/hardware/sw04/">
<title>sw04 · switch · staging · cluster: — · U5</title>
<rect x="43" y="121" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="134" text-anchor="middle" fill="#ffffff">sw04 (U5)</text>
</a>
<a href="/hardware/nas01/">
<title>nas01 · server · staging · cluster: tappaas · U6</title>
<rect x="43" y="141" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="154" text-anchor="middle" fill="#ffffff">nas01 (U6)</text>
</a>
<a href="/hardware/nas02/">
<title>nas02 · server · staging · cluster: tappaas · U7</title>
<rect x="43" y="161" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="174" text-anchor="middle" fill="#ffffff">nas02 (U7)</text>
</a>
<a href="/hardware/sw05/">
<title>sw05 · switch · staging · cluster: — · U8</title>
<rect x="43" y="181" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="194" text-anchor="middle" fill="#ffffff">sw05 (U8)</text>
</a>
<a href="/hardware/sw02/">
<title>sw02 · switch · staging · cluster: — · U9</title>
<rect x="43" y="201" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="214" text-anchor="middle" fill="#ffffff">sw02 (U9)</text>
</a>
<a href="/hardware/sw03/">
<title>sw03 · switch · staging · cluster: — · U10</title>
<rect x="43" y="221" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="234" text-anchor="middle" fill="#ffffff">sw03 (U10)</text>
</a>
<a href="/hardware/pdu02/">
<title>pdu02 · pdu · in-use · cluster: — · U12</title>
<rect x="333" y="261" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="274" text-anchor="middle" fill="#ffffff">pdu02 (U12)</text>
</a>
<a href="/hardware/pdu04/">
<title>pdu04 · pdu · in-use · cluster: — · U12</title>
<rect x="43" y="261" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="274" text-anchor="middle" fill="#ffffff">pdu04 (U12)</text>
</a>
<a href="/hardware/sw01/">
<title>sw01 · switch · in-use · cluster: — · U23</title>
<rect x="43" y="481" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="494" text-anchor="middle" fill="#ffffff">sw01 (U23)</text>
</a>
<a href="/hardware/pp01/">
<title>pp01 · patch-panel · in-use · cluster: — · U24</title>
<rect x="43" y="501" width="238" height="18" rx="3" fill="#9c755f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="514" text-anchor="middle" fill="#ffffff">pp01 (U24)</text>
</a>
<a href="/hardware/pp02/">
<title>pp02 · patch-panel · in-use · cluster: — · U25</title>
<rect x="43" y="521" width="238" height="18" rx="3" fill="#9c755f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="534" text-anchor="middle" fill="#ffffff">pp02 (U25)</text>
</a>
<a href="/hardware/pdu03/">
<title>pdu03 · pdu · in-use · cluster: — · U34</title>
<rect x="333" y="701" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="714" text-anchor="middle" fill="#ffffff">pdu03 (U34)</text>
</a>
<text x="42" y="1020" font-weight="bold">Legend</text>
<rect x="42" y="1028" width="12" height="12" fill="#9c755f" stroke="#333"/>
<text x="58" y="1038">patch-panel</text>
<rect x="147" y="1028" width="12" height="12" fill="#e15759" stroke="#333"/>
<text x="163" y="1038">pdu</text>
<rect x="196" y="1028" width="12" height="12" fill="#4c78a8" stroke="#333"/>
<text x="212" y="1038">server</text>
<rect x="266" y="1028" width="12" height="12" fill="#bab0ac" stroke="#333"/>
<text x="282" y="1038">shelf</text>
<rect x="329" y="1028" width="12" height="12" fill="#59a14f" stroke="#333"/>
<text x="345" y="1038">switch</text>
<rect x="399" y="1028" width="12" height="12" fill="#edc948" stroke="#333"/>
<text x="415" y="1038">ups</text>
<rect x="42" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5"/>
<text x="58" y="1056">in-use</text>
<rect x="112" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="128" y="1056">staging</text>
<rect x="189" y="1046" width="12" height="12" fill="#ffffff" stroke="#e15759" stroke-width="3"/>
<text x="205" y="1056">broken</text>
<rect x="259" y="1046" width="12" height="12" fill="#ffffff" stroke="#bbbbbb" stroke-width="1.5"/>
<text x="275" y="1056">spare</text>
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,509 @@
# Rack rack01
_Auto-generated from `docs/hardware/*.md` (items with `rack: rack01`) — do not edit by hand. Run `make docs-index` after changing a source file._
## Elevation
<div class="rack-elevation">
<svg xmlns="http://www.w3.org/2000/svg" width="614" height="1068" viewBox="0 0 614 1068" style="max-width:100%;height:auto" font-family="sans-serif" font-size="11">
<rect width="614" height="1068" fill="#ffffff"/>
<text x="12" y="28" font-size="16" font-weight="bold">Rack rack01</text>
<text x="162" y="34" text-anchor="middle" font-weight="bold">front</text>
<rect x="42" y="40" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="60" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="80" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="100" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="120" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="140" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="160" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="180" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="200" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="220" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="240" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="260" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="280" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="300" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="320" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="340" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="360" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="380" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="400" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="420" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="440" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="460" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="480" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="500" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="520" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="540" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="560" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="580" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="600" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="620" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="640" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="660" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="680" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="700" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="720" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="740" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="760" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="780" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="800" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="820" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="840" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="860" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="880" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="900" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="920" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="940" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="960" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="42" y="980" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<text x="452" y="34" text-anchor="middle" font-weight="bold">rear</text>
<rect x="332" y="40" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="60" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="80" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="100" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="120" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="140" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="160" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="180" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="200" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="220" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="240" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="260" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="280" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="300" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="320" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="340" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="360" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="380" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="400" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="420" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="440" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="460" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="480" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="500" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="520" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="540" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="560" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="580" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="600" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="620" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="640" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="660" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="680" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="700" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="720" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="740" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="760" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="780" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="800" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="820" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="840" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="860" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="880" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="900" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="920" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="940" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="960" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="332" y="980" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<text x="38" y="54" text-anchor="end" fill="#999">1</text>
<text x="38" y="74" text-anchor="end" fill="#999">2</text>
<text x="38" y="94" text-anchor="end" fill="#999">3</text>
<text x="38" y="114" text-anchor="end" fill="#999">4</text>
<text x="38" y="134" text-anchor="end" fill="#999">5</text>
<text x="38" y="154" text-anchor="end" fill="#999">6</text>
<text x="38" y="174" text-anchor="end" fill="#999">7</text>
<text x="38" y="194" text-anchor="end" fill="#999">8</text>
<text x="38" y="214" text-anchor="end" fill="#999">9</text>
<text x="38" y="234" text-anchor="end" fill="#999">10</text>
<text x="38" y="254" text-anchor="end" fill="#999">11</text>
<text x="38" y="274" text-anchor="end" fill="#999">12</text>
<text x="38" y="294" text-anchor="end" fill="#999">13</text>
<text x="38" y="314" text-anchor="end" fill="#999">14</text>
<text x="38" y="334" text-anchor="end" fill="#999">15</text>
<text x="38" y="354" text-anchor="end" fill="#999">16</text>
<text x="38" y="374" text-anchor="end" fill="#999">17</text>
<text x="38" y="394" text-anchor="end" fill="#999">18</text>
<text x="38" y="414" text-anchor="end" fill="#999">19</text>
<text x="38" y="434" text-anchor="end" fill="#999">20</text>
<text x="38" y="454" text-anchor="end" fill="#999">21</text>
<text x="38" y="474" text-anchor="end" fill="#999">22</text>
<text x="38" y="494" text-anchor="end" fill="#999">23</text>
<text x="38" y="514" text-anchor="end" fill="#999">24</text>
<text x="38" y="534" text-anchor="end" fill="#999">25</text>
<text x="38" y="554" text-anchor="end" fill="#999">26</text>
<text x="38" y="574" text-anchor="end" fill="#999">27</text>
<text x="38" y="594" text-anchor="end" fill="#999">28</text>
<text x="38" y="614" text-anchor="end" fill="#999">29</text>
<text x="38" y="634" text-anchor="end" fill="#999">30</text>
<text x="38" y="654" text-anchor="end" fill="#999">31</text>
<text x="38" y="674" text-anchor="end" fill="#999">32</text>
<text x="38" y="694" text-anchor="end" fill="#999">33</text>
<text x="38" y="714" text-anchor="end" fill="#999">34</text>
<text x="38" y="734" text-anchor="end" fill="#999">35</text>
<text x="38" y="754" text-anchor="end" fill="#999">36</text>
<text x="38" y="774" text-anchor="end" fill="#999">37</text>
<text x="38" y="794" text-anchor="end" fill="#999">38</text>
<text x="38" y="814" text-anchor="end" fill="#999">39</text>
<text x="38" y="834" text-anchor="end" fill="#999">40</text>
<text x="38" y="854" text-anchor="end" fill="#999">41</text>
<text x="38" y="874" text-anchor="end" fill="#999">42</text>
<text x="38" y="894" text-anchor="end" fill="#999">43</text>
<text x="38" y="914" text-anchor="end" fill="#999">44</text>
<text x="38" y="934" text-anchor="end" fill="#999">45</text>
<text x="38" y="954" text-anchor="end" fill="#999">46</text>
<text x="38" y="974" text-anchor="end" fill="#999">47</text>
<text x="38" y="994" text-anchor="end" fill="#999">48</text>
<text x="576" y="54" text-anchor="start" fill="#999">1</text>
<text x="576" y="74" text-anchor="start" fill="#999">2</text>
<text x="576" y="94" text-anchor="start" fill="#999">3</text>
<text x="576" y="114" text-anchor="start" fill="#999">4</text>
<text x="576" y="134" text-anchor="start" fill="#999">5</text>
<text x="576" y="154" text-anchor="start" fill="#999">6</text>
<text x="576" y="174" text-anchor="start" fill="#999">7</text>
<text x="576" y="194" text-anchor="start" fill="#999">8</text>
<text x="576" y="214" text-anchor="start" fill="#999">9</text>
<text x="576" y="234" text-anchor="start" fill="#999">10</text>
<text x="576" y="254" text-anchor="start" fill="#999">11</text>
<text x="576" y="274" text-anchor="start" fill="#999">12</text>
<text x="576" y="294" text-anchor="start" fill="#999">13</text>
<text x="576" y="314" text-anchor="start" fill="#999">14</text>
<text x="576" y="334" text-anchor="start" fill="#999">15</text>
<text x="576" y="354" text-anchor="start" fill="#999">16</text>
<text x="576" y="374" text-anchor="start" fill="#999">17</text>
<text x="576" y="394" text-anchor="start" fill="#999">18</text>
<text x="576" y="414" text-anchor="start" fill="#999">19</text>
<text x="576" y="434" text-anchor="start" fill="#999">20</text>
<text x="576" y="454" text-anchor="start" fill="#999">21</text>
<text x="576" y="474" text-anchor="start" fill="#999">22</text>
<text x="576" y="494" text-anchor="start" fill="#999">23</text>
<text x="576" y="514" text-anchor="start" fill="#999">24</text>
<text x="576" y="534" text-anchor="start" fill="#999">25</text>
<text x="576" y="554" text-anchor="start" fill="#999">26</text>
<text x="576" y="574" text-anchor="start" fill="#999">27</text>
<text x="576" y="594" text-anchor="start" fill="#999">28</text>
<text x="576" y="614" text-anchor="start" fill="#999">29</text>
<text x="576" y="634" text-anchor="start" fill="#999">30</text>
<text x="576" y="654" text-anchor="start" fill="#999">31</text>
<text x="576" y="674" text-anchor="start" fill="#999">32</text>
<text x="576" y="694" text-anchor="start" fill="#999">33</text>
<text x="576" y="714" text-anchor="start" fill="#999">34</text>
<text x="576" y="734" text-anchor="start" fill="#999">35</text>
<text x="576" y="754" text-anchor="start" fill="#999">36</text>
<text x="576" y="774" text-anchor="start" fill="#999">37</text>
<text x="576" y="794" text-anchor="start" fill="#999">38</text>
<text x="576" y="814" text-anchor="start" fill="#999">39</text>
<text x="576" y="834" text-anchor="start" fill="#999">40</text>
<text x="576" y="854" text-anchor="start" fill="#999">41</text>
<text x="576" y="874" text-anchor="start" fill="#999">42</text>
<text x="576" y="894" text-anchor="start" fill="#999">43</text>
<text x="576" y="914" text-anchor="start" fill="#999">44</text>
<text x="576" y="934" text-anchor="start" fill="#999">45</text>
<text x="576" y="954" text-anchor="start" fill="#999">46</text>
<text x="576" y="974" text-anchor="start" fill="#999">47</text>
<text x="576" y="994" text-anchor="start" fill="#999">48</text>
<rect x="42" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<rect x="332" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<a href="/hardware/srv01/">
<title>srv01 · server · in-use · cluster: tappaas · shf01/front/slot 1</title>
<rect x="43" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5"/>
<text x="102" y="861" text-anchor="middle" fill="#ffffff">srv01</text>
</a>
<a href="/hardware/srv02/">
<title>srv02 · server · staging · cluster: tappaas · shf01/front/slot 2</title>
<rect x="163" y="801" width="118" height="152" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="222" y="881" text-anchor="middle" fill="#ffffff">srv02</text>
</a>
<a href="/hardware/srv03/">
<title>srv03 · server · staging · cluster: tappaas · shf01/rear/slot 1</title>
<rect x="333" y="841" width="238" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="452" y="901" text-anchor="middle" fill="#ffffff">srv03</text>
</a>
<a href="/hardware/shf01/">
<title>shf01 · shelf · in-use · cluster: tappaas · U46</title>
<rect x="42" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="959" text-anchor="middle" fill="#333" font-size="9">shf01</text>
</a>
<a href="/hardware/srv07/">
<title>srv07 · server · staging · cluster: tappaas · shf02/front/slot 1</title>
<rect x="43" y="621" width="118" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="102" y="681" text-anchor="middle" fill="#ffffff">srv07</text>
</a>
<a href="/hardware/srv04/">
<title>srv04 · server · staging · cluster: — · shf02/front/slot 2</title>
<rect x="163" y="621" width="118" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="222" y="681" text-anchor="middle" fill="#ffffff">srv04</text>
</a>
<a href="/hardware/srv05/">
<title>srv05 · server · staging · cluster: — · shf02/rear/slot 1</title>
<rect x="333" y="601" width="118" height="132" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="392" y="671" text-anchor="middle" fill="#ffffff">srv05</text>
</a>
<a href="/hardware/srv06/">
<title>srv06 · server · staging · cluster: tappaas · shf02/rear/slot 2</title>
<rect x="453" y="601" width="118" height="132" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="512" y="671" text-anchor="middle" fill="#ffffff">srv06</text>
</a>
<a href="/hardware/shf02/">
<title>shf02 · shelf · in-use · cluster: — · U35</title>
<rect x="42" y="734" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="734" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="739" text-anchor="middle" fill="#333" font-size="9">shf02</text>
</a>
<a href="/hardware/shf03/">
<title>shf03 · shelf · in-use · cluster: — · U21</title>
<rect x="42" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="459" text-anchor="middle" fill="#333" font-size="9">shf03</text>
</a>
<a href="/hardware/shf04/">
<title>shf04 · shelf · in-use · cluster: — · U21</title>
<rect x="42" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="459" text-anchor="middle" fill="#333" font-size="9">shf04</text>
</a>
<a href="/hardware/pdu01/">
<title>pdu01 · pdu · in-use · cluster: — · U1</title>
<rect x="333" y="41" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="54" text-anchor="middle" fill="#ffffff">pdu01 (U1)</text>
</a>
<a href="/hardware/ups02/">
<title>ups02 · ups · staging · cluster: — · U3</title>
<rect x="43" y="81" width="238" height="18" rx="3" fill="#edc948" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="94" text-anchor="middle" fill="#ffffff">ups02 (U3)</text>
</a>
<a href="/hardware/ups01/">
<title>ups01 · ups · staging · cluster: — · U4</title>
<rect x="43" y="101" width="238" height="18" rx="3" fill="#edc948" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="114" text-anchor="middle" fill="#ffffff">ups01 (U4)</text>
</a>
<a href="/hardware/sw04/">
<title>sw04 · switch · staging · cluster: — · U5</title>
<rect x="43" y="121" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="134" text-anchor="middle" fill="#ffffff">sw04 (U5)</text>
</a>
<a href="/hardware/nas01/">
<title>nas01 · server · staging · cluster: tappaas · U6</title>
<rect x="43" y="141" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="154" text-anchor="middle" fill="#ffffff">nas01 (U6)</text>
</a>
<a href="/hardware/nas02/">
<title>nas02 · server · staging · cluster: tappaas · U7</title>
<rect x="43" y="161" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="174" text-anchor="middle" fill="#ffffff">nas02 (U7)</text>
</a>
<a href="/hardware/sw05/">
<title>sw05 · switch · staging · cluster: — · U8</title>
<rect x="43" y="181" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="194" text-anchor="middle" fill="#ffffff">sw05 (U8)</text>
</a>
<a href="/hardware/sw02/">
<title>sw02 · switch · staging · cluster: — · U9</title>
<rect x="43" y="201" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="214" text-anchor="middle" fill="#ffffff">sw02 (U9)</text>
</a>
<a href="/hardware/sw03/">
<title>sw03 · switch · staging · cluster: — · U10</title>
<rect x="43" y="221" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="234" text-anchor="middle" fill="#ffffff">sw03 (U10)</text>
</a>
<a href="/hardware/pdu02/">
<title>pdu02 · pdu · in-use · cluster: — · U12</title>
<rect x="333" y="261" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="274" text-anchor="middle" fill="#ffffff">pdu02 (U12)</text>
</a>
<a href="/hardware/pdu04/">
<title>pdu04 · pdu · in-use · cluster: — · U12</title>
<rect x="43" y="261" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="274" text-anchor="middle" fill="#ffffff">pdu04 (U12)</text>
</a>
<a href="/hardware/sw01/">
<title>sw01 · switch · in-use · cluster: — · U23</title>
<rect x="43" y="481" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="494" text-anchor="middle" fill="#ffffff">sw01 (U23)</text>
</a>
<a href="/hardware/pp01/">
<title>pp01 · patch-panel · in-use · cluster: — · U24</title>
<rect x="43" y="501" width="238" height="18" rx="3" fill="#9c755f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="514" text-anchor="middle" fill="#ffffff">pp01 (U24)</text>
</a>
<a href="/hardware/pp02/">
<title>pp02 · patch-panel · in-use · cluster: — · U25</title>
<rect x="43" y="521" width="238" height="18" rx="3" fill="#9c755f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="534" text-anchor="middle" fill="#ffffff">pp02 (U25)</text>
</a>
<a href="/hardware/pdu03/">
<title>pdu03 · pdu · in-use · cluster: — · U34</title>
<rect x="333" y="701" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="714" text-anchor="middle" fill="#ffffff">pdu03 (U34)</text>
</a>
<text x="42" y="1020" font-weight="bold">Legend</text>
<rect x="42" y="1028" width="12" height="12" fill="#9c755f" stroke="#333"/>
<text x="58" y="1038">patch-panel</text>
<rect x="147" y="1028" width="12" height="12" fill="#e15759" stroke="#333"/>
<text x="163" y="1038">pdu</text>
<rect x="196" y="1028" width="12" height="12" fill="#4c78a8" stroke="#333"/>
<text x="212" y="1038">server</text>
<rect x="266" y="1028" width="12" height="12" fill="#bab0ac" stroke="#333"/>
<text x="282" y="1038">shelf</text>
<rect x="329" y="1028" width="12" height="12" fill="#59a14f" stroke="#333"/>
<text x="345" y="1038">switch</text>
<rect x="399" y="1028" width="12" height="12" fill="#edc948" stroke="#333"/>
<text x="415" y="1038">ups</text>
<rect x="42" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5"/>
<text x="58" y="1056">in-use</text>
<rect x="112" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="128" y="1056">staging</text>
<rect x="189" y="1046" width="12" height="12" fill="#ffffff" stroke="#e15759" stroke-width="3"/>
<text x="205" y="1056">broken</text>
<rect x="259" y="1046" width="12" height="12" fill="#ffffff" stroke="#bbbbbb" stroke-width="1.5"/>
<text x="275" y="1056">spare</text>
</svg>
</div>
[Download SVG](rack01-elevation.svg)
## Power
```mermaid
flowchart LR
pdu01["pdu01<br/>9 outlets"]
pdu02["pdu02<br/>5 outlets"]
pdu03["pdu03<br/>11 outlets"]
pdu04["pdu04<br/>5 outlets"]
srv01["srv01"]
srv02["srv02"]
srv03["srv03"]
srv04["srv04"]
srv05["srv05"]
srv06["srv06"]
srv07["srv07"]
nas01["nas01"]
nas02["nas02"]
pdu01 -->|outlet 1| nas01
pdu01 -->|outlet 1| nas02
pdu01 -->|outlet 1| srv01
pdu01 -->|outlet 1| srv06
pdu01 -->|outlet 1| srv07
pdu01 -->|outlet 2| srv02
pdu01 -->|outlet 3| srv03
pdu01 -->|outlet 4| srv04
pdu01 -->|outlet 5| srv05
pdu02 -->|outlet 1| nas01
pdu02 -->|outlet 1| nas02
pdu02 -->|outlet 1| srv06
pdu02 -->|outlet 1| srv07
style nas01 fill:#4c78a8,stroke:#333,color:#ffffff
click nas01 "/hardware/nas01/"
style nas02 fill:#4c78a8,stroke:#333,color:#ffffff
click nas02 "/hardware/nas02/"
style pdu01 fill:#e15759,stroke:#333,color:#ffffff
click pdu01 "/hardware/pdu01/"
style pdu02 fill:#e15759,stroke:#333,color:#ffffff
click pdu02 "/hardware/pdu02/"
style pdu03 fill:#e15759,stroke:#333,color:#ffffff
click pdu03 "/hardware/pdu03/"
style pdu04 fill:#e15759,stroke:#333,color:#ffffff
click pdu04 "/hardware/pdu04/"
style srv01 fill:#4c78a8,stroke:#333,color:#ffffff
click srv01 "/hardware/srv01/"
style srv02 fill:#4c78a8,stroke:#333,color:#ffffff
click srv02 "/hardware/srv02/"
style srv03 fill:#4c78a8,stroke:#333,color:#ffffff
click srv03 "/hardware/srv03/"
style srv04 fill:#4c78a8,stroke:#333,color:#ffffff
click srv04 "/hardware/srv04/"
style srv05 fill:#4c78a8,stroke:#333,color:#ffffff
click srv05 "/hardware/srv05/"
style srv06 fill:#4c78a8,stroke:#333,color:#ffffff
click srv06 "/hardware/srv06/"
style srv07 fill:#4c78a8,stroke:#333,color:#ffffff
click srv07 "/hardware/srv07/"
```
## Network
```mermaid
flowchart LR
pp01["pp01<br/>patch-panel"]
pp02["pp02<br/>patch-panel"]
srv01["srv01"]
srv02["srv02"]
srv03["srv03"]
srv04["srv04"]
srv05["srv05"]
srv06["srv06"]
srv07["srv07"]
sw01["sw01<br/>switch"]
wan01["wan01"]
pp01 -->|1 → p1| wan01
pp02 -->|1 → p1| pp01
pp02 -->|2 → p1| sw01
pp02 -->|3 → p2| sw01
pp02 -->|4 → p3| sw01
pp02 -->|5 → p4| sw01
pp02 -->|6 → p5| sw01
pp02 -->|7 → p6| sw01
pp02 -->|8 → p7| sw01
srv01 -->|eth0 → p1 · 1G| pp02
srv01 -->|eth1 → p2 · 1G| pp02
srv02 -->|eth0 → p3 · 1G| pp02
srv03 -->|eth0 → p4 · 1G| pp02
srv04 -->|eth0 → p5 · 1G| pp02
srv05 -->|eth0 → p6 · 1G| pp02
srv06 -->|eth0 → p7 · 1G| pp02
srv07 -->|eth0 → p8 · 1G| pp02
style pp01 fill:#9c755f,stroke:#333,color:#ffffff
click pp01 "/hardware/pp01/"
style pp02 fill:#9c755f,stroke:#333,color:#ffffff
click pp02 "/hardware/pp02/"
style srv01 fill:#4c78a8,stroke:#333,color:#ffffff
click srv01 "/hardware/srv01/"
style srv02 fill:#4c78a8,stroke:#333,color:#ffffff
click srv02 "/hardware/srv02/"
style srv03 fill:#4c78a8,stroke:#333,color:#ffffff
click srv03 "/hardware/srv03/"
style srv04 fill:#4c78a8,stroke:#333,color:#ffffff
click srv04 "/hardware/srv04/"
style srv05 fill:#4c78a8,stroke:#333,color:#ffffff
click srv05 "/hardware/srv05/"
style srv06 fill:#4c78a8,stroke:#333,color:#ffffff
click srv06 "/hardware/srv06/"
style srv07 fill:#4c78a8,stroke:#333,color:#ffffff
click srv07 "/hardware/srv07/"
style sw01 fill:#59a14f,stroke:#333,color:#ffffff
click sw01 "/hardware/sw01/"
style wan01 fill:#888888,stroke:#333,color:#ffffff
```
## Occupancy
| U | Device | Kind | Face | Status |
|---|---|---|---|---|
| U1 | [pdu01](../../hardware/pdu01.md) | pdu | rear | in-use |
| U3 | [ups02](../../hardware/ups02.md) | ups | front | staging |
| U4 | [ups01](../../hardware/ups01.md) | ups | front | staging |
| U5 | [sw04](../../hardware/sw04.md) | switch | front | staging |
| U6 | [nas01](../../hardware/nas01.md) | server | front | staging |
| U7 | [nas02](../../hardware/nas02.md) | server | front | staging |
| U8 | [sw05](../../hardware/sw05.md) | switch | front | staging |
| U9 | [sw02](../../hardware/sw02.md) | switch | front | staging |
| U10 | [sw03](../../hardware/sw03.md) | switch | front | staging |
| U12 | [pdu02](../../hardware/pdu02.md) | pdu | rear | in-use |
| U12 | [pdu04](../../hardware/pdu04.md) | pdu | front | in-use |
| U21 | [shf03](../../hardware/shf03.md) | shelf | front | in-use |
| U21 | [shf04](../../hardware/shf04.md) | shelf | rear | in-use |
| U23 | [sw01](../../hardware/sw01.md) | switch | front | in-use |
| U24 | [pp01](../../hardware/pp01.md) | patch-panel | front | in-use |
| U25 | [pp02](../../hardware/pp02.md) | patch-panel | front | in-use |
| U34 | [pdu03](../../hardware/pdu03.md) | pdu | rear | in-use |
| U35 | [shf02](../../hardware/shf02.md) | shelf | both | in-use |
| U30U35 | [srv07](../../hardware/srv07.md) | server | front · shf02/1 | staging |
| U30U35 | [srv04](../../hardware/srv04.md) | server | front · shf02/2 | staging |
| U29U35 | [srv05](../../hardware/srv05.md) | server | rear · shf02/1 | staging |
| U29U35 | [srv06](../../hardware/srv06.md) | server | rear · shf02/2 | staging |
| U46 | [shf01](../../hardware/shf01.md) | shelf | both | in-use |
| U37U46 | [srv01](../../hardware/srv01.md) | server | front · shf01/1 | in-use |
| U39U46 | [srv02](../../hardware/srv02.md) | server | front · shf01/2 | staging |
| U41U46 | [srv03](../../hardware/srv03.md) | server | rear · shf01/1 | staging |

View file

@ -1,178 +0,0 @@
---
marp: true
pagination: true
size: 16:9
---
<!--
Option 2 (larger canvas — NOT applied here): the `size:` frontmatter
only accepts sizes the active theme has declared via @size. The
bundled `default`/`gaia` themes only declare 16:9 (1280x720) and 4:3
(960x720). To get a 1920x1080 canvas you must register a custom
theme via marp.config.mjs / --theme that contains
@size fhd 1920px 1080px;
and then reference it as `size: fhd`. Inline <style> blocks cannot
declare theme metadata, so a one-file demo of option 2 isn't possible
without touching the build pipeline.
-->
<style>
/* Option 5: make each slide a query container so children can size
themselves relative to the slide (1cqh = 1% of slide height). */
section {
container-type: size;
font-size: clamp(0.9rem, 2.4cqh, 1.6rem);
}
section h1 { font-size: clamp(1.8rem, 5.5cqh, 3.6rem); }
section h2 { font-size: clamp(1.4rem, 4.2cqh, 2.8rem); }
section h3 { font-size: clamp(1.2rem, 3.4cqh, 2.2rem); }
/* Option 4: cap mermaid by BOTH width and height so tall diagrams
shrink to fit instead of overflowing the slide vertically.
85cqh = at most 85% of slide height, leaving room for the heading. */
.mermaid svg {
max-width: 100% !important;
max-height: 85cqh !important;
width: auto !important;
height: auto !important;
}
</style>
# Introduction
This is assorted notes on what could go into the MakerFLOSS lab
---
# Requirements
- A space to experiment with new software
- A place where software could be "test run" for some time
- A place where errors are not causing IP loss
- even if errors are real big !!
## More details
- Firewalled off from the production network
- Accessible from outside
- Potential for exposing services externally
---
## Constraints
- Cost conscious
- Support constant change
---
# Proposals
## Short term
A VPS in a (European) cloud with one public IP
---
## Midterm
Complement the VPS with some local hardware:
- Firewall with zones (VLANs, DNS/DHCP)
- Netbird access to services in Lab
- Switching infrastructure
- A primary "stable" Proxmox host
- A secondary experimentation machine
- A backup server
- Tunnel for external access via VPS public IP
---
### Basic Services in Lab
- Git: Forgejo
- ...
---
### Lab Diagram
```mermaid
graph LR
subgraph External
Internet[🌐 Internet]
VPS[FLOSS VPS<br/>88.99.32.236]
end
subgraph OrangeMaker["Orange Makerspace"]
OMFirewall[OrangeMaker Firewall]
ProdNet[Production Network]
end
subgraph FLOSSLab["MakerFLOSS Lab"]
Switch[Switch]
Proxmox1[LabZone 1<br/>Test Proxmox]
Proxmox2[LabZone 2<br/>Experimental]
subgraph TAPPaaS
FLOSSFirewall[MakerFLOSS Firewall<br/>DNS/DHCP/VLANs]
PreProd[Pre-production Zone]
Backup[Backup Server]
end
end
Internet --> VPS
Internet --> OMFirewall
VPS -.->|Tunnel| FLOSSFirewall
VPS -.->|Netbird| FLOSSFirewall
OMFirewall --> ProdNet
OMFirewall --> FLOSSFirewall
FLOSSFirewall --> Switch
FLOSSFirewall --> PreProd
Switch --> Proxmox1
Switch --> Proxmox2
Switch --> Backup
```
---
### TAPPaaS Diagram
```mermaid
graph TB
subgraph TAPPaaS
subgraph Firewall["Firewall"]
Zones[Zones]
Caddy[Caddy]
Certs[Certificates]
DHCPDNS[DHCP/DNS]
end
subgraph PreProd["Pre-Production"]
Proxmox[Proxmox]
Authentik[Authentik]
CICD[CI/CD]
Forgejo[Forgejo]
More[...]
end
subgraph BackupSrv["Backup"]
BackupService[PBS Backup Service]
end
end
Firewall --> PreProd
Firewall --> BackupSrv
```
---
## Long term
replace VPS with a direct IP pinhole access
replace the "stable" FLOSS services running on VPS with modules runing on "stable" machine locally
# Design of Mid term solution

View file

@ -1,188 +0,0 @@
---
marp: true
theme: gaia
class: invert
paginate: true
size: 16:9
---
<!--
Option 2 (larger canvas — NOT applied here): the `size:` directive
only accepts sizes the theme has declared via @size. Gaia ships only
16:9 (1280x720) and 4:3 (960x720). To get a 1920x1080 canvas you
must register a custom theme via marp.config.mjs / --theme containing
@size fhd 1920px 1080px;
and then set `size: fhd`. Inline <style> blocks cannot declare theme
metadata, so this can't be demoed in a single file.
-->
<style>
/* Option 5: each slide becomes a query container; tables size against
slide height (1cqh = 1% of slide height) instead of the root font. */
section {
container-type: size;
}
table { font-size: clamp(0.55rem, 1.9cqh, 1.1rem); }
th, td { padding: 0.25em 0.6em; }
section.dense table { font-size: clamp(0.45rem, 1.4cqh, 0.9rem); }
section.dense th, section.dense td { padding: 0.2em 0.5em; }
/* Option 4: cap mermaid by BOTH dimensions so tall diagrams shrink. */
.mermaid svg {
max-width: 100% !important;
max-height: 80cqh !important;
width: auto !important;
height: auto !important;
}
</style>
# Messaging Without Big Tech
### Free & Open Alternatives to WhatsApp and Messenger
MakerFLOSS · May 2026
---
## Why Are We Here?
Most people use WhatsApp, Messenger, or iMessage.
- **WhatsApp** — owned by Meta; metadata harvested
- **Messenger** — no E2EE by default in groups; ad tracking
- **Telegram**_not_ E2EE by default; closed server
- **iMessage** — Apple lock-in; no Android or Linux
These apps are _convenient_ — but the cost is your data.
---
## Wish-list
| Property | Why it matters |
| ------------------------- | ------------------------------------------- |
| End-to-end encryption | Only sender and recipient can read messages |
| Open source | Anyone can audit the code |
| Self-hostable | You control the server and the data |
| No phone number required | Less identity linkage |
| Cross-platform | Linux, Android, iOS, Windows |
| Federated / decentralized | No single point of failure or control |
---
## The Landscape at a Glance
_All apps below support end-to-end encryption._
| App | Open source | Self-host | No phone# | Federation |
| -------------------- | ----------- | --------- | --------- | ---------- |
| **Signal** | Partial | ✗ | ✗ | ✗ |
| **Matrix / Element** | ✓ | ✓ | ✓ | ✓ |
| **XMPP + OMEMO** | ✓ | ✓ | ✓ | ✓ |
| **Briar** | ✓ | N/A | ✓ | N/A |
| **Session** | ✓ | Partial | ✓ | Partial |
---
## Signal — The Gold Standard for E2EE
Non-profit Signal Foundation. The Signal Protocol powers WhatsApp, Google RCS, and Messenger secret chats.
**Pros**
- Simplest UX — works like a normal messaging app
- Audited, battle-tested cryptography; no ads, no tracking
**Cons**
- Phone number required — links identity to account
- Centralized — Signal's servers, Signal's rules
---
## Signal — Under the Hood
```mermaid
sequenceDiagram
participant A as Alice's phone
participant S as Signal Server
participant B as Bob's phone
A->>S: encrypted message
Note over S: sees: who, when, how often<br/>does NOT see: content
S->>B: encrypted message
Note over B: decrypts with private key
```
Metadata still matters — [Signal subpoena responses](https://signal.org/bigbrother/)
---
## Matrix — The Federated Open Standard
Matrix is a **protocol**, not an app — like email for real-time chat.
```mermaid
graph LR
EC[Element client] --> YH[your homeserver]
YH <-->|federation| OH[another homeserver]
FC[FluffyChat] --> OH
```
- **Servers**: Synapse (Python), Conduit (Rust), Dendrite (Go)
- **Clients**: Element, FluffyChat, Cinny, Fractal, Nheko
- **Bridges**: WhatsApp, Telegram, Signal, IRC, Discord…
---
## Matrix — Pros and Cons
**Pros**
- Fully open source, top to bottom
- Self-host your server — you own your data
- Federated — no single company controls the network
- Bridges consolidate all your chats in one place
**Cons**
- E2EE key management is clunky (cross-signing, key backup)
- Synapse is resource-hungry (~1 GB RAM)
- The UX of Element is still maturing
---
## Matrix Bridges — Stay Connected During the Transition
A bridge relays messages between Matrix and another network — both ways.
| Bridge | Network | Notes |
| ------------------------- | ---------- | ------------------------------------------ |
| `mautrix-whatsapp` | WhatsApp | Puppeting — your real WA account |
| `mautrix-telegram` | Telegram | Puppeting — very stable |
| `mautrix-signal` | Signal | Fragile — Signal actively breaks 3rd-party |
| `meshtastic-matrix-relay` | Meshtastic | LoRa mesh ↔ Matrix — off-grid messaging |
**Catch:** Puppeting bridges hold your credentials. WhatsApp's ToS prohibits it — occasional bans occur.
---
## XMPP (Jabber)
The _original_ federated chat standard — 1999. Still alive and kicking.
- Extremely mature and lightweight
- E2EE via OMEMO
- Good clients: **Conversations** (Android), **Monal** (iOS/macOS), **Gajim** (desktop)
- Con: fragmented client quality; less beginner-friendly than Signal or Matrix
---
## Briar
Peer-to-peer messaging — _no server at all_.
- Works over Tor, local WiFi, or Bluetooth (offline!)
- Censorship-resistant by design
- Con: Android-first; no desktop client; both parties must be online to first connect
**For:** activists, disaster scenarios, high-censorship environments

View file

@ -0,0 +1,184 @@
---
marp: true
theme: gaia
class: invert
paginate: true
---
<style>
section { font-size: 25px; line-height: 1.28; }
h1 { font-size: 1.55em; margin-bottom: 0.3em; }
h3 { font-size: 1.05em; }
ul, ol { margin: 0.2em 0; }
li { margin: 0.12em 0; }
li > ul, li > ol { margin: 0.04em 0; }
pre { margin: 0.25em 0; }
pre code { font-size: 0.72em; line-height: 1.25; }
code { font-size: 0.82em; }
table { font-size: 0.62em; }
th, td { padding: 0.22em 0.55em; }
section.dense { font-size: 21px; }
section.dense table { font-size: 0.5em; }
.mermaid svg { max-width: 100% !important; height: auto !important; }
</style>
# Routing TaPPaaS through the VPS
### Split-horizon DNS · public exposure on a static IP
MakerFLOSS · June 2026 · _technical review_
---
## The goal
Publish selected **TaPPaaS** web services under `*.tappaas.makerfloss.eu`.
- **Public exposure** — A records point at the VPS static IP `88.99.32.236`,
so the cluster's services are reachable from anywhere.
- **Split-horizon DNS** — the _same_ hostname resolves to a **local** address
for internal clients, so they never round-trip to the VPS.
- **Isolation preserved** — nothing on the makerspace side egresses through
the VPS or reaches the homelab.
> We are not inventing anything — we clone a pattern already running.
---
## Building block: the proven `mf01` pattern
`mf01` already publishes `*.mf01.makerfloss.eu` exactly this way, live since
2026-06-09:
- TLS terminates on the **VPS** (wildcard cert, Gandi DNS-01).
- Plain HTTP rides the **`wg1`** WireGuard tunnel to an internal reverse proxy.
- The internal proxy routes by **Host** to the right container.
**TaPPaaS = the same shape**, with Caddy as the internal proxy instead of an
internal Traefik. Low risk, known gotchas already solved.
---
## External request flow
```mermaid
flowchart LR
C["Browser<br/>(off-site)"] -->|"https · *.tappaas<br/>DNS → 88.99.32.236"| T["VPS Traefik :443<br/>wildcard cert<br/>TLS ends here"]
T -->|"plain HTTP<br/>inside wg1"| K["FLOSSFirewall / Caddy<br/>10.13.0.9:80"]
K -->|"route by Host"| S["TaPPaaS service<br/>srv01 / srv02 / srv03"]
```
TLS terminates at the VPS; the backend hop is plain HTTP but **encrypted on the
wire inside WireGuard**.
---
## Internal flow — two views, one name
```mermaid
flowchart TD
N["&lt;svc&gt;.tappaas.makerfloss.eu"]
N -->|"public DNS (Gandi)"| V["VPS 88.99.32.236<br/>VPS wildcard cert"]
N -->|"internal DNS (FLOSSFirewall)"| L["Caddy local IP<br/>Caddy's own LE cert"]
```
A client only ever sees the cert for the IP it resolved — **VPS wildcard
externally, Caddy's own cert internally**. The two views never collide.
---
<!-- _class: invert dense -->
## Key decisions
| Decision | Choice | Why |
|---|---|---|
| Transport | New **`wg1`** WireGuard peer | Mirrors mf01; both ends self-owned; static hub-and-spoke |
| TLS (external) | **Terminate at the VPS** | Cert issuance while isolated kills passthrough; Gandi DNS-write key stays on the VPS; keeps edge routing/middleware |
| Naming | **Wildcard** `*.tappaas.makerfloss.eu` | One cert + one route; add services with zero VPS change; no apex clash (`forgejo.tappaas…``forgejo.makerfloss.eu`) |
| Internal DNS | **FLOSSFirewall** (already runs DNS), cluster first | LAN view deferred — depends on third-party router |
---
## Components — VPS side
All additive in `AnsibleBaobabV4/host_vars/makerfloss.yml`, mirroring mf01:
1. **wg1 peer** — FLOSSFirewall, `allowed_ips: 10.13.0.9/32`.
2. **Wildcard cert** — add `tappaas.makerfloss.eu` + `*.tappaas` to
`traefik_wildcard_sets` → ACME anchor issues it via Gandi DNS-01.
3. **Catch-all route**`tappaas-delegate.yml`:
`HostRegexp(...tappaas...)``http://10.13.0.9:80`, `passHostHeader: true`.
4. **Public DNS**`tappaas` + `*.tappaas` A → `88.99.32.236`.
After this, **new services need zero VPS change** — exposure is decided at Caddy.
---
## Components — TaPPaaS / Caddy side
1. **WireGuard client** — peer to the VPS hub, interface `10.13.0.9/32`,
`AllowedIPs = 10.13.0.0/24` only → **split-tunnel** (no general egress
through the VPS).
2. **Caddy plain-HTTP backend** — Host-routed listener on the wg interface
(`10.13.0.9:80`), **HTTPS-redirect OFF**. Caddy keeps serving `:443` with its
own certs to internal clients, unchanged.
3. **Firewall** — allow `tcp/80` **only from `10.13.0.1`** (the VPS) on the wg
interface.
---
## Phasing — VPS edge (steps 13)
1. **Tunnel** — FLOSSFirewall up as `wg1` peer; ping
`10.13.0.1 ↔ 10.13.0.9`.
2. **Caddy backend** — from the VPS,
`curl -H 'Host: …tappaas…' 10.13.0.9:80`.
3. **VPS edge** — add cert + route + DNS; off-site
`curl https://<svc>.tappaas.makerfloss.eu` returns a valid cert.
---
## Phasing — internal & later (steps 45)
4. **Internal DNS** — add the `*.tappaas` override on the FLOSSFirewall; a
cluster node resolves to Caddy's local IP and gets Caddy's own cert (no
VPS round-trip).
5. **(Later)** makerspace LAN view — conditional-forward + firewall pinhole
on the OrangeMakers router.
---
## Isolation — the hard requirement
- **Split-tunnel** (`AllowedIPs = 10.13.0.0/24`) — the cluster never egresses
through the VPS; nothing on `wg1` reaches the makerspace or homelab.
- VPS→Caddy locked to `tcp/80 from 10.13.0.1` on the wg interface.
- Backend hop encrypted inside WireGuard.
- **No public-DNS-write credential leaves the VPS** — the reason TLS terminates
there, not via passthrough.
---
## Risks & open items
- **Caddy redirect** — the wg-interface listener must _not_ force HTTPS-redirect,
or the VPS hits a loop (mf01's known gotcha).
- **YAML safety** — a syntax error in `tappaas-delegate.yml` breaks the whole
VPS file provider; keep the regex single-quoted.
- **`10.13.0.9` free?** — confirm before assigning.
- **TaPPaaS config repo TBD** — the FLOSSFirewall/Caddy/DNS config home is not
yet identified.
- **Phase 5** depends on OrangeMakers router cooperation.
---
## Summary & next steps
- Reuse the **proven mf01 pattern** — terminate TLS at the VPS, proxy over
`wg1` to TaPPaaS Caddy, split-horizon DNS for the internal view.
- VPS side is a small, additive, **zero-new-tech** change.
- **Next:** identify the TaPPaaS config repo, then write the implementation plan
and execute phases 14.
_Design: `MakerFLOSS_Troubleshooting/docs/superpowers/specs/2026-06-28-tappaas-vps-publishing-design.md`_

View file

@ -0,0 +1,352 @@
---
marp: true
theme: gaia
class: invert
paginate: true
---
<style>
table { font-size: 0.65em; }
th, td { padding: 0.25em 0.6em; }
section.dense table { font-size: 0.5em; }
section.dense th, section.dense td { padding: 0.2em 0.5em; }
</style>
# VPN Without Vendor Lock-in
### FLOSS Solutions for Secure Networking
MakerFLOSS · June 2026
---
## Why VPNs?
**Remote Access** — Securely connect to your home/office network from anywhere
**Site-to-Site** — Link multiple locations into one virtual network
**Zero Trust** — Replace traditional perimeter security with identity-based access
**Privacy** — Encrypt traffic on untrusted networks
---
## Traditional vs Modern VPNs
| Aspect | Traditional (IPSec, OpenVPN) | Modern (WireGuard-based) |
|--------|------------------------------|--------------------------|
| Codebase | 100k+ lines | ~4,000 lines |
| Speed | Good | Excellent |
| Configuration | Complex | Simple |
| Cryptography | Configurable (risk) | Fixed, modern |
| NAT traversal | Tricky | Built-in (UDP) |
| Battery/CPU | Higher overhead | Minimal |
WireGuard changed everything in 2020 when it was merged into Linux kernel.
---
## The Landscape at a Glance
| Solution | Type | Self-host | Fully FLOSS | NAT punch | UI |
|----------|------|-----------|-------------|-----------|-----|
| **WireGuard** | Protocol | N/A | ✓ | Manual | ✗ |
| **Pangolin** | Reverse proxy | ✓ | ✓ | Via Gerbil | ✓ |
| **Tailscale** | Mesh VPN | Partial | ✗ | DERP | ✓ |
| **Netbird** | Mesh VPN | ✓ | ✓ | STUN/TURN | ✓ |
---
## NAT Traversal Techniques
**Gerbil** (Pangolin)
Public-facing reverse proxy that accepts incoming connections and forwards them through WireGuard tunnels to internal Newt agents. Clients connect *out* to Gerbil.
**DERP** (Tailscale)
Designated Encrypted Relay for Packets — Tailscale's proprietary relay servers. Used when direct peer-to-peer fails. Traffic is encrypted end-to-end; relays see only ciphertext.
---
## NAT Traversal Techniques
**STUN/TURN** (Netbird, standard)
- **STUN**: Discovers your public IP and port mapping — enables direct connections
- **TURN**: Relay fallback when direct connection impossible (strict NAT/firewall)
---
## WireGuard — The Foundation
WireGuard is a **protocol**, not a product. It's the building block the others use.
**Key properties:**
- In-kernel since Linux 5.6 (2020)
- ~4,000 lines of code — auditable
- Cryptographically opinionated: Curve25519, ChaCha20, Poly1305
- Silent by default — no response to unauthenticated packets
- Roaming — endpoints can change IP seamlessly
---
## WireGuard — How It Works
```mermaid
graph LR
subgraph "Peer A (10.0.0.1)"
A[wg0 interface]
end
subgraph "Peer B (10.0.0.2)"
B[wg0 interface]
end
A <-->|"encrypted UDP"| B
```
Each peer has:
- A **private key** (never leaves the device)
- A **public key** (shared with peers)
- An **allowed IPs** list (what traffic goes through the tunnel)
No central server required — but someone has to distribute configs.
---
## WireGuard — Pros and Cons
**Pros**
- Blazing fast, low latency
- Simple config files
- Kernel-level performance
- Battle-tested cryptography
**Cons**
- No built-in key distribution
- No NAT traversal coordination
- No access control policies
- No management UI
**Best for:** sysadmins who want full control, site-to-site links
---
## Pangolin — Self-Hosted Reverse Proxy
Pangolin is a **reverse proxy** and tunneling solution, not a traditional VPN.
**Architecture:**
- **Pangolin** — Central server with web UI and proxy
- **Gerbil** — Public-facing proxy (handles NAT traversal)
- **Newt** — Agent on each client (creates WireGuard tunnel)
**Use case:** Expose internal services to the internet securely without opening ports.
---
## Pangolin — Architecture
```mermaid
graph TB
Internet[Internet] --> Gerbil[Gerbil Proxy]
Gerbil --> Pangolin[Pangolin Server]
Pangolin --> Newt1[Newt Agent]
Pangolin --> Newt2[Newt Agent]
Newt1 --> Service1[Internal Service]
Newt2 --> Service2[Internal Service]
```
Traffic flows: Internet → Gerbil → Pangolin → Newt → Your service
No port forwarding needed on the client side.
---
## Pangolin — Pros and Cons
**Pros**
- Fully self-hosted and FLOSS (Apache 2.0)
- Web UI for managing sites and users
- Automatic HTTPS via Let's Encrypt
- Works behind any NAT
- SSO integration (OIDC)
**Cons**
- Not a mesh VPN — hub-and-spoke only
- Relatively new project
- Requires a public-facing server
**Best for:** exposing self-hosted services, homelab access
---
## Tailscale — The Polished Option
Tailscale builds a **mesh VPN** on top of WireGuard with zero configuration.
**How it works:**
- Coordination server distributes keys and handles NAT traversal
- Devices connect directly when possible (peer-to-peer)
- Falls back to DERP relays when direct connection fails
- MagicDNS provides automatic DNS for all devices
---
## Tailscale — Architecture
```mermaid
graph TB
Coord[Coordination Server] -.->|key exchange| A
Coord -.->|key exchange| B
Coord -.->|key exchange| C
A[Device A] <-->|"direct WireGuard"| B[Device B]
A <-->|"via DERP relay"| C[Device C]
DERP[DERP Relay] --> C
```
Direct connections when possible, relayed when behind strict NAT.
---
## Tailscale — Pros and Cons
**Pros**
- Zero-config setup — just install and sign in
- Excellent NAT traversal
- Cross-platform (Linux, macOS, Windows, iOS, Android)
- MagicDNS and HTTPS certificates
- ACLs and SSO
**Cons**
- Coordination server is **not open source**
- Free tier limited; business features require subscription
- Vendor lock-in concern
**Alternative:** Headscale — FLOSS coordination server (community project)
---
## Netbird — Self-Hosted Mesh VPN
Netbird is a **fully FLOSS** alternative to Tailscale with self-hosting support.
**Components:**
- **Management Server** — handles key distribution, ACLs
- **Signal Server** — coordinates peer connections
- **STUN/TURN** — NAT traversal (coturn)
- **Netbird Agent** — runs on each device
---
## Netbird — Architecture
```mermaid
graph TB
Mgmt[Management Server] -.->|policies, keys| A
Mgmt -.->|policies, keys| B
Signal[Signal Server] -.->|peer discovery| A
Signal -.->|peer discovery| B
A[Device A] <-->|"direct WireGuard"| B[Device B]
TURN[TURN Relay] -.->|fallback| A
```
Self-host everything or use their managed service.
---
## Netbird — Pros and Cons
**Pros**
- Fully FLOSS (BSD-3-Clause)
- Self-hostable control plane
- Web UI for management
- SSO integration (OIDC, SAML)
- Network policies and ACLs
- **Built-in reverse proxy** (v0.65+) — expose services publicly like Pangolin
- Active development
**Cons**
- More complex to self-host than Tailscale to use
- Younger project than Tailscale
- Smaller community
**Best for:** organizations wanting Tailscale-like UX with full control
---
<!-- _class: dense -->
## Detailed Comparison
| Feature | WireGuard | Pangolin | Tailscale | Netbird |
|---------|-----------|----------|-----------|---------|
| **License** | GPL | Apache 2.0 | Proprietary* | BSD-3 |
| **Self-host control plane** | N/A | ✓ | Via Headscale | ✓ |
| **Mesh networking** | Manual | ✗ | ✓ | ✓ |
| **NAT traversal** | Manual | ✓ (Gerbil) | ✓ (DERP) | ✓ (TURN) |
| **Web UI** | ✗ | ✓ | ✓ | ✓ |
| **SSO (OIDC/SAML)** | ✗ | ✓ | ✓ | ✓ |
| **ACLs / Policies** | ✗ | ✓ | ✓ | ✓ |
| **Reverse proxy mode** | ✗ | ✓ | ✗ | ✓ (v0.65+) |
*Tailscale clients are open source, coordination server is not.
---
## Which Should You Choose?
**WireGuard directly** — Full control, simple site-to-site, technical users
**Pangolin** — Expose services publicly, homelab, reverse proxy use case
**Tailscale** — Easiest setup, don't mind some vendor dependency
**Tailscale + Headscale** — Tailscale UX with self-hosted control plane
**Netbird** — Full FLOSS, mesh VPN + reverse proxy, organization with SSO needs
---
## MakerFLOSS Lab Context
From our lab design, we plan to use **Netbird** for:
- Remote access to lab services from outside
- Connecting VPS to local infrastructure via tunnel
- Zero-trust access to pre-production zone
```
VPS -.->|Netbird| FLOSSFirewall
```
Self-hosted on our infrastructure, integrated with Authentik for SSO.
---
## Resources
| Resource | Link |
|----------|------|
| WireGuard | [wireguard.com](https://www.wireguard.com) |
| Pangolin | [github.com/fosrl/pangolin](https://github.com/fosrl/pangolin) |
| Tailscale | [tailscale.com](https://tailscale.com) |
| Headscale | [github.com/juanfont/headscale](https://github.com/juanfont/headscale) |
| Netbird | [netbird.io](https://netbird.io) |
| Netbird GitHub | [github.com/netbirdio/netbird](https://github.com/netbirdio/netbird) |
---
# Questions?
_Slides made with [Marp](https://marp.app)_

14
docs/services/docs.md Normal file
View file

@ -0,0 +1,14 @@
---
name: docs
kind: static-site
status: in-use
host: makerfloss.eu
url: https://docs.makerfloss.eu
upstream: https://www.mkdocs.org
tech: MkDocs Material
tls: letsencrypt
---
## Notes
Public-facing documentation site. Built from this repo's `docs/` tree on every push to `main` by the Forgejo Actions runner (`.forgejo/workflows/docs.yml`), then rsynced to the VPS and served by nginx behind Traefik.

14
docs/services/forgejo.md Normal file
View file

@ -0,0 +1,14 @@
---
name: forgejo
kind: web-app
status: in-use
host: makerfloss.eu
url: https://forgejo.makerfloss.eu
upstream: https://codeberg.org/forgejo/forgejo
tech: Go
tls: letsencrypt
---
## Notes
Self-hosted git forge. Hosts this repository similar MakerFLOSS projects. SSH access on port 7577 (`ssh://git@forgejo.makerfloss.eu:7577/<user>/<repo>.git`). Also runs the Forgejo Actions runner that builds [docs](docs.md) and [slides](slides.md) on push.

View file

@ -0,0 +1,12 @@
---
name: gandi-dns
kind: dns
status: in-use
host: Gandi.net (external)
url: https://www.gandi.net
tech: Gandi LiveDNS API
---
## Notes
External DNS provider for the `makerfloss.eu` zone. Records are managed declaratively from the [AnsibleBaobabV4](https://forgejo.nyumbani.baobab.band/sjat/AnsibleBaobabV4) project (`play_dns.yml --limit makerfloss`); **never edit DNS records directly in the Gandi web UI** — Ansible will overwrite them on the next run.

34
docs/services/index.md Normal file
View file

@ -0,0 +1,34 @@
# Services Overview
_Auto-generated from `docs/services/*.md` — do not edit by hand. Run `make docs-index` after changing a file._
## DNS
| Name | URL | Host | Tech | Status |
|---|---|---|---|---|
| [gandi-dns](gandi-dns.md) | [www.gandi.net](https://www.gandi.net) | Gandi.net (external) | Gandi LiveDNS API | in-use |
## Libraries
| Name | URL | Host | Tech | Status |
|---|---|---|---|---|
| [mermaid](mermaid.md) | [cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs](https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs) | CDN (jsdelivr) | JavaScript (ESM) | in-use |
## Slide builders
| Name | URL | Host | Tech | Status |
|---|---|---|---|---|
| [marp](marp.md) | | CI runner (Forgejo Actions) | Node.js (marp-cli) / Docker | in-use |
## Static sites
| Name | URL | Host | Tech | Status |
|---|---|---|---|---|
| [docs](docs.md) | [docs.makerfloss.eu](https://docs.makerfloss.eu) | makerfloss.eu | MkDocs Material | in-use |
| [slides](slides.md) | [slides.makerfloss.eu](https://slides.makerfloss.eu) | makerfloss.eu | Marp + Mermaid.js | in-use |
## Web applications
| Name | URL | Host | Tech | Status |
|---|---|---|---|---|
| [forgejo](forgejo.md) | [forgejo.makerfloss.eu](https://forgejo.makerfloss.eu) | makerfloss.eu | Go | in-use |

12
docs/services/marp.md Normal file
View file

@ -0,0 +1,12 @@
---
name: marp
kind: slide-builder
status: in-use
host: CI runner (Forgejo Actions)
upstream: https://marp.app
tech: Node.js (marp-cli) / Docker
---
## Notes
Markdown-to-HTML slide compiler. Invoked by `build-slides.sh` against every `docs/presentations/*.md` (and any other `.md` with `marp: true` in frontmatter), either via a local `marp` binary or the `marpteam/marp-cli` Docker image. Output feeds the [slides](slides.md) site. See [`notes/dev/marp-mermaid-setup.md`](https://forgejo.makerfloss.eu/sjat/MakerFLOSS/src/branch/main/notes/dev/marp-mermaid-setup.md) for the [mermaid](mermaid.md) integration design.

13
docs/services/mermaid.md Normal file
View file

@ -0,0 +1,13 @@
---
name: mermaid
kind: library
status: in-use
host: CDN (jsdelivr)
url: https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs
upstream: https://mermaid.js.org
tech: JavaScript (ESM)
---
## Notes
Diagram-rendering JS library, injected into built slide HTML by the `inject_mermaid` step of `build-slides.sh`. Each `<pre><code class="language-mermaid">…</code></pre>` block emitted by [marp](marp.md) is rewritten into `<div class="mermaid">`, then `mermaid.run()` renders it client-side on page load.

14
docs/services/slides.md Normal file
View file

@ -0,0 +1,14 @@
---
name: slides
kind: static-site
status: in-use
host: makerfloss.eu
url: https://slides.makerfloss.eu
upstream: https://marp.app
tech: Marp + Mermaid.js
tls: letsencrypt
---
## Notes
Slide-deck site. Decks are authored as Marp markdown in `docs/presentations/` and compiled to HTML by `build-slides.sh` (CI invokes it via the [marp](marp.md) toolchain). Built output is rsynced to the VPS and served alongside [docs](docs.md).

View file

@ -1,5 +0,0 @@
# ToDos efter første møde
- [ ] Beskriv ønsker til hardware og spørg ud i makerspace-gruppen om nogen har noget de vil donere
- [ ] Få et underdomæne fra bestyrelsen (fx makerfloss.orangemakerspace.com) og sæt relevant DNS api op.
- [ ] Konkretiser netværksbehov til bestyrelsen

View file

@ -1,7 +0,0 @@
# ToDo
ø Facebook rekleme
- Indkøbsliste
g Netværk inden mandag
- Skaf penge

View file

@ -1,5 +0,0 @@
# Indkøbsliste
- Rack-studs
- 3 =< 3B+
- Kølepasta

View file

@ -36,7 +36,11 @@ markdown_extensions:
- tables
- attr_list
- md_in_html
- pymdownx.superfences
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
@ -48,8 +52,19 @@ markdown_extensions:
plugins:
- search
hooks:
- scripts/mkdocs_hooks.py
nav:
- Home: index.md
- Hardware:
- hardware/index.md
- House rules: makerFLOSS_house_rules.md
- Editing the hardware docs: guides/editing-hardware-docs.md
- Naming scheme: guides/hardware-naming-scheme.md
- Services:
- services/index.md
- Infrastructure:
- Lab design: infrastructure/labdesign.md
- VPS & DNS: infrastructure/vps-and-dns.md
- Rack rack01: infrastructure/racks/rack01.md
- House rules: house-rules.md

8
notes/README.md Normal file
View file

@ -0,0 +1,8 @@
# notes/
Repo-only working material — meeting notes, todos, internal design docs, and community-communications artefacts. **Nothing here is built into the public site**; if you want a page to appear on <https://docs.makerfloss.eu>, move it under `docs/`.
- `meetings/` — meeting minutes (Danish allowed)
- `todo/` — task lists, working norms, wishlist, OM services
- `dev/` — internal plans and specs (pre-implementation thinking)
- `communications/` — Facebook posts and other community artefacts

79
notes/coursemanagement.md Normal file
View file

@ -0,0 +1,79 @@
---
name: coursemanagement
kind: web-app
status: planned
---
# Problem statement
Not the ad-space - but the event management, sign-up space.
# Operative user requirements
- Overview of events and their repetition
- ID-management
- Only the needed data
- ID = email, and so we can let you know if we cancel
- Name - To give the instructor a ledger to tick off
- Comment field
- Profile picture
- People who are signed up, can see others who are signed up for the same event
- Members are automatically set up
- Login via OM-key-chip?
- Communication system
- COM = email
- Receipt for sign-up
- event materials
- Reminders...
- Open standards
- Vcard or similiardstandard
- JSON or XML
- FLOSS (MIT or lighter)
- SSO
- Authentik
- Oauth
- Security
- Own hosting
- 3-2-1 backup
- Uptime: 99.9 (but we try for more)
- HA
- Loki+Grafana Alloy+Grafana alerts
- Supply-chain / dependencies
- GIT
- Separation of concerns
- Sign-up and things directly related to each event
- event catalogue/program
- Advert
- SoMe
- Newsletter
- MeshCore
- Who graduated
- Admin stuff
- Wait lists
- Max number of participants
- SSoT: Where is the event described
- Here or on Facebook?
- Adding new events
- Templates
- Repeatable
- Idea: All can add new events
- Depreciated for now: Is English an option?
- How are people updated on what happens
- RSS
- Dedicated newsletter
- Bot on Matrix
- Shareable - so no login unless required. Short links/SoMe/e-mail/chat...
- Application in Danish
- Responsive
- GDPR compliant
- High trust (readable links), HTTPS
- Do one (a few) things well
- Modular - so we can drop or add things
- Very clear for the user to understand if they did the right thing
- Easy for the user - and for the admin and the AI. Proper headers so that when you post about in on SoMe it picks up the rights graphics
- REST-API
# PoA
- Marcus - Talk with Kjeld about moving over to GIT
- PR FTW

View file

@ -0,0 +1,207 @@
# Hardware Naming Migration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Apply the approved hardware naming scheme by renaming the placeholder compute nodes `mf00``mf04` to `srv01``srv05`, recording cluster membership in frontmatter, and syncing the prose references — leaving all generated artifacts and the drift guard green.
**Architecture:** Pure data + docs migration; **no generator logic changes**. The compute files are renamed (`git mv`), their `hostname:` field updated to match (the repo enforces filename-stem == `hostname`), `cluster: tappaas` added to the one known node, then `make docs-index` regenerates the hardware index and rack artifacts. Because `mf00``mf04` are referenced only by themselves (their `power:`/`links:` point outward to `pdu*`/`sw*`/`pp*`, with no reverse references), the rename cascades cleanly through regeneration.
**Tech Stack:** Markdown frontmatter, Python generators (`gen_overview.py`, `gen_rack.py`) run via `make`, MkDocs Material, pytest.
**Spec:** `docs/guides/hardware-naming-scheme.md`.
## Global Constraints
- **No code changes** to `scripts/*.py`, `tests/*`, `Makefile`, CI, `mkdocs.yml`, or `scripts/overview_config.yml`. The `cluster:` field is free-form frontmatter the generators ignore by design; it needs no enum or validation in this migration (the spec defers generator/grouping work).
- Identifier format per the scheme: `<kind-abbrev><NN>`, 2-digit zero-padded, unique within a kind, starting at `01`. Compute = `srv`.
- A renamed file's `hostname:` field MUST equal its new filename stem, or `gen_overview.py` fails (`filename stem != hostname`).
- Preserve each file's body and all other frontmatter unchanged — only `hostname:` changes (plus the one added `cluster:` line).
- `cluster:` values are provisional except the one known mapping: `srv01` (was `mf00`, "TaPPaaS node 1") → `cluster: tappaas`. Do **not** invent cluster values for `srv02``srv05`; leave the field off them until real assignments are given.
- `makerfloss.eu` (cloud FQDN) is untouched — it keeps its real hostname and gets no `cluster:`.
- Test-fixture hostnames in `tests/test_gen_rack.py` (synthetic `mf00`/`mf01` strings) and the historical phase specs/plans under `notes/dev/` are **out of scope** — they are illustrative/historical, not references to the renamed files.
- After each task: `make test` passes (49 tests, unaffected — fixtures are synthetic), `mkdocs build --strict` passes, and `make docs-check` exits 0.
## Name mapping (this migration)
| Old file | New file | `hostname:` | `cluster:` |
|----------|----------|-------------|------------|
| `mf00.md` | `srv01.md` | `srv01` | `tappaas` |
| `mf01.md` | `srv02.md` | `srv02` | (omit) |
| `mf02.md` | `srv03.md` | `srv03` | (omit) |
| `mf03.md` | `srv04.md` | `srv04` | (omit) |
| `mf04.md` | `srv05.md` | `srv05` | (omit) |
---
### Task 1: Rename `mf00``mf04``srv01``srv05`, add cluster, regenerate
**Files:**
- Rename: `docs/hardware/mf00.md``srv01.md`, `mf01.md``srv02.md`, `mf02.md``srv03.md`, `mf03.md``srv04.md`, `mf04.md``srv05.md`
- Modify (each renamed file): `hostname:` line; add `cluster: tappaas` to `srv01.md`
- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
**Interfaces:**
- Consumes: `make docs-index` (runs `gen_overview.py` + `gen_rack.py`), `make test`, `make docs-check`, `mkdocs build --strict`.
- [ ] **Step 1: Rename the five files with `git mv` (preserves history + body)**
```bash
cd /home/sjat/Projects/MakerFLOSS
git mv docs/hardware/mf00.md docs/hardware/srv01.md
git mv docs/hardware/mf01.md docs/hardware/srv02.md
git mv docs/hardware/mf02.md docs/hardware/srv03.md
git mv docs/hardware/mf03.md docs/hardware/srv04.md
git mv docs/hardware/mf04.md docs/hardware/srv05.md
```
- [ ] **Step 2: Update the `hostname:` field in each renamed file**
In each file, change the `hostname:` line (line 2) to match its new stem. Make exactly these five edits:
- `docs/hardware/srv01.md`: `hostname: mf00``hostname: srv01`
- `docs/hardware/srv02.md`: `hostname: mf01``hostname: srv02`
- `docs/hardware/srv03.md`: `hostname: mf02``hostname: srv03`
- `docs/hardware/srv04.md`: `hostname: mf03``hostname: srv04`
- `docs/hardware/srv05.md`: `hostname: mf04``hostname: srv05`
Do not change any other line in these files.
- [ ] **Step 3: Add `cluster: tappaas` to `srv01.md` only**
In `docs/hardware/srv01.md`, insert a `cluster: tappaas` line immediately after the `status: staging` line, so the top of the frontmatter reads:
```yaml
---
hostname: srv01
kind: server
status: staging
cluster: tappaas
location: The pile
```
Leave `srv02.md``srv05.md` without a `cluster:` field.
- [ ] **Step 4: Regenerate all indices and rack artifacts**
Run: `make docs-index`
Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing `srv01``srv05` under Servers, no `mf0x`); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (9 item(s))`. No `gen_overview` schema error (each stem now matches its `hostname`).
- [ ] **Step 5: Confirm no `mf0x` remains anywhere under `docs/`**
Run: `grep -rn -E "mf0[0-4]" docs/ ; echo "exit=$?"`
Expected: no matches, `exit=1` (grep found nothing).
Run: `ls docs/hardware/srv0*.md | wc -l`
Expected: `5`.
Run: `grep -n "cluster: tappaas" docs/hardware/srv01.md`
Expected: one match.
- [ ] **Step 6: Run the full test suite**
Run: `make test`
Expected: PASS (49 tests — the suite uses synthetic fixtures and is unaffected by the rename).
- [ ] **Step 7: Build the site strictly**
Run: `mkdocs build --strict` (if `mkdocs` is not on PATH, use `python3 -m mkdocs build --strict`)
Expected: build succeeds with no warnings-as-errors. The rack page and hardware index now show `srv01``srv05`.
- [ ] **Step 8: Confirm the drift guard is satisfied**
Run: `make docs-check`
Expected: exit 0 — committed artifacts match a fresh regeneration.
- [ ] **Step 9: Commit**
```bash
git add docs/hardware/ docs/infrastructure/racks/
git commit -m "refactor(hardware): rename mf00-mf04 to srv01-srv05, add cluster field"
```
---
### Task 2: Sync prose references and record the scheme in CLAUDE.md
**Files:**
- Modify: `CLAUDE.md` (stale hardware hint + new naming-scheme note)
- Modify: `README.md` (stale hardware hint)
**Interfaces:**
- Consumes: `grep`, `make test`, `make docs-check` (sanity).
- [ ] **Step 1: Fix the stale hardware hint in `CLAUDE.md`**
In `CLAUDE.md`, change the repository-structure comment line:
```
hardware/ # auto-indexed per-host frontmatter (mf00..mf03, makerfloss.eu)
```
to:
```
hardware/ # auto-indexed per-host frontmatter (srv01..srv05, makerfloss.eu)
```
- [ ] **Step 2: Add a naming-scheme note to the `CLAUDE.md` working norms**
In `CLAUDE.md`, immediately after the existing `- **Filenames**: …` bullet, add this new bullet:
```markdown
- **Hardware identifiers**: `<kind-abbrev><NN>` — 2-digit, unique per kind (`srv`, `sw`, `pp`, `pdu`, `ups`, `shf`). Grouping (`cluster:`) lives in frontmatter, not the name. Full scheme: `docs/guides/hardware-naming-scheme.md`.
```
- [ ] **Step 3: Fix the stale hardware hint in `README.md`**
In `README.md`, change the repository-layout comment line:
```
hardware/ # auto-indexed per-host frontmatter (mf00..mf03, makerfloss.eu)
```
to:
```
hardware/ # auto-indexed per-host frontmatter (srv01..srv05, makerfloss.eu)
```
- [ ] **Step 4: Confirm no stale `mf0x` hint remains in the two files**
Run: `grep -n -E "mf0[0-4]" CLAUDE.md README.md ; echo "exit=$?"`
Expected: no matches, `exit=1`.
Run: `grep -c "Hardware identifiers" CLAUDE.md`
Expected: `1`.
- [ ] **Step 5: Sanity-check tests and drift still pass**
Run: `make test`
Expected: PASS (49 tests).
Run: `make docs-check`
Expected: exit 0 (CLAUDE.md/README.md are not generated artifacts, so this only confirms nothing regressed).
- [ ] **Step 6: Commit**
```bash
git add CLAUDE.md README.md
git commit -m "docs: record hardware naming scheme, refresh stale mf0x hints"
```
---
## Self-Review
**Spec coverage (`docs/guides/hardware-naming-scheme.md`):**
- Format `<kind-abbrev><NN>`, `srv` for compute — Task 1 (rename to `srv01``srv05`). ✔
- Stable identifier + `cluster:` in frontmatter — Task 1 (rename + add `cluster: tappaas` to the one known node). ✔
- `srv01` = `tappaas`; others provisional/omitted — Task 1 Step 3 + Global Constraints. ✔
- `makerfloss.eu` FQDN exception untouched — Global Constraints (not in the rename set). ✔
- No generator/grouping/validation work (deferred by spec) — Global Constraints (no `*.py`/config changes). ✔
- Scheme discoverable to contributors — Task 2 (CLAUDE.md note + spec pointer). ✔
- Stale prose hints corrected — Task 2 (CLAUDE.md + README.md). ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". Cluster values for `srv02``srv05` are intentionally omitted (real-data dependency, explicitly bounded), not a placeholder.
**Type consistency:** No code interfaces in this migration. The name mapping table (Task 1) and the `hostname:` edits (Step 2) are internally consistent: `mf00→srv01`, `mf01→srv02`, `mf02→srv03`, `mf03→srv04`, `mf04→srv05`, with `cluster: tappaas` on `srv01` only. Verification greps reference the same `srv0x` names produced by the edits.

View file

@ -0,0 +1,925 @@
# Rack Elevation (Phase 1) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Document the 48U rack as host frontmatter and generate, via CI, an SVG rack elevation plus an occupancy table — mirroring the existing `gen_overview.py` → generated-index pattern.
**Architecture:** A new self-contained script `scripts/gen_rack.py` reads `docs/hardware/*.md`, selects files carrying a `rack:` field, validates placement (U range, no overlaps), and writes two generated artifacts per rack into `docs/infrastructure/racks/`: a `<rack>-elevation.svg` picture and a `<rack>.md` page embedding it. CI regenerates and fails on drift, exactly like the existing indices.
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest (new dev dependency), MkDocs Material, Forgejo Actions CI.
**Spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (Phase 1 only — power and network are later phases).
## Global Constraints
- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy `gen_overview.py`'s style). No `Date.now`/randomness in generated output.
- Rack has **48 U**; the physical rack is labeled **U1 at the top**, descending to U48 — the SVG must render U1 at the top.
- Generated files carry the banner: `_Auto-generated … do not edit by hand. Run `make docs-index` after changing a source file._`
- Filenames: ASCII lowercase kebab-case; generated rack files are named after the rack id (e.g. `rack01.md`, `rack01-elevation.svg`).
- Language: English for code, docs, commits. Trunk-based; simple commit messages.
- `mkdocs build --strict` must pass; the drift guard must cover the new generated artifacts.
---
### Task 1: Extend the hardware `kind` enum for rack items
**Files:**
- Modify: `scripts/overview_config.yml`
**Interfaces:**
- Produces: new valid `kind` values (`pdu`, `patch-panel`, `shelf`, `blank`, `ups`, `kvm`) that later tasks' rack item files may use. Phase 1 only uses existing kinds (`server`), but the enum must accept the rest so Phase 2/3 files validate.
- [ ] **Step 1: Extend the `kind` enum and `group_titles` under the `hardware` block**
In `scripts/overview_config.yml`, the `hardware` block currently has:
```yaml
enums:
kind: [server, laptop, sbc, switch, ap, desktop]
status: [in-use, staging, spare, broken, donated]
storage_type: [nvme, ssd, hdd, mixed]
```
Replace the `kind:` line with:
```yaml
kind: [server, laptop, sbc, switch, ap, desktop, pdu, patch-panel, shelf, blank, ups, kvm]
```
And in the same block's `group_titles:` map, add these entries below the existing ones:
```yaml
pdu: PDUs
patch-panel: Patch panels
shelf: Shelves
blank: Blank panels
ups: UPS
kvm: KVM
```
- [ ] **Step 2: Confirm the existing hardware index still regenerates cleanly**
Run: `python3 scripts/gen_overview.py --category hardware`
Expected: `Wrote docs/hardware/index.md (N item(s))` and `git diff --exit-code docs/hardware/index.md` is clean (no new kinds are used yet, so the table is unchanged).
- [ ] **Step 3: Commit**
```bash
git add scripts/overview_config.yml
git commit -m "feat(hardware): allow rack item kinds (pdu, patch-panel, shelf, blank, ups, kvm)"
```
---
### Task 2: `gen_rack.py` core — parse, load, validate placement (TDD)
This task introduces the test harness and the first slice of the generator: frontmatter parsing, selecting rack items, and per-item placement validation.
**Files:**
- Create: `scripts/gen_rack.py`
- Create: `tests/test_gen_rack.py`
- Create: `tests/conftest.py`
- Create: `requirements-dev.txt`
**Interfaces:**
- Produces:
- `SchemaError` (exception)
- `RACK_UNITS = 48`, `FACES`, `ZERO_U_FACES` (constants)
- `parse_frontmatter(path: Path) -> dict | None`
- `load_rack_items(hardware_dir: Path) -> list[dict]` — returns frontmatter dicts (each with an added `_path` key) for files declaring a `rack`
- `validate_item(fm: dict) -> None` — raises `SchemaError` on bad placement
- [ ] **Step 1: Create `requirements-dev.txt`**
```
-r requirements.txt
pytest==8.*
```
- [ ] **Step 2: Install dev dependencies**
Run: `pip install -r requirements-dev.txt`
Expected: pytest installs successfully.
- [ ] **Step 3: Create `tests/conftest.py` so tests can import the script**
```python
import sys
from pathlib import Path
# Make scripts/ importable as top-level modules in tests.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
```
- [ ] **Step 4: Write the failing tests for `validate_item`**
Create `tests/test_gen_rack.py`:
```python
import pytest
import gen_rack
def item(**kw):
base = {"hostname": "x", "kind": "server", "status": "in-use", "rack": "rack01"}
base.update(kw)
return base
def test_validate_accepts_valid_placement():
gen_rack.validate_item(item(rack_u=12, u_height=2, rack_face="front"))
def test_validate_rejects_u_overflow():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack_u=47, u_height=3, rack_face="front"))
def test_validate_rejects_u_below_one():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack_u=0, u_height=1, rack_face="front"))
def test_validate_rejects_bad_face():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways"))
def test_validate_rejects_zero_u_with_units():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1))
def test_validate_accepts_zero_u_rail():
gen_rack.validate_item(item(rack_face="left"))
def test_validate_rejects_missing_units_on_faced_item():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack_face="front"))
```
- [ ] **Step 5: Run the tests to verify they fail**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `ModuleNotFoundError: No module named 'gen_rack'`.
- [ ] **Step 6: Create `scripts/gen_rack.py` with constants, parsing, loading, and `validate_item`**
```python
#!/usr/bin/env python3
"""Generate per-rack elevation SVG + page from hardware frontmatter.
Reads `docs/hardware/*.md`, selects files that declare a `rack` field,
validates rack placement, and writes for each rack:
docs/infrastructure/racks/<rack>-elevation.svg
docs/infrastructure/racks/<rack>.md
Deterministic, offline, stdlib + PyYAML. Non-zero exit on schema violation.
The physical rack is labeled U1 at the top; the SVG renders U1 at the top.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
HARDWARE_DIR = REPO_ROOT / "docs" / "hardware"
OUTPUT_DIR = REPO_ROOT / "docs" / "infrastructure" / "racks"
RACK_UNITS = 48
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
FACES = {"front", "rear", "both", "left", "right"}
ZERO_U_FACES = {"left", "right"}
KIND_COLORS = {
"server": "#4c78a8",
"switch": "#59a14f",
"patch-panel": "#9c755f",
"pdu": "#e15759",
"ups": "#edc948",
"shelf": "#bab0ac",
"kvm": "#b07aa1",
"blank": "#d4d4d4",
}
DEFAULT_COLOR = "#888888"
class SchemaError(Exception):
pass
def parse_frontmatter(path: Path) -> dict | None:
text = path.read_text(encoding="utf-8")
m = FRONTMATTER_RE.match(text)
if not m:
return None
data = yaml.safe_load(m.group(1))
if not isinstance(data, dict):
raise SchemaError(f"{path}: frontmatter is not a mapping")
return data
def load_rack_items(hardware_dir: Path) -> list[dict]:
"""Return frontmatter dicts for hardware files that declare a rack."""
items: list[dict] = []
for path in sorted(hardware_dir.glob("*.md")):
if path.name == "index.md":
continue
fm = parse_frontmatter(path)
if fm is None or "rack" not in fm:
continue
fm = dict(fm)
fm["_path"] = str(path)
items.append(fm)
return items
def validate_item(fm: dict) -> None:
name = fm.get("hostname") or fm.get("_path", "?")
face = fm.get("rack_face")
if face not in FACES:
raise SchemaError(f"{name}: rack_face={face!r} not in {sorted(FACES)}")
if face in ZERO_U_FACES:
if "rack_u" in fm or "u_height" in fm:
raise SchemaError(
f"{name}: 0U item (face={face}) must omit rack_u/u_height"
)
return
u = fm.get("rack_u")
h = fm.get("u_height")
if not isinstance(u, int) or not isinstance(h, int):
raise SchemaError(f"{name}: rack_u and u_height must be integers")
if u < 1 or u > RACK_UNITS:
raise SchemaError(f"{name}: rack_u={u} out of range 1..{RACK_UNITS}")
if h < 1:
raise SchemaError(f"{name}: u_height={h} must be >= 1")
if u + h - 1 > RACK_UNITS:
raise SchemaError(
f"{name}: occupies U{u}..U{u + h - 1}, exceeds {RACK_UNITS}U"
)
```
- [ ] **Step 7: Run the tests to verify they pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (7 passed).
- [ ] **Step 8: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py tests/conftest.py requirements-dev.txt
git commit -m "feat(rack): gen_rack placement parsing and validation"
```
---
### Task 3: Overlap detection (TDD)
**Files:**
- Modify: `scripts/gen_rack.py`
- Modify: `tests/test_gen_rack.py`
**Interfaces:**
- Consumes: `validate_item` semantics (items already individually valid).
- Produces: `check_overlaps(items: list[dict]) -> None` — raises `SchemaError` if any two items share a U on the same face. `both` expands to both `front` and `rear`; 0U rail items are exempt.
- [ ] **Step 1: Add failing overlap tests to `tests/test_gen_rack.py`**
Append:
```python
def test_overlaps_detects_same_face_overlap():
items = [
item(hostname="a", rack_u=1, u_height=2, rack_face="front"),
item(hostname="b", rack_u=2, u_height=1, rack_face="front"),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.check_overlaps(items)
def test_overlaps_allows_same_u_different_face():
items = [
item(hostname="a", rack_u=5, u_height=1, rack_face="front"),
item(hostname="b", rack_u=5, u_height=1, rack_face="rear"),
]
gen_rack.check_overlaps(items) # no raise
def test_overlaps_both_face_conflicts_with_front():
items = [
item(hostname="a", rack_u=5, u_height=1, rack_face="both"),
item(hostname="b", rack_u=5, u_height=1, rack_face="front"),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.check_overlaps(items)
def test_overlaps_ignores_zero_u_rails():
items = [
item(hostname="p1", rack_face="left"),
item(hostname="p2", rack_face="left"),
]
gen_rack.check_overlaps(items) # no raise
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'check_overlaps'`.
- [ ] **Step 3: Implement `check_overlaps` in `scripts/gen_rack.py`**
Add after `validate_item`:
```python
def check_overlaps(items: list[dict]) -> None:
"""Raise if two items share a U on the same face within one rack."""
occupied: dict[tuple[str, int], str] = {}
for fm in items:
face = fm.get("rack_face")
if face in ZERO_U_FACES:
continue
faces = ("front", "rear") if face == "both" else (face,)
u = fm["rack_u"]
h = fm["u_height"]
name = fm.get("hostname", "?")
for f in faces:
for uu in range(u, u + h):
key = (f, uu)
if key in occupied:
raise SchemaError(
f"U{uu} {f}: {name} overlaps {occupied[key]}"
)
occupied[key] = name
```
- [ ] **Step 4: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (11 passed).
- [ ] **Step 5: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): detect U overlaps within a rack face"
```
---
### Task 4: SVG elevation rendering (TDD)
**Files:**
- Modify: `scripts/gen_rack.py`
- Modify: `tests/test_gen_rack.py`
**Interfaces:**
- Consumes: validated items (non-0U items have integer `rack_u`/`u_height`).
- Produces: `render_svg(rack: str, items: list[dict]) -> str` — a complete deterministic `<svg>…</svg>` string ending in a newline; front and rear columns of 48 U slots with U1 at the top, device boxes colored by kind, 0U items as side rails.
- [ ] **Step 1: Add failing SVG tests**
Append to `tests/test_gen_rack.py`:
```python
def test_render_svg_has_two_columns_of_48_slots():
svg = gen_rack.render_svg("rack01", [])
# one faint slot rect per U per column (front + rear)
assert svg.count('fill="#f5f5f5"') == 2 * gen_rack.RACK_UNITS
assert svg.startswith("<svg")
assert svg.rstrip().endswith("</svg>")
def test_render_svg_includes_device_label():
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert "mf00" in svg
assert "U1" in svg
def test_render_svg_is_deterministic():
items = [
item(hostname="b", rack_u=3, u_height=1, rack_face="front"),
item(hostname="a", rack_u=1, u_height=1, rack_face="rear"),
]
assert gen_rack.render_svg("rack01", items) == gen_rack.render_svg(
"rack01", list(reversed(items))
)
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'render_svg'`.
- [ ] **Step 3: Implement `_esc`, `_sorted_items`, and `render_svg`**
Add to `scripts/gen_rack.py`:
```python
def _esc(s: object) -> str:
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _sorted_items(items: list[dict]) -> list[dict]:
"""Deterministic order: faced items by U then hostname, 0U items last."""
return sorted(
items,
key=lambda i: (
0 if i.get("rack_face") not in ZERO_U_FACES else 1,
i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
i.get("hostname", ""),
),
)
def render_svg(rack: str, items: list[dict]) -> str:
U_H = 20
COL_W = 240
LABEL_W = 30
RAIL_W = 16
PAD = 12
GAP = 50
TITLE_H = 28
items = _sorted_items(items)
left_items = [i for i in items if i.get("rack_face") == "left"]
right_items = [i for i in items if i.get("rack_face") == "right"]
body_h = RACK_UNITS * U_H
height = PAD + TITLE_H + body_h + PAD
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
rear_x = front_x + COL_W + GAP
width = rear_x + COL_W + len(right_items) * RAIL_W + PAD
top = PAD + TITLE_H
def u_y(u: int) -> int:
# U1 at the top; U numbers increase downward.
return top + (u - 1) * U_H
p: list[str] = []
p.append(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
f'height="{height}" viewBox="0 0 {width} {height}" '
f'font-family="sans-serif" font-size="11">'
)
p.append(f'<rect width="{width}" height="{height}" fill="#ffffff"/>')
p.append(
f'<text x="{PAD}" y="{PAD + 16}" font-size="16" '
f'font-weight="bold">Rack {_esc(rack)}</text>'
)
for col_x, col_label in ((front_x, "front"), (rear_x, "rear")):
p.append(
f'<text x="{col_x + COL_W // 2}" y="{top - 6}" '
f'text-anchor="middle" font-weight="bold">{col_label}</text>'
)
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<rect x="{col_x}" y="{y}" width="{COL_W}" height="{U_H}" '
f'fill="#f5f5f5" stroke="#e0e0e0"/>'
)
# U numbers in the gutter left of the front column.
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<text x="{front_x - 4}" y="{y + 14}" text-anchor="end" '
f'fill="#999">{u}</text>'
)
def draw_device(fm: dict, col_x: int) -> None:
u = fm["rack_u"]
h = fm["u_height"]
y = u_y(u)
box_h = h * U_H
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
name = fm.get("hostname", "?")
urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
p.append(
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
f'height="{box_h - 2}" rx="3" fill="{color}" stroke="#333"/>'
)
p.append(
f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">'
f'{_esc(name)} ({urange})</text>'
)
for fm in items:
face = fm.get("rack_face")
if face in ("front", "both"):
draw_device(fm, front_x)
if face in ("rear", "both"):
draw_device(fm, rear_x)
def draw_rail(fm: dict, x: int) -> None:
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
name = fm.get("hostname", "?")
cx = x + RAIL_W // 2
cy = top + body_h // 2
p.append(
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
f'fill="{color}" stroke="#333"/>'
)
p.append(
f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" '
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
)
for idx, fm in enumerate(left_items):
draw_rail(fm, PAD + idx * RAIL_W)
for idx, fm in enumerate(right_items):
draw_rail(fm, rear_x + COL_W + idx * RAIL_W)
p.append("</svg>")
return "\n".join(p) + "\n"
```
- [ ] **Step 4: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (14 passed).
- [ ] **Step 5: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): render SVG elevation (U1 at top, front/rear columns)"
```
---
### Task 5: Page rendering + orchestration (TDD)
**Files:**
- Modify: `scripts/gen_rack.py`
- Modify: `tests/test_gen_rack.py`
**Interfaces:**
- Consumes: `render_svg`, `validate_item`, `check_overlaps`, `load_rack_items`.
- Produces:
- `render_page(rack: str, items: list[dict]) -> str` — the generated Markdown page (banner, embedded SVG image, occupancy table linking to host pages).
- `generate(hardware_dir: Path, output_dir: Path) -> int` — orchestrates load/validate/group/write; returns `0` on success, `1` on any schema error (printing errors to stderr, writing nothing on failure).
- `main() -> int` — calls `generate(HARDWARE_DIR, OUTPUT_DIR)`.
- [ ] **Step 1: Add failing tests for `render_page` and `generate`**
Append to `tests/test_gen_rack.py`:
```python
def test_render_page_has_banner_image_and_table():
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
page = gen_rack.render_page("rack01", items)
assert "do not edit by hand" in page
assert "![Rack rack01 elevation](rack01-elevation.svg)" in page
assert "../../hardware/mf00.md" in page
assert "U1U2" in page
def _write_item(d, name, body):
(d / f"{name}.md").write_text(body, encoding="utf-8")
def test_generate_writes_artifacts(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw,
"mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
)
# a non-rack file must be ignored
_write_item(hw, "cloud", "---\nhostname: cloud\nkind: server\nstatus: in-use\n---\n")
rc = gen_rack.generate(hw, out)
assert rc == 0
assert (out / "rack01.md").exists()
assert (out / "rack01-elevation.svg").exists()
assert "mf00" in (out / "rack01-elevation.svg").read_text()
def test_generate_returns_1_on_overlap(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
for n, u in (("a", 1), ("b", 1)):
_write_item(
hw,
n,
f"---\nhostname: {n}\nkind: server\nstatus: in-use\n"
f"rack: rack01\nrack_u: {u}\nu_height: 1\nrack_face: front\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 1
assert not (out / "rack01.md").exists()
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'render_page'`.
- [ ] **Step 3: Implement `render_page`, `generate`, and `main`**
Add to `scripts/gen_rack.py`:
```python
def render_page(rack: str, items: list[dict]) -> str:
items = _sorted_items(items)
lines: list[str] = []
lines.append(f"# Rack {rack}")
lines.append("")
lines.append(
f"_Auto-generated from `docs/hardware/*.md` (items with `rack: {rack}`) "
f"— do not edit by hand. Run `make docs-index` after changing a "
f"source file._"
)
lines.append("")
lines.append("## Elevation")
lines.append("")
lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)")
lines.append("")
lines.append("## Occupancy")
lines.append("")
lines.append("| U | Device | Kind | Face | Status |")
lines.append("|---|---|---|---|---|")
for fm in items:
name = fm.get("hostname", "?")
link = f"[{name}](../../hardware/{name}.md)"
face = fm.get("rack_face", "")
if face in ZERO_U_FACES:
urange = "0U"
else:
u = fm["rack_u"]
h = fm["u_height"]
urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
lines.append(
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
f"| {fm.get('status', '')} |"
)
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def generate(hardware_dir: Path, output_dir: Path) -> int:
items = load_rack_items(hardware_dir)
errors: list[str] = []
for fm in items:
try:
validate_item(fm)
except SchemaError as e:
errors.append(str(e))
racks: dict[str, list[dict]] = {}
for fm in items:
racks.setdefault(fm["rack"], []).append(fm)
if not errors: # only check overlaps once placements are individually valid
for rack, ritems in racks.items():
try:
check_overlaps(ritems)
except SchemaError as e:
errors.append(f"{rack}: {e}")
if errors:
for err in errors:
print(f"ERROR: {err}", file=sys.stderr)
return 1
output_dir.mkdir(parents=True, exist_ok=True)
for rack in sorted(racks):
ritems = racks[rack]
(output_dir / f"{rack}-elevation.svg").write_text(
render_svg(rack, ritems), encoding="utf-8"
)
(output_dir / f"{rack}.md").write_text(
render_page(rack, ritems), encoding="utf-8"
)
print(f"Wrote {rack}.md + {rack}-elevation.svg ({len(ritems)} item(s))")
return 0
def main() -> int:
return generate(HARDWARE_DIR, OUTPUT_DIR)
if __name__ == "__main__":
sys.exit(main())
```
- [ ] **Step 4: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (18 passed).
- [ ] **Step 5: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): render page and orchestrate generation"
```
---
### Task 6: Wire build tooling and populate rack01
**Files:**
- Modify: `Makefile`
- Modify: `docs/hardware/mf00.md` (and other host files actually in the rack — see note)
- Create (generated): `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
**Interfaces:**
- Consumes: `gen_rack.main` via `python3 scripts/gen_rack.py`.
> **Operator note — real data required.** I do not know the true U positions of the devices in the physical rack, and the `mfNN` machines are tower/desktop units that may sit on a shelf rather than occupy U slots. The edit below is a **worked example** for `mf00`. Apply the same shape to **each device actually mounted in the rack**, using its real `rack_u`, `u_height`, and `rack_face` (front/rear/both). Remove rack fields from any host not in the rack. The overlap validator (`check_overlaps`) will reject conflicting positions, so wrong guesses fail loudly rather than silently. `makerfloss.eu` is cloud-hosted and must NOT get a `rack` field.
- [ ] **Step 1: Add rack placement to each in-rack host file (example: `mf00`)**
In `docs/hardware/mf00.md`, add these four lines to the frontmatter (between the existing keys and the closing `---`):
```yaml
rack: rack01
rack_u: 1
u_height: 1
rack_face: front
```
Repeat for every other device physically in the rack, choosing real, non-overlapping U positions.
- [ ] **Step 2: Add the `gen_rack` step to the Makefile**
In `Makefile`, change the `docs-index` target to:
```makefile
docs-index:
python3 scripts/gen_overview.py --category hardware
python3 scripts/gen_overview.py --category services
python3 scripts/gen_rack.py
```
Change the `docs-check` target to:
```makefile
docs-check:
python3 scripts/gen_overview.py --category hardware
python3 scripts/gen_overview.py --category services
python3 scripts/gen_rack.py
git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/
```
Add a `test` target at the end of the file:
```makefile
test:
pytest -q
```
And add `test` to the `.PHONY` line and a help line:
```makefile
.PHONY: help docs-index docs-build docs-serve docs-check slides test
```
```makefile
@echo " test Run the Python unit tests (pytest)"
```
- [ ] **Step 3: Generate the rack artifacts**
Run: `make docs-index`
Expected: prints `Wrote rack01.md + rack01-elevation.svg (N item(s))`; the two files now exist under `docs/infrastructure/racks/`.
- [ ] **Step 4: Eyeball the SVG**
Open `docs/infrastructure/racks/rack01-elevation.svg` in a browser. Expected: a "Rack rack01" title, front and rear columns, U numbers running **1 at the top → 48 at the bottom**, and each placed device as a colored box at its U position.
- [ ] **Step 5: Commit**
```bash
git add Makefile docs/hardware/*.md docs/infrastructure/racks/
git commit -m "feat(rack): populate rack01 and wire gen_rack into make targets"
```
---
### Task 7: CI integration, nav, and end-to-end verification
**Files:**
- Modify: `.forgejo/workflows/docs.yml`
- Modify: `mkdocs.yml`
**Interfaces:**
- Consumes: `python3 scripts/gen_rack.py`, `pytest`, the generated artifacts under `docs/infrastructure/racks/`.
- [ ] **Step 1: Add a test step and the rack generator to CI**
In `.forgejo/workflows/docs.yml`, after the `Install Python dependencies` step, add a new step:
```yaml
- name: Install dev dependencies and run tests
run: |
pip install --quiet -r requirements-dev.txt
pytest -q
```
In the `Regenerate hardware and services indices` step, append the rack generator so the `run:` block reads:
```yaml
run: |
python3 scripts/gen_overview.py --category hardware
python3 scripts/gen_overview.py --category services
python3 scripts/gen_rack.py
```
In the `Fail on drift in generated indices` step, extend the diff to cover the rack artifacts:
```yaml
run: |
if ! git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/; then
echo
echo "::error::A generated index is stale."
echo "Regenerate locally via 'make docs-index' and commit the result."
exit 1
fi
```
- [ ] **Step 2: Add an Infrastructure section to the MkDocs nav**
In `mkdocs.yml`, replace the `nav:` block with:
```yaml
nav:
- Home: index.md
- Hardware:
- hardware/index.md
- Services:
- services/index.md
- Infrastructure:
- Lab design: infrastructure/labdesign.md
- VPS & DNS: infrastructure/vps-and-dns.md
- Rack rack01: infrastructure/racks/rack01.md
- House rules: house-rules.md
```
- [ ] **Step 3: Build the site strictly and confirm it passes**
Run: `mkdocs build --strict`
Expected: build succeeds with no warnings-as-errors. The rack page and its SVG appear under `site/infrastructure/racks/`.
- [ ] **Step 4: Confirm the drift guard is satisfied**
Run: `make docs-check`
Expected: exit 0 (no diff) — the committed artifacts match a fresh regeneration.
- [ ] **Step 5: Preview the page**
Run: `mkdocs serve`
Open `http://127.0.0.1:8000/infrastructure/racks/rack01/`. Expected: the elevation SVG renders inline, U1 at the top; the occupancy table lists devices and links to their host pages.
- [ ] **Step 6: Commit**
```bash
git add .forgejo/workflows/docs.yml mkdocs.yml
git commit -m "ci(rack): generate rack artifacts, run tests, add nav entry"
```
- [ ] **Step 7: Push and confirm CI is green**
```bash
git push origin main
```
Open the Forgejo Actions run for this push. Expected: the tests step passes, the drift guard passes, the site builds, and `docs.makerfloss.eu/infrastructure/racks/rack01/` shows the elevation.
---
## Self-Review
**Spec coverage (Phase 1 scope):**
- Placement schema (`rack`, `rack_u`, `u_height`, `rack_face`) — Task 2 (validation), Task 6 (population). ✔
- New `kind` values — Task 1. ✔
- `gen_rack.py` producing SVG elevation + occupancy table — Tasks 4, 5. ✔
- U1-at-top rendering — Task 4 (`u_y`), verified Task 6 Step 4 / Task 7 Step 5. ✔
- Validation rules 1, 2, 5 (U range, overlap, 0U-omits-units) — Tasks 2, 3. (Rules 3, 4 are power/network — Phases 2/3, out of scope.) ✔
- Do-not-edit banner — Task 5 (`render_page`). ✔
- CI drift check + nav + strict build — Task 7. ✔
- Generated artifacts under `docs/infrastructure/racks/` — Tasks 5, 6. ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to" placeholders. The only deferred-to-operator item is real U-position data in Task 6, which is unavoidable physical-world input and is explicitly bounded with a worked example and the overlap validator as a safety net.
**Type consistency:** `SchemaError`, `RACK_UNITS`, `FACES`, `ZERO_U_FACES`, `parse_frontmatter`, `load_rack_items`, `validate_item`, `check_overlaps`, `_esc`, `_sorted_items`, `render_svg`, `render_page`, `generate`, `main` — names and signatures match across tasks and tests. `generate` returns `int` (0/1); `render_*` return `str`; `validate_item`/`check_overlaps` return `None` and raise `SchemaError`. Consistent.

View file

@ -0,0 +1,593 @@
# Rack Network (Phase 3) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add network-cabling data (`links:` feeds + switch/patch-panel peer files) to the rack pipeline, validate it (rule 4), and render a mermaid network graph on the generated rack page — reusing every Phase 1/2 mechanism.
**Architecture:** Extend the existing `scripts/gen_rack.py` with `load_hardware_index` (global hostname→frontmatter map for peer resolution), `validate_links` (rule 4), and `render_network` (a `flowchart LR` with local interface, peer port, and speed on each edge label); insert a `## Network` section into `render_page` between Power and Occupancy. Switch/patch-panel files are normal placed items that Phase 1 already draws and `gen_overview.py` already lists. Mermaid is already enabled.
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material, Forgejo Actions CI.
**Spec:** `notes/dev/specs/2026-06-24-rack-network-design.md`.
## Global Constraints
- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy existing `gen_rack.py` style). No randomness/time in generated output.
- `re` and `yaml` are already imported in `scripts/gen_rack.py`; do not add new imports.
- `_node_id` (Phase 2) is reused for mermaid node ids — do not redefine it.
- Validation failures raise `SchemaError`; `generate` prints `ERROR: …` to stderr and returns `1`, **writing nothing** on failure (existing behaviour).
- Generated files keep the existing `_Auto-generated … do not edit by hand_` banner (already emitted by `render_page`).
- **Peer resolution is global** (against all `docs/hardware/*.md` hostnames), not per-rack — rule 4 says "resolves to a real file".
- `peer_port` range is checked **only when the peer declares an integer `ports`**.
- Edge label format: `{local} → p{peer_port} · {speed}G`, with the ` · {speed}G` suffix omitted when `speed_gbps` is absent. Use the unicode arrow `→` (not `->`) to avoid clashing with mermaid's `-->` syntax.
- A node whose kind is `switch` or `patch-panel` renders as `{name}<br/>{kind}`; all other nodes render as the bare hostname.
- Network data added here is **provisional placeholder data** (like the mfNN positions and the Phase 2 power data), not real values.
- **No edits** to `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml` (`switch`/`patch-panel`/`ap` already in the enum; drift already covers `racks/`).
- `mkdocs build --strict` must pass; `make docs-check` must exit 0 after regeneration.
---
### Task 1: `load_hardware_index` + `validate_links` — rule 4 (TDD)
Add the global peer index and link validation, and wire `validate_links` into `generate`. Testable on validation alone.
**Files:**
- Modify: `scripts/gen_rack.py` (add `load_hardware_index`, `validate_links`; build the index and call `validate_links` in `generate`)
- Modify: `tests/test_gen_rack.py` (append tests)
**Interfaces:**
- Consumes: `SchemaError`, `parse_frontmatter`, the `item()`/`_write_item` test helpers, `generate`.
- Produces:
- `load_hardware_index(hardware_dir: Path) -> dict[str, dict]``{hostname: frontmatter}` for every `*.md` (excluding `index.md`).
- `validate_links(items: list[dict], hw_index: dict[str, dict]) -> None` — raises `SchemaError` on a malformed/dangling link.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_load_hardware_index_maps_all_hostnames(tmp_path):
hw = tmp_path / "hardware"
hw.mkdir()
_write_item(
hw, "sw01",
"---\nhostname: sw01\nkind: switch\nstatus: in-use\nports: 24\n---\n",
)
_write_item(
hw, "mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
)
idx = gen_rack.load_hardware_index(hw)
assert set(idx) == {"sw01", "mf00"}
assert idx["sw01"]["ports"] == 24
def test_validate_links_accepts_valid_link():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 1, "speed_gbps": 1}])]
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
gen_rack.validate_links(items, hw_index)
def test_validate_links_rejects_unknown_peer():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "ghost", "peer_port": 1}])]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, {})
def test_validate_links_rejects_peer_port_over_count():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 25}])]
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, hw_index)
def test_validate_links_accepts_peer_without_ports():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "rtr01", "peer_port": 99}])]
hw_index = {"rtr01": item(hostname="rtr01", kind="server")}
gen_rack.validate_links(items, hw_index) # no ports -> range check skipped
def test_validate_links_rejects_missing_local():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"peer": "sw01", "peer_port": 1}])]
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, hw_index)
def test_validate_links_rejects_malformed_entry():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=["sw01"])]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, {})
def test_generate_returns_1_on_bad_link_peer(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw, "mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
"links:\n - { local: eth0, peer: ghost, peer_port: 1 }\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 1
assert not (out / "rack01.md").exists()
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'load_hardware_index'`.
- [ ] **Step 3: Add `load_hardware_index` and `validate_links` after `check_overlaps` in `scripts/gen_rack.py`**
Add these two functions (place them just after `check_overlaps`, before `_pdu_index`):
```python
def load_hardware_index(hardware_dir: Path) -> dict[str, dict]:
"""Map hostname -> frontmatter for every hardware file (global peer lookup)."""
index: dict[str, dict] = {}
for path in sorted(hardware_dir.glob("*.md")):
if path.name == "index.md":
continue
fm = parse_frontmatter(path)
if fm is None:
continue
name = fm.get("hostname")
if isinstance(name, str) and name:
index[name] = fm
return index
def validate_links(items: list[dict], hw_index: dict[str, dict]) -> None:
"""Validate `links` cable declarations (rule 4).
Every links[].peer must resolve to a real hardware file (global lookup via
hw_index); peer_port must fall within the peer's declared `ports` when it
declares an integer count.
"""
for fm in items:
links = fm.get("links")
if links is None:
continue
name = fm.get("hostname", "?")
if not isinstance(links, list):
raise SchemaError(f"{name}: links must be a list")
for link in links:
if not isinstance(link, dict):
raise SchemaError(f"{name}: links entry must be a mapping")
local = link.get("local")
peer = link.get("peer")
peer_port = link.get("peer_port")
if not isinstance(local, str) or not local:
raise SchemaError(f"{name}: links entry needs a non-empty 'local'")
if not isinstance(peer, str) or not peer:
raise SchemaError(f"{name}: links entry needs a non-empty 'peer'")
if not isinstance(peer_port, int):
raise SchemaError(
f"{name}: links entry for {peer} needs an integer 'peer_port'"
)
target = hw_index.get(peer)
if target is None:
raise SchemaError(
f"{name}: links peer={peer!r} is not a known hardware file"
)
ports = target.get("ports")
if isinstance(ports, int) and (peer_port < 1 or peer_port > ports):
raise SchemaError(
f"{name}: peer_port {peer_port} out of range 1..{ports} on {peer}"
)
```
- [ ] **Step 4: Wire `validate_links` into `generate` in `scripts/gen_rack.py`**
`generate` currently begins:
```python
def generate(hardware_dir: Path, output_dir: Path) -> int:
items = load_rack_items(hardware_dir)
errors: list[str] = []
```
Add the global index right after `items` is loaded:
```python
def generate(hardware_dir: Path, output_dir: Path) -> int:
items = load_rack_items(hardware_dir)
hw_index = load_hardware_index(hardware_dir)
errors: list[str] = []
```
Then extend the per-rack validation loop. Replace:
```python
if not errors: # only check overlaps once placements are individually valid
for rack, ritems in racks.items():
try:
check_overlaps(ritems)
validate_power(ritems)
except SchemaError as e:
errors.append(f"{rack}: {e}")
```
with:
```python
if not errors: # only check overlaps once placements are individually valid
for rack, ritems in racks.items():
try:
check_overlaps(ritems)
validate_power(ritems)
validate_links(ritems, hw_index)
except SchemaError as e:
errors.append(f"{rack}: {e}")
```
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 8 new).
- [ ] **Step 6: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): validate network links against peer files and ports"
```
---
### Task 2: `render_network` + page section (TDD)
**Files:**
- Modify: `scripts/gen_rack.py` (add `render_network`; edit `render_page`)
- Modify: `tests/test_gen_rack.py` (append tests)
**Interfaces:**
- Consumes: `_node_id` (Phase 2), `render_page`, `generate`.
- Produces: `render_network(rack: str, items: list[dict]) -> str` — a fenced `mermaid` `flowchart LR` ending in a newline, or `""` when no item has a `links` feed.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_render_network_has_nodes_and_edge_labels():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 1, "speed_gbps": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "```mermaid" in out
assert "flowchart LR" in out
assert "sw01<br/>switch" in out
assert "mf00" in out
assert "eth0" in out
assert "p1" in out
assert "1G" in out
def test_render_network_patch_panel_subtitle():
items = [
item(hostname="pp01", kind="patch-panel", rack_u=24, u_height=1,
rack_face="front", ports=24),
item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "pp01",
"peer_port": 1, "speed_gbps": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "pp01<br/>patch-panel" in out
def test_render_network_empty_when_no_links():
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
assert gen_rack.render_network("rack01", items) == ""
def test_render_network_omits_speed_when_absent():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "eth0" in out and "p1" in out
assert "·" not in out # no speed suffix rendered
def test_render_network_is_deterministic():
a = item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24)
b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 2, "speed_gbps": 1}])
c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 1, "speed_gbps": 1}])
assert gen_rack.render_network("rack01", [a, b, c]) == \
gen_rack.render_network("rack01", [c, b, a])
def test_generate_includes_network_section(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw, "sw01",
"---\nhostname: sw01\nkind: switch\nstatus: in-use\n"
"rack: rack01\nrack_u: 10\nu_height: 1\nrack_face: front\nports: 24\n---\n",
)
_write_item(
hw, "mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
"links:\n - { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 0
page = (out / "rack01.md").read_text()
assert "## Network" in page
assert "```mermaid" in page
assert "eth0" in page
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'render_network'`.
- [ ] **Step 3: Add `render_network` after `render_power` in `scripts/gen_rack.py`**
```python
def render_network(rack: str, items: list[dict]) -> str:
"""Return a mermaid network-cabling flowchart, or '' if no links.
Assumes `validate_links` has already passed: every link has a non-empty
`local`/`peer` and an integer `peer_port`, and `peer` resolves to a real
hardware file. `generate` validates before any render call.
"""
linked = [fm for fm in items if fm.get("links")]
if not linked:
return ""
by_host = {fm.get("hostname"): fm for fm in items}
edges: list[tuple[str, str, str, int, object]] = []
nodes: set[str] = set()
for fm in linked:
source = fm.get("hostname", "?")
nodes.add(source)
for link in fm["links"]:
peer = link["peer"]
nodes.add(peer)
edges.append(
(source, link["local"], peer, link["peer_port"],
link.get("speed_gbps"))
)
edges.sort(key=lambda e: (e[0], e[1], e[2], e[3]))
def node_label(name: str) -> str:
fm = by_host.get(name)
kind = fm.get("kind") if fm else None
if kind in ("switch", "patch-panel"):
return f"{name}<br/>{kind}"
return name
lines: list[str] = ["```mermaid", "flowchart LR"]
for name in sorted(nodes):
lines.append(f' {_node_id(name)}["{node_label(name)}"]')
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
lines.append("```")
return "\n".join(lines) + "\n"
```
- [ ] **Step 4: Insert the `## Network` section in `render_page` in `scripts/gen_rack.py`**
`render_page` currently has this block (the Power section followed directly by Occupancy):
```python
power = render_power(rack, items)
if power:
lines.append("## Power")
lines.append("")
lines.append(power.rstrip())
lines.append("")
lines.append("## Occupancy")
```
Insert the Network section between the Power block and the Occupancy line:
```python
power = render_power(rack, items)
if power:
lines.append("## Power")
lines.append("")
lines.append(power.rstrip())
lines.append("")
network = render_network(rack, items)
if network:
lines.append("## Network")
lines.append("")
lines.append(network.rstrip())
lines.append("")
lines.append("## Occupancy")
```
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 6 new).
- [ ] **Step 6: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): render mermaid network graph into the rack page"
```
---
### Task 3: Populate provisional network data, regenerate
**Files:**
- Create: `docs/hardware/sw01.md`, `docs/hardware/pp01.md`
- Modify: `docs/hardware/mf00.md`..`mf04.md` (add `links:`)
- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
**Interfaces:**
- Consumes: `python3 scripts/gen_rack.py` / `make docs-index`, `mkdocs build --strict`, `make docs-check`.
> **Operator note — provisional data.** The switch/patch-panel placements and the cable assignments below are placeholders proving the feature, matching the existing fictional mfNN positions and Phase 2 power data. Replace with real values when known; `validate_links` rejects dangling peers and over-count ports loudly. sw01/pp01 deliberately get no `power:` feeds in this phase.
- [ ] **Step 1: Create the switch and patch-panel files**
Create `docs/hardware/sw01.md`:
```markdown
---
hostname: sw01
kind: switch
status: in-use
rack: rack01
rack_u: 10
u_height: 1
rack_face: front
ports: 24
---
## Notes
- Provisional placeholder switch. Port assignments are not yet real.
```
Create `docs/hardware/pp01.md`:
```markdown
---
hostname: pp01
kind: patch-panel
status: in-use
rack: rack01
rack_u: 24
u_height: 1
rack_face: front
ports: 24
links:
- { local: uplink, peer: sw01, peer_port: 24, speed_gbps: 1 }
---
## Notes
- Provisional placeholder patch panel. Devices patch in here; rear uplink to sw01.
```
- [ ] **Step 2: Add `links:` to the five host files**
These files already carry rack-placement and `power:` frontmatter. ADD a `links:` block to each (before the closing `---`); do not remove anything.
In `docs/hardware/mf00.md` add:
```yaml
links:
- { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }
```
In `docs/hardware/mf01.md` add:
```yaml
links:
- { local: eth0, peer: pp01, peer_port: 1, speed_gbps: 1 }
```
In `docs/hardware/mf02.md` add:
```yaml
links:
- { local: eth0, peer: pp01, peer_port: 2, speed_gbps: 1 }
```
In `docs/hardware/mf03.md` add:
```yaml
links:
- { local: eth0, peer: pp01, peer_port: 3, speed_gbps: 1 }
```
In `docs/hardware/mf04.md` add:
```yaml
links:
- { local: eth0, peer: pp01, peer_port: 4, speed_gbps: 1 }
```
- [ ] **Step 3: Regenerate all indices and rack artifacts**
Run: `make docs-index`
Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing sw01 under "Switches" and pp01 under "Patch panels"); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (9 item(s))`.
- [ ] **Step 4: Confirm the generated page has a network graph and the new boxes**
Run: `grep -c "→ p" docs/infrastructure/racks/rack01.md`
Expected: `6` (one network edge per link: mf00→sw01, mf01..mf04→pp01, pp01→sw01).
Run: `grep -q "sw01" docs/infrastructure/racks/rack01-elevation.svg && grep -q "pp01" docs/infrastructure/racks/rack01-elevation.svg && echo OK`
Expected: `OK` (switch and patch-panel drawn as boxes in the elevation).
- [ ] **Step 5: Run the full test suite**
Run: `make test`
Expected: PASS (all tests).
- [ ] **Step 6: Build the site strictly**
Run: `mkdocs build --strict` (if `mkdocs` is not on PATH, use `python3 -m mkdocs build --strict`)
Expected: build succeeds with no warnings-as-errors.
Verify: `grep -c "mermaid" site/infrastructure/racks/rack01/index.html`
Expected: `≥ 2` (a power block and a network block both render as mermaid diagrams).
- [ ] **Step 7: Confirm the drift guard is satisfied**
Run: `make docs-check`
Expected: exit 0 — committed artifacts match a fresh regeneration.
- [ ] **Step 8: Commit**
```bash
git add docs/hardware/ docs/infrastructure/racks/
git commit -m "feat(rack): populate provisional network topology (sw01, pp01, links)"
```
---
## Self-Review
**Spec coverage (`2026-06-24-rack-network-design.md`):**
- `links:` frontmatter on devices/peers — Task 3 (populate); validated Task 1. ✔
- Switch + patch-panel peer files (`ports`, placed 1U front) — Task 3; appear via Phase 1 SVG + gen_overview, no new code. ✔
- Validation rule 4 (peer resolves to a real file globally; peer_port within `ports` when declared; malformed/missing fields) — Task 1 (`validate_links` + `load_hardware_index`), wired into `generate`. ✔
- Global peer resolution (not per-rack) — Task 1 (`load_hardware_index` over all files; `generate` passes `hw_index`). ✔
- Mermaid network graph, full edge label (local → port · speed), kind subtitle for switch/patch-panel, omit-when-empty, deterministic — Task 2 (`render_network`), inserted in `render_page` between Power and Occupancy. ✔
- Node-id sanitization reused (`_node_id`) — Task 2. ✔
- Speed omitted when absent; unicode `→` — Task 2 (label build), tested. ✔
- No mkdocs/Makefile/CI/overview_config changes — honored (Global Constraints); drift covered by existing `racks/` diff — Task 3 Steps 3/7. ✔
- Provisional data (mf01mf04 → pp01 14; pp01 uplink → sw01:24; mf00 → sw01:1) — Task 3 Steps 12. ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The only operator-judgement item is provisional network values, explicitly bounded and guarded by `validate_links`.
**Type consistency:** `load_hardware_index``dict[str, dict]`; `validate_links(items, hw_index)`/`check_overlaps`/`validate_power``None` (raise `SchemaError`); `render_network`/`render_power`/`render_page`/`_node_id``str`; `generate``int` (0/1). `validate_links(ritems, hw_index)` is called per-rack alongside `check_overlaps`/`validate_power`, with `hw_index` built once at the top of `generate`. `render_network` consumes `_node_id` and feeds `render_page`. Names match across tasks and tests.

View file

@ -0,0 +1,553 @@
# Rack Power (Phase 2) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add power-distribution data (`power:` feeds + PDU files) to the rack pipeline, validate it, and render a mermaid power graph on the generated rack page — reusing every Phase 1 mechanism.
**Architecture:** Extend the existing `scripts/gen_rack.py` with `validate_power` (rule 3 from the spec) and `render_power` (a `flowchart LR` with the outlet number as the edge label); insert a `## Power` section into `render_page`. PDU files are 0U `left`/`right` items that Phase 1 already renders as side-rails and `gen_overview.py` already lists. Pull the mermaid superfences fence forward into `mkdocs.yml` so the graph renders.
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material, Forgejo Actions CI.
**Spec:** `notes/dev/specs/2026-06-24-rack-power-design.md`.
## Global Constraints
- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy `gen_rack.py`/`gen_overview.py` style). No randomness/time in generated output.
- `re` and `yaml` are already imported in `scripts/gen_rack.py`; do not add new imports.
- Validation failures raise `SchemaError`; `generate` prints `ERROR: …` to stderr and returns `1`, **writing nothing** on failure (existing Phase 1 behaviour).
- Generated files keep the existing `_Auto-generated … do not edit by hand_` banner.
- PDU files are **0U**: `rack_face: left|right`, **no** `rack_u`/`u_height`, and a positive-int `outlets`.
- Power data added here is **provisional placeholder data** (like the existing `mfNN` U positions), not real values.
- The Makefile `docs-check` and CI drift step already diff the whole `docs/infrastructure/racks/` dir — **do not edit** `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml`.
- `mkdocs build --strict` must pass; `make docs-check` must exit 0 after regeneration.
---
### Task 1: `validate_power` — rule 3 validation (TDD)
Add PDU lookup + power-feed validation and wire it into `generate`. This task is testable on validation alone (no rendering needed).
**Files:**
- Modify: `scripts/gen_rack.py` (add `_pdu_index`, `validate_power`; call in `generate`)
- Modify: `tests/test_gen_rack.py` (append tests)
**Interfaces:**
- Consumes: `SchemaError`, the `item()`/`_write_item` test helpers, `generate`.
- Produces:
- `_pdu_index(items: list[dict]) -> dict[str, dict]``{hostname: fm}` for `kind == "pdu"` items.
- `validate_power(items: list[dict]) -> None` — raises `SchemaError` on a bad PDU `outlets` declaration or a bad `power` feed.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_validate_power_accepts_valid_feed():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}]),
]
gen_rack.validate_power(items)
def test_validate_power_rejects_unknown_pdu():
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "ghost", "outlet": 1}])]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_non_pdu_target():
items = [
item(hostname="sw01", kind="switch", rack_u=1, u_height=1,
rack_face="front"),
item(hostname="mf00", rack_u=2, u_height=1, rack_face="front",
power=[{"pdu": "sw01", "outlet": 1}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_outlet_over_count():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 9}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_outlet_zero():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 0}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_malformed_entry():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=["pdu01"]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_pdu_without_outlets():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left"),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_generate_returns_1_on_bad_power_ref(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw,
"mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
"power:\n - { pdu: ghost, outlet: 1 }\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 1
assert not (out / "rack01.md").exists()
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'validate_power'`.
- [ ] **Step 3: Add `_pdu_index` and `validate_power` after `check_overlaps` in `scripts/gen_rack.py`**
```python
def _pdu_index(items: list[dict]) -> dict[str, dict]:
"""Map hostname -> frontmatter for every kind:pdu item."""
return {
fm.get("hostname"): fm
for fm in items
if fm.get("kind") == "pdu"
}
def validate_power(items: list[dict]) -> None:
"""Validate PDU outlet declarations and `power` feeds within one rack.
Rule 3: every power[].pdu resolves to a kind:pdu file, and outlet is
within that PDU's `outlets` count.
"""
pdus = _pdu_index(items)
for name, fm in pdus.items():
outlets = fm.get("outlets")
if not isinstance(outlets, int) or outlets < 1:
raise SchemaError(
f"{name}: kind:pdu must declare a positive integer 'outlets'"
)
for fm in items:
feeds = fm.get("power")
if feeds is None:
continue
name = fm.get("hostname", "?")
if not isinstance(feeds, list):
raise SchemaError(f"{name}: power must be a list")
for feed in feeds:
if not isinstance(feed, dict):
raise SchemaError(f"{name}: power entry must be a mapping")
pdu = feed.get("pdu")
outlet = feed.get("outlet")
if not isinstance(pdu, str) or not pdu:
raise SchemaError(f"{name}: power entry needs a non-empty 'pdu'")
if not isinstance(outlet, int):
raise SchemaError(
f"{name}: power entry for {pdu} needs an integer 'outlet'"
)
target = pdus.get(pdu)
if target is None:
raise SchemaError(
f"{name}: power pdu={pdu!r} is not a known kind:pdu file"
)
count = target["outlets"]
if outlet < 1 or outlet > count:
raise SchemaError(
f"{name}: outlet {outlet} out of range 1..{count} on {pdu}"
)
```
- [ ] **Step 4: Wire `validate_power` into `generate` in `scripts/gen_rack.py`**
Change the overlap loop so it also validates power. Replace:
```python
if not errors: # only check overlaps once placements are individually valid
for rack, ritems in racks.items():
try:
check_overlaps(ritems)
except SchemaError as e:
errors.append(f"{rack}: {e}")
```
with:
```python
if not errors: # only check overlaps once placements are individually valid
for rack, ritems in racks.items():
try:
check_overlaps(ritems)
validate_power(ritems)
except SchemaError as e:
errors.append(f"{rack}: {e}")
```
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 8 new).
- [ ] **Step 6: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): validate power feeds against PDU outlets"
```
---
### Task 2: `render_power` + page section (TDD)
**Files:**
- Modify: `scripts/gen_rack.py` (add `_node_id`, `render_power`; edit `render_page`)
- Modify: `tests/test_gen_rack.py` (append tests)
**Interfaces:**
- Consumes: `_pdu_index` (Task 1), `render_page`, `generate`.
- Produces:
- `_node_id(name: str) -> str` — hostname with non-alphanumeric chars replaced by `_`.
- `render_power(rack: str, items: list[dict]) -> str` — a fenced `mermaid` `flowchart LR` ending in a newline, or `""` when no item has a `power` feed.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_render_power_has_nodes_and_edge_labels():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 3}]),
]
out = gen_rack.render_power("rack01", items)
assert "```mermaid" in out
assert "flowchart LR" in out
assert "pdu01" in out
assert "8 outlets" in out
assert "outlet 3" in out
assert "mf00" in out
def test_render_power_redundant_device_has_two_edges():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="pdu02", kind="pdu", rack_face="right", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1},
{"pdu": "pdu02", "outlet": 1}]),
]
out = gen_rack.render_power("rack01", items)
assert out.count("-->|outlet") == 2
def test_render_power_empty_when_no_feeds():
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
assert gen_rack.render_power("rack01", items) == ""
def test_render_power_is_deterministic():
a = item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8)
b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 2}])
c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}])
assert gen_rack.render_power("rack01", [a, b, c]) == \
gen_rack.render_power("rack01", [c, b, a])
def test_generate_includes_power_section(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw,
"pdu01",
"---\nhostname: pdu01\nkind: pdu\nstatus: in-use\n"
"rack: rack01\nrack_face: left\noutlets: 8\n---\n",
)
_write_item(
hw,
"mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
"power:\n - { pdu: pdu01, outlet: 1 }\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 0
page = (out / "rack01.md").read_text()
assert "## Power" in page
assert "```mermaid" in page
assert "outlet 1" in page
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'render_power'`.
- [ ] **Step 3: Add `_node_id` and `render_power` after `render_svg` in `scripts/gen_rack.py`**
```python
def _node_id(name: str) -> str:
"""A mermaid-safe node id derived from a hostname."""
return re.sub(r"[^0-9A-Za-z]", "_", str(name))
def render_power(rack: str, items: list[dict]) -> str:
"""Return a mermaid power-distribution flowchart, or '' if no feeds."""
powered = [fm for fm in items if fm.get("power")]
if not powered:
return ""
pdus = _pdu_index(items)
edges: list[tuple[str, int, str]] = []
for fm in powered:
device = fm.get("hostname", "?")
for feed in fm["power"]:
edges.append((feed["pdu"], feed["outlet"], device))
edges.sort()
lines: list[str] = ["```mermaid", "flowchart LR"]
for pdu in sorted(pdus):
outlets = pdus[pdu].get("outlets")
lines.append(f' {_node_id(pdu)}["{pdu}<br/>{outlets} outlets"]')
devices = sorted(
powered,
key=lambda i: (
i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
i.get("hostname", ""),
),
)
for fm in devices:
device = fm.get("hostname", "?")
lines.append(f' {_node_id(device)}["{device}"]')
for pdu, outlet, device in edges:
lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
)
lines.append("```")
return "\n".join(lines) + "\n"
```
- [ ] **Step 4: Insert the `## Power` section in `render_page` in `scripts/gen_rack.py`**
In `render_page`, replace this block:
```python
lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)")
lines.append("")
lines.append("## Occupancy")
```
with:
```python
lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)")
lines.append("")
power = render_power(rack, items)
if power:
lines.append("## Power")
lines.append("")
lines.append(power.rstrip())
lines.append("")
lines.append("## Occupancy")
```
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 5 new).
- [ ] **Step 6: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): render mermaid power graph into the rack page"
```
---
### Task 3: Enable mermaid, populate provisional power data, regenerate
**Files:**
- Modify: `mkdocs.yml` (mermaid superfences fence)
- Create: `docs/hardware/pdu01.md`, `docs/hardware/pdu02.md`
- Modify: `docs/hardware/mf00.md`..`mf04.md` (add `power:`)
- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
**Interfaces:**
- Consumes: `python3 scripts/gen_rack.py` / `make docs-index`, `mkdocs build --strict`, `make docs-check`.
> **Operator note — provisional data.** The PDU placements and outlet assignments below are placeholders proving the feature, matching the existing fictional `mfNN` U positions. Replace with real values when known; `validate_power` will reject dangling/over-count feeds loudly.
- [ ] **Step 1: Enable the mermaid custom fence in `mkdocs.yml`**
In `mkdocs.yml`, replace the bare superfences line:
```yaml
- pymdownx.superfences
```
with:
```yaml
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
```
- [ ] **Step 2: Create the two PDU files**
Create `docs/hardware/pdu01.md`:
```markdown
---
hostname: pdu01
kind: pdu
status: in-use
rack: rack01
rack_face: left
outlets: 8
---
## Notes
- Provisional placeholder PDU (left rail). Outlet assignments are not yet real.
```
Create `docs/hardware/pdu02.md`:
```markdown
---
hostname: pdu02
kind: pdu
status: in-use
rack: rack01
rack_face: right
outlets: 8
---
## Notes
- Provisional placeholder PDU (right rail). Provides redundant feeds.
```
- [ ] **Step 3: Add `power:` to the five host files**
In `docs/hardware/mf00.md`, add to the frontmatter (before the closing `---`) — note mf00 has **two** feeds (the redundant demonstration):
```yaml
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
```
In `docs/hardware/mf01.md` add:
```yaml
power:
- { pdu: pdu01, outlet: 2 }
```
In `docs/hardware/mf02.md` add:
```yaml
power:
- { pdu: pdu01, outlet: 3 }
```
In `docs/hardware/mf03.md` add:
```yaml
power:
- { pdu: pdu01, outlet: 4 }
```
In `docs/hardware/mf04.md` add:
```yaml
power:
- { pdu: pdu01, outlet: 5 }
```
- [ ] **Step 4: Regenerate all indices and rack artifacts**
Run: `make docs-index`
Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing pdu01/pdu02 under "PDUs"); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (7 item(s))`.
- [ ] **Step 5: Confirm the generated page has a rendered power graph and PDU rails**
Run: `grep -c "outlet" docs/infrastructure/racks/rack01.md`
Expected: ≥ 6 (one edge per feed: mf00 ×2, mf01..mf04 ×1).
Run: `grep -c "pdu0" docs/infrastructure/racks/rack01-elevation.svg`
Expected: ≥ 2 (pdu01 + pdu02 drawn as side-rails).
- [ ] **Step 6: Run the full test suite**
Run: `make test`
Expected: PASS (all tests).
- [ ] **Step 7: Build the site strictly**
Run: `mkdocs build --strict`
Expected: build succeeds with no warnings-as-errors; `site/infrastructure/racks/rack01/index.html` contains a `<pre class="mermaid">` (or `<div class="mermaid">`) block rather than a plain `<code>` fence.
Verify: `grep -c "mermaid" site/infrastructure/racks/rack01/index.html`
Expected: ≥ 1.
- [ ] **Step 8: Confirm the drift guard is satisfied**
Run: `make docs-check`
Expected: exit 0 — committed artifacts match a fresh regeneration.
- [ ] **Step 9: Commit**
```bash
git add mkdocs.yml docs/hardware/ docs/infrastructure/racks/
git commit -m "feat(rack): enable mermaid, populate provisional power data"
```
---
## Self-Review
**Spec coverage (`2026-06-24-rack-power-design.md`):**
- `power:` frontmatter on devices — Task 3 (populate); validated Task 1. ✔
- PDU files (`kind: pdu`, `outlets`, 0U `left`/`right`) — Task 3; outlets validated Task 1. ✔
- Validation rule 3 (pdu resolves to kind:pdu; outlet in range; pdu declares outlets) — Task 1 (`validate_power`), wired into `generate`. ✔
- Mermaid power graph, outlet as edge label, redundancy as two edges, omit-when-empty, deterministic — Task 2 (`render_power`), inserted in `render_page`. ✔
- Node-id sanitization — Task 2 (`_node_id`). ✔
- Mermaid pulled forward in `mkdocs.yml` — Task 3 Step 1. ✔
- No Makefile/CI/overview_config changes — honored (Global Constraints); drift covered by existing `racks/` diff — Task 3 Steps 4/8. ✔
- Provisional data (pdu01 left → mf00..mf04 o1..o5; pdu02 right → mf00 o1) — Task 3 Steps 23. ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The only operator-judgement item is provisional power values, explicitly bounded and guarded by `validate_power`.
**Type consistency:** `_pdu_index``dict[str, dict]`; `validate_power`/`check_overlaps``None` (raise `SchemaError`); `render_power`/`render_page`/`render_svg`/`_node_id``str`; `generate``int` (0/1). `validate_power(ritems)` is called per-rack alongside `check_overlaps(ritems)`. `render_power` consumes `_pdu_index` and feeds `render_page`. Names match across tasks and tests.

View file

@ -0,0 +1,702 @@
# Rack Presentation Improvements Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the generated rack elevation interactive (inline SVG, clickable boxes, hover tooltips) with status-encoded borders and a legend, and theme the mermaid power/network graphs (colour-by-kind + clickable nodes).
**Architecture:** Pure rendering upgrade in `scripts/gen_rack.py`. New module-level helpers (`_host_url`, `_status_stroke`, `_stroke_attrs`, `_placement`, `_tooltip`); `render_svg` wraps each device in `<a>`+`<title>`, encodes status via border, adds a legend, both-gutter U-numbers, and column frames; `render_page` inlines the SVG (via `md_in_html`) with a download link; `render_power`/`render_network` add per-node `style` + `click`. Each task regenerates the artifacts so the drift guard stays green.
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material (`md_in_html`, mermaid), Forgejo Actions CI.
**Spec:** `notes/dev/specs/2026-06-24-rack-presentation-design.md`.
## Global Constraints
- Scripts use **stdlib + PyYAML only**; deterministic and offline. `re`/`yaml` already imported — no new imports.
- Links/clicks use **root-relative final URLs** `/hardware/<host>/` (mkdocs does not rewrite raw hrefs inside inline SVG or mermaid; `use_directory_urls` defaults true; site at domain root).
- Status border mapping (verbatim): in-use `#333333`/1.5/solid; staging `#333333`/1.5/dash `4 2`; broken `#e15759`/3/solid; spare `#bbbbbb`/1.5/solid; donated `#bbbbbb`/1.5/solid; other → `#333333`/1.5/solid. Fill stays the kind colour.
- The inline elevation block is `<div class="rack-elevation">` + the SVG markup (no blank lines inside) + `</div>`; **no** `markdown` attribute (raw passthrough). The standalone `rack01-elevation.svg` is still generated, plus a `[Download SVG](rack01-elevation.svg)` link.
- Tooltip text: `"<host> · <kind> · <status> · cluster: <cluster|—> · <placement>"`, `_esc`-applied; placement = U-range / `0U <face>` / `<shelf>/<face>/slot <slot>`.
- Reuse `item()`/`_write_item()`/`shelf()` test helpers; do not redefine them.
- Each task ends with regenerated `docs/infrastructure/racks/rack01.{md,svg}`, `make test` green, `mkdocs build --strict` passing, and `make docs-check` exit 0.
- **No edits** to `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml`.
---
### Task 1: Helpers + interactive elevation (inline, links, tooltips, status borders)
**Files:**
- Modify: `scripts/gen_rack.py` (helpers; `render_svg` `<a>`/`<title>`/status borders + `style=` on `<svg>`; `render_page` inline + download link)
- Modify: `tests/test_gen_rack.py` (append tests)
- Regenerate: `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg`
**Interfaces:**
- Produces (module-level, used by Task 2): `_host_url(host) -> str`, `_status_stroke(status) -> tuple[str, float, str]`, `_stroke_attrs(status) -> str`, `_placement(fm) -> str`, `_tooltip(fm) -> str`.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_svg_boxes_link_to_host_pages():
items = [item(hostname="srv04", rack_u=5, u_height=1, rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert '<a href="/hardware/srv04/">' in svg
assert "<title>" in svg
def test_svg_status_border_styles():
staging = gen_rack.render_svg("rack01", [
item(hostname="a", rack_u=1, u_height=1, rack_face="front",
status="staging")])
broken = gen_rack.render_svg("rack01", [
item(hostname="b", rack_u=1, u_height=1, rack_face="front",
status="broken")])
assert 'stroke-dasharray="4 2"' in staging
assert 'stroke="#e15759"' in broken and 'stroke-width="3"' in broken
def test_svg_tooltip_has_cluster_and_placement():
items = [item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
status="staging", cluster="tappaas")]
svg = gen_rack.render_svg("rack01", items)
assert "cluster: tappaas" in svg
assert "U1" in svg
def test_svg_has_responsive_style():
svg = gen_rack.render_svg("rack01", [])
assert "max-width:100%" in svg
def test_render_page_inlines_svg_with_download_link():
items = [item(hostname="srv04", rack_u=5, u_height=1, rack_face="front")]
page = gen_rack.render_page("rack01", items)
assert '<div class="rack-elevation">' in page
assert "<svg" in page
assert "[Download SVG](rack01-elevation.svg)" in page
assert "![Rack rack01 elevation]" not in page
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — the new assertions fail (`<a href` / `<title>` / `max-width` / inline `<div>` not present yet).
- [ ] **Step 3: Add the helper functions after `_esc` in `scripts/gen_rack.py`**
Insert immediately after the `_esc` function (before `_sorted_items`):
```python
STATUS_STROKE: dict[str, tuple[str, float, str]] = {
"in-use": ("#333333", 1.5, ""),
"staging": ("#333333", 1.5, "4 2"),
"broken": ("#e15759", 3, ""),
"spare": ("#bbbbbb", 1.5, ""),
"donated": ("#bbbbbb", 1.5, ""),
}
DEFAULT_STATUS_STROKE: tuple[str, float, str] = ("#333333", 1.5, "")
def _status_stroke(status: object) -> tuple[str, float, str]:
return STATUS_STROKE.get(status, DEFAULT_STATUS_STROKE)
def _stroke_attrs(status: object) -> str:
stroke, sw, dash = _status_stroke(status)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
return f'stroke="{stroke}" stroke-width="{sw}"{dash_attr}'
def _host_url(host: object) -> str:
return f"/hardware/{host}/"
def _placement(fm: dict) -> str:
if "mounted_on" in fm:
return (
f"{fm.get('mounted_on', '?')}/{fm.get('shelf_face', '')}/"
f"slot {fm.get('shelf_slot', '')}"
)
face = fm.get("rack_face")
if face in ZERO_U_FACES:
return f"0U {face}"
u = fm.get("rack_u")
h = fm.get("u_height")
if isinstance(u, int) and isinstance(h, int):
return f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
return "?"
def _tooltip(fm: dict) -> str:
host = fm.get("hostname", "?")
return _esc(
f"{host} · {fm.get('kind', '')} · {fm.get('status', '')} · "
f"cluster: {fm.get('cluster', '—')} · {_placement(fm)}"
)
```
- [ ] **Step 4: Add the responsive `style` to the `<svg>` tag in `render_svg`**
Replace:
```python
p.append(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
f'height="{height}" viewBox="0 0 {width} {height}" '
f'font-family="sans-serif" font-size="11">'
)
```
with:
```python
p.append(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
f'height="{height}" viewBox="0 0 {width} {height}" '
f'style="max-width:100%;height:auto" '
f'font-family="sans-serif" font-size="11">'
)
```
- [ ] **Step 5: Wrap `draw_device` in a link + title and use the status border**
Replace the whole `draw_device` function with:
```python
def draw_device(fm: dict, col_x: int) -> None:
u = fm["rack_u"]
h = fm["u_height"]
y = u_y(u)
box_h = h * U_H
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
name = fm.get("hostname", "?")
urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
p.append(f'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
f'height="{box_h - 2}" rx="3" fill="{color}" '
f"{_stroke_attrs(fm.get('status'))}/>"
)
p.append(
f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">'
f"{_esc(name)} ({urange})</text>"
)
p.append("</a>")
```
- [ ] **Step 6: Wrap `draw_rail` in a link + title and use the status border**
Replace the whole `draw_rail` function with:
```python
def draw_rail(fm: dict, x: int) -> None:
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
name = fm.get("hostname", "?")
cx = x + RAIL_W // 2
cy = top + body_h // 2
p.append(f'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
f"fill=\"{color}\" {_stroke_attrs(fm.get('status'))}/>"
)
p.append(
f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" '
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
)
p.append("</a>")
```
- [ ] **Step 7: Make shelf occupants and the shelf strip links in `draw_shelf`**
Replace the whole `draw_shelf` function with this version (occupant boxes each link to their host; the two shelf strips + label are one link to the shelf host):
```python
def draw_shelf(fm: dict) -> None:
u = fm["rack_u"]
h = fm["u_height"]
y = u_y(u)
block_h = h * U_H
strip_y = y + block_h - SHELF_STRIP_H
avail_h = block_h - SHELF_STRIP_H
shelf_color = KIND_COLORS.get("shelf", DEFAULT_COLOR)
sname = fm.get("hostname", "?")
for col_x, sface in ((front_x, "front"), (rear_x, "rear")):
occ = sorted(
(m for m in mounted
if m.get("mounted_on") == sname
and m.get("shelf_face") == sface),
key=lambda m: (m.get("shelf_slot", 0), m.get("hostname", "")),
)
n = len(occ)
for idx, m in enumerate(occ):
sub_w = COL_W // n
bx = col_x + idx * sub_w
bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w
mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR)
mname = m.get("hostname", "?")
p.append(f'<a href="{_host_url(mname)}">')
p.append(f"<title>{_tooltip(m)}</title>")
p.append(
f'<rect x="{bx + 1}" y="{y + 1}" width="{bw - 2}" '
f'height="{avail_h - 2}" rx="3" fill="{mcolor}" '
f"{_stroke_attrs(m.get('status'))}/>"
)
p.append(
f'<text x="{bx + bw // 2}" y="{y + avail_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">{_esc(mname)}</text>'
)
p.append("</a>")
p.append(f'<a href="{_host_url(sname)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
for col_x in (front_x, rear_x):
p.append(
f'<rect x="{col_x}" y="{strip_y}" width="{COL_W}" '
f'height="{SHELF_STRIP_H}" fill="{shelf_color}" stroke="#333"/>'
)
p.append(
f'<text x="{front_x + COL_W // 2}" y="{strip_y + SHELF_STRIP_H - 1}" '
f'text-anchor="middle" fill="#333" font-size="9">{_esc(sname)}</text>'
)
p.append("</a>")
```
- [ ] **Step 8: Inline the SVG in `render_page` with a download link**
In `render_page`, replace:
```python
lines.append("## Elevation")
lines.append("")
lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)")
lines.append("")
```
with:
```python
lines.append("## Elevation")
lines.append("")
lines.append('<div class="rack-elevation">')
lines.append(render_svg(rack, items).rstrip())
lines.append("</div>")
lines.append("")
lines.append(f"[Download SVG]({rack}-elevation.svg)")
lines.append("")
```
- [ ] **Step 9: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 5 new).
- [ ] **Step 10: Regenerate and verify build + drift**
Run: `make docs-index`
Expected: `Wrote rack01.md + rack01-elevation.svg (10 item(s))`.
Run: `grep -c 'href="/hardware/' docs/infrastructure/racks/rack01.md`
Expected: ≥ 10 (one link per device box + the shelf).
Run: `make test`
Expected: PASS.
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict`)
Expected: build succeeds, no warnings-as-errors.
Run: `grep -c '<svg' site/infrastructure/racks/rack01/index.html && grep -c 'href="/hardware/' site/infrastructure/racks/rack01/index.html`
Expected: both ≥ 1 — confirms the inline SVG and its links survived `md_in_html` into the built HTML (not escaped).
Run: `make docs-check`
Expected: exit 0.
- [ ] **Step 11: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py docs/infrastructure/racks/
git commit -m "feat(rack): inline interactive elevation with links, tooltips, status borders"
```
---
### Task 2: Legend, both-gutter U-numbers, column frames
**Files:**
- Modify: `scripts/gen_rack.py` (`render_svg` layout + legend)
- Modify: `tests/test_gen_rack.py` (append tests)
- Regenerate: `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg`
**Interfaces:**
- Consumes: `_status_stroke` (Task 1), `KIND_COLORS`, `DEFAULT_COLOR`, `_esc`.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_svg_legend_shows_present_kinds():
items = [item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert ">Legend<" in svg
assert ">switch<" in svg
def test_svg_legend_omits_absent_kinds():
items = [item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert ">ups<" not in svg
def test_svg_u_numbers_in_both_gutters():
svg = gen_rack.render_svg("rack01", [])
assert 'text-anchor="end"' in svg # left gutter
assert 'text-anchor="start"' in svg # right gutter
def test_svg_has_column_frames():
svg = gen_rack.render_svg("rack01", [])
assert svg.count('fill="none"') >= 2 # one frame per column
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `>Legend<`, right-gutter `text-anchor="start"`, and `fill="none"` frames are not present yet.
- [ ] **Step 3: Add the `LEGEND_H` constant and grow the canvas**
In `render_svg`, the constants block currently ends with `TITLE_H = 28`. Add a legend-band constant right after it:
```python
TITLE_H = 28
LEGEND_H = 56
```
Then replace the height line:
```python
height = PAD + TITLE_H + body_h + PAD
```
with:
```python
height = PAD + TITLE_H + body_h + PAD + LEGEND_H
```
- [ ] **Step 4: Add a right-side U-number gutter to the layout**
Replace:
```python
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
rear_x = front_x + COL_W + GAP
width = rear_x + COL_W + len(right_items) * RAIL_W + PAD
```
with:
```python
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
rear_x = front_x + COL_W + GAP
right_gutter_x = rear_x + COL_W
width = right_gutter_x + LABEL_W + len(right_items) * RAIL_W + PAD
```
And replace the right-rail drawing loop:
```python
for idx, fm in enumerate(right_items):
draw_rail(fm, rear_x + COL_W + idx * RAIL_W)
```
with:
```python
for idx, fm in enumerate(right_items):
draw_rail(fm, right_gutter_x + LABEL_W + idx * RAIL_W)
```
- [ ] **Step 5: Draw the column frames and the right-gutter U-numbers**
The left-gutter U-number loop currently reads:
```python
# U numbers in the gutter left of the front column.
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<text x="{front_x - 4}" y="{y + 14}" text-anchor="end" '
f'fill="#999">{u}</text>'
)
```
Immediately after that loop, add the right-gutter numbers and the two column frames:
```python
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<text x="{right_gutter_x + 4}" y="{y + 14}" text-anchor="start" '
f'fill="#999">{u}</text>'
)
for col_x in (front_x, rear_x):
p.append(
f'<rect x="{col_x}" y="{top}" width="{COL_W}" height="{body_h}" '
f'fill="none" stroke="#999" stroke-width="1.5"/>'
)
```
- [ ] **Step 6: Draw the legend before `</svg>`**
The function currently ends:
```python
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm)
p.append("</svg>")
return "\n".join(p) + "\n"
```
Insert the legend between the shelf loop and `p.append("</svg>")`:
```python
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm)
legend_y = top + body_h + PAD + 8
p.append(
f'<text x="{front_x}" y="{legend_y}" font-weight="bold">Legend</text>'
)
present_kinds = sorted({i.get("kind", "") for i in items if i.get("kind")})
kx = front_x
ky = legend_y + 18
for kind in present_kinds:
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
p.append(
f'<rect x="{kx}" y="{ky - 10}" width="12" height="12" '
f'fill="{color}" stroke="#333"/>'
)
p.append(f'<text x="{kx + 16}" y="{ky}">{_esc(kind)}</text>')
kx += 28 + 7 * len(kind)
sx = front_x
sy = ky + 18
for label in ("in-use", "staging", "broken", "spare"):
stroke, sw, dash = _status_stroke(label)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
p.append(
f'<rect x="{sx}" y="{sy - 10}" width="12" height="12" '
f'fill="#ffffff" stroke="{stroke}" stroke-width="{sw}"{dash_attr}/>'
)
p.append(f'<text x="{sx + 16}" y="{sy}">{_esc(label)}</text>')
sx += 28 + 7 * len(label)
p.append("</svg>")
return "\n".join(p) + "\n"
```
- [ ] **Step 7: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 4 new).
- [ ] **Step 8: Regenerate and verify build + drift**
Run: `make docs-index`
Expected: `Wrote rack01.md + rack01-elevation.svg (10 item(s))`.
Run: `grep -c ">Legend<" docs/infrastructure/racks/rack01-elevation.svg`
Expected: `1`.
Run: `make test`
Expected: PASS.
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict`)
Expected: build succeeds.
Run: `make docs-check`
Expected: exit 0.
- [ ] **Step 9: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py docs/infrastructure/racks/
git commit -m "feat(rack): add elevation legend, both-gutter U-numbers, column frames"
```
---
### Task 3: Mermaid node colours + clickable nodes
**Files:**
- Modify: `scripts/gen_rack.py` (`render_power`, `render_network`)
- Modify: `tests/test_gen_rack.py` (append tests)
- Regenerate: `docs/infrastructure/racks/rack01.md`
**Interfaces:**
- Consumes: `_node_id`, `_host_url` (Task 1), `KIND_COLORS`, `DEFAULT_COLOR`.
> **Note on mermaid `click` interactivity.** Node **colouring** (`style …`) always
> works. Whether a `click` actually navigates depends on Material's mermaid
> `securityLevel` (default `strict` may render the link inert) — and this phase
> makes **no `mkdocs.yml` change**. The `click` directive still parses and the
> graph still renders either way; the tests only assert the directives are
> emitted. In Step 6, confirm the graphs still render as **diagrams** (not broken
> code blocks). If clicks turn out inert in the built site, that is acceptable for
> this phase (the SVG elevation already provides clickable navigation) — note it
> in the report rather than adding config.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_power_graph_colors_and_links_nodes():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}]),
]
out = gen_rack.render_power("rack01", items)
assert "style srv01 fill:" in out
assert "style pdu01 fill:" in out
assert 'click srv01 "/hardware/srv01/"' in out
def test_network_graph_colors_and_links_nodes():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24),
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "style sw01 fill:" in out
assert 'click sw01 "/hardware/sw01/"' in out
assert 'click srv01 "/hardware/srv01/"' in out
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `style …`/`click …` lines are not emitted yet.
- [ ] **Step 3: Emit node colours + clicks in `render_power`**
In `render_power`, the function currently ends:
```python
for pdu, outlet, device in edges:
lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
)
lines.append("```")
return "\n".join(lines) + "\n"
```
Insert a styling/click block between the edge loop and `lines.append("```")`:
```python
for pdu, outlet, device in edges:
lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
)
by_host = {fm.get("hostname"): fm for fm in items}
node_hosts = sorted(set(pdus) | {fm.get("hostname", "?") for fm in powered})
for host in node_hosts:
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\n"
```
- [ ] **Step 4: Emit node colours + clicks in `render_network`**
In `render_network`, the function currently ends:
```python
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
lines.append("```")
return "\n".join(lines) + "\n"
```
Insert a styling/click block between the edge loop and `lines.append("```")`:
```python
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
for host in sorted(nodes):
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
if host in by_host:
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\n"
```
(`by_host` already exists in `render_network`; off-rack peers — not in `by_host` — get a default-coloured node and no click, per the spec.)
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 2 new).
- [ ] **Step 6: Regenerate and verify build + drift**
Run: `make docs-index`
Expected: `Wrote rack01.md + rack01-elevation.svg (10 item(s))`.
Run: `grep -c "click srv01" docs/infrastructure/racks/rack01.md`
Expected: `2` (one in the power graph, one in the network graph).
Run: `make test`
Expected: PASS.
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict`)
Expected: build succeeds.
Run: `grep -c 'class="mermaid"' site/infrastructure/racks/rack01/index.html`
Expected: `≥ 2` — confirms both graphs still render as **diagrams** after adding `style`/`click` (i.e. the directives did not break mermaid parsing).
Run: `make docs-check`
Expected: exit 0.
- [ ] **Step 7: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py docs/infrastructure/racks/
git commit -m "feat(rack): colour and link mermaid power/network nodes by kind"
```
---
## Self-Review
**Spec coverage (`2026-06-24-rack-presentation-design.md`):**
- A. Inline SVG + clickable boxes + tooltips + standalone .svg + download link + responsive style — Task 1. ✔
- B. Status border encoding (table verbatim) — Task 1 (`_status_stroke`/`_stroke_attrs`, applied in draw_device/draw_rail/draw_shelf). ✔
- C. Legend (present kinds + status key) — Task 2. ✔
- D. U-numbers both gutters, column frame — Task 2; mermaid node colours + clicks — Task 3. ✔
- Root-relative `/hardware/<host>/` URLs — Task 1 (`_host_url`), used in SVG links and mermaid clicks. ✔
- md_in_html raw passthrough (`<div class="rack-elevation">`, no `markdown` attr) — Task 1 Step 8, verified Task 1 Step 10 (built HTML contains `<svg`/`href`). ✔
- No mkdocs/Makefile/CI/overview_config changes; occupancy + validation unchanged — honored. ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". Legend spacing uses a deterministic width heuristic (`28 + 7*len(label)`), not a placeholder.
**Type consistency:** `_status_stroke -> tuple[str, float, str]`; `_stroke_attrs`/`_host_url`/`_placement`/`_tooltip` -> `str`; `render_svg`/`render_page`/`render_power`/`render_network` -> `str`. `_status_stroke` defined in Task 1 is reused by Task 2's legend; `_host_url` defined in Task 1 is reused by Task 3's clicks. `by_host`/`nodes`/`pdus`/`powered` names in the mermaid edits match the existing function locals. Each task regenerates the artifacts so drift stays clean at every boundary.

View file

@ -0,0 +1,677 @@
# Shelf-Mounted Devices Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Model cabinet/tower PCs that sit on a rack shelf — the shelf reserves a U-range, occupants attach via `mounted_on`/`shelf_face`/`shelf_slot` — and render them as side-by-side boxes in the elevation.
**Architecture:** Extend `scripts/gen_rack.py`: a `mounted_on` branch in `validate_item`, a skip in `check_overlaps`, a new `check_shelves` cross-item validator, and shelf rendering in `render_svg` (a shelf strip plus per-occupant boxes subdividing the column) and `render_page` (occupancy rows for mounted devices). Then populate the worked example. No generator-config or CI changes.
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material, Forgejo Actions CI.
**Spec:** `notes/dev/specs/2026-06-24-rack-shelves-design.md`.
## Global Constraints
- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy existing `gen_rack.py` style). No randomness/time in generated output.
- `re` and `yaml` are already imported in `scripts/gen_rack.py`; do not add new imports.
- Validation failures raise `SchemaError`; `generate` prints `ERROR: …` to stderr and returns `1`, **writing nothing** on failure.
- A **mounted device** declares `mounted_on` (str), `shelf_face` ∈ {front, rear}, `shelf_slot` (int ≥ 1), and **omits** `rack_u`/`u_height`/`rack_face`. A **shelf** (`kind: shelf`) is placed normally (`rack_u`/`u_height`/`rack_face: both`) and reserves the assembly's U-range.
- Peer/PDU/grouping fields (`power:`, `links:`, `cluster:`) on a mounted device are unchanged — they key off hostname, not placement.
- Reuse the existing `item()` and `_write_item` test helpers in `tests/test_gen_rack.py`; add a local `shelf()` helper where noted.
- `isinstance(x, int)` style (bool-is-int acceptable, matching existing code).
- Provisional placeholder data only (matches the existing `srvNN` positions and power/network demos).
- **No edits** to `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml` (`shelf`/`server` already in the enum; drift already covers `racks/`).
- `mkdocs build --strict` must pass; `make docs-check` must exit 0 after regeneration.
---
### Task 1: Validation — mounted branch, overlap skip, `check_shelves` (TDD)
**Files:**
- Modify: `scripts/gen_rack.py` (add `SHELF_FACES`; `validate_item` mounted branch; `check_overlaps` skip; new `check_shelves`; call it in `generate`)
- Modify: `tests/test_gen_rack.py` (append tests + a `shelf()` helper)
**Interfaces:**
- Consumes: `SchemaError`, `item()`/`_write_item` helpers, `generate`.
- Produces:
- `SHELF_FACES = {"front", "rear"}` (constant)
- `check_shelves(items: list[dict]) -> None` — raises `SchemaError` on a bad mount.
- `validate_item` and `check_overlaps` gain mounted-item handling.
- [ ] **Step 1: Append a `shelf()` helper and failing tests to `tests/test_gen_rack.py`**
Add near the top, just after the existing `item()` helper:
```python
def shelf(**kw):
base = {"hostname": "shf01", "kind": "shelf", "status": "in-use",
"rack": "rack01", "rack_u": 37, "u_height": 10, "rack_face": "both"}
base.update(kw)
return base
```
Append these tests at the end of the file:
```python
def test_validate_accepts_mounted_item():
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1))
def test_validate_rejects_mounted_with_rack_u():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1, rack_u=5))
def test_validate_rejects_mounted_bad_shelf_face():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
shelf_face="left", shelf_slot=1))
def test_validate_rejects_mounted_bad_slot():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=0))
def test_overlaps_skips_mounted_items():
items = [
item(hostname="a", mounted_on="shf01", shelf_face="front", shelf_slot=1),
item(hostname="b", mounted_on="shf01", shelf_face="front", shelf_slot=2),
]
gen_rack.check_overlaps(items) # no raise — mounted items claim no U-range
def test_check_shelves_accepts_valid_mount():
items = [shelf(),
item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1)]
gen_rack.check_shelves(items)
def test_check_shelves_rejects_missing_shelf():
items = [item(hostname="srv01", mounted_on="ghost",
shelf_face="front", shelf_slot=1)]
with pytest.raises(gen_rack.SchemaError):
gen_rack.check_shelves(items)
def test_check_shelves_rejects_non_shelf_target():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front"),
item(hostname="srv01", mounted_on="sw01",
shelf_face="front", shelf_slot=1),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.check_shelves(items)
def test_check_shelves_rejects_duplicate_slot():
items = [shelf(),
item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1),
item(hostname="srv02", mounted_on="shf01",
shelf_face="front", shelf_slot=1)]
with pytest.raises(gen_rack.SchemaError):
gen_rack.check_shelves(items)
def test_generate_returns_1_on_dangling_mount(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw, "srv01",
"---\nhostname: srv01\nkind: server\nstatus: in-use\n"
"rack: rack01\nmounted_on: ghost\nshelf_face: front\nshelf_slot: 1\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 1
assert not (out / "rack01.md").exists()
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'check_shelves'` (and the mounted-validation tests fail because `validate_item` rejects the missing `rack_face`).
- [ ] **Step 3: Add the `SHELF_FACES` constant**
In `scripts/gen_rack.py`, just after the existing `ZERO_U_FACES = {"left", "right"}` line, add:
```python
SHELF_FACES = {"front", "rear"}
```
- [ ] **Step 4: Add the mounted branch to `validate_item`**
In `validate_item`, the function currently begins:
```python
def validate_item(fm: dict) -> None:
name = fm.get("hostname") or fm.get("_path", "?")
rack = fm.get("rack")
if not isinstance(rack, str) or not rack:
raise SchemaError(f"{name}: rack must be a non-empty string")
face = fm.get("rack_face")
```
Insert the mounted branch between the `rack` check and the `face = …` line, so it reads:
```python
def validate_item(fm: dict) -> None:
name = fm.get("hostname") or fm.get("_path", "?")
rack = fm.get("rack")
if not isinstance(rack, str) or not rack:
raise SchemaError(f"{name}: rack must be a non-empty string")
if "mounted_on" in fm:
mounted_on = fm.get("mounted_on")
if not isinstance(mounted_on, str) or not mounted_on:
raise SchemaError(f"{name}: mounted_on must be a non-empty string")
for forbidden in ("rack_u", "u_height", "rack_face"):
if forbidden in fm:
raise SchemaError(
f"{name}: mounted item must omit {forbidden}"
)
sface = fm.get("shelf_face")
if sface not in SHELF_FACES:
raise SchemaError(
f"{name}: shelf_face={sface!r} not in {sorted(SHELF_FACES)}"
)
slot = fm.get("shelf_slot")
if not isinstance(slot, int) or slot < 1:
raise SchemaError(f"{name}: shelf_slot must be an integer >= 1")
return
face = fm.get("rack_face")
```
(The rest of `validate_item` — the `face`/0U/U-range checks — is unchanged.)
- [ ] **Step 5: Skip mounted items in `check_overlaps`**
In `check_overlaps`, the loop body currently starts:
```python
for fm in items:
face = fm.get("rack_face")
if face in ZERO_U_FACES:
continue
```
Add a mounted skip as the first thing in the loop:
```python
for fm in items:
if "mounted_on" in fm:
continue
face = fm.get("rack_face")
if face in ZERO_U_FACES:
continue
```
- [ ] **Step 6: Add `check_shelves` after `check_overlaps`**
Add this function immediately after `check_overlaps` in `scripts/gen_rack.py`:
```python
def check_shelves(items: list[dict]) -> None:
"""Validate shelf-mounted devices within one rack.
Every mounted_on resolves to a placed kind:shelf item in the same rack;
no two devices share (shelf, face, slot).
"""
by_host = {fm.get("hostname"): fm for fm in items}
occupied: dict[tuple[str, str, int], str] = {}
for fm in items:
if "mounted_on" not in fm:
continue
name = fm.get("hostname", "?")
shelf_name = fm["mounted_on"]
target = by_host.get(shelf_name)
if target is None:
raise SchemaError(
f"{name}: mounted_on={shelf_name!r} is not in this rack"
)
if target.get("kind") != "shelf":
raise SchemaError(
f"{name}: mounted_on={shelf_name!r} is not a kind:shelf item"
)
if not isinstance(target.get("rack_u"), int) or not isinstance(
target.get("u_height"), int
):
raise SchemaError(
f"{name}: shelf {shelf_name!r} is not placed (needs rack_u/u_height)"
)
key = (shelf_name, fm["shelf_face"], fm["shelf_slot"])
if key in occupied:
raise SchemaError(
f"{shelf_name} {fm['shelf_face']} slot {fm['shelf_slot']}: "
f"{name} overlaps {occupied[key]}"
)
occupied[key] = name
```
- [ ] **Step 7: Wire `check_shelves` into `generate`**
In `generate`, the per-rack validation loop currently reads:
```python
try:
check_overlaps(ritems)
validate_power(ritems)
validate_links(ritems, hw_index)
except SchemaError as e:
errors.append(f"{rack}: {e}")
```
Add `check_shelves(ritems)`:
```python
try:
check_overlaps(ritems)
validate_power(ritems)
validate_links(ritems, hw_index)
check_shelves(ritems)
except SchemaError as e:
errors.append(f"{rack}: {e}")
```
- [ ] **Step 8: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 10 new).
- [ ] **Step 9: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): validate shelf-mounted devices (mounted_on/shelf_face/shelf_slot)"
```
---
### Task 2: Rendering — shelf strip + occupant boxes + occupancy rows (TDD)
**Files:**
- Modify: `scripts/gen_rack.py` (`render_svg` shelf drawing; `render_page` occupancy)
- Modify: `tests/test_gen_rack.py` (append tests)
**Interfaces:**
- Consumes: `render_svg`, `render_page`, the `shelf()` helper (Task 1).
- Produces: `render_svg`/`render_page` render shelves and mounted occupants. No new public function.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_render_svg_draws_shelf_and_occupants():
items = [
shelf(),
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
item(hostname="srv03", mounted_on="shf01", shelf_face="rear", shelf_slot=1),
]
svg = gen_rack.render_svg("rack01", items)
assert "shf01" in svg
assert "srv01" in svg and "srv02" in svg and "srv03" in svg
# the shelf is NOT drawn as a generic full-height device box
assert "shf01 (U37" not in svg
def test_render_svg_shelf_is_deterministic():
base = [
shelf(),
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
]
assert gen_rack.render_svg("rack01", base) == gen_rack.render_svg(
"rack01", list(reversed(base))
)
def test_render_page_lists_mounted_devices():
items = [shelf(),
item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1)]
page = gen_rack.render_page("rack01", items)
assert "../../hardware/srv01.md" in page
assert "front · shf01/1" in page
assert "U37U46" in page # mounted device shows its shelf's U-range
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `test_render_page_lists_mounted_devices` raises `KeyError: 'rack_u'` (occupancy loop has no mounted handling) and the SVG tests fail (`shf01`/occupant boxes not drawn; the shelf is drawn as a generic box so `"shf01 (U37"` is present).
- [ ] **Step 3: Render shelves in `render_svg`**
In `render_svg`, the generic device loop currently reads:
```python
for fm in items:
face = fm.get("rack_face")
if face in ("front", "both"):
draw_device(fm, front_x)
if face in ("rear", "both"):
draw_device(fm, rear_x)
```
Replace it with a version that skips shelves and mounted items:
```python
for fm in items:
if fm.get("kind") == "shelf" or "mounted_on" in fm:
continue
face = fm.get("rack_face")
if face in ("front", "both"):
draw_device(fm, front_x)
if face in ("rear", "both"):
draw_device(fm, rear_x)
```
Then, immediately before the final `p.append("</svg>")` line, add the shelf drawing:
```python
SHELF_STRIP_H = 6
shelves = [i for i in items if i.get("kind") == "shelf"]
mounted = [i for i in items if "mounted_on" in i]
def draw_shelf(fm: dict) -> None:
u = fm["rack_u"]
h = fm["u_height"]
y = u_y(u)
block_h = h * U_H
strip_y = y + block_h - SHELF_STRIP_H
avail_h = block_h - SHELF_STRIP_H
shelf_color = KIND_COLORS.get("shelf", DEFAULT_COLOR)
sname = fm.get("hostname", "?")
for col_x, sface in ((front_x, "front"), (rear_x, "rear")):
occ = sorted(
(m for m in mounted
if m.get("mounted_on") == sname
and m.get("shelf_face") == sface),
key=lambda m: (m.get("shelf_slot", 0), m.get("hostname", "")),
)
n = len(occ)
for idx, m in enumerate(occ):
sub_w = COL_W // n
bx = col_x + idx * sub_w
bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w
mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR)
mname = m.get("hostname", "?")
p.append(
f'<rect x="{bx + 1}" y="{y + 1}" width="{bw - 2}" '
f'height="{avail_h - 2}" rx="3" fill="{mcolor}" stroke="#333"/>'
)
p.append(
f'<text x="{bx + bw // 2}" y="{y + avail_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">{_esc(mname)}</text>'
)
p.append(
f'<rect x="{col_x}" y="{strip_y}" width="{COL_W}" '
f'height="{SHELF_STRIP_H}" fill="{shelf_color}" stroke="#333"/>'
)
p.append(
f'<text x="{front_x + COL_W // 2}" y="{strip_y + SHELF_STRIP_H - 1}" '
f'text-anchor="middle" fill="#333" font-size="9">{_esc(sname)}</text>'
)
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm)
```
- [ ] **Step 4: Render mounted devices in the `render_page` occupancy table**
In `render_page`, the occupancy loop currently reads:
```python
for fm in items:
name = fm.get("hostname", "?")
link = f"[{name}](../../hardware/{name}.md)"
face = fm.get("rack_face", "")
if face in ZERO_U_FACES:
urange = "0U"
else:
u = fm["rack_u"]
h = fm["u_height"]
urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
lines.append(
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
f"| {fm.get('status', '')} |"
)
```
Replace that whole loop with one that orders mounted devices right after their
shelf and renders their shelf-relative position:
```python
by_host = {fm.get("hostname"): fm for fm in items}
mounted_by_shelf: dict[str, list[dict]] = {}
for fm in items:
if "mounted_on" in fm:
mounted_by_shelf.setdefault(fm["mounted_on"], []).append(fm)
def occ_row(fm: dict) -> str:
name = fm.get("hostname", "?")
link = f"[{name}](../../hardware/{name}.md)"
if "mounted_on" in fm:
target = by_host.get(fm["mounted_on"])
if target and isinstance(target.get("rack_u"), int):
su = target["rack_u"]
sh = target["u_height"]
urange = f"U{su}" if sh == 1 else f"U{su}U{su + sh - 1}"
else:
urange = "—"
face = (
f"{fm.get('shelf_face', '')} · "
f"{fm['mounted_on']}/{fm.get('shelf_slot', '')}"
)
else:
face = fm.get("rack_face", "")
if face in ZERO_U_FACES:
urange = "0U"
else:
u = fm["rack_u"]
h = fm["u_height"]
urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
return (
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
f"| {fm.get('status', '')} |"
)
for fm in _sorted_items([i for i in items if "mounted_on" not in i]):
lines.append(occ_row(fm))
if fm.get("kind") == "shelf":
occ = sorted(
mounted_by_shelf.get(fm.get("hostname"), []),
key=lambda m: (m.get("shelf_face", ""), m.get("shelf_slot", 0)),
)
for m in occ:
lines.append(occ_row(m))
```
(The `items = _sorted_items(items)` line at the top of `render_page` and the
graph sections above are unchanged.)
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 3 new).
- [ ] **Step 6: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): render shelf strip, occupant boxes, and mounted occupancy rows"
```
---
### Task 3: Populate the shelf demo, regenerate
**Files:**
- Create: `docs/hardware/shf01.md`
- Modify: `docs/hardware/srv01.md`, `srv02.md`, `srv03.md` (convert to mounted)
- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
**Interfaces:**
- Consumes: `make docs-index`, `make test`, `mkdocs build --strict`, `make docs-check`.
> **Operator note — provisional data.** The shelf placement and the front/rear/slot assignments are placeholders matching the worked example (TaPPaaS: srv01/srv02 front, srv03 rear, on a shelf reserving U37U46). `check_shelves`/`check_overlaps` reject inconsistent data loudly.
- [ ] **Step 1: Create the shelf file**
Create `docs/hardware/shf01.md`:
```markdown
---
hostname: shf01
kind: shelf
status: in-use
rack: rack01
rack_u: 37
u_height: 10
rack_face: both
cluster: tappaas
---
## Notes
- Provisional placeholder shelf holding the TaPPaaS nodes (srv01/srv02 front, srv03 rear).
```
- [ ] **Step 2: Convert `srv01.md` to mounted**
In `docs/hardware/srv01.md`, replace these three frontmatter lines:
```yaml
rack_u: 1
u_height: 1
rack_face: front
```
with:
```yaml
mounted_on: shf01
shelf_face: front
shelf_slot: 1
```
Leave everything else (including `cluster: tappaas`, `power:`, `links:`) unchanged.
- [ ] **Step 3: Convert `srv02.md` to mounted and tag its cluster**
In `docs/hardware/srv02.md`, replace these three frontmatter lines:
```yaml
rack_u: 2
u_height: 1
rack_face: front
```
with:
```yaml
mounted_on: shf01
shelf_face: front
shelf_slot: 2
```
Then add a `cluster: tappaas` line immediately after the `status: staging` line, so the top reads:
```yaml
hostname: srv02
kind: server
status: staging
cluster: tappaas
location: The pile
```
- [ ] **Step 4: Convert `srv03.md` to mounted and tag its cluster**
In `docs/hardware/srv03.md`, replace these three frontmatter lines:
```yaml
rack_u: 3
u_height: 1
rack_face: front
```
with:
```yaml
mounted_on: shf01
shelf_face: rear
shelf_slot: 1
```
Then add a `cluster: tappaas` line immediately after the `status: staging` line:
```yaml
hostname: srv03
kind: server
status: staging
cluster: tappaas
location: The pile
```
- [ ] **Step 5: Regenerate all indices and rack artifacts**
Run: `make docs-index`
Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing `shf01` under Shelves); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (10 item(s))`, exit 0 (no schema error).
- [ ] **Step 6: Confirm the shelf and mounted devices rendered**
Run: `grep -c "shf01/" docs/infrastructure/racks/rack01.md`
Expected: `3` (occupancy notes `front · shf01/1`, `front · shf01/2`, `rear · shf01/1`).
Run: `grep -q "shf01" docs/infrastructure/racks/rack01-elevation.svg && echo OK`
Expected: `OK` (shelf strip drawn).
Run: `grep -q "U37U46" docs/infrastructure/racks/rack01.md && echo OK`
Expected: `OK` (mounted devices show the shelf's U-range).
- [ ] **Step 7: Run the full test suite**
Run: `make test`
Expected: PASS.
- [ ] **Step 8: Build the site strictly**
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict` if `mkdocs` is not on PATH)
Expected: build succeeds with no warnings-as-errors.
- [ ] **Step 9: Confirm the drift guard is satisfied**
Run: `make docs-check`
Expected: exit 0.
- [ ] **Step 10: Commit**
```bash
git add docs/hardware/ docs/infrastructure/racks/
git commit -m "feat(rack): place TaPPaaS nodes on shelf shf01 (provisional)"
```
---
## Self-Review
**Spec coverage (`2026-06-24-rack-shelves-design.md`):**
- Container model: shelf placed + reserves U-range; mounted devices via `mounted_on`/`shelf_face`/`shelf_slot` — Task 1 (validation), Task 3 (data). ✔
- `validate_item` mounted branch (forbid rack_u/u_height/rack_face; require shelf_face/shelf_slot) — Task 1 Step 4. ✔
- `check_overlaps` skips mounted items — Task 1 Step 5. ✔
- `check_shelves` (resolves to placed kind:shelf in rack; unique slot) wired into `generate` — Task 1 Steps 67. ✔
- SVG shelf strip + subdivided occupant boxes — Task 2 Step 3. ✔
- Occupancy rows for mounted devices (shelf U-range + `face · shelf/slot`, ordered after the shelf) — Task 2 Step 4. ✔
- Power/network unchanged — no edits to `render_power`/`render_network`; mounted devices keep `power:`/`links:`. ✔
- Demo data (shf01 U37U46; srv01/srv02 front 12, srv03 rear 1; cluster tappaas) — Task 3. ✔
- No mkdocs/Makefile/CI/overview_config changes — Global Constraints. ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The provisional demo placements are real-data-dependent and explicitly bounded.
**Type consistency:** `SHELF_FACES: set`; `check_shelves(items) -> None` (raises `SchemaError`), called per-rack alongside `check_overlaps`/`validate_power`/`validate_links`; `render_svg`/`render_page` -> `str`. Mounted items are identified uniformly by `"mounted_on" in fm` across `validate_item`, `check_overlaps`, `check_shelves`, `render_svg`, and `render_page`. The `shelf()` test helper (Task 1) is reused in Task 2. Field names (`mounted_on`, `shelf_face`, `shelf_slot`) match across tasks, tests, and demo data.

View file

@ -0,0 +1,168 @@
# Rack Documentation Design
**Date:** 2026-06-24
**Status:** Approved
## Goal
Document the 48U server rack as hand-edited Markdown that a CI pass turns into a
clear visual presentation, mirroring the existing `docs/hardware/*.md`
`gen_overview.py` → generated-index pattern. The rendered output covers three
views: a physical rack **elevation** (SVG), a **power** distribution graph
(mermaid), and a **network cabling** graph (mermaid).
Authors (operator or AI) edit frontmatter; a push regenerates the artifacts and
CI fails on drift, so the published page at `docs.makerfloss.eu` is always in
sync with the source.
## Context
- Per-item frontmatter is already the norm: `docs/hardware/*.md` and
`docs/services/*.md` carry YAML frontmatter validated by
`scripts/gen_overview.py` against schemas in `scripts/overview_config.yml`.
- The generator writes a grouped/sorted `index.md`; CI (`.forgejo/workflows/docs.yml`)
regenerates it and runs `git diff --exit-code` to **fail on drift**.
- Mermaid already renders in the Marp slide pipeline; it is **not yet enabled in
MkDocs**. Enabling it is a small `mkdocs.yml` change (superfences custom fence;
Material ships mermaid.js).
- The rack contains devices that already have host pages (`mf01`..`mf04`).
- **The physical rack is labeled U1 at the top**, descending to U48 at the
bottom (non-standard; standard racks number U1 at the bottom). The elevation
must match the physical labels.
## Data model
Rack data is added to **host frontmatter** (decision: extend existing files
rather than introduce a separate layout file). Rack-mounted items that are not
hosts (PDUs, patch panels, shelves, blank panels, UPS, KVM) each get their own
lightweight file in `docs/hardware/` using new `kind` values.
### Frontmatter fields
```yaml
# placement (Phase 1)
rack: rack01 # rack identifier; one rack today, field enables future racks
rack_u: 12 # lowest U occupied (148)
u_height: 2 # number of U occupied
rack_face: front # front | rear | both | left | right (0U PDUs use left/right rails)
# power (Phase 2) — on each powered device
power:
- { pdu: pdu01, outlet: 3 }
- { pdu: pdu02, outlet: 3 } # a second entry expresses a redundant PSU feed
# network (Phase 3) — on each device, one entry per cable end originating here
links:
- { local: eth0, peer: sw01, peer_port: 12, speed_gbps: 1 }
```
### New `kind` enum values
Extend the `hardware` enum in `overview_config.yml` with: `pdu`, `patch-panel`,
`shelf`, `blank`, `ups`, `kvm` (joining the existing `server`, `laptop`, `sbc`,
`switch`, `ap`, `desktop`).
Non-host item files declare their own capacity where relevant:
```yaml
# docs/hardware/pdu01.md
hostname: pdu01
kind: pdu
status: in-use
rack: rack01
rack_face: left
outlets: 8
```
```yaml
# docs/hardware/pp01.md
hostname: pp01
kind: patch-panel
status: in-use
rack: rack01
rack_u: 24
u_height: 1
rack_face: front
ports: 24
```
## Generator: `scripts/gen_rack.py`
A sibling to `gen_overview.py`, sharing its style (stdlib + PyYAML, deterministic,
offline, `SchemaError` → non-zero exit). It reads every `docs/hardware/*.md` with
a `rack:` field, validates the rack schema, groups by `rack`, and writes
generated artifacts per rack.
### Outputs (do-not-edit, generated)
- `docs/infrastructure/racks/rack01-elevation.svg` — the elevation picture.
- `docs/infrastructure/racks/rack01.md` — generated page embedding, in order:
1. the elevation SVG (`![Rack rack01 elevation](rack01-elevation.svg)`),
2. a mermaid **power** graph (Phase 2),
3. a mermaid **network** graph (Phase 3),
4. an occupancy table (U-range, hostname/link, kind, face, status).
Each generated file carries the same "Auto-generated … do not edit by hand"
banner the existing indices use.
### Rendering
- **Elevation (SVG):** two side-by-side columns, **front** and **rear**. 48 U
rows numbered **U1 at the top → U48 at the bottom** to match the physical
rack. Each device is a rectangle spanning its true `u_height`, filled by a
per-`kind` color, labeled with hostname and U-range. Empty U slots drawn
faintly. 0U side-rail items (`rack_face: left|right`) drawn as thin vertical
bars beside the columns. Plain hand-written SVG strings — no external drawing
library.
- **Power (mermaid):** `flowchart` of `pdu → (outlet) → device`; redundant feeds
appear as multiple edges into one device.
- **Network (mermaid):** `flowchart` of `device[:local] -- speed --> peer[:port]`
edges built from `links`.
### Validation rules (CI-enforced, fail with a clear message)
1. `rack_u` in 148; `rack_u + u_height 1 ≤ 48`.
2. No two items overlap the same U range on the same `rack_face` within a rack.
3. Every `power[].pdu` resolves to a file whose `kind: pdu`; `outlet` within that
PDU's `outlets`.
4. Every `links[].peer` resolves to a real file; `peer_port` within the peer's
declared `ports`/port count where the peer declares one.
5. Items with `rack_face: left|right` (0U) must omit `rack_u`/`u_height`;
all other rack items must include them.
## Integration
| File | Change |
|------|--------|
| `scripts/gen_rack.py` | New generator (SVG + mermaid + table + validation) |
| `scripts/overview_config.yml` | Extend `hardware` `kind` enum with new values; optional `rack` config block if reusing config-driven validation |
| `docs/hardware/*.md` | Add placement (then power, then links) fields to rack occupants; add new non-host item files |
| `docs/infrastructure/racks/` | New dir holding generated `rack01.md` + `rack01-elevation.svg` |
| `Makefile` | `docs-index`/`docs-check` gain a `gen_rack.py` step |
| `.forgejo/workflows/docs.yml` | Run `gen_rack.py`; extend the drift `git diff` guard to the generated rack artifacts |
| `mkdocs.yml` | Enable mermaid (superfences custom fence) [Phase 3]; add the rack page to `nav` |
## Phasing
Each phase is independently shippable as its own PR.
1. **Phase 1 — Elevation.** Placement schema + new kinds + `gen_rack.py`
producing the SVG elevation and occupancy table + CI drift check + nav entry.
Populate `rack01` with the current devices.
2. **Phase 2 — Power.** `power` fields + PDU files + generated mermaid power
graph + validation rules 3.
3. **Phase 3 — Network.** `links` fields + patch-panel files + generated mermaid
network graph + validation rule 4 + enable mermaid in `mkdocs.yml`.
## Test plan
- **Unit (per phase):** run `python3 scripts/gen_rack.py`; assert it writes the
expected artifacts and exits 0 on a valid fixture set.
- **Validation:** craft fixtures that violate each rule (U overflow, overlap,
dangling `pdu`/`peer`, bad outlet/port, 0U with U fields) and assert non-zero
exit with the right message.
- **Drift:** run the generator, confirm `git diff --exit-code` is clean; mutate a
source file without regenerating and confirm CI's guard fails.
- **Visual:** `make docs-build` (or `docs-serve`), open the rack page, confirm
the SVG shows U1 at the top, devices at correct positions/faces, and (Phase
2/3) the mermaid graphs render rather than appearing as code blocks.

View file

@ -0,0 +1,178 @@
# Rack Network (Phase 3) Design
**Date:** 2026-06-24
**Status:** Approved
**Parent spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (Phase 3)
## Goal
Add network-cabling data to the rack documentation pipeline and render it as a
mermaid network graph on the generated rack page, so the published page at
`docs.makerfloss.eu` shows which interface/port connects each device to its
switch or patch panel. This reuses every Phase 1/2 mechanism: the same
`scripts/gen_rack.py` generator, the same generated files under
`docs/infrastructure/racks/`, and the same CI drift guard. Mermaid is already
enabled (pulled forward in Phase 2), so no `mkdocs.yml` change is needed.
## Context
- Phase 1 (elevation) and Phase 2 (power) are merged. `scripts/gen_rack.py`
reads `docs/hardware/*.md` files carrying a `rack:` field, validates
placement and power, and writes `<rack>-elevation.svg` + `<rack>.md` per rack.
The page already has `## Elevation`, `## Power`, `## Occupancy` sections.
- The `switch`, `patch-panel`, and `ap` values are already in the `hardware`
`kind` enum (`scripts/overview_config.yml`), so peer files already validate
and already appear in the hardware index. **No `overview_config.yml` change.**
- Phase 1 already renders placed (`rack_u`/`u_height`) items as colored boxes in
the elevation, with `switch` green and `patch-panel` brown in `KIND_COLORS`,
so the new peer files need **no new SVG code** to appear.
- The Makefile `docs-check` and CI drift step already diff the entire
`docs/infrastructure/racks/` directory — **no Makefile/CI edits required**.
- `_node_id` (Phase 2) sanitizes hostnames into mermaid-safe node ids and is
reused here.
- The `mfNN` rack positions and the Phase 2 power data are fictional
placeholders proving the pipeline. The network data added here is **similarly
provisional** until real values are given.
## Data model
### Devices/peers — `links:` frontmatter
Each cable end is declared **once**, on the originating end:
```yaml
links:
- { local: eth0, peer: pp01, peer_port: 1, speed_gbps: 1 }
```
`links` is optional — an item with no `links` contributes no edges. Any rack
item may declare links, not just servers (e.g. `pp01` declares its uplink).
Fields per entry:
- `local` — the local interface/port label (non-empty string), the cable end on
this item.
- `peer` — the hostname of the device at the other end (non-empty string).
- `peer_port` — the port number on the peer (integer).
- `speed_gbps` — link speed in Gbps (optional integer; shown on the edge when
present).
**Declare-once convention:** a physical cable is declared on exactly one end to
avoid duplicate edges. The generator renders whatever is declared; it does not
infer the reverse direction.
### New peer files
```yaml
# docs/hardware/sw01.md
hostname: sw01
kind: switch
status: in-use
rack: rack01
rack_u: 10
u_height: 1
rack_face: front
ports: 24
```
```yaml
# docs/hardware/pp01.md
hostname: pp01
kind: patch-panel
status: in-use
rack: rack01
rack_u: 24
u_height: 1
rack_face: front
ports: 24
```
`ports` is a new field, validated by `gen_rack.py` only as the bound for
`peer_port`. `gen_overview.py` ignores `ports` and `links`.
### Provisional data populated by this phase (full pass-through)
- `sw01``kind: switch`, U10 front, `ports: 24`.
- `pp01``kind: patch-panel`, U24 front, `ports: 24`, with one uplink link
`{ local: uplink, peer: sw01, peer_port: 24, speed_gbps: 1 }`.
- `mf01..mf04``eth0 → pp01` ports 1..4 respectively, `speed_gbps: 1`.
- `mf00``eth0 → sw01` port 1, `speed_gbps: 1`.
sw01 and pp01 deliberately get **no** `power:` feeds in this phase, to keep the
Phase 3 diff network-focused; the power graph is unchanged.
## Validation (rule 4 from the parent spec)
A new `load_hardware_index(hardware_dir) -> dict[str, dict]` returns
`{hostname: frontmatter}` for **every** `docs/hardware/*.md` (excluding
`index.md`), giving global peer resolution.
A new `validate_links(items, hw_index) -> None` in `gen_rack.py`, called from
`generate()` per rack after placement/power validation, raises `SchemaError`
(→ stderr + exit 1, nothing written) when:
1. A `links` value is not a list, or an entry is not a mapping.
2. An entry lacks a non-empty string `local`, a non-empty string `peer`, or an
integer `peer_port`.
3. An entry's `peer` does not resolve to a hostname in `hw_index` (rule 4:
"resolves to a real file" — global, not per-rack).
4. The peer declares an integer `ports` and `peer_port` is outside
`1..ports`. (Peers without a declared `ports` skip the range check.)
Peer resolution is intentionally **global** (against all hardware files),
diverging from power's per-rack PDU resolution, so a link may target non-racked
gear (e.g. an upstream router) in future.
## Rendering
### `render_network(rack, items) -> str`
Returns a fenced mermaid block, or `""` when no item in the rack has any
`links` entry (so the `## Network` section is omitted for link-less racks).
- ` ```mermaid ` + `flowchart LR`.
- One node per host that appears as a link source or peer. A node whose kind
(resolved from the rack's items) is `switch` or `patch-panel` gets a
`<br/>kind` subtitle (e.g. `sw01<br/>switch`); all other nodes render as the
bare hostname. Peers not present in the rack's items render as the bare
hostname.
- One edge per link: `sourceNode -->|{local} → p{peer_port} · {speed}G| peerNode`.
The ` · {speed}G` suffix is omitted when `speed_gbps` is absent. The unicode
arrow `→` avoids any clash with mermaid's `-->` edge syntax.
- Node ids via `_node_id` (Phase 2). Deterministic: nodes sorted by hostname,
edges sorted by `(source, local, peer, peer_port)`.
### `render_page` change
Insert a `## Network` section containing `render_network(...)` **between** the
Power and Occupancy sections — only when `render_network` returns non-empty.
## Integration
| File | Change |
|------|--------|
| `scripts/gen_rack.py` | Add `load_hardware_index`, `validate_links`; call `validate_links` in `generate`; add `render_network`; insert `## Network` in `render_page` |
| `tests/test_gen_rack.py` | Add `validate_links` + `render_network` + `generate` network cases |
| `docs/hardware/sw01.md`, `pp01.md` | New peer files (`switch`/`patch-panel`, `ports: 24`); `pp01` carries the uplink `links` entry |
| `docs/hardware/mf00.md`..`mf04.md` | Add `links:` lists |
| `docs/hardware/index.md` | Regenerated (switch + patch panel now listed) |
| `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg` | Regenerated (network section + sw01/pp01 boxes in the elevation) |
No `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or
`overview_config.yml` changes.
## Test plan
- **Unit — `validate_links`:** accept a valid link; reject an unknown `peer`, a
`peer_port` above the peer's `ports`, a malformed entry (non-mapping / missing
`local`/`peer`/`peer_port`). Accept a link whose peer declares no `ports`
(range check skipped).
- **Unit — `render_network`:** source/peer nodes present; peer kind subtitle for
switch/patch-panel; edge label carries local interface, peer port, and speed;
returns `""` when no item has links; deterministic for reordered input.
- **Integration — `generate`:** with valid fixtures the page contains the
`## Network` section and the mermaid fence; with a dangling `peer` it returns
`1` and writes nothing.
- **Drift:** `make docs-check` exits 0 after regeneration (existing guard).
- **Visual:** `mkdocs build --strict` succeeds and the rack page shows the
network graph as a rendered diagram, with the pass-through chain
mf01..mf04 → pp01 → sw01 and mf00 → sw01 visible.

View file

@ -0,0 +1,163 @@
# Rack Power (Phase 2) Design
**Date:** 2026-06-24
**Status:** Approved
**Parent spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (Phase 2)
## Goal
Add power-distribution data to the rack documentation pipeline and render it as a
mermaid graph on the generated rack page, so the published page at
`docs.makerfloss.eu` shows which PDU/outlet feeds each device and which devices
have redundant (dual-PSU) feeds. This reuses every Phase 1 mechanism: the same
`scripts/gen_rack.py` generator, the same generated files under
`docs/infrastructure/racks/`, and the same CI drift guard.
## Context
- Phase 1 (rack elevation) is merged. `scripts/gen_rack.py` reads
`docs/hardware/*.md` files carrying a `rack:` field, validates placement
(U range, overlap, 0U rules), and writes `<rack>-elevation.svg` +
`<rack>.md` per rack. Tests in `tests/test_gen_rack.py`.
- The `pdu` value is already in the `hardware` `kind` enum
(`scripts/overview_config.yml`), so PDU files already validate and already
appear in the hardware index under "PDUs".
- Phase 1 already renders 0U `rack_face: left|right` items as side-rails in the
SVG, so PDU files need **no new SVG code** to appear in the elevation.
- The Makefile `docs-check` target and CI `Fail on drift` step already diff the
**entire** `docs/infrastructure/racks/` directory, so a regenerated page with
a power graph is already drift-covered — **no Makefile/CI edits required**.
- Mermaid is **not yet enabled** in `mkdocs.yml` (superfences has no mermaid
custom fence). Enabling it was nominally a Phase 3 item; Phase 2's graph needs
it, so it is **pulled forward** into this phase (decided during brainstorming).
- The `mfNN` rack positions are fictional placeholders proving the pipeline. The
power data added here is **similarly provisional** until real values are given.
## Data model
### Powered devices — `power:` frontmatter
Each powered device gains a `power` list; each entry is one feed:
```yaml
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 } # a second entry = a redundant PSU feed
```
`power` is optional — a device with no `power` field simply contributes no power
edges.
### PDU files — new lightweight hardware items
```yaml
# docs/hardware/pdu01.md
hostname: pdu01
kind: pdu
status: in-use
rack: rack01
rack_face: left
outlets: 8
```
PDUs are 0U side-rail items (`rack_face: left|right`, no `rack_u`/`u_height`),
exactly the shape Phase 1's validator and SVG already handle. `outlets` is a new
field, validated by `gen_rack.py` (below). No `overview_config.yml` change is
needed: `kind: pdu` is already an enum value, and `outlets` is an extra field
that `gen_overview.py` ignores.
### Provisional data populated by this phase
- `pdu01``rack_face: left`, `outlets: 8`.
- `pdu02``rack_face: right`, `outlets: 8`.
- `mf00..mf04` — fed from `pdu01` outlets 1..5 respectively.
- `mf00` — additionally fed from `pdu02` outlet 1 (the redundant demonstration).
## Validation (rule 3 from the parent spec)
A new `validate_power(items: list[dict]) -> None` in `gen_rack.py`, called from
`generate()` after per-item placement validation and before/with overlap
checking. It raises `SchemaError` (→ stderr + exit 1, nothing written) when:
1. A `kind: pdu` file does not declare `outlets` as a positive integer.
2. A `power` value is not a list, or an entry is not a mapping.
3. An entry lacks a non-empty string `pdu` or an integer `outlet`.
4. An entry's `pdu` does not resolve to a loaded file whose `kind == pdu`.
5. An entry's `outlet` is outside `1..outlets` of the referenced PDU.
PDU resolution is by `hostname` against all loaded rack items (a PDU lookup map
`{hostname: fm for fm in items if kind == pdu}`).
## Rendering
### `render_power(rack, items) -> str`
Returns a fenced mermaid block, or `""` when no device in the rack has any
`power` entry (so the `## Power` section is omitted for power-less racks).
- ` ```mermaid ` + `flowchart LR`.
- One node per PDU that is referenced or placed in the rack:
label `pdu01<br/>8 outlets`.
- One node per powered device: label = hostname.
- One edge per feed: `pduNode -->|outlet N| deviceNode`.
- Node ids are the hostname with non-alphanumeric characters replaced by `_`
(display text keeps the real hostname via the quoted label), guarding against
ids that mermaid would reject.
- Deterministic: PDU nodes sorted by hostname, device nodes by
`(rack_u, hostname)`, edges sorted by `(pdu, outlet, device)`.
Redundant feeds render naturally as two edges into one device node, from two
different PDUs.
### `render_page` change
Insert a `## Power` section containing `render_power(...)` **between** the
Elevation and Occupancy sections — only when `render_power` returns non-empty.
## mkdocs (pulled forward)
Add the mermaid custom fence to the existing `pymdownx.superfences` entry in
`mkdocs.yml`:
```yaml
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
```
Material ships `mermaid.js` and activates it on this fence, so
`mkdocs build --strict` renders the graph as a diagram.
## Integration
| File | Change |
|------|--------|
| `scripts/gen_rack.py` | Add `validate_power`; call it in `generate`; add `render_power`; insert `## Power` in `render_page` |
| `tests/test_gen_rack.py` | Add `validate_power` + `render_power` + `generate` power cases |
| `mkdocs.yml` | Enable mermaid via superfences custom fence |
| `docs/hardware/pdu01.md`, `pdu02.md` | New 0U PDU files (`kind: pdu`, `outlets: 8`) |
| `docs/hardware/mf00.md`..`mf04.md` | Add `power:` lists |
| `docs/hardware/index.md` | Regenerated (PDUs now listed) |
| `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg` | Regenerated (power section + PDU side-rails) |
No `Makefile`, `.forgejo/workflows/docs.yml`, or `overview_config.yml` changes.
## Test plan
- **Unit — `validate_power`:** accept a valid feed; reject unknown `pdu`,
`pdu` pointing at a non-`pdu` kind, `outlet` of 0 / above `outlets`, a
malformed entry (non-mapping / missing keys), and a `pdu` file with
missing/zero/non-int `outlets`.
- **Unit — `render_power`:** PDU and device nodes present; a redundant device
has two incoming edges; returns `""` when no device has power; deterministic
for reordered input.
- **Integration — `generate`:** with valid fixtures the page contains the
`## Power` section and the mermaid fence; with a dangling `pdu` reference it
returns `1` and writes nothing.
- **Drift:** `make docs-check` exits 0 after regeneration (existing guard,
unchanged).
- **Visual:** `mkdocs build --strict` succeeds and the rack page shows the power
graph as a rendered diagram (not a raw code block), with mf00 showing two
incoming feeds.

View file

@ -0,0 +1,136 @@
# Rack Presentation Improvements Design
**Date:** 2026-06-24
**Status:** Approved
**Parent spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (extension)
## Goal
Improve the graphical presentation of the generated rack page: make the
elevation **interactive** (inline SVG, clickable boxes, hover tooltips), encode
device **status** visually, add a **legend**, and **polish** both the elevation
and the mermaid power/network graphs. No new data is modelled; this is a
rendering upgrade of what `gen_rack.py` already produces.
## Context
- `render_svg` writes a standalone `rack01-elevation.svg`, embedded in
`rack01.md` as `![Rack rack01 elevation](rack01-elevation.svg)` — i.e. an
`<img>`, which is a **flat image**: links and tooltips inside it do not work.
- `render_power`/`render_network` emit default-themed mermaid flowcharts with
uncoloured, non-clickable nodes.
- The occupancy table already links to host pages; the SVG and graphs do not.
- `md_in_html` is enabled in `mkdocs.yml`, so raw inline HTML/SVG in a Markdown
page is rendered. Mermaid is enabled (Phase 2).
- `use_directory_urls` is unset → defaults to **true**: pages build to
`/<path>/index.html`, so a host page is served at `/hardware/<host>/` and the
rack page at `/infrastructure/racks/rack01/`. The site is served at the domain
root (`docs.makerfloss.eu`).
- `KIND_COLORS` already maps kinds → fill colours. There is no status encoding,
legend, link, or tooltip today.
## Decisions
### A. Inline, interactive elevation
- `render_page` embeds the SVG markup **inline** in `rack01.md`, inside a
`<div class="rack-elevation"> … </div>` block, replacing the
`![…](rack01-elevation.svg)` image line. (`md_in_html` passes the raw block
through.)
- The standalone `rack01-elevation.svg` is **still generated** (identical markup,
still drift-checked) and offered below the diagram as a
`[Download SVG](rack01-elevation.svg)` link.
- The `<svg>` opening tag gains `style="max-width:100%;height:auto"` so it scales
on narrow screens.
- **Clickable boxes:** every device drawing (rail box, shelf-occupant box, 0U
rail bar, shelf strip) is wrapped in
`<a href="/hardware/<hostname>/"> … </a>`. URLs are **root-relative final
URLs** — mkdocs does **not** rewrite `.md`/source-relative hrefs inside raw
inline SVG, and `use_directory_urls` + domain-root hosting make
`/hardware/<host>/` correct and robust.
- **Tooltips:** the `<a>` wrapper's first child is an SVG `<title>` carrying full
details: `"<hostname> · <kind> · <status> · cluster: <cluster|—> · <placement>"`,
where `<placement>` is the U-range (rail item), `0U <left|right>` (0U rail), or
`<shelf>/<face>/slot <slot>` (mounted item). Text run through `_esc`.
### B. Status encoded as border
Fill stays the kind colour; the box **border** encodes `status` via a
`_status_stroke(status) -> (stroke, stroke_width, dash)` helper:
| status | stroke | width | dash |
|--------|--------|-------|------|
| in-use | `#333` | 1.5 | none |
| staging | `#333` | 1.5 | `4 2` (dashed) |
| broken | `#e15759` | 3 | none |
| spare | `#bbbbbb` | 1.5 | none |
| donated | `#bbbbbb` | 1.5 | none |
| (other/missing) | `#333` | 1.5 | none |
Applied to every device rect (rail `draw_device` and shelf-occupant boxes). The
shelf strip and the faint empty-U slots keep their existing styling.
### C. Legend
A legend strip is drawn at the **bottom of the SVG canvas**, below the columns;
the canvas height grows by a fixed `LEGEND_H` band:
- **Kind swatches:** for each `kind` actually present in the rack (sorted), a
small colour rect + the kind name.
- **Status key:** four small sample borders (solid, dashed, red-thick, grey) with
labels `in-use`, `staging`, `broken`, `spare`.
### D. Aesthetic polish
- **U-numbers in both gutters:** the existing left-gutter U-number column is
mirrored to the right of the rear column.
- **Column frame:** a thin outer stroke rectangle around each column's full body
(front and rear).
- **Mermaid graphs** (`render_power`/`render_network`):
- **Colour nodes by kind:** emit a `style <id> fill:<kind-colour>,stroke:#333,color:#ffffff`
line per node, looking the kind up from the rack items (`KIND_COLORS`,
falling back to `DEFAULT_COLOR`).
- **Clickable nodes:** emit `click <id> "/hardware/<host>/"` for every node whose
hostname resolves to a rack item; off-rack nodes get no click.
- Edge labels and layout are unchanged.
## Technical constraints (rationale carried into implementation)
- `<img>`-embedded SVG is inert → inlining is required for links/tooltips.
- mkdocs rewrites Markdown `.md` links but **not** raw `href`/`click` URLs inside
inline SVG or mermaid → emit final root-relative URLs `/hardware/<host>/`.
- `md_in_html` (already enabled) renders the raw `<div><svg>…</svg></div>` block.
## Integration
| File | Change |
|------|--------|
| `scripts/gen_rack.py` | helpers `_host_url`, `_status_stroke`, `_tooltip`; `render_svg` (status borders, `<a>`+`<title>` wrapping, both-gutter U-numbers, column frame, legend, `style=` on `<svg>`); `render_page` (inline SVG `<div>` + download link); `render_power`/`render_network` (per-node `style` + `click`) |
| `tests/test_gen_rack.py` | assertions for links, titles, status strokes, legend, both-gutter numbers, inline page block, mermaid style/click |
| `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg` | regenerated |
No `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or
`overview_config.yml` changes. Occupancy table and all validation unchanged.
## Test plan
- **`render_svg`:** output contains `<a href="/hardware/srv04/"`, a `<title>` with
the host details, a dashed stroke for a `staging` fixture and a thick red stroke
for a `broken` fixture, kind swatches for the kinds present, U-number text on
both sides; deterministic for reordered input.
- **`render_page`:** contains `<div class="rack-elevation">` and `<svg`, the
`[Download SVG](rack01-elevation.svg)` link, and **no** `![`-image embed of the
elevation.
- **`render_power`/`render_network`:** contain `style <id> fill:` lines coloured
by kind and `click <id> "/hardware/<host>/"` lines; still start with
` ```mermaid ` + `flowchart LR`.
- **Drift / build / visual:** `make docs-check` exits 0; `mkdocs build --strict`
passes; on the rendered page the elevation boxes are clickable, hovering shows
the tooltip, the legend is visible, and the mermaid nodes are coloured and
clickable.
## Out of scope
- The summary dashboard (U-utilisation / counts) — not selected.
- Dark-mode-specific palettes.
- A cluster colour system — cluster appears in the tooltip only.

View file

@ -0,0 +1,155 @@
# Shelf-Mounted Devices Design
**Date:** 2026-06-24
**Status:** Approved
**Parent spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (extension)
## Goal
Let the rack model represent cabinet/tower-style PCs that sit on a rack shelf
rather than bolting into the rails. Several PCs share one shelf, side by side and
front/back, and the assembly spans a tall U-range (e.g. a shelf at U46 with three
towers standing up to U37 = a reserved block U37U46). The current model cannot
express this: two PCs in the same U-range on the same face trip `check_overlaps`.
## Context
- Placement today (`scripts/gen_rack.py`): a rack item declares `rack_u` (lowest
U), `u_height`, and `rack_face` ∈ {front, rear, both, left, right}. `left`/`right`
are 0U side-rail items that omit `rack_u`/`u_height` and attach to a rail.
`check_overlaps` rejects two items sharing a U on the same face; it already
skips 0U rail items.
- This gives a precedent: **some items don't claim a U-range directly** (0U rails
attach to a rail). Shelf-mounted PCs are a third placement style — they attach
to a shelf.
- The physical rack is labeled U1 at the top → U48 at the bottom, so a shelf at
U46 (near the bottom) with towers standing upward to U37 reserves U37U46.
- `shelf` is already a valid `kind` (abbrev `shf`) with a color in `KIND_COLORS`.
## Decisions
### 1. Container model
The **shelf** is the rack-placed item; tower PCs are **contained by** it.
- A shelf (`kind: shelf`) is placed normally: `rack_u`, `u_height`,
`rack_face: both` (full depth). Its `rack_u`/`u_height` **reserve the whole
assembly's U-range** (the shelf plus the towers standing on it), e.g.
`rack_u: 37, u_height: 10`.
- A **mounted device** declares, instead of `rack_u`/`u_height`/`rack_face`:
- `mounted_on: <shelf hostname>` — the shelf it sits on.
- `shelf_face: front | rear` — which side of the shelf.
- `shelf_slot: <integer ≥ 1>` — left-to-right position within that face.
Mounted items still declare `rack: <rack>` (so they load as rack items) and may
carry `cluster:`, `power:`, `links:` unchanged — those key off hostname, not
placement.
No `scripts/overview_config.yml` change: `shelf` and `server` kinds already
exist; `mounted_on`/`shelf_face`/`shelf_slot` are extra fields `gen_overview.py`
ignores and `gen_rack.py` validates.
### 2. Validation (in `gen_rack.py`)
`validate_item` gains a placement branch, checked before the existing rail logic:
- If `mounted_on` is present, the item is **mounted**:
- require `shelf_face ∈ {front, rear}`;
- require `shelf_slot` to be an integer ≥ 1;
- forbid `rack_u`, `u_height`, and `rack_face` (mutually exclusive with
mounting, mirroring the 0U rule).
- Otherwise the existing rules apply unchanged (rail item: `rack_face` ∈ FACES,
0U rules for left/right, U-range for the rest).
`check_overlaps` **skips mounted items** (they claim no U-range; their shelf
reserves the block) — added alongside the existing 0U skip. The shelf itself is
a normal placed item, so it still cannot overlap rail gear.
New `check_shelves(items: list[dict]) -> None` (called per rack from `generate`,
alongside `check_overlaps`/`validate_power`/`validate_links`):
- every `mounted_on` resolves to an item **in the same rack** whose `kind` is
`shelf` and which is itself placed (has integer `rack_u`/`u_height`);
- `(mounted_on, shelf_face, shelf_slot)` is unique — no two devices in the same
spot on the same shelf.
### 3. Rendering (`gen_rack.py`)
**Elevation SVG (`render_svg`):**
- The shelf draws as a thin **shelf strip** (shelf-colored rect) at the bottom 1U
of its reserved U-range, in both columns, labeled with the shelf hostname.
- Mounted occupants draw inside the reserved range, above the strip: for each
face (`front` → front column, `rear` → rear column), gather that shelf's
occupants for that face, order them by `shelf_slot` ascending, and subdivide
the column width by the **number of occupants on that face** — one labeled box
per tower (hostname), drawn side by side. This produces the approved
"two front, one back" picture.
- Determinism: occupants ordered by `(shelf_slot, hostname)`; shelves processed
in hostname order.
**Occupancy table (`render_page`):**
- Mounted devices list the **shelf's U-range** in the U column (e.g. `U37U46`)
and a `\<shelf_face\> · \<mounted_on\>/\<shelf_slot\>` note in the Face column
(e.g. `front · shf01/1`).
- Ordering: rail items by U; each shelf's mounted devices appear immediately
after the shelf, ordered by `(shelf_face, shelf_slot)`; 0U rail items last.
**Power/network graphs:** unchanged — mounted PCs appear by hostname exactly as
rail-mounted devices do.
## Provisional demo data
Applies the worked example to the existing TaPPaaS nodes (user-confirmed):
- New `docs/hardware/shf01.md`: `kind: shelf`, `status: in-use`, `rack: rack01`,
`rack_u: 37`, `u_height: 10`, `rack_face: both`, `cluster: tappaas`.
- `srv01`: drop `rack_u`/`u_height`/`rack_face`; add `mounted_on: shf01`,
`shelf_face: front`, `shelf_slot: 1`. (Keeps `cluster: tappaas`, `power:`,
`links:`.)
- `srv02`: same, `shelf_face: front`, `shelf_slot: 2`; add `cluster: tappaas`.
- `srv03`: same, `shelf_face: rear`, `shelf_slot: 1`; add `cluster: tappaas`.
- `srv04`, `srv05` stay rail-mounted at U5U6 (unchanged). The shelf block
U37U46 does not overlap any existing rail item (sw01 U10, pp01 U24, srv04/05
U5U6, pdu rails).
All placements remain provisional placeholders; `check_shelves`/`check_overlaps`
reject inconsistent data loudly.
## Integration
| File | Change |
|------|--------|
| `scripts/gen_rack.py` | `validate_item` mounted branch; `check_overlaps` skip mounted; new `check_shelves`; `render_svg` shelf strip + subdivided occupant boxes; `render_page` occupancy rows for mounted items |
| `tests/test_gen_rack.py` | mounted-validation, `check_shelves`, SVG, and occupancy cases |
| `docs/hardware/shf01.md` | New shelf file |
| `docs/hardware/srv01.md`..`srv03.md` | Convert to mounted; `srv02`/`srv03` gain `cluster: tappaas` |
| `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.*` | Regenerated |
No `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or
`overview_config.yml` changes.
## Test plan
- **Unit — `validate_item`:** accept a valid mounted item; reject a mounted item
that also has `rack_u`/`u_height`/`rack_face`, a bad `shelf_face`, or a
non-integer/<1 `shelf_slot`.
- **Unit — `check_overlaps`:** two mounted items sharing their shelf's U-range do
not raise (mounted items are skipped).
- **Unit — `check_shelves`:** accept valid mounts; reject `mounted_on` pointing at
a missing item, at a non-`shelf` kind, or at an unplaced shelf; reject two
occupants sharing `(shelf, face, slot)`.
- **Unit — `render_svg`:** a shelf with two front + one rear occupant produces a
shelf strip and three labeled occupant boxes; front occupants share the front
column side by side; deterministic for reordered input.
- **Unit — `render_page`:** mounted devices appear in the occupancy table with the
shelf's U-range and the `face · shelf/slot` note.
- **Integration — `generate`:** valid fixtures write a page; a dangling
`mounted_on` returns `1` and writes nothing.
- **Drift / visual:** `make docs-check` exits 0; `mkdocs build --strict` renders
the rack page with the shelf block showing srv01/srv02 front and srv03 rear.
## Out of scope
- Shelf capacity limits (max slots per face) — slot-collision detection suffices.
- Modeling individual tower height separately from the shelf's reserved block.
- Nested shelves or shelves spanning racks.

8
notes/relevantLinks.md Normal file
View file

@ -0,0 +1,8 @@
# Links of general relevance to the project
- https://nordicmedialab.dk/#hvorfor
- https://zensocial.dk/
- https://oase.app/oase/46ec46bf-5445-4a32-bb21-ada8a61b4b0a/join/8f84eb1a-1e07-478c-9153-6e21850141fe?spa=1
- https://meningspunktet.dk
- https://sustainablemanifesto.org/
- https://www.demai.tech/

View file

@ -5,3 +5,4 @@
- Bo's invetory system and DNS for his QR-codes
- Task management system for BuildDays
- Display systemet Konrad
- touch display med kalender etc.

1
notes/todo/wishlist.md Normal file
View file

@ -0,0 +1 @@
# Ønske/skaffe-liste

2
requirements-dev.txt Normal file
View file

@ -0,0 +1,2 @@
-r requirements.txt
pytest==8.*

3
sandbox/README.md Normal file
View file

@ -0,0 +1,3 @@
# sandbox/
Scratch space and pipeline fixtures. Files here are kept around as test inputs (e.g. for the Marp/Mermaid slide pipeline) — they are not meant to be read as documentation.

7
scripts/README.md Normal file
View file

@ -0,0 +1,7 @@
# scripts/
Build helpers for the documentation site.
- `gen_overview.py` — generates a category index table from per-item YAML frontmatter (currently used for `docs/hardware/index.md`). Configured by `overview_config.yml`. Run via `make docs-index`; CI drift-checks it via `make docs-check`.
- `mkdocs_hooks.py` — MkDocs build hook that renders a Specs table on each hardware host page from its frontmatter, sharing `fmt_*` helpers with `gen_overview.py` so the host page and the index row stay in sync.
- `overview_config.yml` — schema (required fields, enums) and table layout for `gen_overview.py`.

View file

@ -22,11 +22,39 @@ CONFIG_PATH = REPO_ROOT / "scripts" / "overview_config.yml"
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
# Shown at the bottom of every error report so a newcomer knows where to look.
GUIDE_URL = "https://docs.makerfloss.eu/guides/editing-hardware-docs/"
class SchemaError(Exception):
pass
def _allowed_hint(field: str, enums: dict) -> str:
allowed = enums.get(field)
return f" Allowed values: {', '.join(map(str, allowed))}." if allowed else ""
def _example_value(field: str, enums: dict) -> str:
allowed = enums.get(field)
return str(allowed[0]) if allowed else "..."
def report_errors(errors: list[str], category: str) -> None:
"""Print a collected list of problems with orientation for newcomers."""
print(
f"\ngen_overview: found {len(errors)} problem(s) in the {category} docs:",
file=sys.stderr,
)
for err in errors:
print(f"{err}", file=sys.stderr)
print(
"\nFix the field(s) named above, then run 'make docs-index' again.\n"
f"Guide: {GUIDE_URL}",
file=sys.stderr,
)
def parse_frontmatter(path: Path) -> dict | None:
text = path.read_text(encoding="utf-8")
m = FRONTMATTER_RE.match(text)
@ -42,67 +70,117 @@ def parse_frontmatter(path: Path) -> dict | None:
def validate(path: Path, fm: dict, cfg: dict) -> None:
enums = cfg.get("enums", {})
name = path.name
for field in cfg["required_fields"]:
if field not in fm:
raise SchemaError(f"{path}: missing required field '{field}'")
for field, allowed in cfg.get("enums", {}).items():
raise SchemaError(
f"{name}: missing required field '{field}'. Add a line like "
f"'{field}: {_example_value(field, enums)}' to the frontmatter."
f"{_allowed_hint(field, enums)}"
)
for field, allowed in enums.items():
if field in fm and fm[field] not in allowed:
raise SchemaError(
f"{path}: {field}={fm[field]!r} not in {allowed}"
f"{name}: {field} {fm[field]!r} is not allowed. "
f"Use one of: {', '.join(map(str, allowed))}."
)
key_field = cfg.get("key_field", "hostname")
if key_field not in fm:
raise SchemaError(
f"{name}: missing the '{key_field}' field (the device's id). It must "
f"match the filename, e.g. '{key_field}: {path.stem}'."
)
stem = path.stem
hostname = fm["hostname"]
if stem != hostname:
value = fm[key_field]
if stem != value:
raise SchemaError(
f"{path}: filename stem {stem!r} != hostname {hostname!r}"
f"{name}: '{key_field}: {value}' does not match the filename '{name}'. "
f"Rename the file to '{value}.md', or set {key_field} to '{stem}'."
)
def fmt_cpu(fm: dict) -> str:
model = fm.get("cpu", "")
model = fm.get("cpu") or ""
cores = fm.get("cpu_cores")
threads = fm.get("cpu_threads")
suffix = ""
if cores and threads and threads != cores:
if isinstance(cores, int) and isinstance(threads, int) and threads != cores:
suffix = f" · {cores}c/{threads}t"
elif cores:
elif isinstance(cores, int):
suffix = f" · {cores}c"
return (model + suffix).strip()
return (str(model) + suffix).strip()
def fmt_ram(fm: dict) -> str:
n = fm.get("ram_gb")
return f"{n} GB" if isinstance(n, int) else ""
if isinstance(n, int):
return f"{n} GB"
if isinstance(n, str) and n:
return n
return ""
def _fmt_size_gb(n: int) -> str:
if n >= 1000 and n % 1000 == 0:
return f"{n // 1000} TB"
if n >= 1000:
return f"{n / 1000:.1f} TB"
return f"{n} GB"
def fmt_storage(fm: dict) -> str:
drives = fm.get("storage")
if isinstance(drives, list) and drives:
parts = []
for d in drives:
gb = d.get("gb")
t = (d.get("type") or "").upper()
if isinstance(gb, int):
parts.append(f"{_fmt_size_gb(gb)} {t}".strip())
elif t:
parts.append(t)
return " + ".join(parts)
if isinstance(drives, str) and drives:
return drives
n = fm.get("storage_gb")
t = fm.get("storage_type", "").upper() if fm.get("storage_type") else ""
if not isinstance(n, int):
return t # type alone if no capacity
if n >= 1000 and n % 1000 == 0:
size = f"{n // 1000} TB"
elif n >= 1000:
size = f"{n / 1000:.1f} TB"
else:
size = f"{n} GB"
return f"{size} {t}".strip()
return f"{_fmt_size_gb(n)} {t}".strip()
def fmt_nic(fm: dict) -> str:
g = fm.get("nic_gbps")
if g is None:
if g is None or g == "":
return ""
if isinstance(g, float) and not g.is_integer():
return f"{g} GbE"
return f"{int(g)} GbE"
if isinstance(g, str):
return g
def one(v: float | int) -> str:
if isinstance(v, float) and not v.is_integer():
return f"{v}"
return f"{int(v)}"
if isinstance(g, list):
if not g:
return ""
return "/".join(one(v) for v in g) + " GbE"
return f"{one(g)} GbE"
def cell(fm: dict, col: dict) -> str:
kind = col.get("kind")
if kind == "hostname-link":
h = fm["hostname"]
return f"[{h}]({h}.md)"
if kind == "key-link":
v = fm[col["field"]]
return f"[{v}]({v}.md)"
if kind == "url-link":
u = fm.get(col["field"], "")
if not u:
return ""
label = u.removeprefix("https://").removeprefix("http://")
return f"[{label}]({u})"
if kind == "cpu":
return fmt_cpu(fm)
if kind == "ram":
@ -181,7 +259,11 @@ def main() -> int:
errors.append(str(e))
continue
if fm is None:
print(f"WARNING: {path}: no YAML frontmatter, skipping", file=sys.stderr)
print(
f"WARNING: {path.name}: no '---' frontmatter block — skipping "
f"(it will not appear in the {args.category} index).",
file=sys.stderr,
)
continue
try:
validate(path, fm, cfg)
@ -191,8 +273,7 @@ def main() -> int:
items.append(fm)
if errors:
for err in errors:
print(f"ERROR: {err}", file=sys.stderr)
report_errors(errors, args.category)
return 1
output_file.parent.mkdir(parents=True, exist_ok=True)

895
scripts/gen_rack.py Normal file
View file

@ -0,0 +1,895 @@
#!/usr/bin/env python3
"""Generate per-rack elevation SVG + page from hardware frontmatter.
Reads `docs/hardware/*.md`, selects files that declare a `rack` field,
validates rack placement, and writes for each rack:
docs/infrastructure/racks/<rack>-elevation.svg
docs/infrastructure/racks/<rack>.md
Deterministic, offline, stdlib + PyYAML. Non-zero exit on schema violation.
The physical rack is labeled U1 at the top; the SVG renders U1 at the top.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
HARDWARE_DIR = REPO_ROOT / "docs" / "hardware"
OUTPUT_DIR = REPO_ROOT / "docs" / "infrastructure" / "racks"
RACK_UNITS = 48
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
FACES = {"front", "rear", "both", "left", "right"}
ZERO_U_FACES = {"left", "right"}
SHELF_FACES = {"front", "rear"}
# Shown at the bottom of every error report so a newcomer knows where to look.
GUIDE_URL = "https://docs.makerfloss.eu/guides/editing-hardware-docs/"
KIND_COLORS = {
"server": "#4c78a8",
"switch": "#59a14f",
"patch-panel": "#9c755f",
"pdu": "#e15759",
"ups": "#edc948",
"shelf": "#bab0ac",
"kvm": "#b07aa1",
"blank": "#d4d4d4",
}
DEFAULT_COLOR = "#888888"
class SchemaError(Exception):
pass
def parse_frontmatter(path: Path) -> dict | None:
text = path.read_text(encoding="utf-8")
m = FRONTMATTER_RE.match(text)
if not m:
return None
data = yaml.safe_load(m.group(1))
if not isinstance(data, dict):
raise SchemaError(f"{path}: frontmatter is not a mapping")
return data
def load_rack_items(hardware_dir: Path) -> list[dict]:
"""Return frontmatter dicts for hardware files that declare a rack."""
items: list[dict] = []
for path in sorted(hardware_dir.glob("*.md")):
if path.name == "index.md":
continue
fm = parse_frontmatter(path)
if fm is None or "rack" not in fm:
continue
fm = dict(fm)
fm["_path"] = str(path)
items.append(fm)
return items
def validate_item(fm: dict) -> None:
name = fm.get("hostname") or fm.get("_path", "?")
rack = fm.get("rack")
if not isinstance(rack, str) or not rack:
raise SchemaError(f"{name}: 'rack' must name a rack, e.g. 'rack: rack01'.")
if "mounted_on" in fm:
mounted_on = fm.get("mounted_on")
if not isinstance(mounted_on, str) or not mounted_on:
raise SchemaError(
f"{name}: 'mounted_on' must name the shelf it sits on, "
f"e.g. 'mounted_on: shf01'."
)
for forbidden in ("rack_u", "u_height", "rack_face"):
if forbidden in fm:
raise SchemaError(
f"{name}: a shelf-mounted device must not set '{forbidden}'"
f"it takes its position from the shelf. Use 'shelf_face' and "
f"'shelf_slot' instead."
)
sface = fm.get("shelf_face")
if sface not in SHELF_FACES:
raise SchemaError(
f"{name}: shelf_face {sface!r} must be 'front' or 'rear'."
)
slot = fm.get("shelf_slot")
if not isinstance(slot, int) or slot < 1:
raise SchemaError(
f"{name}: 'shelf_slot' must be a whole number 1 or higher "
f"(got {slot!r})."
)
if "chassis_u" in fm:
cu = fm.get("chassis_u")
if isinstance(cu, bool) or not isinstance(cu, int) or cu < 1:
raise SchemaError(
f"{name}: 'chassis_u' is the device's height in U where it "
f"stands on the shelf — it must be a whole number 1 or "
f"higher (got {cu!r})."
)
return
face = fm.get("rack_face")
if face not in FACES:
raise SchemaError(
f"{name}: rack_face {face!r} is not valid. Use 'front', 'rear' or "
f"'both' for a U-mounted device, or 'left'/'right' for a 0U side rail."
)
if face in ZERO_U_FACES:
if "rack_u" in fm or "u_height" in fm:
raise SchemaError(
f"{name}: a side-rail device (rack_face: {face}) is 0U — remove "
f"'rack_u' and 'u_height'."
)
return
u = fm.get("rack_u")
h = fm.get("u_height")
if not isinstance(u, int) or not isinstance(h, int):
raise SchemaError(
f"{name}: a {face}-mounted device needs whole-number 'rack_u' and "
f"'u_height' (e.g. 'rack_u: 12' and 'u_height: 2')."
)
if u < 1 or u > RACK_UNITS:
raise SchemaError(
f"{name}: rack_u={u} is outside the rack — it must be between 1 "
f"and {RACK_UNITS}."
)
if h < 1:
raise SchemaError(f"{name}: u_height={h} must be at least 1.")
if u + h - 1 > RACK_UNITS:
raise SchemaError(
f"{name}: a {h}U device starting at U{u} runs off the top of the rack "
f"(it would need U{u}U{u + h - 1}, but the rack is only {RACK_UNITS}U). "
f"Lower 'rack_u' or 'u_height'."
)
def check_overlaps(items: list[dict]) -> None:
"""Raise if two items share a U on the same face within one rack."""
occupied: dict[tuple[str, int], str] = {}
for fm in items:
if "mounted_on" in fm:
continue
face = fm.get("rack_face")
if face in ZERO_U_FACES:
continue
faces = ("front", "rear") if face == "both" else (face,)
u = fm["rack_u"]
h = fm["u_height"]
name = fm.get("hostname", "?")
for f in faces:
for uu in range(u, u + h):
key = (f, uu)
if key in occupied:
raise SchemaError(
f"U{uu} {f}: {name} overlaps {occupied[key]} — two devices "
f"can't share the same U on the same face. Move one to a "
f"free U or to the other face."
)
occupied[key] = name
def check_shelves(items: list[dict]) -> None:
"""Validate shelf-mounted devices within one rack.
Every mounted_on resolves to a placed kind:shelf item in the same rack;
no two devices share (shelf, face, slot).
"""
by_host = {fm.get("hostname"): fm for fm in items}
occupied: dict[tuple[str, str, int], str] = {}
for fm in items:
if "mounted_on" not in fm:
continue
name = fm.get("hostname", "?")
shelf_name = fm["mounted_on"]
target = by_host.get(shelf_name)
if target is None:
raise SchemaError(
f"{name}: mounted_on={shelf_name!r} — no device with that id is in "
f"this rack. Check the shelf's hostname."
)
if target.get("kind") != "shelf":
raise SchemaError(
f"{name}: mounted_on={shelf_name!r} is a {target.get('kind')!r}, "
f"not a shelf. Only kind:shelf devices can hold mounted gear."
)
if not isinstance(target.get("rack_u"), int) or not isinstance(
target.get("u_height"), int
):
raise SchemaError(
f"{name}: the shelf {shelf_name!r} has no position yet — give it "
f"'rack_u' and 'u_height' first."
)
key = (shelf_name, fm["shelf_face"], fm["shelf_slot"])
if key in occupied:
raise SchemaError(
f"{shelf_name} {fm['shelf_face']} slot {fm['shelf_slot']}: "
f"{name} overlaps {occupied[key]} — each shelf face and slot holds "
f"one device."
)
occupied[key] = name
def load_hardware_index(hardware_dir: Path) -> dict[str, dict]:
"""Map hostname -> frontmatter for every hardware file (global peer lookup)."""
index: dict[str, dict] = {}
for path in sorted(hardware_dir.glob("*.md")):
if path.name == "index.md":
continue
fm = parse_frontmatter(path)
if fm is None:
continue
name = fm.get("hostname")
if isinstance(name, str) and name:
index[name] = fm
return index
def validate_links(items: list[dict], hw_index: dict[str, dict]) -> None:
"""Validate `links` cable declarations (rule 4).
Every links[].peer must resolve to a real hardware file (global lookup via
hw_index); peer_port must fall within the peer's declared `ports` when it
declares an integer count.
"""
for fm in items:
links = fm.get("links")
if links is None:
continue
name = fm.get("hostname", "?")
if not isinstance(links, list):
raise SchemaError(
f"{name}: 'links' must be a list of cables like "
f"'- {{ local: eth0, peer: sw01, peer_port: 1 }}'."
)
for link in links:
if not isinstance(link, dict):
raise SchemaError(
f"{name}: each 'links' entry must look like "
f"'{{ local: eth0, peer: sw01, peer_port: 1 }}'."
)
local = link.get("local")
peer = link.get("peer")
peer_port = link.get("peer_port")
if not isinstance(local, str) or not local:
raise SchemaError(
f"{name}: a 'links' entry needs a 'local' port name, "
f"e.g. 'local: eth0'."
)
if not isinstance(peer, str) or not peer:
raise SchemaError(
f"{name}: a 'links' entry needs a 'peer' device, "
f"e.g. 'peer: sw01'."
)
if not isinstance(peer_port, int):
raise SchemaError(
f"{name}: the link to {peer} needs a whole-number 'peer_port'."
)
target = hw_index.get(peer)
if target is None:
raise SchemaError(
f"{name}: link points at peer={peer!r}, but no hardware file "
f"has that id. Check the peer hostname."
)
ports = target.get("ports")
if isinstance(ports, int) and (peer_port < 1 or peer_port > ports):
raise SchemaError(
f"{name}: peer_port {peer_port} doesn't exist on {peer} — it "
f"has {ports} port(s) (valid 1{ports})."
)
def _pdu_index(items: list[dict]) -> dict[str, dict]:
"""Map hostname -> frontmatter for every kind:pdu item."""
return {
fm.get("hostname"): fm
for fm in items
if fm.get("kind") == "pdu"
}
def validate_power(items: list[dict]) -> None:
"""Validate PDU outlet declarations and `power` feeds within one rack.
Rule 3: every power[].pdu resolves to a kind:pdu file, and outlet is
within that PDU's `outlets` count.
"""
pdus = _pdu_index(items)
for name, fm in pdus.items():
outlets = fm.get("outlets")
if not isinstance(outlets, int) or outlets < 1:
raise SchemaError(
f"{name}: a PDU must say how many outlets it has, e.g. 'outlets: 8'."
)
for fm in items:
feeds = fm.get("power")
if feeds is None:
continue
name = fm.get("hostname", "?")
if not isinstance(feeds, list):
raise SchemaError(
f"{name}: 'power' must be a list of feeds like "
f"'- {{ pdu: pdu01, outlet: 1 }}'."
)
for feed in feeds:
if not isinstance(feed, dict):
raise SchemaError(
f"{name}: each 'power' feed must look like "
f"'{{ pdu: pdu01, outlet: 1 }}'."
)
pdu = feed.get("pdu")
outlet = feed.get("outlet")
if not isinstance(pdu, str) or not pdu:
raise SchemaError(
f"{name}: a 'power' feed needs a 'pdu' name, "
f"e.g. '{{ pdu: pdu01, outlet: 1 }}'."
)
if not isinstance(outlet, int):
raise SchemaError(
f"{name}: the 'power' feed to {pdu} needs a whole-number 'outlet'."
)
target = pdus.get(pdu)
if target is None:
raise SchemaError(
f"{name}: power feed points at pdu={pdu!r}, but no kind:pdu "
f"device has that id. Check the PDU hostname."
)
count = target["outlets"]
if outlet < 1 or outlet > count:
raise SchemaError(
f"{name}: outlet {outlet} doesn't exist on {pdu} — it has "
f"{count} outlet(s) (valid 1{count})."
)
def _esc(s: object) -> str:
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
STATUS_STROKE: dict[str, tuple[str, float, str]] = {
"in-use": ("#333333", 1.5, ""),
"staging": ("#333333", 1.5, "4 2"),
"broken": ("#e15759", 3, ""),
"spare": ("#bbbbbb", 1.5, ""),
"donated": ("#bbbbbb", 1.5, ""),
}
DEFAULT_STATUS_STROKE: tuple[str, float, str] = ("#333333", 1.5, "")
def _status_stroke(status: object) -> tuple[str, float, str]:
return STATUS_STROKE.get(status, DEFAULT_STATUS_STROKE)
def _stroke_attrs(status: object) -> str:
stroke, sw, dash = _status_stroke(status)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
return f'stroke="{stroke}" stroke-width="{sw}"{dash_attr}'
def _host_url(host: object) -> str:
return f"/hardware/{host}/"
def _placement(fm: dict) -> str:
if "mounted_on" in fm:
return (
f"{fm.get('mounted_on', '?')}/{fm.get('shelf_face', '')}/"
f"slot {fm.get('shelf_slot', '')}"
)
face = fm.get("rack_face")
if face in ZERO_U_FACES:
return f"0U {face}"
u = fm.get("rack_u")
h = fm.get("u_height")
if isinstance(u, int) and isinstance(h, int):
return f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
return "?"
def _tooltip(fm: dict) -> str:
host = fm.get("hostname", "?")
return _esc(
f"{host} · {fm.get('kind', '')} · {fm.get('status', '')} · "
f"cluster: {fm.get('cluster', '')} · {_placement(fm)}"
)
def _sorted_items(items: list[dict]) -> list[dict]:
"""Deterministic order: faced items by U then hostname, 0U items last."""
return sorted(
items,
key=lambda i: (
0 if i.get("rack_face") not in ZERO_U_FACES else 1,
i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
i.get("hostname", ""),
),
)
def render_svg(rack: str, items: list[dict]) -> str:
U_H = 20
COL_W = 240
LABEL_W = 30
RAIL_W = 16
PAD = 12
GAP = 50
TITLE_H = 28
LEGEND_H = 56
items = _sorted_items(items)
left_items = [i for i in items if i.get("rack_face") == "left"]
right_items = [i for i in items if i.get("rack_face") == "right"]
body_h = RACK_UNITS * U_H
height = PAD + TITLE_H + body_h + PAD + LEGEND_H
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
rear_x = front_x + COL_W + GAP
right_gutter_x = rear_x + COL_W
width = right_gutter_x + LABEL_W + len(right_items) * RAIL_W + PAD
top = PAD + TITLE_H
def u_y(u: int) -> int:
# U1 at the top; U numbers increase downward.
return top + (u - 1) * U_H
p: list[str] = []
p.append(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
f'height="{height}" viewBox="0 0 {width} {height}" '
f'style="max-width:100%;height:auto" '
f'font-family="sans-serif" font-size="11">'
)
p.append(f'<rect width="{width}" height="{height}" fill="#ffffff"/>')
p.append(
f'<text x="{PAD}" y="{PAD + 16}" font-size="16" '
f'font-weight="bold">Rack {_esc(rack)}</text>'
)
for col_x, col_label in ((front_x, "front"), (rear_x, "rear")):
p.append(
f'<text x="{col_x + COL_W // 2}" y="{top - 6}" '
f'text-anchor="middle" font-weight="bold">{col_label}</text>'
)
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<rect x="{col_x}" y="{y}" width="{COL_W}" height="{U_H}" '
f'fill="#f5f5f5" stroke="#e0e0e0"/>'
)
# U numbers in the gutter left of the front column.
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<text x="{front_x - 4}" y="{y + 14}" text-anchor="end" '
f'fill="#999">{u}</text>'
)
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<text x="{right_gutter_x + 4}" y="{y + 14}" text-anchor="start" '
f'fill="#999">{u}</text>'
)
for col_x in (front_x, rear_x):
p.append(
f'<rect x="{col_x}" y="{top}" width="{COL_W}" height="{body_h}" '
f'fill="none" stroke="#999" stroke-width="1.5"/>'
)
def draw_device(fm: dict, col_x: int) -> None:
u = fm["rack_u"]
h = fm["u_height"]
y = u_y(u)
box_h = h * U_H
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
name = fm.get("hostname", "?")
urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
p.append(f'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
f'height="{box_h - 2}" rx="3" fill="{color}" '
f"{_stroke_attrs(fm.get('status'))}/>"
)
p.append(
f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">'
f"{_esc(name)} ({urange})</text>"
)
p.append("</a>")
def draw_rail(fm: dict, x: int) -> None:
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
name = fm.get("hostname", "?")
cx = x + RAIL_W // 2
cy = top + body_h // 2
p.append(f'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
f"fill=\"{color}\" {_stroke_attrs(fm.get('status'))}/>"
)
p.append(
f'<text x="{cx}" y="{cy}" text-anchor="middle" '
f'dominant-baseline="central" fill="#ffffff" '
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
)
p.append("</a>")
SHELF_STRIP_H = 6
shelves = [i for i in items if i.get("kind") == "shelf"]
mounted = [i for i in items if "mounted_on" in i]
def draw_shelf(fm: dict) -> None:
u = fm["rack_u"]
h = fm["u_height"]
y = u_y(u)
block_h = h * U_H
strip_y = y + block_h - SHELF_STRIP_H
shelf_color = KIND_COLORS.get("shelf", DEFAULT_COLOR)
sname = fm.get("hostname", "?")
for col_x, sface in ((front_x, "front"), (rear_x, "rear")):
occ = sorted(
(m for m in mounted
if m.get("mounted_on") == sname
and m.get("shelf_face") == sface),
key=lambda m: (m.get("shelf_slot", 0), m.get("hostname", "")),
)
n = len(occ)
for idx, m in enumerate(occ):
sub_w = COL_W // n
bx = col_x + idx * sub_w
bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w
mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR)
mname = m.get("hostname", "?")
# The device stands on the shelf strip and rises chassis_u U's
# upward; without chassis_u it fills the shelf block (legacy).
dev_u = m.get("chassis_u")
if not isinstance(dev_u, int) or isinstance(dev_u, bool) or dev_u < 1:
dev_u = h
dev_h = dev_u * U_H - SHELF_STRIP_H
by = strip_y - dev_h
p.append(f'<a href="{_host_url(mname)}">')
p.append(f"<title>{_tooltip(m)}</title>")
p.append(
f'<rect x="{bx + 1}" y="{by + 1}" width="{bw - 2}" '
f'height="{dev_h - 2}" rx="3" fill="{mcolor}" '
f"{_stroke_attrs(m.get('status'))}/>"
)
p.append(
f'<text x="{bx + bw // 2}" y="{by + dev_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">{_esc(mname)}</text>'
)
p.append("</a>")
p.append(f'<a href="{_host_url(sname)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
for col_x in (front_x, rear_x):
p.append(
f'<rect x="{col_x}" y="{strip_y}" width="{COL_W}" '
f'height="{SHELF_STRIP_H}" fill="{shelf_color}" stroke="#333"/>'
)
p.append(
f'<text x="{front_x + COL_W // 2}" y="{strip_y + SHELF_STRIP_H - 1}" '
f'text-anchor="middle" fill="#333" font-size="9">{_esc(sname)}</text>'
)
p.append("</a>")
# Paint order (bottom → top): shelves and their towers first, then
# U-mounted devices (so a rail-mounted PDU stays visible over a tower),
# then 0U side rails.
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm)
for fm in items:
if fm.get("kind") == "shelf" or "mounted_on" in fm:
continue
face = fm.get("rack_face")
if face in ("front", "both"):
draw_device(fm, front_x)
if face in ("rear", "both"):
draw_device(fm, rear_x)
for idx, fm in enumerate(left_items):
draw_rail(fm, PAD + idx * RAIL_W)
for idx, fm in enumerate(right_items):
draw_rail(fm, right_gutter_x + LABEL_W + idx * RAIL_W)
legend_y = top + body_h + PAD + 8
p.append(
f'<text x="{front_x}" y="{legend_y}" font-weight="bold">Legend</text>'
)
present_kinds = sorted({i.get("kind", "") for i in items if i.get("kind")})
kx = front_x
ky = legend_y + 18
for kind in present_kinds:
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
p.append(
f'<rect x="{kx}" y="{ky - 10}" width="12" height="12" '
f'fill="{color}" stroke="#333"/>'
)
p.append(f'<text x="{kx + 16}" y="{ky}">{_esc(kind)}</text>')
kx += 28 + 7 * len(kind)
sx = front_x
sy = ky + 18
for label in ("in-use", "staging", "broken", "spare"):
stroke, sw, dash = _status_stroke(label)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
p.append(
f'<rect x="{sx}" y="{sy - 10}" width="12" height="12" '
f'fill="#ffffff" stroke="{stroke}" stroke-width="{sw}"{dash_attr}/>'
)
p.append(f'<text x="{sx + 16}" y="{sy}">{_esc(label)}</text>')
sx += 28 + 7 * len(label)
p.append("</svg>")
return "\n".join(p) + "\n"
def _node_id(name: str) -> str:
"""A mermaid-safe node id derived from a hostname."""
return re.sub(r"[^0-9A-Za-z]", "_", str(name))
def render_power(rack: str, items: list[dict]) -> str:
"""Return a mermaid power-distribution flowchart, or '' if no feeds.
Assumes `validate_power(items)` has already passed: every referenced PDU
resolves to a kind:pdu item with a positive-int `outlets`. `generate`
guarantees this by validating before any render call.
"""
powered = [fm for fm in items if fm.get("power")]
if not powered:
return ""
pdus = _pdu_index(items)
edges: list[tuple[str, int, str]] = []
for fm in powered:
device = fm.get("hostname", "?")
for feed in fm["power"]:
edges.append((feed["pdu"], feed["outlet"], device))
edges.sort()
lines: list[str] = ["```mermaid", "flowchart LR"]
for pdu in sorted(pdus):
outlets = pdus[pdu].get("outlets")
lines.append(f' {_node_id(pdu)}["{pdu}<br/>{outlets} outlets"]')
devices = sorted(
powered,
key=lambda i: (
i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
i.get("hostname", ""),
),
)
for fm in devices:
device = fm.get("hostname", "?")
lines.append(f' {_node_id(device)}["{device}"]')
for pdu, outlet, device in edges:
lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
)
by_host = {fm.get("hostname"): fm for fm in items}
node_hosts = sorted(set(pdus) | {fm.get("hostname", "?") for fm in powered})
for host in node_hosts:
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\n"
def render_network(rack: str, items: list[dict]) -> str:
"""Return a mermaid network-cabling flowchart, or '' if no links.
Assumes `validate_links` has already passed: every link has a non-empty
`local`/`peer` and an integer `peer_port`, and `peer` resolves to a real
hardware file. `generate` validates before any render call.
"""
linked = [fm for fm in items if fm.get("links")]
if not linked:
return ""
by_host = {fm.get("hostname"): fm for fm in items}
edges: list[tuple[str, str, str, int, object]] = []
nodes: set[str] = set()
for fm in linked:
source = fm.get("hostname", "?")
nodes.add(source)
for link in fm["links"]:
peer = link["peer"]
nodes.add(peer)
edges.append(
(source, link["local"], peer, link["peer_port"],
link.get("speed_gbps"))
)
edges.sort(key=lambda e: (e[0], e[1], e[2], e[3]))
def node_label(name: str) -> str:
fm = by_host.get(name)
kind = fm.get("kind") if fm else None
if kind in ("switch", "patch-panel"):
return f"{name}<br/>{kind}"
return name
lines: list[str] = ["```mermaid", "flowchart LR"]
for name in sorted(nodes):
lines.append(f' {_node_id(name)}["{node_label(name)}"]')
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
for host in sorted(nodes):
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
if host in by_host:
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\n"
def render_page(rack: str, items: list[dict]) -> str:
items = _sorted_items(items)
lines: list[str] = []
lines.append(f"# Rack {rack}")
lines.append("")
lines.append(
f"_Auto-generated from `docs/hardware/*.md` (items with `rack: {rack}`) "
f"— do not edit by hand. Run `make docs-index` after changing a "
f"source file._"
)
lines.append("")
lines.append("## Elevation")
lines.append("")
lines.append('<div class="rack-elevation">')
lines.append(render_svg(rack, items).rstrip())
lines.append("</div>")
lines.append("")
lines.append(f"[Download SVG]({rack}-elevation.svg)")
lines.append("")
power = render_power(rack, items)
if power:
lines.append("## Power")
lines.append("")
lines.append(power.rstrip())
lines.append("")
network = render_network(rack, items)
if network:
lines.append("## Network")
lines.append("")
lines.append(network.rstrip())
lines.append("")
lines.append("## Occupancy")
lines.append("")
lines.append("| U | Device | Kind | Face | Status |")
lines.append("|---|---|---|---|---|")
by_host = {fm.get("hostname"): fm for fm in items}
mounted_by_shelf: dict[str, list[dict]] = {}
for fm in items:
if "mounted_on" in fm:
mounted_by_shelf.setdefault(fm["mounted_on"], []).append(fm)
def occ_row(fm: dict) -> str:
name = fm.get("hostname", "?")
link = f"[{name}](../../hardware/{name}.md)"
if "mounted_on" in fm:
target = by_host.get(fm["mounted_on"])
if target and isinstance(target.get("rack_u"), int):
su = target["rack_u"]
sh = target["u_height"]
cu = fm.get("chassis_u")
if isinstance(cu, int) and not isinstance(cu, bool) and cu >= 1:
base = su + sh - 1 # the shelf's bottom U; towers rise from it
top = base - cu + 1
urange = f"U{base}" if cu == 1 else f"U{top}U{base}"
else:
urange = f"U{su}" if sh == 1 else f"U{su}U{su + sh - 1}"
else:
urange = ""
face = (
f"{fm.get('shelf_face', '')} · "
f"{fm['mounted_on']}/{fm.get('shelf_slot', '')}"
)
else:
face = fm.get("rack_face", "")
if face in ZERO_U_FACES:
urange = "0U"
else:
u = fm["rack_u"]
h = fm["u_height"]
urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
return (
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
f"| {fm.get('status', '')} |"
)
for fm in _sorted_items([i for i in items if "mounted_on" not in i]):
lines.append(occ_row(fm))
if fm.get("kind") == "shelf":
occ = sorted(
mounted_by_shelf.get(fm.get("hostname"), []),
key=lambda m: (m.get("shelf_face", ""), m.get("shelf_slot", 0)),
)
for m in occ:
lines.append(occ_row(m))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def report_errors(errors: list[str]) -> None:
"""Print a collected list of problems with orientation for newcomers."""
print(
f"\ngen_rack: found {len(errors)} problem(s) in docs/hardware/:",
file=sys.stderr,
)
for err in errors:
print(f"{err}", file=sys.stderr)
print(
"\nEach line is '<device>: what's wrong'. Fix the named frontmatter "
"field(s),\nthen run 'make docs-index' again.\n"
f"Guide: {GUIDE_URL}",
file=sys.stderr,
)
def generate(hardware_dir: Path, output_dir: Path) -> int:
items = load_rack_items(hardware_dir)
hw_index = load_hardware_index(hardware_dir)
errors: list[str] = []
for fm in items:
try:
validate_item(fm)
except SchemaError as e:
errors.append(str(e))
racks: dict[str, list[dict]] = {}
for fm in items:
racks.setdefault(fm["rack"], []).append(fm)
if not errors: # only check overlaps once placements are individually valid
for rack, ritems in racks.items():
try:
check_overlaps(ritems)
validate_power(ritems)
validate_links(ritems, hw_index)
check_shelves(ritems)
except SchemaError as e:
errors.append(f"{rack}: {e}")
if errors:
report_errors(errors)
return 1
output_dir.mkdir(parents=True, exist_ok=True)
for rack in sorted(racks):
ritems = racks[rack]
(output_dir / f"{rack}-elevation.svg").write_text(
render_svg(rack, ritems), encoding="utf-8"
)
(output_dir / f"{rack}.md").write_text(
render_page(rack, ritems), encoding="utf-8"
)
print(f"Wrote {rack}.md + {rack}-elevation.svg ({len(ritems)} item(s))")
return 0
def main() -> int:
return generate(HARDWARE_DIR, OUTPUT_DIR)
if __name__ == "__main__":
sys.exit(main())

54
scripts/mkdocs_hooks.py Normal file
View file

@ -0,0 +1,54 @@
"""MkDocs build hook: render a Specs section on each hardware or service page from its YAML frontmatter."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from gen_overview import fmt_cpu, fmt_nic, fmt_ram, fmt_storage # noqa: E402
def _table(rows: list[tuple[str, str]]) -> str:
lines = ["| Field | Value |", "|---|---|"]
for label, value in rows:
if not value:
continue
lines.append(f"| {label} | {value} |")
return "\n".join(lines)
def _hardware_page(meta: dict, body: str) -> str:
rows = [
("Location", meta.get("location")),
("CPU", fmt_cpu(meta)),
("RAM", fmt_ram(meta)),
("Storage", fmt_storage(meta)),
("NIC", fmt_nic(meta)),
("Status", meta.get("status")),
]
return f"# {meta['hostname']}\n\n## Specs\n\n{_table(rows)}\n\n{body}"
def _service_page(meta: dict, body: str) -> str:
url = meta.get("url")
upstream = meta.get("upstream")
rows = [
("Kind", meta.get("kind")),
("Host", meta.get("host")),
("URL", f"[{url}]({url})" if url else ""),
("Tech", meta.get("tech")),
("Upstream", f"[{upstream}]({upstream})" if upstream else ""),
("TLS", meta.get("tls")),
("Status", meta.get("status")),
]
return f"# {meta['name']}\n\n## Service\n\n{_table(rows)}\n\n{body}"
def on_page_markdown(markdown, page, config, files): # noqa: ARG001
meta = page.meta or {}
src = (page.file.src_uri or page.file.src_path or "").replace("\\", "/")
if src.startswith("hardware/") and meta.get("hostname"):
return _hardware_page(meta, markdown)
if src.startswith("services/") and meta.get("name"):
return _service_page(meta, markdown)
return markdown

View file

@ -8,13 +8,14 @@ hardware:
title: "Hardware Overview"
source_dir: docs/hardware
output_file: docs/hardware/index.md
key_field: hostname
required_fields:
- hostname
- kind
- status
enums:
kind: [server, laptop, sbc, switch, ap, desktop]
status: [in-use, spare, broken, donated]
kind: [server, laptop, sbc, switch, ap, desktop, pdu, patch-panel, shelf, blank, ups, kvm, wan]
status: [in-use, staging, spare, broken, donated]
storage_type: [nvme, ssd, hdd, mixed]
group_by: kind
# Human-friendly H2 names per group_by value. Anything missing falls back
@ -26,13 +27,48 @@ hardware:
switch: Switches
ap: Access points
desktop: Desktops
pdu: PDUs
patch-panel: Patch panels
shelf: Shelves
blank: Blank panels
ups: UPS
kvm: KVM
wan: WAN uplinks
sort_by: hostname
columns:
- { header: Hostname, kind: hostname-link }
- { header: Model, field: model }
- { header: Hostname, kind: key-link, field: hostname }
- { header: Location, field: location }
- { header: CPU, kind: cpu }
- { header: RAM, kind: ram }
- { header: Storage, kind: storage }
- { header: NIC, kind: nic }
- { header: Status, field: status }
services:
title: "Services Overview"
source_dir: docs/services
output_file: docs/services/index.md
key_field: name
required_fields:
- name
- kind
- status
enums:
kind: [web-app, static-site, dns, slide-builder, library, reverse-proxy, mail]
status: [in-use, staging, planned, broken, decommissioned]
group_by: kind
group_titles:
web-app: Web applications
static-site: Static sites
dns: DNS
slide-builder: Slide builders
library: Libraries
reverse-proxy: Reverse proxies
mail: Mail
sort_by: name
columns:
- { header: Name, kind: key-link, field: name }
- { header: URL, kind: url-link, field: url }
- { header: Host, field: host }
- { header: Tech, field: tech }
- { header: Status, field: status }

5
tests/conftest.py Normal file
View file

@ -0,0 +1,5 @@
import sys
from pathlib import Path
# Make scripts/ importable as top-level modules in tests.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))

View file

@ -0,0 +1,48 @@
import pytest
import gen_overview
CFG = {
"required_fields": ["hostname", "kind", "status"],
"enums": {
"kind": ["server", "switch", "pdu"],
"status": ["in-use", "staging", "spare"],
},
"key_field": "hostname",
}
def validate(stem, fm):
from pathlib import Path
gen_overview.validate(Path(f"{stem}.md"), fm, CFG)
def test_missing_required_field_names_field_and_lists_allowed():
with pytest.raises(gen_overview.SchemaError) as ei:
validate("srv06", {"hostname": "srv06", "kind": "server"})
msg = str(ei.value)
assert "status" in msg # which field
assert "in-use" in msg # an allowed value, so a novice knows what to type
def test_enum_violation_lists_allowed_values():
with pytest.raises(gen_overview.SchemaError) as ei:
validate("x", {"hostname": "x", "kind": "router", "status": "in-use"})
msg = str(ei.value)
assert "router" in msg # the offending value
assert "server" in msg # an allowed value
def test_filename_mismatch_explains_the_rename():
with pytest.raises(gen_overview.SchemaError) as ei:
validate("srv06", {"hostname": "srv07", "kind": "server", "status": "in-use"})
msg = str(ei.value).lower()
assert "srv07.md" in msg or "rename" in msg
def test_error_report_points_to_the_guide(capsys):
gen_overview.report_errors(["srv06.md: missing required field 'status'"], "hardware")
err = capsys.readouterr().err
assert "make docs-index" in err
assert gen_overview.GUIDE_URL in err

834
tests/test_gen_rack.py Normal file
View file

@ -0,0 +1,834 @@
import pytest
import gen_rack
def item(**kw):
base = {"hostname": "x", "kind": "server", "status": "in-use", "rack": "rack01"}
base.update(kw)
return base
def shelf(**kw):
base = {"hostname": "shf01", "kind": "shelf", "status": "in-use",
"rack": "rack01", "rack_u": 37, "u_height": 10, "rack_face": "both"}
base.update(kw)
return base
def test_validate_accepts_valid_placement():
gen_rack.validate_item(item(rack_u=12, u_height=2, rack_face="front"))
def test_validate_rejects_u_overflow():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack_u=47, u_height=3, rack_face="front"))
def test_validate_rejects_u_below_one():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack_u=0, u_height=1, rack_face="front"))
def test_validate_rejects_bad_face():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways"))
def test_validate_rejects_zero_u_with_units():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1))
def test_validate_accepts_zero_u_rail():
gen_rack.validate_item(item(rack_face="left"))
def test_validate_rejects_missing_units_on_faced_item():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack_face="front"))
def test_validate_rejects_empty_rack():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(rack=None, rack_u=1, u_height=1, rack_face="front"))
def test_overlaps_detects_same_face_overlap():
items = [
item(hostname="a", rack_u=1, u_height=2, rack_face="front"),
item(hostname="b", rack_u=2, u_height=1, rack_face="front"),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.check_overlaps(items)
def test_overlaps_allows_same_u_different_face():
items = [
item(hostname="a", rack_u=5, u_height=1, rack_face="front"),
item(hostname="b", rack_u=5, u_height=1, rack_face="rear"),
]
gen_rack.check_overlaps(items) # no raise
def test_overlaps_both_face_conflicts_with_front():
items = [
item(hostname="a", rack_u=5, u_height=1, rack_face="both"),
item(hostname="b", rack_u=5, u_height=1, rack_face="front"),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.check_overlaps(items)
def test_overlaps_ignores_zero_u_rails():
items = [
item(hostname="p1", rack_face="left"),
item(hostname="p2", rack_face="left"),
]
gen_rack.check_overlaps(items) # no raise
def test_render_svg_has_two_columns_of_48_slots():
svg = gen_rack.render_svg("rack01", [])
# one faint slot rect per U per column (front + rear)
assert svg.count('fill="#f5f5f5"') == 2 * gen_rack.RACK_UNITS
assert svg.startswith("<svg")
assert svg.rstrip().endswith("</svg>")
def test_render_svg_includes_device_label():
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert "mf00" in svg
assert "U1" in svg
def test_render_svg_is_deterministic():
items = [
item(hostname="b", rack_u=3, u_height=1, rack_face="front"),
item(hostname="a", rack_u=1, u_height=1, rack_face="rear"),
]
assert gen_rack.render_svg("rack01", items) == gen_rack.render_svg(
"rack01", list(reversed(items))
)
def test_render_page_has_banner_image_and_table():
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
page = gen_rack.render_page("rack01", items)
assert "do not edit by hand" in page
assert '<div class="rack-elevation">' in page
assert "<svg" in page
assert "[Download SVG](rack01-elevation.svg)" in page
assert "../../hardware/mf00.md" in page
assert "U1U2" in page
def _write_item(d, name, body):
(d / f"{name}.md").write_text(body, encoding="utf-8")
def test_generate_writes_artifacts(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw,
"mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
)
# a non-rack file must be ignored
_write_item(hw, "cloud", "---\nhostname: cloud\nkind: server\nstatus: in-use\n---\n")
rc = gen_rack.generate(hw, out)
assert rc == 0
assert (out / "rack01.md").exists()
assert (out / "rack01-elevation.svg").exists()
assert "mf00" in (out / "rack01-elevation.svg").read_text()
def test_render_svg_draws_zero_u_rail():
items = [item(hostname="pdu01", kind="pdu", rack_face="left")]
svg = gen_rack.render_svg("rack01", items)
assert "pdu01" in svg
assert "rotate(-90" in svg
def test_render_svg_rail_label_is_centered_across_bar():
# The rotated 0U rail label must be centered across the narrow bar width,
# not sitting on the alphabetic baseline (which reads off-centre).
items = [item(hostname="pdu01", kind="pdu", rack_face="left")]
svg = gen_rack.render_svg("rack01", items)
assert 'dominant-baseline="central"' in svg
def test_render_svg_both_face_draws_in_both_columns():
items = [item(hostname="dev", rack_u=10, u_height=1, rack_face="both")]
svg = gen_rack.render_svg("rack01", items)
assert svg.count("dev (U10)") == 2
def test_generate_writes_one_pair_per_rack(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw,
"a",
"---\nhostname: a\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
)
_write_item(
hw,
"b",
"---\nhostname: b\nkind: server\nstatus: in-use\n"
"rack: rack02\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 0
assert (out / "rack01.md").exists() and (out / "rack02.md").exists()
assert (out / "rack01-elevation.svg").exists()
assert (out / "rack02-elevation.svg").exists()
def test_generate_returns_1_on_overlap(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
for n, u in (("a", 1), ("b", 1)):
_write_item(
hw,
n,
f"---\nhostname: {n}\nkind: server\nstatus: in-use\n"
f"rack: rack01\nrack_u: {u}\nu_height: 1\nrack_face: front\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 1
assert not (out / "rack01.md").exists()
def test_validate_power_accepts_valid_feed():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}]),
]
gen_rack.validate_power(items)
def test_validate_power_rejects_unknown_pdu():
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "ghost", "outlet": 1}])]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_non_pdu_target():
items = [
item(hostname="sw01", kind="switch", rack_u=1, u_height=1,
rack_face="front"),
item(hostname="mf00", rack_u=2, u_height=1, rack_face="front",
power=[{"pdu": "sw01", "outlet": 1}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_outlet_over_count():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 9}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_outlet_zero():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 0}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_malformed_entry():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=["pdu01"]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_pdu_without_outlets():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left"),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_generate_returns_1_on_bad_power_ref(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw,
"mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
"power:\n - { pdu: ghost, outlet: 1 }\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 1
assert not (out / "rack01.md").exists()
def test_render_power_has_nodes_and_edge_labels():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 3}]),
]
out = gen_rack.render_power("rack01", items)
assert "```mermaid" in out
assert "flowchart LR" in out
assert "pdu01" in out
assert "8 outlets" in out
assert "outlet 3" in out
assert "mf00" in out
def test_render_power_redundant_device_has_two_edges():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="pdu02", kind="pdu", rack_face="right", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1},
{"pdu": "pdu02", "outlet": 1}]),
]
out = gen_rack.render_power("rack01", items)
assert out.count("-->|outlet") == 2
def test_render_power_empty_when_no_feeds():
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
assert gen_rack.render_power("rack01", items) == ""
def test_render_power_is_deterministic():
a = item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8)
b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 2}])
c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}])
assert gen_rack.render_power("rack01", [a, b, c]) == \
gen_rack.render_power("rack01", [c, b, a])
def test_generate_includes_power_section(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw,
"pdu01",
"---\nhostname: pdu01\nkind: pdu\nstatus: in-use\n"
"rack: rack01\nrack_face: left\noutlets: 8\n---\n",
)
_write_item(
hw,
"mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
"power:\n - { pdu: pdu01, outlet: 1 }\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 0
page = (out / "rack01.md").read_text()
assert "## Power" in page
assert "```mermaid" in page
assert "outlet 1" in page
def test_load_hardware_index_maps_all_hostnames(tmp_path):
hw = tmp_path / "hardware"
hw.mkdir()
_write_item(
hw, "sw01",
"---\nhostname: sw01\nkind: switch\nstatus: in-use\nports: 24\n---\n",
)
_write_item(
hw, "mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
)
idx = gen_rack.load_hardware_index(hw)
assert set(idx) == {"sw01", "mf00"}
assert idx["sw01"]["ports"] == 24
def test_validate_links_accepts_valid_link():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 1, "speed_gbps": 1}])]
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
gen_rack.validate_links(items, hw_index)
def test_validate_links_rejects_unknown_peer():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "ghost", "peer_port": 1}])]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, {})
def test_validate_links_rejects_peer_port_over_count():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 25}])]
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, hw_index)
def test_validate_links_rejects_peer_port_below_one():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 0}])]
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, hw_index)
def test_validate_links_accepts_peer_without_ports():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "rtr01", "peer_port": 99}])]
hw_index = {"rtr01": item(hostname="rtr01", kind="server")}
gen_rack.validate_links(items, hw_index) # no ports -> range check skipped
def test_validate_links_rejects_missing_local():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"peer": "sw01", "peer_port": 1}])]
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, hw_index)
def test_validate_links_rejects_malformed_entry():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=["sw01"])]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, {})
def test_generate_returns_1_on_bad_link_peer(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw, "mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
"links:\n - { local: eth0, peer: ghost, peer_port: 1 }\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 1
assert not (out / "rack01.md").exists()
def test_render_network_has_nodes_and_edge_labels():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 1, "speed_gbps": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "```mermaid" in out
assert "flowchart LR" in out
assert "sw01<br/>switch" in out
assert "mf00" in out
assert "eth0" in out
assert "p1" in out
assert "1G" in out
def test_render_network_patch_panel_subtitle():
items = [
item(hostname="pp01", kind="patch-panel", rack_u=24, u_height=1,
rack_face="front", ports=24),
item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "pp01",
"peer_port": 1, "speed_gbps": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "pp01<br/>patch-panel" in out
def test_render_network_empty_when_no_links():
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
assert gen_rack.render_network("rack01", items) == ""
def test_render_network_omits_speed_when_absent():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "eth0" in out and "p1" in out
assert "·" not in out # no speed suffix rendered
def test_render_network_is_deterministic():
a = item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24)
b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 2, "speed_gbps": 1}])
c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 1, "speed_gbps": 1}])
assert gen_rack.render_network("rack01", [a, b, c]) == \
gen_rack.render_network("rack01", [c, b, a])
def test_generate_includes_network_section(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw, "sw01",
"---\nhostname: sw01\nkind: switch\nstatus: in-use\n"
"rack: rack01\nrack_u: 10\nu_height: 1\nrack_face: front\nports: 24\n---\n",
)
_write_item(
hw, "mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
"links:\n - { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 0
page = (out / "rack01.md").read_text()
assert "## Network" in page
assert "```mermaid" in page
assert "eth0" in page
def test_validate_accepts_mounted_item():
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1))
def test_validate_rejects_mounted_with_rack_u():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1, rack_u=5))
def test_validate_rejects_mounted_bad_shelf_face():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
shelf_face="left", shelf_slot=1))
def test_validate_rejects_mounted_bad_slot():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=0))
def test_overlaps_skips_mounted_items():
items = [
item(hostname="a", mounted_on="shf01", shelf_face="front", shelf_slot=1),
item(hostname="b", mounted_on="shf01", shelf_face="front", shelf_slot=2),
]
gen_rack.check_overlaps(items) # no raise — mounted items claim no U-range
def test_check_shelves_accepts_valid_mount():
items = [shelf(),
item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1)]
gen_rack.check_shelves(items)
def test_check_shelves_rejects_missing_shelf():
items = [item(hostname="srv01", mounted_on="ghost",
shelf_face="front", shelf_slot=1)]
with pytest.raises(gen_rack.SchemaError):
gen_rack.check_shelves(items)
def test_check_shelves_rejects_non_shelf_target():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front"),
item(hostname="srv01", mounted_on="sw01",
shelf_face="front", shelf_slot=1),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.check_shelves(items)
def test_check_shelves_rejects_duplicate_slot():
items = [shelf(),
item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1),
item(hostname="srv02", mounted_on="shf01",
shelf_face="front", shelf_slot=1)]
with pytest.raises(gen_rack.SchemaError):
gen_rack.check_shelves(items)
def test_generate_returns_1_on_dangling_mount(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw, "srv01",
"---\nhostname: srv01\nkind: server\nstatus: in-use\n"
"rack: rack01\nmounted_on: ghost\nshelf_face: front\nshelf_slot: 1\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 1
assert not (out / "rack01.md").exists()
def test_render_svg_draws_shelf_and_occupants():
items = [
shelf(),
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
item(hostname="srv03", mounted_on="shf01", shelf_face="rear", shelf_slot=1),
]
svg = gen_rack.render_svg("rack01", items)
assert "shf01" in svg
assert "srv01" in svg and "srv02" in svg and "srv03" in svg
# the shelf is NOT drawn as a generic full-height device box
assert "shf01 (U37" not in svg
def test_validate_accepts_chassis_u():
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1, chassis_u=10))
def test_validate_rejects_bad_chassis_u():
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1, chassis_u=0))
def test_render_svg_tower_height_reflects_chassis_u():
items = [
shelf(hostname="shf01", rack_u=46, u_height=1),
item(hostname="srv01", mounted_on="shf01", shelf_face="front",
shelf_slot=1, chassis_u=10),
]
svg = gen_rack.render_svg("rack01", items)
# 10U tower drawn as 10*U_H - SHELF_STRIP_H - 2 px tall
assert 'height="192"' in svg
def test_render_svg_paints_rail_device_over_shelf_tower():
# A PDU rail-mounted within a tower's span must be painted after the tower
# (later in the SVG = on top) so it stays visible.
items = [
shelf(hostname="shf02", rack_u=35, u_height=1),
item(hostname="srv05", mounted_on="shf02", shelf_face="rear",
shelf_slot=1, chassis_u=7),
item(hostname="pdu03", kind="pdu", rack_u=34, u_height=1,
rack_face="rear"),
]
svg = gen_rack.render_svg("rack01", items)
assert svg.index("srv05") < svg.index("pdu03")
def test_render_page_mounted_shows_chassis_span():
items = [shelf(hostname="shf01", rack_u=46, u_height=1),
item(hostname="srv01", mounted_on="shf01", shelf_face="front",
shelf_slot=1, chassis_u=10)]
page = gen_rack.render_page("rack01", items)
assert "U37U46" in page
def test_render_svg_shelf_is_deterministic():
base = [
shelf(),
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
]
assert gen_rack.render_svg("rack01", base) == gen_rack.render_svg(
"rack01", list(reversed(base))
)
def test_render_page_lists_mounted_devices():
items = [shelf(),
item(hostname="srv01", mounted_on="shf01",
shelf_face="front", shelf_slot=1)]
page = gen_rack.render_page("rack01", items)
assert "../../hardware/srv01.md" in page
assert "front · shf01/1" in page
assert "U37U46" in page # mounted device shows its shelf's U-range
def test_svg_boxes_link_to_host_pages():
items = [item(hostname="srv04", rack_u=5, u_height=1, rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert '<a href="/hardware/srv04/">' in svg
assert "<title>" in svg
def test_svg_status_border_styles():
staging = gen_rack.render_svg("rack01", [
item(hostname="a", rack_u=1, u_height=1, rack_face="front",
status="staging")])
broken = gen_rack.render_svg("rack01", [
item(hostname="b", rack_u=1, u_height=1, rack_face="front",
status="broken")])
assert 'stroke-dasharray="4 2"' in staging
assert 'stroke="#e15759"' in broken and 'stroke-width="3"' in broken
def test_svg_tooltip_has_cluster_and_placement():
items = [item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
status="staging", cluster="tappaas")]
svg = gen_rack.render_svg("rack01", items)
assert "cluster: tappaas" in svg
assert "U1" in svg
def test_svg_has_responsive_style():
svg = gen_rack.render_svg("rack01", [])
assert "max-width:100%" in svg
def test_render_page_inlines_svg_with_download_link():
items = [item(hostname="srv04", rack_u=5, u_height=1, rack_face="front")]
page = gen_rack.render_page("rack01", items)
assert '<div class="rack-elevation">' in page
assert "<svg" in page
assert "[Download SVG](rack01-elevation.svg)" in page
assert "![Rack rack01 elevation]" not in page
def test_svg_legend_shows_present_kinds():
items = [item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert ">Legend<" in svg
assert ">switch<" in svg
def test_svg_legend_omits_absent_kinds():
items = [item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert ">ups<" not in svg
def test_svg_u_numbers_in_both_gutters():
svg = gen_rack.render_svg("rack01", [])
assert 'text-anchor="end"' in svg # left gutter
assert 'text-anchor="start"' in svg # right gutter
def test_svg_has_column_frames():
svg = gen_rack.render_svg("rack01", [])
assert svg.count('fill="none"') >= 2 # one frame per column
def test_power_graph_colors_and_links_nodes():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}]),
]
out = gen_rack.render_power("rack01", items)
assert "style srv01 fill:" in out
assert "style pdu01 fill:" in out
assert 'click srv01 "/hardware/srv01/"' in out
def test_network_graph_colors_and_links_nodes():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24),
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "style sw01 fill:" in out
assert 'click sw01 "/hardware/sw01/"' in out
assert 'click srv01 "/hardware/srv01/"' in out
def test_network_graph_off_rack_peer_has_no_click():
items = [
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "router0", "peer_port": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "style router0 fill:" in out # off-rack peer is still colored
assert 'click router0 "' not in out # but it is NOT clickable
assert 'click srv01 "/hardware/srv01/"' in out
# --- novice-friendly error messages ---------------------------------------
def test_overlap_message_explains_the_conflict():
items = [
item(hostname="a", rack_u=1, u_height=2, rack_face="front"),
item(hostname="b", rack_u=2, u_height=1, rack_face="front"),
]
with pytest.raises(gen_rack.SchemaError) as ei:
gen_rack.check_overlaps(items)
msg = str(ei.value).lower()
assert "overlap" in msg
assert "same u" in msg or "free u" in msg or "other face" in msg # tells them how to fix
def test_zero_u_message_tells_user_to_drop_units():
with pytest.raises(gen_rack.SchemaError) as ei:
gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1))
msg = str(ei.value).lower()
assert "rack_u" in msg and "remove" in msg
def test_bad_face_message_lists_valid_faces():
with pytest.raises(gen_rack.SchemaError) as ei:
gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways"))
msg = str(ei.value)
assert "front" in msg and "left" in msg # both U-mounted and 0U options shown
def test_generate_error_output_is_novice_friendly(tmp_path, capsys):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw, "srv01",
"---\nhostname: srv01\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_face: front\n---\n", # front face but no rack_u/u_height
)
rc = gen_rack.generate(hw, out)
err = capsys.readouterr().err
assert rc == 1
assert "make docs-index" in err
assert gen_rack.GUIDE_URL in err