docs(rack): graphical presentation improvements implementation plan
This commit is contained in:
parent
aad5672a6b
commit
d5cfe9665c
1 changed files with 702 additions and 0 deletions
702
notes/dev/plans/2026-06-24-rack-presentation.md
Normal file
702
notes/dev/plans/2026-06-24-rack-presentation.md
Normal 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"")
|
||||||
|
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.
|
||||||
Loading…
Add table
Reference in a new issue