Compare commits

...

6 commits

Author SHA1 Message Date
sjat
e1c05a6c88 test(rack): cover off-rack peer (styled, not clickable) in network graph
All checks were successful
Build docs site / build (push) Successful in 49s
Build slides / build (push) Successful in 1m8s
2026-06-24 18:48:13 +02:00
sjat
d3d5e9c69e feat(rack): colour and link mermaid power/network nodes by kind 2026-06-24 18:43:52 +02:00
sjat
08862fde51 feat(rack): add elevation legend, both-gutter U-numbers, column frames
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 18:39:56 +02:00
sjat
8d39fbcdf5 feat(rack): inline interactive elevation with links, tooltips, status borders 2026-06-24 18:34:06 +02:00
sjat
d5cfe9665c docs(rack): graphical presentation improvements implementation plan 2026-06-24 18:29:36 +02:00
sjat
aad5672a6b docs(rack): graphical presentation improvements design spec 2026-06-24 18:22:41 +02:00
6 changed files with 1492 additions and 23 deletions

View file

@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="616" height="1012" viewBox="0 0 616 1012" font-family="sans-serif" font-size="11"> <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="616" height="1012" fill="#ffffff"/> <rect width="646" height="1068" fill="#ffffff"/>
<text x="12" y="28" font-size="16" font-weight="bold">Rack rack01</text> <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> <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"/> <rect x="58" y="40" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
@ -147,25 +147,124 @@
<text x="54" y="954" text-anchor="end" fill="#999">46</text> <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="974" text-anchor="end" fill="#999">47</text>
<text x="54" y="994" text-anchor="end" fill="#999">48</text> <text x="54" y="994" text-anchor="end" fill="#999">48</text>
<rect x="59" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333"/> <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"/>
<text x="178" y="144" text-anchor="middle" fill="#ffffff">srv04 (U5U6)</text> <text x="178" y="144" text-anchor="middle" fill="#ffffff">srv04 (U5U6)</text>
<rect x="349" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333"/> </a>
<a href="/hardware/srv05/">
<title>srv05 · server · staging · cluster: — · U5U6</title>
<rect x="349" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="468" y="144" text-anchor="middle" fill="#ffffff">srv05 (U5U6)</text> <text x="468" y="144" text-anchor="middle" fill="#ffffff">srv05 (U5U6)</text>
<rect x="59" y="221" width="238" height="18" rx="3" fill="#59a14f" stroke="#333"/> </a>
<a href="/hardware/sw01/">
<title>sw01 · switch · in-use · cluster: — · U10</title>
<rect x="59" y="221" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5"/>
<text x="178" y="234" text-anchor="middle" fill="#ffffff">sw01 (U10)</text> <text x="178" y="234" text-anchor="middle" fill="#ffffff">sw01 (U10)</text>
<rect x="59" y="501" width="238" height="18" rx="3" fill="#9c755f" stroke="#333"/> </a>
<a href="/hardware/pp01/">
<title>pp01 · patch-panel · in-use · cluster: — · U24</title>
<rect x="59" y="501" width="238" height="18" rx="3" fill="#9c755f" stroke="#333333" stroke-width="1.5"/>
<text x="178" y="514" text-anchor="middle" fill="#ffffff">pp01 (U24)</text> <text x="178" y="514" text-anchor="middle" fill="#ffffff">pp01 (U24)</text>
<rect x="12" y="40" width="16" height="960" fill="#e15759" stroke="#333"/> </a>
<a href="/hardware/pdu01/">
<title>pdu01 · pdu · in-use · cluster: — · 0U left</title>
<rect x="12" y="40" width="16" height="960" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="20" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 20 520)">pdu01</text> <text x="20" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 20 520)">pdu01</text>
<rect x="588" y="40" width="16" height="960" fill="#e15759" stroke="#333"/> </a>
<text x="596" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 596 520)">pdu02</text> <a href="/hardware/pdu02/">
<rect x="59" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333"/> <title>pdu02 · pdu · in-use · cluster: — · 0U right</title>
<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>
<rect x="59" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="118" y="861" text-anchor="middle" fill="#ffffff">srv01</text> <text x="118" y="861" text-anchor="middle" fill="#ffffff">srv01</text>
<rect x="179" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333"/> </a>
<a href="/hardware/srv02/">
<title>srv02 · server · staging · cluster: tappaas · shf01/front/slot 2</title>
<rect x="179" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="238" y="861" text-anchor="middle" fill="#ffffff">srv02</text> <text x="238" y="861" text-anchor="middle" fill="#ffffff">srv02</text>
<rect x="58" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/> </a>
<rect x="349" y="761" width="238" height="192" rx="3" fill="#4c78a8" stroke="#333"/> <a href="/hardware/srv03/">
<title>srv03 · server · staging · cluster: tappaas · shf01/rear/slot 1</title>
<rect x="349" y="761" width="238" height="192" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="468" y="861" text-anchor="middle" fill="#ffffff">srv03</text> <text x="468" y="861" text-anchor="middle" fill="#ffffff">srv03</text>
</a>
<a href="/hardware/shf01/">
<title>shf01 · shelf · in-use · cluster: tappaas · U37U46</title>
<rect x="58" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="348" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/> <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> <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> </svg>

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -4,7 +4,280 @@ _Auto-generated from `docs/hardware/*.md` (items with `rack: rack01`) — do not
## Elevation ## Elevation
![Rack rack01 elevation](rack01-elevation.svg) <div class="rack-elevation">
<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"/>
<rect x="58" y="60" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="80" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="100" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="120" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="140" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="160" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="180" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="200" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="220" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="240" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="260" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="280" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="300" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="320" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="340" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="360" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="380" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="400" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="420" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="440" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="460" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="480" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="500" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="520" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="540" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="560" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="580" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="600" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="620" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="640" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="660" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="680" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="700" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="720" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="740" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="760" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="780" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="800" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="820" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="840" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="860" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="880" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="900" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="920" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="940" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="960" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="58" y="980" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<text x="468" y="34" text-anchor="middle" font-weight="bold">rear</text>
<rect x="348" y="40" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="60" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="80" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="100" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="120" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="140" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="160" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="180" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="200" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="220" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="240" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="260" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="280" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="300" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="320" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="340" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="360" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="380" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="400" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="420" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="440" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="460" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="480" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="500" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="520" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="540" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="560" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="580" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="600" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="620" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="640" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="660" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="680" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="700" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="720" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="740" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="760" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="780" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="800" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="820" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="840" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="860" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="880" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="900" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="920" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="940" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="960" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<rect x="348" y="980" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
<text x="54" y="54" text-anchor="end" fill="#999">1</text>
<text x="54" y="74" text-anchor="end" fill="#999">2</text>
<text x="54" y="94" text-anchor="end" fill="#999">3</text>
<text x="54" y="114" text-anchor="end" fill="#999">4</text>
<text x="54" y="134" text-anchor="end" fill="#999">5</text>
<text x="54" y="154" text-anchor="end" fill="#999">6</text>
<text x="54" y="174" text-anchor="end" fill="#999">7</text>
<text x="54" y="194" text-anchor="end" fill="#999">8</text>
<text x="54" y="214" text-anchor="end" fill="#999">9</text>
<text x="54" y="234" text-anchor="end" fill="#999">10</text>
<text x="54" y="254" text-anchor="end" fill="#999">11</text>
<text x="54" y="274" text-anchor="end" fill="#999">12</text>
<text x="54" y="294" text-anchor="end" fill="#999">13</text>
<text x="54" y="314" text-anchor="end" fill="#999">14</text>
<text x="54" y="334" text-anchor="end" fill="#999">15</text>
<text x="54" y="354" text-anchor="end" fill="#999">16</text>
<text x="54" y="374" text-anchor="end" fill="#999">17</text>
<text x="54" y="394" text-anchor="end" fill="#999">18</text>
<text x="54" y="414" text-anchor="end" fill="#999">19</text>
<text x="54" y="434" text-anchor="end" fill="#999">20</text>
<text x="54" y="454" text-anchor="end" fill="#999">21</text>
<text x="54" y="474" text-anchor="end" fill="#999">22</text>
<text x="54" y="494" text-anchor="end" fill="#999">23</text>
<text x="54" y="514" text-anchor="end" fill="#999">24</text>
<text x="54" y="534" text-anchor="end" fill="#999">25</text>
<text x="54" y="554" text-anchor="end" fill="#999">26</text>
<text x="54" y="574" text-anchor="end" fill="#999">27</text>
<text x="54" y="594" text-anchor="end" fill="#999">28</text>
<text x="54" y="614" text-anchor="end" fill="#999">29</text>
<text x="54" y="634" text-anchor="end" fill="#999">30</text>
<text x="54" y="654" text-anchor="end" fill="#999">31</text>
<text x="54" y="674" text-anchor="end" fill="#999">32</text>
<text x="54" y="694" text-anchor="end" fill="#999">33</text>
<text x="54" y="714" text-anchor="end" fill="#999">34</text>
<text x="54" y="734" text-anchor="end" fill="#999">35</text>
<text x="54" y="754" text-anchor="end" fill="#999">36</text>
<text x="54" y="774" text-anchor="end" fill="#999">37</text>
<text x="54" y="794" text-anchor="end" fill="#999">38</text>
<text x="54" y="814" text-anchor="end" fill="#999">39</text>
<text x="54" y="834" text-anchor="end" fill="#999">40</text>
<text x="54" y="854" text-anchor="end" fill="#999">41</text>
<text x="54" y="874" text-anchor="end" fill="#999">42</text>
<text x="54" y="894" text-anchor="end" fill="#999">43</text>
<text x="54" y="914" text-anchor="end" fill="#999">44</text>
<text x="54" y="934" text-anchor="end" fill="#999">45</text>
<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"/>
<text x="178" y="144" text-anchor="middle" fill="#ffffff">srv04 (U5U6)</text>
</a>
<a href="/hardware/srv05/">
<title>srv05 · server · staging · cluster: — · U5U6</title>
<rect x="349" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="468" y="144" text-anchor="middle" fill="#ffffff">srv05 (U5U6)</text>
</a>
<a href="/hardware/sw01/">
<title>sw01 · switch · in-use · cluster: — · U10</title>
<rect x="59" y="221" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5"/>
<text x="178" y="234" text-anchor="middle" fill="#ffffff">sw01 (U10)</text>
</a>
<a href="/hardware/pp01/">
<title>pp01 · patch-panel · in-use · cluster: — · U24</title>
<rect x="59" y="501" width="238" height="18" rx="3" fill="#9c755f" stroke="#333333" stroke-width="1.5"/>
<text x="178" y="514" text-anchor="middle" fill="#ffffff">pp01 (U24)</text>
</a>
<a href="/hardware/pdu01/">
<title>pdu01 · pdu · in-use · cluster: — · 0U left</title>
<rect x="12" y="40" width="16" height="960" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="20" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 20 520)">pdu01</text>
</a>
<a href="/hardware/pdu02/">
<title>pdu02 · pdu · in-use · cluster: — · 0U right</title>
<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>
<rect x="59" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="118" y="861" text-anchor="middle" fill="#ffffff">srv01</text>
</a>
<a href="/hardware/srv02/">
<title>srv02 · server · staging · cluster: tappaas · shf01/front/slot 2</title>
<rect x="179" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="238" y="861" text-anchor="middle" fill="#ffffff">srv02</text>
</a>
<a href="/hardware/srv03/">
<title>srv03 · server · staging · cluster: tappaas · shf01/rear/slot 1</title>
<rect x="349" y="761" width="238" height="192" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="468" y="861" text-anchor="middle" fill="#ffffff">srv03</text>
</a>
<a href="/hardware/shf01/">
<title>shf01 · shelf · in-use · cluster: tappaas · U37U46</title>
<rect x="58" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<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>
[Download SVG](rack01-elevation.svg)
## Power ## Power
@ -23,6 +296,20 @@ flowchart LR
pdu01 -->|outlet 4| srv04 pdu01 -->|outlet 4| srv04
pdu01 -->|outlet 5| srv05 pdu01 -->|outlet 5| srv05
pdu02 -->|outlet 1| srv01 pdu02 -->|outlet 1| srv01
style pdu01 fill:#e15759,stroke:#333,color:#ffffff
click pdu01 "/hardware/pdu01/"
style pdu02 fill:#e15759,stroke:#333,color:#ffffff
click pdu02 "/hardware/pdu02/"
style srv01 fill:#4c78a8,stroke:#333,color:#ffffff
click srv01 "/hardware/srv01/"
style srv02 fill:#4c78a8,stroke:#333,color:#ffffff
click srv02 "/hardware/srv02/"
style srv03 fill:#4c78a8,stroke:#333,color:#ffffff
click srv03 "/hardware/srv03/"
style srv04 fill:#4c78a8,stroke:#333,color:#ffffff
click srv04 "/hardware/srv04/"
style srv05 fill:#4c78a8,stroke:#333,color:#ffffff
click srv05 "/hardware/srv05/"
``` ```
## Network ## Network
@ -42,6 +329,20 @@ flowchart LR
srv03 -->|eth0 → p2 · 1G| pp01 srv03 -->|eth0 → p2 · 1G| pp01
srv04 -->|eth0 → p3 · 1G| pp01 srv04 -->|eth0 → p3 · 1G| pp01
srv05 -->|eth0 → p4 · 1G| pp01 srv05 -->|eth0 → p4 · 1G| pp01
style pp01 fill:#9c755f,stroke:#333,color:#ffffff
click pp01 "/hardware/pp01/"
style srv01 fill:#4c78a8,stroke:#333,color:#ffffff
click srv01 "/hardware/srv01/"
style srv02 fill:#4c78a8,stroke:#333,color:#ffffff
click srv02 "/hardware/srv02/"
style srv03 fill:#4c78a8,stroke:#333,color:#ffffff
click srv03 "/hardware/srv03/"
style srv04 fill:#4c78a8,stroke:#333,color:#ffffff
click srv04 "/hardware/srv04/"
style srv05 fill:#4c78a8,stroke:#333,color:#ffffff
click srv05 "/hardware/srv05/"
style sw01 fill:#59a14f,stroke:#333,color:#ffffff
click sw01 "/hardware/sw01/"
``` ```
## Occupancy ## Occupancy

