702 lines
25 KiB
Markdown
702 lines
25 KiB
Markdown
# 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"")
|
||
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.
|