feat: add standalone server topology view
This commit is contained in:
@@ -678,6 +678,9 @@ func mergeCanonicalDevice(primary, secondary models.HardwareDevice) models.Hardw
|
||||
fillString(&primary.LinkSpeed, secondary.LinkSpeed)
|
||||
fillInt(&primary.MaxLinkWidth, secondary.MaxLinkWidth)
|
||||
fillString(&primary.MaxLinkSpeed, secondary.MaxLinkSpeed)
|
||||
if primary.NUMANode == nil && secondary.NUMANode != nil {
|
||||
primary.NUMANode = secondary.NUMANode
|
||||
}
|
||||
fillInt(&primary.WattageW, secondary.WattageW)
|
||||
fillString(&primary.InputType, secondary.InputType)
|
||||
fillInt(&primary.InputPowerW, secondary.InputPowerW)
|
||||
|
||||
@@ -9,6 +9,33 @@ import (
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
)
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func TestReanimatorExportPreservesNUMAZeroAndOmitsUnknown(t *testing.T) {
|
||||
result := &models.AnalysisResult{Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{SerialNumber: "SN-NUMA"},
|
||||
Devices: []models.HardwareDevice{
|
||||
{ID: "gpu:0", Kind: models.DeviceKindGPU, Slot: "0000:01:00.0", DeviceClass: "VideoController", Model: "GPU 0", NUMANode: intPtr(0)},
|
||||
{ID: "gpu:unknown", Kind: models.DeviceKindGPU, Slot: "0000:02:00.0", DeviceClass: "VideoController", Model: "GPU unknown"},
|
||||
},
|
||||
}}
|
||||
out, err := ConvertToReanimator(result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(body)
|
||||
if !strings.Contains(text, `"slot":"0000:01:00.0","numa_node":0`) {
|
||||
t.Fatalf("NUMA zero was lost: %s", text)
|
||||
}
|
||||
if strings.Contains(text, `"slot":"0000:02:00.0","numa_node"`) {
|
||||
t.Fatalf("unknown NUMA was serialized: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertToReanimator(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1786,7 +1813,7 @@ func TestConvertToReanimator_ExportsContractV24Telemetry(t *testing.T) {
|
||||
Slot: "PCIeCard2",
|
||||
SerialNumber: "NIC-001",
|
||||
DeviceClass: "EthernetController",
|
||||
NUMANode: 1,
|
||||
NUMANode: intPtr(1),
|
||||
Details: map[string]any{
|
||||
"temperature_c": 48.5,
|
||||
"power_w": 18.2,
|
||||
@@ -1828,7 +1855,7 @@ func TestConvertToReanimator_ExportsContractV24Telemetry(t *testing.T) {
|
||||
if got := out.Hardware.Storage[0]; got.TemperatureC != 38.5 || got.PowerOnHours != 12450 || got.LifeRemainingPct != 91.0 {
|
||||
t.Fatalf("unexpected storage telemetry: %#v", got)
|
||||
}
|
||||
if got := out.Hardware.PCIeDevices[0]; got.NUMANode != 1 || got.TemperatureC != 48.5 || got.PowerW != 18.2 || got.SFPTemperatureC != 36.2 {
|
||||
if got := out.Hardware.PCIeDevices[0]; got.NUMANode == nil || *got.NUMANode != 1 || got.TemperatureC != 48.5 || got.PowerW != 18.2 || got.SFPTemperatureC != 36.2 {
|
||||
t.Fatalf("unexpected PCIe telemetry: %#v", got)
|
||||
}
|
||||
if got := out.Hardware.PowerSupplies[0]; got.TemperatureC != 39 || got.LifeRemainingPct != 97.0 || got.LifeUsedPct != 3.0 {
|
||||
|
||||
@@ -145,7 +145,7 @@ type ReanimatorPCIe struct {
|
||||
Slot string `json:"slot"`
|
||||
VendorID int `json:"vendor_id,omitempty"`
|
||||
DeviceID int `json:"device_id,omitempty"`
|
||||
NUMANode int `json:"numa_node,omitempty"`
|
||||
NUMANode *int `json:"numa_node,omitempty"`
|
||||
IOMMUGroup *int `json:"iommu_group,omitempty"`
|
||||
TemperatureC float64 `json:"temperature_c,omitempty"`
|
||||
PowerW float64 `json:"power_w,omitempty"`
|
||||
|
||||
@@ -23,6 +23,7 @@ type AnalysisResult struct {
|
||||
Sensors []SensorReading `json:"sensors"`
|
||||
Hardware *HardwareConfig `json:"hardware"`
|
||||
PrivacyScan *PrivacyScan `json:"privacy_scan,omitempty"` // customer-data / anonymization scan of the source files
|
||||
TopologyEvidence *TopologyEvidence `json:"-"` // source artifacts used to build the separate topology.json document
|
||||
}
|
||||
|
||||
// PrivacyScan is the result of scanning the source files for customer-identifying
|
||||
@@ -232,7 +233,7 @@ type HardwareDevice struct {
|
||||
InputVoltage float64 `json:"input_voltage,omitempty"`
|
||||
TemperatureC int `json:"temperature_c,omitempty"`
|
||||
RemainingEndurancePct *int `json:"remaining_endurance_pct,omitempty"` // 0-100 %; nil = not reported
|
||||
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
|
||||
NUMANode *int `json:"numa_node,omitempty"` // nil = not reported/N/A; 0 is a valid NUMA node
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
@@ -376,7 +377,7 @@ type PCIeDevice struct {
|
||||
PartNumber string `json:"part_number,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
MACAddresses []string `json:"mac_addresses,omitempty"`
|
||||
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
|
||||
NUMANode *int `json:"numa_node,omitempty"` // nil = not reported/N/A; 0 is a valid NUMA node
|
||||
Present *bool `json:"present,omitempty"`
|
||||
IOMMUGroup *int `json:"iommu_group,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
@@ -514,7 +515,7 @@ type NetworkAdapter struct {
|
||||
LinkSpeed string `json:"link_speed,omitempty"`
|
||||
MaxLinkWidth int `json:"max_link_width,omitempty"`
|
||||
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
|
||||
NUMANode int `json:"numa_node,omitempty"` // 0 = not reported/N/A
|
||||
NUMANode *int `json:"numa_node,omitempty"` // nil = not reported/N/A; 0 is a valid NUMA node
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
StatusCheckedAt *time.Time `json:"status_checked_at,omitempty"`
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package models
|
||||
|
||||
// TopologyEvidence contains static source artifacts which add relationships
|
||||
// that the Reanimator inventory schema cannot express. It is deliberately not
|
||||
// serialized into reanimator.json.
|
||||
type TopologyEvidence struct {
|
||||
DIMMType17 string
|
||||
StorageMap string
|
||||
NVIDIAQueryCSV string
|
||||
NVIDIATopology string
|
||||
NVLinkStatus string
|
||||
NVLinkErrors string
|
||||
}
|
||||
|
||||
// TopologyDocument is the standalone, versioned topology.json contract.
|
||||
// Coordinates are presentation details and are intentionally not persisted.
|
||||
type TopologyDocument struct {
|
||||
Version string `json:"version"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Nodes []TopologyNode `json:"nodes,omitempty"`
|
||||
Edges []TopologyEdge `json:"edges,omitempty"`
|
||||
}
|
||||
|
||||
type TopologyNode struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Socket *int `json:"socket,omitempty"`
|
||||
NUMANode *int `json:"numa_node,omitempty"`
|
||||
BDF string `json:"bdf,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
SizeMB int `json:"size_mb,omitempty"`
|
||||
WattageW int `json:"wattage_w,omitempty"`
|
||||
LinkSpeed string `json:"link_speed,omitempty"`
|
||||
MaxLinkSpeed string `json:"max_link_speed,omitempty"`
|
||||
}
|
||||
|
||||
type TopologyEdge struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Kind string `json:"kind"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Links int `json:"links,omitempty"`
|
||||
}
|
||||
+22
-3
@@ -488,7 +488,7 @@ func parsePCIeDeviceView(props map[string]string, result *models.AnalysisResult)
|
||||
DeviceID: deviceID,
|
||||
BDF: formatBDF(props["busnumber"], props["devicenumber"], props["functionnumber"]),
|
||||
Manufacturer: manufacturer,
|
||||
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||
NUMANode: parseOptionalIntLoose(props["cpuaffinity"]),
|
||||
Status: normalizeStatus(props["primarystatus"]),
|
||||
}
|
||||
result.Hardware.PCIeDevices = append(result.Hardware.PCIeDevices, p)
|
||||
@@ -535,7 +535,7 @@ func parseNICView(props map[string]string, result *models.AnalysisResult) {
|
||||
props["controllerbiosversion"],
|
||||
)),
|
||||
PortCount: inferPortCountFromFQDD(fqdd),
|
||||
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||
NUMANode: parseOptionalIntLoose(props["cpuaffinity"]),
|
||||
Status: normalizeStatus(props["primarystatus"]),
|
||||
}
|
||||
if mac != "" {
|
||||
@@ -589,7 +589,7 @@ func parseControllerView(props map[string]string, result *models.AnalysisResult)
|
||||
DeviceClass: "storage-controller",
|
||||
Manufacturer: strings.TrimSpace(firstNonEmpty(props["devicecardmanufacturer"], props["manufacturer"])),
|
||||
PartNumber: strings.TrimSpace(firstNonEmpty(props["ppid"], props["boardpartnumber"])),
|
||||
NUMANode: parseIntLoose(props["cpuaffinity"]),
|
||||
NUMANode: parseOptionalIntLoose(props["cpuaffinity"]),
|
||||
Status: normalizeStatus(props["primarystatus"]),
|
||||
})
|
||||
|
||||
@@ -983,6 +983,25 @@ func parseIntLoose(v string) int {
|
||||
return n
|
||||
}
|
||||
|
||||
// parseOptionalIntLoose preserves zero as a real value while keeping missing
|
||||
// and textual N/A values distinct. CPUAffinity is the motivating field: NUMA
|
||||
// node 0 is valid, whereas "Not Applicable" means there is no affinity.
|
||||
func parseOptionalIntLoose(v string) *int {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
num := firstNumberRE.FindString(v)
|
||||
if num == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := strconv.Atoi(num)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
func parseInt64Loose(v string) int64 {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
|
||||
+23
-12
@@ -390,7 +390,7 @@ func TestParseDellInfiniBandView(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestParseDellCPUAffinity verifies that CPUAffinity is parsed into NUMANode for
|
||||
// NIC, PCIe, and controller views. "Not Applicable" must result in NUMANode=0.
|
||||
// NIC, PCIe, and controller views. Zero is valid; "Not Applicable" is nil.
|
||||
func TestParseDellCPUAffinity(t *testing.T) {
|
||||
const viewXML = `<CIM><MESSAGE><SIMPLEREQ>
|
||||
<VALUE.NAMEDINSTANCE><INSTANCE CLASSNAME="DCIM_SystemView">
|
||||
@@ -439,27 +439,38 @@ func TestParseDellCPUAffinity(t *testing.T) {
|
||||
}
|
||||
|
||||
// NIC CPUAffinity=1 → NUMANode=1
|
||||
nicBySlot := make(map[string]int)
|
||||
nicBySlot := make(map[string]*int)
|
||||
for _, nic := range result.Hardware.NetworkAdapters {
|
||||
nicBySlot[nic.Slot] = nic.NUMANode
|
||||
}
|
||||
if nicBySlot["NIC.Slot.2-1-1"] != 1 {
|
||||
t.Errorf("NIC.Slot.2-1-1 NUMANode = %d, want 1", nicBySlot["NIC.Slot.2-1-1"])
|
||||
if got := nicBySlot["NIC.Slot.2-1-1"]; got == nil || *got != 1 {
|
||||
t.Errorf("NIC.Slot.2-1-1 NUMANode = %v, want 1", got)
|
||||
}
|
||||
if nicBySlot["InfiniBand.Slot.1-1"] != 2 {
|
||||
t.Errorf("InfiniBand.Slot.1-1 NUMANode = %d, want 2", nicBySlot["InfiniBand.Slot.1-1"])
|
||||
if got := nicBySlot["InfiniBand.Slot.1-1"]; got == nil || *got != 2 {
|
||||
t.Errorf("InfiniBand.Slot.1-1 NUMANode = %v, want 2", got)
|
||||
}
|
||||
|
||||
// PCIe device CPUAffinity=2 → NUMANode=2; controller CPUAffinity="Not Applicable" → NUMANode=0
|
||||
pcieBySlot := make(map[string]int)
|
||||
// PCIe device CPUAffinity=2 → NUMANode=2; controller CPUAffinity="Not Applicable" → nil.
|
||||
pcieBySlot := make(map[string]*int)
|
||||
for _, pcie := range result.Hardware.PCIeDevices {
|
||||
pcieBySlot[pcie.Slot] = pcie.NUMANode
|
||||
}
|
||||
if pcieBySlot["Slot.7-1"] != 2 {
|
||||
t.Errorf("Slot.7-1 NUMANode = %d, want 2", pcieBySlot["Slot.7-1"])
|
||||
if got := pcieBySlot["Slot.7-1"]; got == nil || *got != 2 {
|
||||
t.Errorf("Slot.7-1 NUMANode = %v, want 2", got)
|
||||
}
|
||||
if pcieBySlot["RAID.Slot.1-1"] != 0 {
|
||||
t.Errorf("RAID.Slot.1-1 NUMANode = %d, want 0 (Not Applicable)", pcieBySlot["RAID.Slot.1-1"])
|
||||
if got := pcieBySlot["RAID.Slot.1-1"]; got != nil {
|
||||
t.Errorf("RAID.Slot.1-1 NUMANode = %v, want nil (Not Applicable)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOptionalIntLoosePreservesZero(t *testing.T) {
|
||||
if got := parseOptionalIntLoose("0"); got == nil || *got != 0 {
|
||||
t.Fatalf("parseOptionalIntLoose(0) = %v, want pointer to 0", got)
|
||||
}
|
||||
for _, value := range []string{"", "N/A", "Not Applicable"} {
|
||||
if got := parseOptionalIntLoose(value); got != nil {
|
||||
t.Fatalf("parseOptionalIntLoose(%q) = %v, want nil", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
-1
@@ -11,7 +11,7 @@ import (
|
||||
"git.mchus.pro/mchus/logpile/internal/parser"
|
||||
)
|
||||
|
||||
const parserVersion = "1.0"
|
||||
const parserVersion = "1.1"
|
||||
|
||||
func init() {
|
||||
parser.Register(&Parser{})
|
||||
@@ -126,6 +126,7 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
PowerSupply: normalizePSUSlots(snapshot.Hardware.PowerSupply),
|
||||
},
|
||||
}
|
||||
result.TopologyEvidence = collectTopologyEvidence(files)
|
||||
|
||||
if pn := chassisProductPartNumber(files, result.Hardware.BoardInfo.ProductName); pn != "" {
|
||||
result.Hardware.BoardInfo.PartNumber = pn
|
||||
@@ -156,6 +157,39 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func collectTopologyEvidence(files []parser.ExtractedFile) *models.TopologyEvidence {
|
||||
read := func(names ...string) string {
|
||||
for _, name := range names {
|
||||
for _, f := range files {
|
||||
if strings.EqualFold(pathBase(f.Path), name) {
|
||||
return string(f.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
e := &models.TopologyEvidence{
|
||||
DIMMType17: read("dmidecode-type17.txt"),
|
||||
StorageMap: read("storage-controllers.txt"),
|
||||
NVIDIAQueryCSV: read("nvidia-smi-query-fresh.csv", "nvidia-smi-query.csv"),
|
||||
NVIDIATopology: read("nvidia-smi-topo-fresh.txt", "nvidia-smi-topo.txt"),
|
||||
NVLinkStatus: read("nvidia-smi-nvlink-status-fresh.txt", "nvidia-smi-nvlink-status.txt"),
|
||||
NVLinkErrors: read("nvidia-smi-nvlink-errors-fresh.txt", "nvidia-smi-nvlink-errors.txt"),
|
||||
}
|
||||
if e.DIMMType17 == "" && e.StorageMap == "" && e.NVIDIAQueryCSV == "" && e.NVIDIATopology == "" && e.NVLinkStatus == "" && e.NVLinkErrors == "" {
|
||||
return nil
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func pathBase(path string) string {
|
||||
path = strings.ReplaceAll(strings.TrimSpace(path), `\`, "/")
|
||||
if i := strings.LastIndexByte(path, '/'); i >= 0 {
|
||||
return path[i+1:]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
type beeSnapshot struct {
|
||||
SourceType string `json:"source_type,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
|
||||
+17
@@ -30,6 +30,23 @@ func TestDetectBeeSupportArchive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectTopologyEvidenceSupportsCategorizedBundleAndPrefersFresh(t *testing.T) {
|
||||
files := []parser.ExtractedFile{
|
||||
{Path: "bundle/export/memory/dmidecode-type17.txt", Content: []byte("memory")},
|
||||
{Path: "bundle/export/storage/storage-controllers.txt", Content: []byte("storage")},
|
||||
{Path: "bundle/export/gpu/nvidia-smi-query.csv", Content: []byte("query")},
|
||||
{Path: "bundle/export/gpu/nvidia-smi-topo.txt", Content: []byte("old")},
|
||||
{Path: "bundle/export/gpu/nvidia-smi-topo-fresh.txt", Content: []byte("fresh")},
|
||||
}
|
||||
got := collectTopologyEvidence(files)
|
||||
if got == nil {
|
||||
t.Fatal("expected topology evidence")
|
||||
}
|
||||
if got.DIMMType17 != "memory" || got.StorageMap != "storage" || got.NVIDIAQueryCSV != "query" || got.NVIDIATopology != "fresh" {
|
||||
t.Fatalf("unexpected topology evidence: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectBeeSupportArchiveV12Layout covers the real bundle layout produced
|
||||
// by bee-support v12.x, where bee-audit.json lives under tasks/_state/ and
|
||||
// runtime-health.json under status/, not export/. See CLAUDE.md memory on
|
||||
|
||||
+1
-1
@@ -952,7 +952,7 @@ func mergeInspurNIC(dst *models.NetworkAdapter, src models.NetworkAdapter) {
|
||||
if strings.TrimSpace(dst.MaxLinkSpeed) == "" {
|
||||
dst.MaxLinkSpeed = src.MaxLinkSpeed
|
||||
}
|
||||
if dst.NUMANode == 0 {
|
||||
if dst.NUMANode == nil {
|
||||
dst.NUMANode = src.NUMANode
|
||||
}
|
||||
if strings.TrimSpace(dst.Status) == "" {
|
||||
|
||||
@@ -177,6 +177,7 @@ func BuildHardwareDevices(hw *models.HardwareConfig) []models.HardwareDevice {
|
||||
LinkSpeed: p.LinkSpeed,
|
||||
MaxLinkWidth: p.MaxLinkWidth,
|
||||
MaxLinkSpeed: p.MaxLinkSpeed,
|
||||
NUMANode: p.NUMANode,
|
||||
Status: p.Status,
|
||||
StatusCheckedAt: p.StatusCheckedAt,
|
||||
StatusChangedAt: p.StatusChangedAt,
|
||||
@@ -568,6 +569,9 @@ func mergeDevices(primary, secondary models.HardwareDevice) models.HardwareDevic
|
||||
fillString(&primary.LinkSpeed, secondary.LinkSpeed)
|
||||
fillInt(&primary.MaxLinkWidth, secondary.MaxLinkWidth)
|
||||
fillString(&primary.MaxLinkSpeed, secondary.MaxLinkSpeed)
|
||||
if primary.NUMANode == nil && secondary.NUMANode != nil {
|
||||
primary.NUMANode = secondary.NUMANode
|
||||
}
|
||||
fillInt(&primary.WattageW, secondary.WattageW)
|
||||
fillString(&primary.InputType, secondary.InputType)
|
||||
fillInt(&primary.InputPowerW, secondary.InputPowerW)
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
)
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
func TestBuildHardwareDevices_DedupSerialThenBDF(t *testing.T) {
|
||||
hw := &models.HardwareConfig{
|
||||
PCIeDevices: []models.PCIeDevice{
|
||||
@@ -138,7 +140,7 @@ func TestBuildHardwareDevices_NetworkAdapterPreservesPCIeMetadata(t *testing.T)
|
||||
MACAddresses: []string{"44:1A:4C:16:E8:03", "44:1A:4C:16:E8:04"},
|
||||
LinkWidth: 16,
|
||||
LinkSpeed: "32 GT/s",
|
||||
NUMANode: 1,
|
||||
NUMANode: intPtr(1),
|
||||
Status: "ok",
|
||||
},
|
||||
},
|
||||
@@ -149,7 +151,7 @@ func TestBuildHardwareDevices_NetworkAdapterPreservesPCIeMetadata(t *testing.T)
|
||||
if d.Kind != models.DeviceKindNetwork {
|
||||
continue
|
||||
}
|
||||
if d.BDF != "0000:27:00.0" || d.LinkWidth != 16 || d.LinkSpeed != "32 GT/s" || d.NUMANode != 1 {
|
||||
if d.BDF != "0000:27:00.0" || d.LinkWidth != 16 || d.LinkSpeed != "32 GT/s" || d.NUMANode == nil || *d.NUMANode != 1 {
|
||||
t.Fatalf("expected network PCIe metadata to be preserved, got %+v", d)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -16,10 +16,11 @@ import (
|
||||
const rawExportFormatV1 = "logpile.raw-export.v1"
|
||||
|
||||
const (
|
||||
rawExportBundlePackageFile = "raw_export.json"
|
||||
rawExportBundleLogFile = "collect.log"
|
||||
rawExportBundleFieldsFile = "parser_fields.json"
|
||||
rawExportBundlePrivacyFile = "privacy_report.json"
|
||||
rawExportBundlePackageFile = "raw_export.json"
|
||||
rawExportBundleLogFile = "collect.log"
|
||||
rawExportBundleFieldsFile = "parser_fields.json"
|
||||
rawExportBundlePrivacyFile = "privacy_report.json"
|
||||
rawExportBundleTopologyFile = "topology.json"
|
||||
)
|
||||
|
||||
type RawExportPackage struct {
|
||||
@@ -165,6 +166,20 @@ func buildRawExportBundle(pkg *RawExportPackage, result *models.AnalysisResult,
|
||||
}
|
||||
}
|
||||
|
||||
if topologyDoc, topologyErr := currentTopologyDocument(result); topologyErr == nil && len(topologyDoc.Nodes) > 0 {
|
||||
tf, err := zw.Create(rawExportBundleTopologyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topologyJSON, err := json.MarshalIndent(topologyDoc, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tf.Write(topologyJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ func (s *Server) setupRoutes() {
|
||||
// Pages
|
||||
s.mux.HandleFunc("/", s.handleIndex)
|
||||
s.mux.HandleFunc("GET /chart/current", s.handleChartCurrent)
|
||||
s.mux.HandleFunc("GET /topology/current", s.handleTopologyCurrent)
|
||||
|
||||
// API endpoints
|
||||
s.mux.HandleFunc("POST /api/upload", s.handleUpload)
|
||||
@@ -86,6 +87,7 @@ func (s *Server) setupRoutes() {
|
||||
s.mux.HandleFunc("GET /api/events", s.handleGetEvents)
|
||||
s.mux.HandleFunc("GET /api/sensors", s.handleGetSensors)
|
||||
s.mux.HandleFunc("GET /api/config", s.handleGetConfig)
|
||||
s.mux.HandleFunc("GET /api/topology", s.handleGetTopology)
|
||||
s.mux.HandleFunc("GET /api/serials", s.handleGetSerials)
|
||||
s.mux.HandleFunc("GET /api/firmware", s.handleGetFirmware)
|
||||
s.mux.HandleFunc("GET /api/parse-errors", s.handleGetParseErrors)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/exporter"
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
"git.mchus.pro/mchus/logpile/internal/topology"
|
||||
)
|
||||
|
||||
func currentTopologyDocument(result *models.AnalysisResult) (*models.TopologyDocument, error) {
|
||||
if result == nil || result.Hardware == nil {
|
||||
return &models.TopologyDocument{Version: topology.ContractVersion}, nil
|
||||
}
|
||||
snapshot, err := exporter.ConvertToReanimator(result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return topology.Build(snapshot, result.TopologyEvidence), nil
|
||||
}
|
||||
|
||||
func (s *Server) handleTopologyCurrent(w http.ResponseWriter, r *http.Request) {
|
||||
doc, err := currentTopologyDocument(s.GetResult())
|
||||
if err != nil {
|
||||
s.htmlError(w, "failed to build topology", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(topology.RenderHTML(doc))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) {
|
||||
doc, err := currentTopologyDocument(s.GetResult())
|
||||
if err != nil {
|
||||
jsonError(w, "Failed to build topology", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(doc)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
)
|
||||
|
||||
func topologyTestResult() *models.AnalysisResult {
|
||||
return &models.AnalysisResult{Filename: "bee.tar.gz", Hardware: &models.HardwareConfig{
|
||||
BoardInfo: models.BoardInfo{ProductName: "SYS-TEST", SerialNumber: "SN123"},
|
||||
CPUs: []models.CPU{{Socket: 0, Model: "Xeon", Status: "OK"}},
|
||||
PCIeDevices: []models.PCIeDevice{{
|
||||
Slot: "0000:31:00.0", BDF: "0000:31:00.0", DeviceClass: "VideoController",
|
||||
Model: "NVIDIA GPU", NUMANode: intPtr(0), Status: "OK",
|
||||
}},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestTopologyEndpointsPreserveNUMAZero(t *testing.T) {
|
||||
s := New(Config{})
|
||||
s.SetResult(topologyTestResult())
|
||||
for _, tc := range []struct{ path, want string }{{"/api/topology", `"numa_node": 0`}, {"/topology/current", "SYS-TEST - SN123"}} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, tc.path, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s returned %d: %s", tc.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), tc.want) {
|
||||
t.Fatalf("%s missing %q: %s", tc.path, tc.want, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawExportBundleContainsSeparateTopologyJSON(t *testing.T) {
|
||||
result := topologyTestResult()
|
||||
pkg := newRawExportFromUploadedFile(result.Filename, "application/gzip", []byte("source"), result)
|
||||
body, err := buildRawExportBundle(pkg, result, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != rawExportBundleTopologyFile {
|
||||
continue
|
||||
}
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(r)
|
||||
_ = r.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"version": "1.0"`) {
|
||||
t.Fatalf("unexpected topology.json: %s", data)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("raw export bundle has no topology.json")
|
||||
}
|
||||
@@ -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)), " ")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package topology
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.mchus.pro/mchus/logpile/internal/exporter"
|
||||
"git.mchus.pro/mchus/logpile/internal/models"
|
||||
)
|
||||
|
||||
func intp(v int) *int { return &v }
|
||||
|
||||
func TestBuildDistinguishesNUMAZeroFromUnknown(t *testing.T) {
|
||||
snapshot := &exporter.ReanimatorExport{Hardware: exporter.ReanimatorHardware{
|
||||
Board: exporter.ReanimatorBoard{SerialNumber: "SN1"},
|
||||
CPUs: []exporter.ReanimatorCPU{{Socket: 0}, {Socket: 1}},
|
||||
PCIeDevices: []exporter.ReanimatorPCIe{
|
||||
{Slot: "0000:01:00.0", DeviceClass: "VideoController", Model: "GPU A", NUMANode: intp(0)},
|
||||
{Slot: "0000:02:00.0", DeviceClass: "NetworkController", Model: "NIC A", NUMANode: nil},
|
||||
},
|
||||
}}
|
||||
doc := Build(snapshot, nil)
|
||||
var gpuAttached, nicAttached bool
|
||||
for _, e := range doc.Edges {
|
||||
gpuAttached = gpuAttached || (e.From == "cpu:0" && e.To == "gpu:0000:01:00.0")
|
||||
nicAttached = nicAttached || strings.Contains(e.To, "nic:")
|
||||
}
|
||||
if !gpuAttached {
|
||||
t.Fatal("NUMA node 0 GPU was not attached to CPU 0")
|
||||
}
|
||||
if nicAttached {
|
||||
t.Fatal("unknown-affinity NIC must not be attached")
|
||||
}
|
||||
body, err := json.Marshal(doc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"numa_node":0`) {
|
||||
t.Fatalf("topology JSON lost NUMA zero: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUsesBeeStaticEvidence(t *testing.T) {
|
||||
snapshot := &exporter.ReanimatorExport{Hardware: exporter.ReanimatorHardware{
|
||||
Board: exporter.ReanimatorBoard{SerialNumber: "SN1"},
|
||||
CPUs: []exporter.ReanimatorCPU{{Socket: 0}, {Socket: 1}},
|
||||
Memory: []exporter.ReanimatorMemory{{Slot: "DIMM000(A)", SerialNumber: "M1", Status: "OK"}},
|
||||
Storage: []exporter.ReanimatorStorage{{Slot: "0:0:0:0", SerialNumber: "D1", Model: "Disk"}},
|
||||
PCIeDevices: []exporter.ReanimatorPCIe{
|
||||
{Slot: "0000:01:00.0", DeviceClass: "StorageController", NUMANode: intp(0)},
|
||||
{Slot: "0000:31:00.0", DeviceClass: "VideoController", Model: "GPU 0", NUMANode: intp(0)},
|
||||
{Slot: "0000:32:00.0", DeviceClass: "VideoController", Model: "GPU 1", NUMANode: intp(0)},
|
||||
},
|
||||
}}
|
||||
e := &models.TopologyEvidence{
|
||||
DIMMType17: "Memory Device\n Locator: DIMM000(A)\n Bank Locator: Node0_Channel0",
|
||||
StorageMap: "sda hctl=0:0:0:0 ctrl=0000:01:00.0",
|
||||
NVIDIAQueryCSV: "0, 0000:31:00.0\n1, 0000:32:00.0\n",
|
||||
NVIDIATopology: " GPU0 GPU1\nGPU0 X NV4\nGPU1 NV4 X\n",
|
||||
}
|
||||
doc := Build(snapshot, e)
|
||||
kinds := map[string]int{}
|
||||
for _, edge := range doc.Edges {
|
||||
kinds[edge.Kind]++
|
||||
}
|
||||
for _, kind := range []string{"memory", "storage", "nvlink"} {
|
||||
if kinds[kind] == 0 {
|
||||
t.Fatalf("missing %s edge: %+v", kind, doc.Edges)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHTMLIsStaticAndEscapesLabels(t *testing.T) {
|
||||
body := string(RenderHTML(&models.TopologyDocument{Version: ContractVersion, Title: `<script>x</script>`, Nodes: []models.TopologyNode{{ID: "cpu:0", Kind: "cpu", Label: "CPU 0", Socket: intp(0)}}}))
|
||||
if strings.Contains(body, `<script>x</script>`) {
|
||||
t.Fatal("title was not escaped")
|
||||
}
|
||||
for _, forbidden := range []string{"setInterval(", "fetch(", "nvidia-smi"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("rendered page contains live behavior %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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