feat: add standalone server topology view
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
package topology
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/exporter"
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
)
|
||||
|
||||
const ContractVersion = "1.0"
|
||||
|
||||
var (
|
||||
cpuLocatorRE = regexp.MustCompile(`(?i)(?:^|[_ -])cpu\s*0*(\d+)`)
|
||||
bankNodeRE = regexp.MustCompile(`(?i)node\s*0*(\d+)`)
|
||||
nvLinkRE = regexp.MustCompile(`(?i)^NV(\d+)$`)
|
||||
)
|
||||
|
||||
// Build creates the standalone topology.json document from the same
|
||||
// normalized projection used by the chart, augmented by optional static Bee
|
||||
// support-bundle evidence. It never executes commands or reads live state.
|
||||
func Build(snapshot *exporter.ReanimatorExport, evidence *models.TopologyEvidence) *models.TopologyDocument {
|
||||
doc := &models.TopologyDocument{Version: ContractVersion}
|
||||
if snapshot == nil {
|
||||
return doc
|
||||
}
|
||||
doc.Title = topologyTitle(snapshot)
|
||||
hw := snapshot.Hardware
|
||||
|
||||
cpuIDs := make([]string, 0, len(hw.CPUs))
|
||||
socketByCPU := make([]int, 0, len(hw.CPUs))
|
||||
for i, cpu := range hw.CPUs {
|
||||
socket := cpu.Socket
|
||||
id := fmt.Sprintf("cpu:%d", socket)
|
||||
if containsNode(doc.Nodes, id) {
|
||||
id = fmt.Sprintf("cpu:%d:%d", socket, i)
|
||||
}
|
||||
s := socket
|
||||
doc.Nodes = append(doc.Nodes, models.TopologyNode{ID: id, Kind: "cpu", Label: fmt.Sprintf("CPU %d", socket), Model: cleanCPUModel(cpu.Model), Status: cpu.Status, Socket: &s})
|
||||
cpuIDs = append(cpuIDs, id)
|
||||
socketByCPU = append(socketByCPU, socket)
|
||||
}
|
||||
|
||||
numaToCPU := buildNUMAMap(hw.PCIeDevices, cpuIDs, socketByCPU)
|
||||
bdfToNode := map[string]string{}
|
||||
gpuNodeByBDF := map[string]string{}
|
||||
for i, dev := range hw.PCIeDevices {
|
||||
kind := pcieKind(dev)
|
||||
if kind == "" {
|
||||
continue
|
||||
}
|
||||
bdf := normalizeBDF(firstNonEmpty(dev.Slot, dev.BDF))
|
||||
id := fmt.Sprintf("%s:%s", kind, bdf)
|
||||
if bdf == "" {
|
||||
id = fmt.Sprintf("%s:%d", kind, i)
|
||||
}
|
||||
node := models.TopologyNode{ID: id, Kind: kind, Label: strings.ToUpper(kind), Model: dev.Model, Status: dev.Status, NUMANode: dev.NUMANode, BDF: bdf, LinkSpeed: dev.LinkSpeed, MaxLinkSpeed: dev.MaxLinkSpeed}
|
||||
doc.Nodes = append(doc.Nodes, node)
|
||||
if bdf != "" {
|
||||
bdfToNode[bdf] = id
|
||||
if kind == "gpu" {
|
||||
gpuNodeByBDF[bdf] = id
|
||||
}
|
||||
}
|
||||
if dev.NUMANode != nil {
|
||||
if cpuID := numaToCPU[*dev.NUMANode]; cpuID != "" {
|
||||
doc.Edges = append(doc.Edges, models.TopologyEdge{From: cpuID, To: id, Kind: "pcie", Status: linkStatus(dev.LinkSpeed, dev.MaxLinkSpeed)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bankNodes := parseDIMMBankNodes(evidenceString(evidence, func(e *models.TopologyEvidence) string { return e.DIMMType17 }))
|
||||
for i, mem := range hw.Memory {
|
||||
id := fmt.Sprintf("memory:%s", strings.TrimSpace(mem.Slot))
|
||||
if strings.TrimSpace(mem.Slot) == "" || containsNode(doc.Nodes, id) {
|
||||
id = fmt.Sprintf("memory:%d", i)
|
||||
}
|
||||
node := models.TopologyNode{ID: id, Kind: "memory", Label: "Memory", Model: mem.PartNumber, Status: mem.Status, SizeMB: mem.SizeMB}
|
||||
doc.Nodes = append(doc.Nodes, node)
|
||||
if rawNode, ok := memoryNode(mem.Slot, mem.Location, bankNodes); ok {
|
||||
if cpuID := memoryCPU(rawNode, cpuIDs, socketByCPU); cpuID != "" {
|
||||
doc.Edges = append(doc.Edges, models.TopologyEdge{From: cpuID, To: id, Kind: "memory"})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
controllerByHCTL := parseStorageMap(evidenceString(evidence, func(e *models.TopologyEvidence) string { return e.StorageMap }))
|
||||
for i, disk := range hw.Storage {
|
||||
id := fmt.Sprintf("storage:%s", strings.TrimSpace(disk.Slot))
|
||||
if strings.TrimSpace(disk.Slot) == "" || containsNode(doc.Nodes, id) {
|
||||
id = fmt.Sprintf("storage:%d", i)
|
||||
}
|
||||
doc.Nodes = append(doc.Nodes, models.TopologyNode{ID: id, Kind: "storage", Label: firstNonEmpty(disk.Type, "Disk"), Model: disk.Model, Status: disk.Status})
|
||||
if controller := bdfToNode[controllerByHCTL[strings.TrimSpace(disk.Slot)]]; controller != "" {
|
||||
doc.Edges = append(doc.Edges, models.TopologyEdge{From: controller, To: id, Kind: "storage"})
|
||||
}
|
||||
}
|
||||
|
||||
for i, psu := range hw.PowerSupplies {
|
||||
label := strings.TrimSpace(psu.Slot)
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("PSU %d", i+1)
|
||||
}
|
||||
doc.Nodes = append(doc.Nodes, models.TopologyNode{ID: fmt.Sprintf("psu:%d", i), Kind: "psu", Label: label, Model: psu.Model, Status: psu.Status, WattageW: psu.WattageW})
|
||||
}
|
||||
for i, fw := range hw.Firmware {
|
||||
doc.Nodes = append(doc.Nodes, models.TopologyNode{ID: fmt.Sprintf("firmware:%d", i), Kind: "firmware", Label: fw.DeviceName, Model: "fw " + fw.Version})
|
||||
}
|
||||
|
||||
addNVLinkEdges(doc, evidence, gpuNodeByBDF)
|
||||
return doc
|
||||
}
|
||||
|
||||
func topologyTitle(s *exporter.ReanimatorExport) string {
|
||||
parts := []string{strings.TrimSpace(s.Hardware.Board.ProductName), strings.TrimSpace(s.Hardware.Board.SerialNumber)}
|
||||
var out []string
|
||||
for _, p := range parts {
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "Server topology"
|
||||
}
|
||||
return strings.Join(out, " - ")
|
||||
}
|
||||
|
||||
func buildNUMAMap(devices []exporter.ReanimatorPCIe, cpuIDs []string, sockets []int) map[int]string {
|
||||
result := map[int]string{}
|
||||
if len(cpuIDs) == 0 {
|
||||
return result
|
||||
}
|
||||
seen, hasZero := map[int]bool{}, false
|
||||
for _, d := range devices {
|
||||
if d.NUMANode != nil {
|
||||
seen[*d.NUMANode] = true
|
||||
hasZero = hasZero || *d.NUMANode == 0
|
||||
}
|
||||
}
|
||||
var nodes []int
|
||||
for n := range seen {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
sort.Ints(nodes)
|
||||
for rank, n := range nodes {
|
||||
if hasZero && rank < len(cpuIDs) {
|
||||
result[n] = cpuIDs[rank]
|
||||
continue
|
||||
}
|
||||
for i, socket := range sockets {
|
||||
if socket == n {
|
||||
result[n] = cpuIDs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if result[n] == "" && rank < len(cpuIDs) {
|
||||
result[n] = cpuIDs[rank]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func pcieKind(d exporter.ReanimatorPCIe) string {
|
||||
c := strings.ToLower(strings.TrimSpace(d.DeviceClass))
|
||||
model := strings.ToLower(d.Model + " " + d.Manufacturer)
|
||||
switch {
|
||||
case strings.Contains(c, "video"), strings.Contains(c, "display"), strings.Contains(c, "3d"), strings.Contains(c, "processingaccelerator"), strings.Contains(model, "nvidia") && strings.Contains(model, "gpu"):
|
||||
return "gpu"
|
||||
case strings.Contains(c, "network"), strings.Contains(c, "ethernet"), strings.Contains(c, "fibrechannel"), len(d.MACAddresses) > 0:
|
||||
return "nic"
|
||||
case strings.Contains(c, "storage"), strings.Contains(c, "raid"):
|
||||
return "raid"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func linkStatus(current, maximum string) string {
|
||||
a, b := speedRank(current), speedRank(maximum)
|
||||
if a == 0 || b == 0 {
|
||||
return "Unknown"
|
||||
}
|
||||
if a < b {
|
||||
return "Warning"
|
||||
}
|
||||
return "OK"
|
||||
}
|
||||
|
||||
func speedRank(v string) float64 {
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
v = strings.TrimPrefix(v, "gen")
|
||||
for _, suffix := range []string{" gt/s", "gts", "gtps"} {
|
||||
v = strings.TrimSuffix(v, suffix)
|
||||
}
|
||||
n, _ := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func parseDIMMBankNodes(raw string) map[string]int {
|
||||
out := map[string]int{}
|
||||
for _, sec := range strings.Split(raw, "Memory Device") {
|
||||
locator, node := "", -1
|
||||
for _, line := range strings.Split(sec, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if v, ok := strings.CutPrefix(line, "Locator:"); ok {
|
||||
locator = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "Bank Locator:"); ok {
|
||||
if m := bankNodeRE.FindStringSubmatch(v); m != nil {
|
||||
node, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
if locator != "" && node >= 0 {
|
||||
out[locator] = node
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func memoryNode(slot, location string, bank map[string]int) (int, bool) {
|
||||
for _, value := range []string{slot, location} {
|
||||
if m := cpuLocatorRE.FindStringSubmatch(strings.TrimSpace(value)); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n, true
|
||||
}
|
||||
if n, ok := bank[strings.TrimSpace(value)]; ok {
|
||||
return n, true
|
||||
}
|
||||
if m := bankNodeRE.FindStringSubmatch(value); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func memoryCPU(node int, cpuIDs []string, sockets []int) string {
|
||||
for i, s := range sockets {
|
||||
if s == node {
|
||||
return cpuIDs[i]
|
||||
}
|
||||
}
|
||||
if node >= 0 && node < len(cpuIDs) {
|
||||
return cpuIDs[node]
|
||||
}
|
||||
if node > 0 && node <= len(cpuIDs) {
|
||||
return cpuIDs[node-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseStorageMap(raw string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
hctl, ctrl := "", ""
|
||||
for _, field := range strings.Fields(line) {
|
||||
if v, ok := strings.CutPrefix(field, "hctl="); ok {
|
||||
hctl = v
|
||||
}
|
||||
if v, ok := strings.CutPrefix(field, "ctrl="); ok {
|
||||
ctrl = normalizeBDF(v)
|
||||
}
|
||||
}
|
||||
if hctl != "" && ctrl != "" {
|
||||
out[hctl] = ctrl
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addNVLinkEdges(doc *models.TopologyDocument, evidence *models.TopologyEvidence, gpuByBDF map[string]string) {
|
||||
if evidence == nil || evidence.NVIDIATopology == "" || evidence.NVIDIAQueryCSV == "" {
|
||||
return
|
||||
}
|
||||
idxToNode := map[int]string{}
|
||||
for _, line := range strings.Split(evidence.NVIDIAQueryCSV, "\n") {
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if id := gpuByBDF[normalizeBDF(parts[1])]; id != "" {
|
||||
idxToNode[idx] = id
|
||||
}
|
||||
}
|
||||
for idx, id := range idxToNode {
|
||||
for i := range doc.Nodes {
|
||||
if doc.Nodes[i].ID == id {
|
||||
doc.Nodes[i].Label = fmt.Sprintf("GPU %d", idx)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
lines := strings.Split(evidence.NVIDIATopology, "\n")
|
||||
var gpuCols []int
|
||||
header := -1
|
||||
for i, line := range lines {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 1 && fields[0] == "GPU0" {
|
||||
for _, f := range fields {
|
||||
if strings.HasPrefix(f, "GPU") {
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(f, "GPU"))
|
||||
if err == nil {
|
||||
gpuCols = append(gpuCols, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
header = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if header < 0 {
|
||||
return
|
||||
}
|
||||
seen := map[[2]int]bool{}
|
||||
nvStatus := "OK"
|
||||
if strings.Contains(strings.ToLower(evidence.NVLinkStatus), "<inactive>") {
|
||||
nvStatus = "Warning"
|
||||
}
|
||||
if nvlinkHasErrors(evidence.NVLinkErrors) {
|
||||
nvStatus = "Critical"
|
||||
}
|
||||
for _, line := range lines[header+1:] {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || !strings.HasPrefix(fields[0], "GPU") {
|
||||
continue
|
||||
}
|
||||
row, err := strconv.Atoi(strings.TrimPrefix(fields[0], "GPU"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for col, gpu := range gpuCols {
|
||||
if row == gpu || col+1 >= len(fields) {
|
||||
continue
|
||||
}
|
||||
m := nvLinkRE.FindStringSubmatch(fields[col+1])
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
links, _ := strconv.Atoi(m[1])
|
||||
a, b := row, gpu
|
||||
if a > b {
|
||||
a, b = b, a
|
||||
}
|
||||
key := [2]int{a, b}
|
||||
if seen[key] || idxToNode[a] == "" || idxToNode[b] == "" {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
doc.Edges = append(doc.Edges, models.TopologyEdge{From: idxToNode[a], To: idxToNode[b], Kind: "nvlink", Status: nvStatus, Label: fmt.Sprintf("NV%d", links), Links: links})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nvlinkHasErrors(raw string) bool {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
lower := strings.ToLower(line)
|
||||
if !strings.Contains(lower, "error") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
last := strings.Trim(fields[len(fields)-1], " ,")
|
||||
if n, err := strconv.ParseInt(last, 10, 64); err == nil && n > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func evidenceString(e *models.TopologyEvidence, get func(*models.TopologyEvidence) string) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return get(e)
|
||||
}
|
||||
func containsNode(nodes []models.TopologyNode, id string) bool {
|
||||
for _, n := range nodes {
|
||||
if n.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func normalizeBDF(v string) string { return strings.ToLower(strings.TrimSpace(v)) }
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func cleanCPUModel(v string) string {
|
||||
return strings.Join(strings.Fields(strings.NewReplacer("(R)", "", "(TM)", "", " CPU", "", " Processor", "").Replace(v)), " ")
|
||||
}
|
||||
Reference in New Issue
Block a user