feat: add standalone server topology view
This commit is contained in:
@@ -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}}`
|
||||
Reference in New Issue
Block a user