feat: add standalone server topology view

This commit is contained in:
Mikhail Chusavitin
2026-09-14 10:29:36 +03:00
parent 5b8be0e464
commit 9ac38c2914
28 changed files with 1287 additions and 31 deletions
+399
View File
@@ -0,0 +1,399 @@
package topology
import (
"fmt"
"html"
"sort"
"strings"
"git.mchus.pro/mchus/logpile/internal/models"
)
type card struct {
label, model, status string
count int
}
type branch struct {
info card
edgeStatus string
subs []card
}
type block struct {
head card
branches []branch
}
// RenderHTML renders a self-contained, static topology page. It intentionally
// contains no polling, live telemetry, shell integration, or chart dependency.
func RenderHTML(doc *models.TopologyDocument) []byte {
if doc == nil {
doc = &models.TopologyDocument{Version: ContractVersion}
}
title := doc.Title
if strings.TrimSpace(title) == "" {
title = "Server topology"
}
var b strings.Builder
b.WriteString(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>`)
b.WriteString(html.EscapeString(title))
b.WriteString(` — Topology</title><style>` + pageCSS + `</style></head><body><header><div><span class="eyebrow">LOGPile</span><h1>`)
b.WriteString(html.EscapeString(title))
b.WriteString(`</h1><p>Server topology</p></div><button onclick="window.close()">Close</button></header><main>`)
if len(doc.Nodes) == 0 {
b.WriteString(`<section class="card"><div class="card-head">Topology</div><div class="empty">No topology data loaded.</div></section>`)
} else {
b.WriteString(`<section class="card"><div class="card-head">Topology</div><div class="card-body">`)
b.WriteString(renderDiagram(doc))
b.WriteString(renderLooseRows(doc))
b.WriteString(`</div></section>`)
if nv := renderNVLink(doc); nv != "" {
b.WriteString(nv)
}
}
b.WriteString(`</main></body></html>`)
return []byte(b.String())
}
func renderDiagram(doc *models.TopologyDocument) string {
nodeByID := map[string]models.TopologyNode{}
for _, n := range doc.Nodes {
nodeByID[n.ID] = n
}
children := map[string][]models.TopologyNode{}
edgeStatus := map[string]string{}
storageChildren := map[string][]models.TopologyNode{}
attached := map[string]bool{}
for _, e := range doc.Edges {
if child, ok := nodeByID[e.To]; ok {
switch e.Kind {
case "pcie", "memory":
children[e.From] = append(children[e.From], child)
edgeStatus[e.To] = e.Status
attached[e.To] = true
case "storage":
storageChildren[e.From] = append(storageChildren[e.From], child)
attached[e.To] = true
}
}
}
var cpus []models.TopologyNode
for _, n := range doc.Nodes {
if n.Kind == "cpu" {
cpus = append(cpus, n)
}
}
sort.SliceStable(cpus, func(i, j int) bool { return ptrValue(cpus[i].Socket) < ptrValue(cpus[j].Socket) })
var blocks []block
for _, cpu := range cpus {
blocks = append(blocks, block{head: nodeCard(cpu), branches: groupBranches(children[cpu.ID], edgeStatus, storageChildren)})
}
var other []models.TopologyNode
for _, n := range doc.Nodes {
if (n.Kind == "gpu" || n.Kind == "nic" || n.Kind == "raid" || n.Kind == "memory" || n.Kind == "storage") && !attached[n.ID] {
other = append(other, n)
}
}
if len(other) > 0 {
blocks = append(blocks, block{head: card{label: "Other", model: "no socket affinity", status: "Unknown", count: 1}, branches: groupBranches(other, edgeStatus, storageChildren)})
}
if len(blocks) == 0 {
return `<div class="empty">No CPU or attachable device topology.</div>`
}
const barW, branchW, subW, gap, boxH, boxGap, minBarH, midGap = 118, 208, 172, 46, 70, 14, 92, 72
hasSubs := false
for _, blk := range blocks {
for _, br := range blk.branches {
hasSubs = hasSubs || len(br.subs) > 0
}
}
leftReach := barW + gap + branchW
if hasSubs {
leftReach += gap + subW
}
width := 24 + leftReach + 24
if len(blocks) > 1 {
width = 24 + leftReach + midGap + leftReach + 24
}
type box struct {
x, y, w, h int
c card
}
type edge struct {
x1, y1, x2, y2 int
status string
}
var boxes []box
var edges []edge
rowTop := 30
for i := 0; i < len(blocks); {
rowBottom := rowTop
for side := 0; side < 2 && i < len(blocks); side++ {
blk := blocks[i]
barX, branchX, subX := 24, 24+barW+gap, 24+barW+gap+branchW+gap
if side == 1 {
barX = width - 24 - barW
branchX = barX - gap - branchW
subX = branchX - gap - subW
}
y := rowTop
for _, br := range blk.branches {
boxes = append(boxes, box{x: branchX, y: y, w: branchW, h: boxH, c: br.info})
x1, x2 := barX+barW, branchX
if side == 1 {
x1 = barX
x2 = branchX + branchW
}
edges = append(edges, edge{x1: x1, y1: y + boxH/2, x2: x2, y2: y + boxH/2, status: br.edgeStatus})
advance := boxH + boxGap + stackLayers(br.info.count)*4
if len(br.subs) > 0 {
sy := y
branchSubX := branchX + branchW
if side == 1 {
branchSubX = branchX
}
for _, sub := range br.subs {
boxes = append(boxes, box{x: subX, y: sy, w: subW, h: boxH, c: sub})
sx := subX
if side == 1 {
sx = subX + subW
}
subStatus := sub.status
if severity(subStatus) == 0 {
subStatus = "OK"
}
edges = append(edges, edge{x1: branchSubX, y1: y + boxH/2, x2: sx, y2: sy + boxH/2, status: subStatus})
sy += boxH + boxGap + stackLayers(sub.count)*4
}
if sy-y > advance {
advance = sy - y
}
}
y += advance
}
bottom := y - boxGap
if bottom < rowTop+minBarH {
bottom = rowTop + minBarH
}
boxes = append(boxes, box{x: barX, y: rowTop, w: barW, h: bottom - rowTop, c: blk.head})
if bottom > rowBottom {
rowBottom = bottom
}
i++
}
rowTop = rowBottom + 30
}
height := rowTop + 10
var b strings.Builder
fmt.Fprintf(&b, `<div class="diagram-scroll"><svg width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="Server topology">`, width, height, width, height)
for _, e := range edges {
fmt.Fprintf(&b, `<line x1="%d" y1="%d" x2="%d" y2="%d" class="edge %s"/>`, e.x1, e.y1, e.x2, e.y2, statusClass(e.status))
}
for _, bx := range boxes {
writeBox(&b, bx.x, bx.y, bx.w, bx.h, bx.c)
}
b.WriteString(`</svg></div>`)
return b.String()
}
func groupCards(nodes []models.TopologyNode) []card {
order := []string{"memory", "gpu", "nic", "raid", "storage"}
groups := map[string][]models.TopologyNode{}
for _, n := range nodes {
groups[n.Kind] = append(groups[n.Kind], n)
}
var out []card
for _, kind := range order {
items := groups[kind]
if len(items) == 0 {
continue
}
model := items[0].Model
status := "Unknown"
for _, n := range items {
if severity(n.Status) > severity(status) {
status = n.Status
}
}
label := map[string]string{"memory": "Memory", "gpu": "GPU", "nic": "NIC", "raid": "RAID", "storage": "Storage"}[kind]
if kind == "memory" {
total := 0
for _, n := range items {
total += n.SizeMB
}
if total > 0 {
model = fmt.Sprintf("%d GB total", total/1024)
}
}
out = append(out, card{label: label, model: model, status: status, count: len(items)})
}
return out
}
func groupBranches(nodes []models.TopologyNode, edgeStatuses map[string]string, storageChildren map[string][]models.TopologyNode) []branch {
cards := groupCards(nodes)
byKind := map[string][]models.TopologyNode{}
for _, n := range nodes {
byKind[n.Kind] = append(byKind[n.Kind], n)
}
var out []branch
for _, c := range cards {
kind := strings.ToLower(c.label)
if kind == "storage" {
kind = "storage"
}
status := "OK"
for _, n := range byKind[kind] {
if s := edgeStatuses[n.ID]; severity(s) > severity(status) {
status = s
}
}
br := branch{info: c, edgeStatus: status}
if kind == "raid" {
var disks []models.TopologyNode
for _, n := range byKind[kind] {
disks = append(disks, storageChildren[n.ID]...)
}
br.subs = groupCards(disks)
}
out = append(out, br)
}
return out
}
func nodeCard(n models.TopologyNode) card {
return card{label: n.Label, model: n.Model, status: n.Status, count: max(1, n.Count)}
}
func writeBox(b *strings.Builder, x, y, w, h int, c card) {
cls := statusClass(c.status)
for i := stackLayers(c.count); i >= 1; i-- {
off := i * 4
fmt.Fprintf(b, `<rect x="%d" y="%d" width="%d" height="%d" rx="6" class="node-back %s"/>`, x+off, y+off, w, h, cls)
}
fmt.Fprintf(b, `<rect x="%d" y="%d" width="%d" height="%d" rx="6" class="node %s"/>`, x, y, w, h, cls)
label := c.label
if c.count > 1 {
label = fmt.Sprintf("%s ×%d", label, c.count)
}
fmt.Fprintf(b, `<text x="%d" y="%d" class="node-label %s">%s</text>`, x+10, y+20, cls, html.EscapeString(label))
if c.model != "" {
fmt.Fprintf(b, `<text x="%d" y="%d" class="node-model %s">%s</text>`, x+10, y+37, cls, html.EscapeString(truncate(c.model, 28)))
}
fmt.Fprintf(b, `<text x="%d" y="%d" class="node-status %s">%s</text>`, x+10, y+h-10, cls, html.EscapeString(displayStatus(c.status)))
}
func renderLooseRows(doc *models.TopologyDocument) string {
var firmware, psus []models.TopologyNode
for _, n := range doc.Nodes {
if n.Kind == "firmware" {
firmware = append(firmware, n)
}
if n.Kind == "psu" {
psus = append(psus, n)
}
}
var b strings.Builder
writeRow := func(title string, nodes []models.TopologyNode) {
if len(nodes) == 0 {
return
}
b.WriteString(`<h2 class="row-title">` + html.EscapeString(title) + `</h2><div class="tile-row">`)
for _, n := range nodes {
b.WriteString(`<div class="tile ` + statusClass(n.Status) + `"><strong>` + html.EscapeString(n.Label) + `</strong>`)
detail := n.Model
if n.WattageW > 0 {
detail = fmt.Sprintf("%d W", n.WattageW)
}
if detail != "" {
b.WriteString(`<span>` + html.EscapeString(detail) + `</span>`)
}
b.WriteString(`</div>`)
}
b.WriteString(`</div>`)
}
writeRow("Firmware", firmware)
writeRow("Power supplies", psus)
return b.String()
}
func renderNVLink(doc *models.TopologyDocument) string {
byID := map[string]models.TopologyNode{}
for _, n := range doc.Nodes {
byID[n.ID] = n
}
var links []models.TopologyEdge
for _, e := range doc.Edges {
if e.Kind == "nvlink" {
links = append(links, e)
}
}
if len(links) == 0 {
return ""
}
var b strings.Builder
b.WriteString(`<section class="card"><div class="card-head">NVLink Topology</div><div class="card-body nvlinks">`)
for _, e := range links {
a, bn := byID[e.From], byID[e.To]
b.WriteString(`<div class="nvpair"><span>` + html.EscapeString(firstNonEmpty(a.Label, a.ID)) + `</span><i class="` + statusClass(e.Status) + `"></i><span>` + html.EscapeString(firstNonEmpty(bn.Label, bn.ID)) + `</span><small>` + html.EscapeString(e.Label) + `</small></div>`)
}
b.WriteString(`</div></section>`)
return b.String()
}
func severity(s string) int {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "CRITICAL", "FAIL", "FAILED", "ERROR":
return 3
case "WARNING", "WARN", "PARTIAL":
return 2
case "OK":
return 1
default:
return 0
}
}
func statusClass(s string) string {
switch severity(s) {
case 3:
return "critical"
case 2:
return "warning"
case 1:
return "ok"
default:
return "unknown"
}
}
func displayStatus(s string) string {
if strings.TrimSpace(s) == "" {
return "Unknown"
}
return s
}
func ptrValue(v *int) int {
if v == nil {
return 1 << 30
}
return *v
}
func stackLayers(n int) int {
if n <= 1 {
return 0
}
if n > 2 {
return 2
}
return n - 1
}
func truncate(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[:n-1]) + "…"
}
const pageCSS = `:root{--ink:#172033;--muted:#687386;--border:#d9dee8;--surface:#fff;--surface2:#f5f7fa;--ok-bg:#edf7e8;--ok-fg:#316523;--ok-border:#a3c293;--warn-bg:#fff7df;--warn-fg:#7a5700;--warn-border:#d5bd75;--crit-bg:#fff0f0;--crit-fg:#a12626;--crit-border:#dc9b9b}*{box-sizing:border-box}body{margin:0;background:#f3f5f8;color:var(--ink);font:14px/1.5 Lato,"Helvetica Neue",Arial,sans-serif}header{display:flex;justify-content:space-between;align-items:center;padding:18px 28px;background:#182231;color:#fff}header h1{font-size:20px;margin:2px 0}header p{margin:0;color:#bdc7d4}.eyebrow{font-size:11px;text-transform:uppercase;letter-spacing:.12em;color:#8fbbe9}button{padding:7px 12px;border:1px solid #718096;border-radius:4px;background:transparent;color:#fff;cursor:pointer}main{max-width:1320px;margin:24px auto;padding:0 20px}.card{background:#fff;border:1px solid var(--border);border-radius:4px;box-shadow:0 1px 3px #18223112;margin-bottom:18px}.card-head{padding:10px 14px;background:var(--surface2);border-bottom:1px solid var(--border);font-weight:700}.card-body,.empty{padding:16px}.empty{color:var(--muted)}.diagram-scroll{overflow-x:auto}.edge{stroke-width:2}.edge.ok{stroke:var(--ok-fg)}.edge.warning{stroke:var(--warn-fg)}.edge.critical{stroke:var(--crit-fg)}.edge.unknown{stroke:var(--muted)}.node,.node-back{stroke-width:1}.node-back{opacity:.55}.node.ok,.node-back.ok,.tile.ok{fill:var(--ok-bg);background:var(--ok-bg);stroke:var(--ok-border);border-color:var(--ok-border);color:var(--ok-fg)}.node.warning,.node-back.warning,.tile.warning{fill:var(--warn-bg);background:var(--warn-bg);stroke:var(--warn-border);border-color:var(--warn-border);color:var(--warn-fg)}.node.critical,.node-back.critical,.tile.critical{fill:var(--crit-bg);background:var(--crit-bg);stroke:var(--crit-border);border-color:var(--crit-border);color:var(--crit-fg)}.node.unknown,.node-back.unknown,.tile.unknown{fill:var(--surface2);background:var(--surface2);stroke:var(--border);border-color:var(--border);color:var(--muted)}.node-label{font-size:13px;font-weight:700}.node-model{font-size:11px;opacity:.86}.node-status{font-size:10px;font-weight:600}.node-label.ok,.node-model.ok,.node-status.ok{fill:var(--ok-fg)}.node-label.warning,.node-model.warning,.node-status.warning{fill:var(--warn-fg)}.node-label.critical,.node-model.critical,.node-status.critical{fill:var(--crit-fg)}.node-label.unknown,.node-model.unknown,.node-status.unknown{fill:var(--muted)}.row-title{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin:18px 0 7px}.tile-row{display:flex;flex-wrap:wrap;gap:9px}.tile{display:flex;flex-direction:column;min-width:150px;padding:9px 11px;border:1px solid;border-radius:6px}.tile span{font-size:11px;opacity:.86}.nvlinks{display:grid;gap:10px}.nvpair{display:grid;grid-template-columns:auto minmax(80px,1fr) auto auto;align-items:center;gap:10px}.nvpair i{height:2px;background:var(--muted)}.nvpair i.ok{background:var(--ok-fg)}.nvpair i.warning{background:var(--warn-fg)}.nvpair i.critical{background:var(--crit-fg)}.nvpair small{color:var(--muted)}@media(max-width:600px){header{padding:14px 16px}main{padding:0 10px;margin:12px auto}}`