diff --git a/notes/dev/plans/2026-06-24-rack-presentation.md b/notes/dev/plans/2026-06-24-rack-presentation.md new file mode 100644 index 0000000..f24115d --- /dev/null +++ b/notes/dev/plans/2026-06-24-rack-presentation.md @@ -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 ``+``, 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)}") + p.append( + f'" + ) + p.append( + f'' + f"{_esc(name)} ({urange})" + ) + p.append("") +``` + +- [ ] **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'') + p.append(f"{_tooltip(fm)}") + p.append( + f'" + ) + p.append( + f'{_esc(name)}' + ) + p.append("") +``` + +- [ ] **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'') + p.append(f"{_tooltip(m)}") + p.append( + f'" + ) + p.append( + f'{_esc(mname)}' + ) + p.append("") + p.append(f'') + p.append(f"{_tooltip(fm)}") + for col_x in (front_x, rear_x): + p.append( + f'' + ) + p.append( + f'{_esc(sname)}' + ) + p.append("") +``` + +- [ ] **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('
') + lines.append(render_svg(rack, items).rstrip()) + lines.append("
") + 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 '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'{u}' + ) +``` + +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'{u}' + ) + for col_x in (front_x, rear_x): + p.append( + f'' + ) +``` + +- [ ] **Step 6: Draw the legend before ``** + +The function currently ends: + +```python + for fm in sorted(shelves, key=lambda s: s.get("hostname", "")): + draw_shelf(fm) + + p.append("") + return "\n".join(p) + "\n" +``` + +Insert the legend between the shelf loop and `p.append("")`: + +```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'Legend' + ) + 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'' + ) + p.append(f'{_esc(kind)}') + 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'' + ) + p.append(f'{_esc(label)}') + sx += 28 + 7 * len(label) + + p.append("") + 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//` URLs — Task 1 (`_host_url`), used in SVG links and mermaid clicks. ✔ +- md_in_html raw passthrough (`
`, no `markdown` attr) — Task 1 Step 8, verified Task 1 Step 10 (built HTML contains ` 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.