400 lines
14 KiB
Go
400 lines
14 KiB
Go
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}}`
|