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), "") { 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)), " ") }