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(``) b.WriteString(html.EscapeString(title)) b.WriteString(` — Topology
LOGPile

`) b.WriteString(html.EscapeString(title)) b.WriteString(`

Server topology

`) if len(doc.Nodes) == 0 { b.WriteString(`
Topology
No topology data loaded.
`) } else { b.WriteString(`
Topology
`) b.WriteString(renderDiagram(doc)) b.WriteString(renderLooseRows(doc)) b.WriteString(`
`) if nv := renderNVLink(doc); nv != "" { b.WriteString(nv) } } b.WriteString(`
`) 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 `
No CPU or attachable device topology.
` } 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, `
`, width, height, width, height) for _, e := range edges { fmt.Fprintf(&b, ``, 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(`
`) 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, ``, x+off, y+off, w, h, cls) } fmt.Fprintf(b, ``, x, y, w, h, cls) label := c.label if c.count > 1 { label = fmt.Sprintf("%s ×%d", label, c.count) } fmt.Fprintf(b, `%s`, x+10, y+20, cls, html.EscapeString(label)) if c.model != "" { fmt.Fprintf(b, `%s`, x+10, y+37, cls, html.EscapeString(truncate(c.model, 28))) } fmt.Fprintf(b, `%s`, 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(`

` + html.EscapeString(title) + `

`) for _, n := range nodes { b.WriteString(`
` + html.EscapeString(n.Label) + ``) detail := n.Model if n.WattageW > 0 { detail = fmt.Sprintf("%d W", n.WattageW) } if detail != "" { b.WriteString(`` + html.EscapeString(detail) + ``) } b.WriteString(`
`) } b.WriteString(`
`) } 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(`
NVLink Topology
`) 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}}`