Files
logpile/internal/topology/render.go
T

400 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}}`