From 8d39fbcdf50c0c90889f92ca21420cd9a405f44c Mon Sep 17 00:00:00 2001 From: sjat Date: Wed, 24 Jun 2026 18:34:06 +0200 Subject: [PATCH] feat(rack): inline interactive elevation with links, tooltips, status borders --- .../infrastructure/racks/rack01-elevation.svg | 52 ++++- docs/infrastructure/racks/rack01.md | 206 +++++++++++++++++- scripts/gen_rack.py | 78 ++++++- tests/test_gen_rack.py | 44 +++- 4 files changed, 362 insertions(+), 18 deletions(-) diff --git a/docs/infrastructure/racks/rack01-elevation.svg b/docs/infrastructure/racks/rack01-elevation.svg index 78a102f..67034a5 100644 --- a/docs/infrastructure/racks/rack01-elevation.svg +++ b/docs/infrastructure/racks/rack01-elevation.svg @@ -1,4 +1,4 @@ - + Rack rack01 front @@ -147,25 +147,55 @@ 46 47 48 - + +srv04 · server · staging · cluster: — · U5–U6 + srv04 (U5–U6) - + + +srv05 · server · staging · cluster: — · U5–U6 + srv05 (U5–U6) - + + +sw01 · switch · in-use · cluster: — · U10 + sw01 (U10) - + + +pp01 · patch-panel · in-use · cluster: — · U24 + pp01 (U24) - + + +pdu01 · pdu · in-use · cluster: — · 0U left + pdu01 - + + +pdu02 · pdu · in-use · cluster: — · 0U right + pdu02 - + + +srv01 · server · staging · cluster: tappaas · shf01/front/slot 1 + srv01 - + + +srv02 · server · staging · cluster: tappaas · shf01/front/slot 2 + srv02 - - + + +srv03 · server · staging · cluster: tappaas · shf01/rear/slot 1 + srv03 + + +shf01 · shelf · in-use · cluster: tappaas · U37–U46 + shf01 + diff --git a/docs/infrastructure/racks/rack01.md b/docs/infrastructure/racks/rack01.md index 0d1b3ba..a6719f8 100644 --- a/docs/infrastructure/racks/rack01.md +++ b/docs/infrastructure/racks/rack01.md @@ -4,7 +4,211 @@ _Auto-generated from `docs/hardware/*.md` (items with `rack: rack01`) — do not ## Elevation -![Rack rack01 elevation](rack01-elevation.svg) +
+ + +Rack rack01 +front + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +rear + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 + +srv04 · server · staging · cluster: — · U5–U6 + +srv04 (U5–U6) + + +srv05 · server · staging · cluster: — · U5–U6 + +srv05 (U5–U6) + + +sw01 · switch · in-use · cluster: — · U10 + +sw01 (U10) + + +pp01 · patch-panel · in-use · cluster: — · U24 + +pp01 (U24) + + +pdu01 · pdu · in-use · cluster: — · 0U left + +pdu01 + + +pdu02 · pdu · in-use · cluster: — · 0U right + +pdu02 + + +srv01 · server · staging · cluster: tappaas · shf01/front/slot 1 + +srv01 + + +srv02 · server · staging · cluster: tappaas · shf01/front/slot 2 + +srv02 + + +srv03 · server · staging · cluster: tappaas · shf01/rear/slot 1 + +srv03 + + +shf01 · shelf · in-use · cluster: tappaas · U37–U46 + + +shf01 + + +
+ +[Download SVG](rack01-elevation.svg) ## Power diff --git a/scripts/gen_rack.py b/scripts/gen_rack.py index a43a17b..3aecac0 100644 --- a/scripts/gen_rack.py +++ b/scripts/gen_rack.py @@ -289,6 +289,54 @@ def _esc(s: object) -> str: return str(s).replace("&", "&").replace("<", "<").replace(">", ">") +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( @@ -329,6 +377,7 @@ def render_svg(rack: str, items: list[dict]) -> str: p.append( f'' ) p.append(f'') @@ -365,15 +414,19 @@ def render_svg(rack: str, items: list[dict]) -> str: 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'') + p.append(f"{_tooltip(fm)}") p.append( f'' + f'height="{box_h - 2}" rx="3" fill="{color}" ' + f"{_stroke_attrs(fm.get('status'))}/>" ) p.append( f'' - f'{_esc(name)} ({urange})' + f"{_esc(name)} ({urange})" ) + p.append("") for fm in items: if fm.get("kind") == "shelf" or "mounted_on" in fm: @@ -389,14 +442,17 @@ def render_svg(rack: str, items: list[dict]) -> str: 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'' + f"fill=\"{color}\" {_stroke_attrs(fm.get('status'))}/>" ) p.append( f'{_esc(name)}' ) + p.append("") for idx, fm in enumerate(left_items): draw_rail(fm, PAD + idx * RAIL_W) @@ -430,14 +486,21 @@ def render_svg(rack: str, items: list[dict]) -> str: 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'' + f'height="{avail_h - 2}" rx="3" fill="{mcolor}" ' + f"{_stroke_attrs(m.get('status'))}/>" ) 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'' @@ -446,6 +509,7 @@ def render_svg(rack: str, items: list[dict]) -> str: f'{_esc(sname)}' ) + p.append("") for fm in sorted(shelves, key=lambda s: s.get("hostname", "")): draw_shelf(fm) @@ -559,7 +623,11 @@ def render_page(rack: str, items: list[dict]) -> str: lines.append("") lines.append("## Elevation") lines.append("") - lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)") + lines.append('
') + lines.append(render_svg(rack, items).rstrip()) + lines.append("
") + lines.append("") + lines.append(f"[Download SVG]({rack}-elevation.svg)") lines.append("") power = render_power(rack, items) if power: diff --git a/tests/test_gen_rack.py b/tests/test_gen_rack.py index b3a208b..d7e2d96 100644 --- a/tests/test_gen_rack.py +++ b/tests/test_gen_rack.py @@ -117,7 +117,9 @@ 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 '
' in page + assert "' in svg + assert "" 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