View 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"![Rack {rack} elevation]({rack}-elevation.svg)")
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.

View file

@ -0,0 +1,136 @@
# Rack Presentation Improvements Design
**Date:** 2026-06-24
**Status:** Approved
**Parent spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (extension)
## Goal
Improve the graphical presentation of the generated rack page: make the
elevation **interactive** (inline SVG, clickable boxes, hover tooltips), encode
device **status** visually, add a **legend**, and **polish** both the elevation
and the mermaid power/network graphs. No new data is modelled; this is a
rendering upgrade of what `gen_rack.py` already produces.
## Context
- `render_svg` writes a standalone `rack01-elevation.svg`, embedded in
`rack01.md` as `![Rack rack01 elevation](rack01-elevation.svg)` — i.e. an
`<img>`, which is a **flat image**: links and tooltips inside it do not work.
- `render_power`/`render_network` emit default-themed mermaid flowcharts with
uncoloured, non-clickable nodes.
- The occupancy table already links to host pages; the SVG and graphs do not.
- `md_in_html` is enabled in `mkdocs.yml`, so raw inline HTML/SVG in a Markdown
page is rendered. Mermaid is enabled (Phase 2).
- `use_directory_urls` is unset → defaults to **true**: pages build to
`/<path>/index.html`, so a host page is served at `/hardware/<host>/` and the
rack page at `/infrastructure/racks/rack01/`. The site is served at the domain
root (`docs.makerfloss.eu`).
- `KIND_COLORS` already maps kinds → fill colours. There is no status encoding,
legend, link, or tooltip today.
## Decisions
### A. Inline, interactive elevation
- `render_page` embeds the SVG markup **inline** in `rack01.md`, inside a
`<div class="rack-elevation"> … </div>` block, replacing the
`![…](rack01-elevation.svg)` image line. (`md_in_html` passes the raw block
through.)
- The standalone `rack01-elevation.svg` is **still generated** (identical markup,
still drift-checked) and offered below the diagram as a
`[Download SVG](rack01-elevation.svg)` link.
- The `<svg>` opening tag gains `style="max-width:100%;height:auto"` so it scales
on narrow screens.
- **Clickable boxes:** every device drawing (rail box, shelf-occupant box, 0U
rail bar, shelf strip) is wrapped in
`<a href="/hardware/<hostname>/"> … </a>`. URLs are **root-relative final
URLs** — mkdocs does **not** rewrite `.md`/source-relative hrefs inside raw
inline SVG, and `use_directory_urls` + domain-root hosting make
`/hardware/<host>/` correct and robust.
- **Tooltips:** the `<a>` wrapper's first child is an SVG `<title>` carrying full
details: `"<hostname> · <kind> · <status> · cluster: <cluster|—> · <placement>"`,
where `<placement>` is the U-range (rail item), `0U <left|right>` (0U rail), or
`<shelf>/<face>/slot <slot>` (mounted item). Text run through `_esc`.
### B. Status encoded as border
Fill stays the kind colour; the box **border** encodes `status` via a
`_status_stroke(status) -> (stroke, stroke_width, dash)` helper:
| status | stroke | width | dash |
|--------|--------|-------|------|
| in-use | `#333` | 1.5 | none |
| staging | `#333` | 1.5 | `4 2` (dashed) |
| broken | `#e15759` | 3 | none |
| spare | `#bbbbbb` | 1.5 | none |
| donated | `#bbbbbb` | 1.5 | none |
| (other/missing) | `#333` | 1.5 | none |
Applied to every device rect (rail `draw_device` and shelf-occupant boxes). The
shelf strip and the faint empty-U slots keep their existing styling.
### C. Legend
A legend strip is drawn at the **bottom of the SVG canvas**, below the columns;
the canvas height grows by a fixed `LEGEND_H` band:
- **Kind swatches:** for each `kind` actually present in the rack (sorted), a
small colour rect + the kind name.
- **Status key:** four small sample borders (solid, dashed, red-thick, grey) with
labels `in-use`, `staging`, `broken`, `spare`.
### D. Aesthetic polish
- **U-numbers in both gutters:** the existing left-gutter U-number column is
mirrored to the right of the rear column.
- **Column frame:** a thin outer stroke rectangle around each column's full body
(front and rear).
- **Mermaid graphs** (`render_power`/`render_network`):
- **Colour nodes by kind:** emit a `style <id> fill:<kind-colour>,stroke:#333,color:#ffffff`
line per node, looking the kind up from the rack items (`KIND_COLORS`,
falling back to `DEFAULT_COLOR`).
- **Clickable nodes:** emit `click <id> "/hardware/<host>/"` for every node whose
hostname resolves to a rack item; off-rack nodes get no click.
- Edge labels and layout are unchanged.
## Technical constraints (rationale carried into implementation)
- `<img>`-embedded SVG is inert → inlining is required for links/tooltips.
- mkdocs rewrites Markdown `.md` links but **not** raw `href`/`click` URLs inside
inline SVG or mermaid → emit final root-relative URLs `/hardware/<host>/`.
- `md_in_html` (already enabled) renders the raw `<div><svg>…</svg></div>` block.
## Integration
| File | Change |
|------|--------|
| `scripts/gen_rack.py` | helpers `_host_url`, `_status_stroke`, `_tooltip`; `render_svg` (status borders, `<a>`+`<title>` wrapping, both-gutter U-numbers, column frame, legend, `style=` on `<svg>`); `render_page` (inline SVG `<div>` + download link); `render_power`/`render_network` (per-node `style` + `click`) |
| `tests/test_gen_rack.py` | assertions for links, titles, status strokes, legend, both-gutter numbers, inline page block, mermaid style/click |
| `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg` | regenerated |
No `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or
`overview_config.yml` changes. Occupancy table and all validation unchanged.
## Test plan
- **`render_svg`:** output contains `<a href="/hardware/srv04/"`, a `<title>` with
the host details, a dashed stroke for a `staging` fixture and a thick red stroke
for a `broken` fixture, kind swatches for the kinds present, U-number text on
both sides; deterministic for reordered input.
- **`render_page`:** contains `<div class="rack-elevation">` and `<svg`, the
`[Download SVG](rack01-elevation.svg)` link, and **no** `![`-image embed of the
elevation.
- **`render_power`/`render_network`:** contain `style <id> fill:` lines coloured
by kind and `click <id> "/hardware/<host>/"` lines; still start with
` ```mermaid ` + `flowchart LR`.
- **Drift / build / visual:** `make docs-check` exits 0; `mkdocs build --strict`
passes; on the rendered page the elevation boxes are clickable, hovering shows
the tooltip, the legend is visible, and the mermaid nodes are coloured and
clickable.
## Out of scope
- The summary dashboard (U-utilisation / counts) — not selected.
- Dark-mode-specific palettes.
- A cluster colour system — cluster appears in the tooltip only.

View file

@ -289,6 +289,54 @@ def _esc(s: object) -> str:
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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]: def _sorted_items(items: list[dict]) -> list[dict]:
"""Deterministic order: faced items by U then hostname, 0U items last.""" """Deterministic order: faced items by U then hostname, 0U items last."""
return sorted( return sorted(
@ -309,16 +357,18 @@ def render_svg(rack: str, items: list[dict]) -> str:
PAD = 12 PAD = 12
GAP = 50 GAP = 50
TITLE_H = 28 TITLE_H = 28
LEGEND_H = 56
items = _sorted_items(items) items = _sorted_items(items)
left_items = [i for i in items if i.get("rack_face") == "left"] 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"] right_items = [i for i in items if i.get("rack_face") == "right"]
body_h = RACK_UNITS * U_H 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 front_x = PAD + len(left_items) * RAIL_W + LABEL_W
rear_x = front_x + COL_W + GAP 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 top = PAD + TITLE_H
def u_y(u: int) -> int: def u_y(u: int) -> int:
@ -329,6 +379,7 @@ def render_svg(rack: str, items: list[dict]) -> str:
p.append( p.append(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" ' f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
f'height="{height}" viewBox="0 0 {width} {height}" ' f'height="{height}" viewBox="0 0 {width} {height}" '
f'style="max-width:100%;height:auto" '
f'font-family="sans-serif" font-size="11">' f'font-family="sans-serif" font-size="11">'
) )
p.append(f'<rect width="{width}" height="{height}" fill="#ffffff"/>') p.append(f'<rect width="{width}" height="{height}" fill="#ffffff"/>')
@ -356,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'<text x="{front_x - 4}" y="{y + 14}" text-anchor="end" '
f'fill="#999">{u}</text>' 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: def draw_device(fm: dict, col_x: int) -> None:
u = fm["rack_u"] u = fm["rack_u"]
@ -365,15 +427,19 @@ def render_svg(rack: str, items: list[dict]) -> str:
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR) color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
name = fm.get("hostname", "?") name = fm.get("hostname", "?")
urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}" 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( p.append(
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" ' f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
f'height="{box_h - 2}" rx="3" fill="{color}" stroke="#333"/>' f'height="{box_h - 2}" rx="3" fill="{color}" '
f"{_stroke_attrs(fm.get('status'))}/>"
) )
p.append( p.append(
f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" ' f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">' f'text-anchor="middle" fill="#ffffff">'
f'{_esc(name)} ({urange})</text>' f"{_esc(name)} ({urange})</text>"
) )
p.append("</a>")
for fm in items: for fm in items:
if fm.get("kind") == "shelf" or "mounted_on" in fm: if fm.get("kind") == "shelf" or "mounted_on" in fm:
@ -389,19 +455,22 @@ def render_svg(rack: str, items: list[dict]) -> str:
name = fm.get("hostname", "?") name = fm.get("hostname", "?")
cx = x + RAIL_W // 2 cx = x + RAIL_W // 2
cy = top + body_h // 2 cy = top + body_h // 2
p.append(f'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append( p.append(
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" ' f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
f'fill="{color}" stroke="#333"/>' f"fill=\"{color}\" {_stroke_attrs(fm.get('status'))}/>"
) )
p.append( p.append(
f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" ' f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" '
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>' f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
) )
p.append("</a>")
for idx, fm in enumerate(left_items): for idx, fm in enumerate(left_items):
draw_rail(fm, PAD + idx * RAIL_W) draw_rail(fm, PAD + idx * RAIL_W)
for idx, fm in enumerate(right_items): 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 SHELF_STRIP_H = 6
shelves = [i for i in items if i.get("kind") == "shelf"] shelves = [i for i in items if i.get("kind") == "shelf"]
@ -430,14 +499,21 @@ def render_svg(rack: str, items: list[dict]) -> str:
bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w
mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR) mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR)
mname = m.get("hostname", "?") mname = m.get("hostname", "?")
p.append(f'<a href="{_host_url(mname)}">')
p.append(f"<title>{_tooltip(m)}</title>")
p.append( p.append(
f'<rect x="{bx + 1}" y="{y + 1}" width="{bw - 2}" ' f'<rect x="{bx + 1}" y="{y + 1}" width="{bw - 2}" '
f'height="{avail_h - 2}" rx="3" fill="{mcolor}" stroke="#333"/>' f'height="{avail_h - 2}" rx="3" fill="{mcolor}" '
f"{_stroke_attrs(m.get('status'))}/>"
) )
p.append( p.append(
f'<text x="{bx + bw // 2}" y="{y + avail_h // 2 + 4}" ' f'<text x="{bx + bw // 2}" y="{y + avail_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">{_esc(mname)}</text>' 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( p.append(
f'<rect x="{col_x}" y="{strip_y}" width="{COL_W}" ' f'<rect x="{col_x}" y="{strip_y}" width="{COL_W}" '
f'height="{SHELF_STRIP_H}" fill="{shelf_color}" stroke="#333"/>' f'height="{SHELF_STRIP_H}" fill="{shelf_color}" stroke="#333"/>'
@ -446,10 +522,38 @@ def render_svg(rack: str, items: list[dict]) -> str:
f'<text x="{front_x + COL_W // 2}" y="{strip_y + SHELF_STRIP_H - 1}" ' 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>' f'text-anchor="middle" fill="#333" font-size="9">{_esc(sname)}</text>'
) )
p.append("</a>")
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")): for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm) 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>") p.append("</svg>")
return "\n".join(p) + "\n" return "\n".join(p) + "\n"
@ -496,6 +600,16 @@ def render_power(rack: str, items: list[dict]) -> str:
lines.append( lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}" 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("```") lines.append("```")
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@ -542,6 +656,15 @@ def render_network(rack: str, items: list[dict]) -> str:
if speed is not None: if speed is not None:
label += f" · {speed}G" label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}") 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("```") lines.append("```")
return "\n".join(lines) + "\n" return "\n".join(lines) + "\n"
@ -559,7 +682,11 @@ def render_page(rack: str, items: list[dict]) -> str:
lines.append("") lines.append("")
lines.append("## Elevation") lines.append("## Elevation")
lines.append("") lines.append("")
lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)") 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("") lines.append("")
power = render_power(rack, items) power = render_power(rack, items)
if power: if power:

View file

@ -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")] items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
page = gen_rack.render_page("rack01", items) page = gen_rack.render_page("rack01", items)
assert "do not edit by hand" in page assert "do not edit by hand" in page
assert "![Rack rack01 elevation](rack01-elevation.svg)" in page assert '<div class="rack-elevation">' in page
assert "<svg" in page
assert "[Download SVG](rack01-elevation.svg)" in page
assert "../../hardware/mf00.md" in page assert "../../hardware/mf00.md" in page
assert "U1U2" in page assert "U1U2" in page
@ -632,3 +634,105 @@ def test_render_page_lists_mounted_devices():
assert "../../hardware/srv01.md" in page assert "../../hardware/srv01.md" in page
assert "front · shf01/1" in page assert "front · shf01/1" in page
assert "U37U46" in page # mounted device shows its shelf's U-range assert "U37U46" in page # mounted device shows its shelf's U-range
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
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
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
def test_network_graph_off_rack_peer_has_no_click():
items = [
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "router0", "peer_port": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "style router0 fill:" in out # off-rack peer is still colored
assert 'click router0 "' not in out # but it is NOT clickable
assert 'click srv01 "/hardware/srv01/"' in out