feat(rack): add elevation legend, both-gutter U-numbers, column frames

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-24 18:39:56 +02:00
parent 8d39fbcdf5
commit 08862fde51
4 changed files with 215 additions and 11 deletions

View file

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="616" height="1012" viewBox="0 0 616 1012" style="max-width:100%;height:auto" font-family="sans-serif" font-size="11">
<rect width="616" height="1012" fill="#ffffff"/>
<svg xmlns="http://www.w3.org/2000/svg" width="646" height="1068" viewBox="0 0 646 1068" style="max-width:100%;height:auto" font-family="sans-serif" font-size="11">
<rect width="646" height="1068" fill="#ffffff"/>
<text x="12" y="28" font-size="16" font-weight="bold">Rack rack01</text>
<text x="178" y="34" text-anchor="middle" font-weight="bold">front</text>
<rect x="58" y="40" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
@ -147,6 +147,56 @@
<text x="54" y="954" text-anchor="end" fill="#999">46</text>
<text x="54" y="974" text-anchor="end" fill="#999">47</text>
<text x="54" y="994" text-anchor="end" fill="#999">48</text>
<text x="592" y="54" text-anchor="start" fill="#999">1</text>
<text x="592" y="74" text-anchor="start" fill="#999">2</text>
<text x="592" y="94" text-anchor="start" fill="#999">3</text>
<text x="592" y="114" text-anchor="start" fill="#999">4</text>
<text x="592" y="134" text-anchor="start" fill="#999">5</text>
<text x="592" y="154" text-anchor="start" fill="#999">6</text>
<text x="592" y="174" text-anchor="start" fill="#999">7</text>
<text x="592" y="194" text-anchor="start" fill="#999">8</text>
<text x="592" y="214" text-anchor="start" fill="#999">9</text>
<text x="592" y="234" text-anchor="start" fill="#999">10</text>
<text x="592" y="254" text-anchor="start" fill="#999">11</text>
<text x="592" y="274" text-anchor="start" fill="#999">12</text>
<text x="592" y="294" text-anchor="start" fill="#999">13</text>
<text x="592" y="314" text-anchor="start" fill="#999">14</text>
<text x="592" y="334" text-anchor="start" fill="#999">15</text>
<text x="592" y="354" text-anchor="start" fill="#999">16</text>
<text x="592" y="374" text-anchor="start" fill="#999">17</text>
<text x="592" y="394" text-anchor="start" fill="#999">18</text>
<text x="592" y="414" text-anchor="start" fill="#999">19</text>
<text x="592" y="434" text-anchor="start" fill="#999">20</text>
<text x="592" y="454" text-anchor="start" fill="#999">21</text>
<text x="592" y="474" text-anchor="start" fill="#999">22</text>
<text x="592" y="494" text-anchor="start" fill="#999">23</text>
<text x="592" y="514" text-anchor="start" fill="#999">24</text>
<text x="592" y="534" text-anchor="start" fill="#999">25</text>
<text x="592" y="554" text-anchor="start" fill="#999">26</text>
<text x="592" y="574" text-anchor="start" fill="#999">27</text>
<text x="592" y="594" text-anchor="start" fill="#999">28</text>
<text x="592" y="614" text-anchor="start" fill="#999">29</text>
<text x="592" y="634" text-anchor="start" fill="#999">30</text>
<text x="592" y="654" text-anchor="start" fill="#999">31</text>
<text x="592" y="674" text-anchor="start" fill="#999">32</text>
<text x="592" y="694" text-anchor="start" fill="#999">33</text>
<text x="592" y="714" text-anchor="start" fill="#999">34</text>
<text x="592" y="734" text-anchor="start" fill="#999">35</text>
<text x="592" y="754" text-anchor="start" fill="#999">36</text>
<text x="592" y="774" text-anchor="start" fill="#999">37</text>
<text x="592" y="794" text-anchor="start" fill="#999">38</text>
<text x="592" y="814" text-anchor="start" fill="#999">39</text>
<text x="592" y="834" text-anchor="start" fill="#999">40</text>
<text x="592" y="854" text-anchor="start" fill="#999">41</text>
<text x="592" y="874" text-anchor="start" fill="#999">42</text>
<text x="592" y="894" text-anchor="start" fill="#999">43</text>
<text x="592" y="914" text-anchor="start" fill="#999">44</text>
<text x="592" y="934" text-anchor="start" fill="#999">45</text>
<text x="592" y="954" text-anchor="start" fill="#999">46</text>
<text x="592" y="974" text-anchor="start" fill="#999">47</text>
<text x="592" y="994" text-anchor="start" fill="#999">48</text>
<rect x="58" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<rect x="348" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<a href="/hardware/srv04/">
<title>srv04 · server · staging · cluster: — · U5U6</title>
<rect x="59" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
@ -174,8 +224,8 @@
</a>
<a href="/hardware/pdu02/">
<title>pdu02 · pdu · in-use · cluster: — · 0U right</title>
<rect x="588" y="40" width="16" height="960" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="596" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 596 520)">pdu02</text>
<rect x="618" y="40" width="16" height="960" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="626" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 626 520)">pdu02</text>
</a>
<a href="/hardware/srv01/">
<title>srv01 · server · staging · cluster: tappaas · shf01/front/slot 1</title>
@ -198,4 +248,23 @@
<rect x="348" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="178" y="959" text-anchor="middle" fill="#333" font-size="9">shf01</text>
</a>
<text x="58" y="1020" font-weight="bold">Legend</text>
<rect x="58" y="1028" width="12" height="12" fill="#9c755f" stroke="#333"/>
<text x="74" y="1038">patch-panel</text>
<rect x="163" y="1028" width="12" height="12" fill="#e15759" stroke="#333"/>
<text x="179" y="1038">pdu</text>
<rect x="212" y="1028" width="12" height="12" fill="#4c78a8" stroke="#333"/>
<text x="228" y="1038">server</text>
<rect x="282" y="1028" width="12" height="12" fill="#bab0ac" stroke="#333"/>
<text x="298" y="1038">shelf</text>
<rect x="345" y="1028" width="12" height="12" fill="#59a14f" stroke="#333"/>
<text x="361" y="1038">switch</text>
<rect x="58" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5"/>
<text x="74" y="1056">in-use</text>
<rect x="128" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="144" y="1056">staging</text>
<rect x="205" y="1046" width="12" height="12" fill="#ffffff" stroke="#e15759" stroke-width="3"/>
<text x="221" y="1056">broken</text>
<rect x="275" y="1046" width="12" height="12" fill="#ffffff" stroke="#bbbbbb" stroke-width="1.5"/>
<text x="291" y="1056">spare</text>
</svg>

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -5,8 +5,8 @@ _Auto-generated from `docs/hardware/*.md` (items with `rack: rack01`) — do not
## Elevation
<div class="rack-elevation">
<svg xmlns="http://www.w3.org/2000/svg" width="616" height="1012" viewBox="0 0 616 1012" style="max-width:100%;height:auto" font-family="sans-serif" font-size="11">
<rect width="616" height="1012" fill="#ffffff"/>
<svg xmlns="http://www.w3.org/2000/svg" width="646" height="1068" viewBox="0 0 646 1068" style="max-width:100%;height:auto" font-family="sans-serif" font-size="11">
<rect width="646" height="1068" fill="#ffffff"/>
<text x="12" y="28" font-size="16" font-weight="bold">Rack rack01</text>
<text x="178" y="34" text-anchor="middle" font-weight="bold">front</text>
<rect x="58" y="40" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
@ -154,6 +154,56 @@ _Auto-generated from `docs/hardware/*.md` (items with `rack: rack01`) — do not
<text x="54" y="954" text-anchor="end" fill="#999">46</text>
<text x="54" y="974" text-anchor="end" fill="#999">47</text>
<text x="54" y="994" text-anchor="end" fill="#999">48</text>
<text x="592" y="54" text-anchor="start" fill="#999">1</text>
<text x="592" y="74" text-anchor="start" fill="#999">2</text>
<text x="592" y="94" text-anchor="start" fill="#999">3</text>
<text x="592" y="114" text-anchor="start" fill="#999">4</text>
<text x="592" y="134" text-anchor="start" fill="#999">5</text>
<text x="592" y="154" text-anchor="start" fill="#999">6</text>
<text x="592" y="174" text-anchor="start" fill="#999">7</text>
<text x="592" y="194" text-anchor="start" fill="#999">8</text>
<text x="592" y="214" text-anchor="start" fill="#999">9</text>
<text x="592" y="234" text-anchor="start" fill="#999">10</text>
<text x="592" y="254" text-anchor="start" fill="#999">11</text>
<text x="592" y="274" text-anchor="start" fill="#999">12</text>
<text x="592" y="294" text-anchor="start" fill="#999">13</text>
<text x="592" y="314" text-anchor="start" fill="#999">14</text>
<text x="592" y="334" text-anchor="start" fill="#999">15</text>
<text x="592" y="354" text-anchor="start" fill="#999">16</text>
<text x="592" y="374" text-anchor="start" fill="#999">17</text>
<text x="592" y="394" text-anchor="start" fill="#999">18</text>
<text x="592" y="414" text-anchor="start" fill="#999">19</text>
<text x="592" y="434" text-anchor="start" fill="#999">20</text>
<text x="592" y="454" text-anchor="start" fill="#999">21</text>
<text x="592" y="474" text-anchor="start" fill="#999">22</text>
<text x="592" y="494" text-anchor="start" fill="#999">23</text>
<text x="592" y="514" text-anchor="start" fill="#999">24</text>
<text x="592" y="534" text-anchor="start" fill="#999">25</text>
<text x="592" y="554" text-anchor="start" fill="#999">26</text>
<text x="592" y="574" text-anchor="start" fill="#999">27</text>
<text x="592" y="594" text-anchor="start" fill="#999">28</text>
<text x="592" y="614" text-anchor="start" fill="#999">29</text>
<text x="592" y="634" text-anchor="start" fill="#999">30</text>
<text x="592" y="654" text-anchor="start" fill="#999">31</text>
<text x="592" y="674" text-anchor="start" fill="#999">32</text>
<text x="592" y="694" text-anchor="start" fill="#999">33</text>
<text x="592" y="714" text-anchor="start" fill="#999">34</text>
<text x="592" y="734" text-anchor="start" fill="#999">35</text>
<text x="592" y="754" text-anchor="start" fill="#999">36</text>
<text x="592" y="774" text-anchor="start" fill="#999">37</text>
<text x="592" y="794" text-anchor="start" fill="#999">38</text>
<text x="592" y="814" text-anchor="start" fill="#999">39</text>
<text x="592" y="834" text-anchor="start" fill="#999">40</text>
<text x="592" y="854" text-anchor="start" fill="#999">41</text>
<text x="592" y="874" text-anchor="start" fill="#999">42</text>
<text x="592" y="894" text-anchor="start" fill="#999">43</text>
<text x="592" y="914" text-anchor="start" fill="#999">44</text>
<text x="592" y="934" text-anchor="start" fill="#999">45</text>
<text x="592" y="954" text-anchor="start" fill="#999">46</text>
<text x="592" y="974" text-anchor="start" fill="#999">47</text>
<text x="592" y="994" text-anchor="start" fill="#999">48</text>
<rect x="58" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<rect x="348" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<a href="/hardware/srv04/">
<title>srv04 · server · staging · cluster: — · U5U6</title>
<rect x="59" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
@ -181,8 +231,8 @@ _Auto-generated from `docs/hardware/*.md` (items with `rack: rack01`) — do not
</a>
<a href="/hardware/pdu02/">
<title>pdu02 · pdu · in-use · cluster: — · 0U right</title>
<rect x="588" y="40" width="16" height="960" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="596" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 596 520)">pdu02</text>
<rect x="618" y="40" width="16" height="960" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="626" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 626 520)">pdu02</text>
</a>
<a href="/hardware/srv01/">
<title>srv01 · server · staging · cluster: tappaas · shf01/front/slot 1</title>
@ -205,6 +255,25 @@ _Auto-generated from `docs/hardware/*.md` (items with `rack: rack01`) — do not
<rect x="348" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="178" y="959" text-anchor="middle" fill="#333" font-size="9">shf01</text>
</a>
<text x="58" y="1020" font-weight="bold">Legend</text>
<rect x="58" y="1028" width="12" height="12" fill="#9c755f" stroke="#333"/>
<text x="74" y="1038">patch-panel</text>
<rect x="163" y="1028" width="12" height="12" fill="#e15759" stroke="#333"/>
<text x="179" y="1038">pdu</text>
<rect x="212" y="1028" width="12" height="12" fill="#4c78a8" stroke="#333"/>
<text x="228" y="1038">server</text>
<rect x="282" y="1028" width="12" height="12" fill="#bab0ac" stroke="#333"/>
<text x="298" y="1038">shelf</text>
<rect x="345" y="1028" width="12" height="12" fill="#59a14f" stroke="#333"/>
<text x="361" y="1038">switch</text>
<rect x="58" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5"/>
<text x="74" y="1056">in-use</text>
<rect x="128" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="144" y="1056">staging</text>
<rect x="205" y="1046" width="12" height="12" fill="#ffffff" stroke="#e15759" stroke-width="3"/>
<text x="221" y="1056">broken</text>
<rect x="275" y="1046" width="12" height="12" fill="#ffffff" stroke="#bbbbbb" stroke-width="1.5"/>
<text x="291" y="1056">spare</text>
</svg>
</div>

View file

@ -357,16 +357,18 @@ def render_svg(rack: str, items: list[dict]) -> str:
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
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
width = rear_x + COL_W + len(right_items) * RAIL_W + PAD
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:
@ -405,6 +407,17 @@ def render_svg(rack: str, items: list[dict]) -> str:
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"]
@ -457,7 +470,7 @@ def render_svg(rack: str, items: list[dict]) -> str:
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)
draw_rail(fm, right_gutter_x + LABEL_W + idx * RAIL_W)
SHELF_STRIP_H = 6
shelves = [i for i in items if i.get("kind") == "shelf"]
@ -514,6 +527,33 @@ def render_svg(rack: str, items: list[dict]) -> str:
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"

View file

@ -674,3 +674,29 @@ def test_render_page_inlines_svg_with_download_link():
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