feat: add standalone server topology view

This commit is contained in:
Mikhail Chusavitin
2026-09-14 10:29:36 +03:00
parent 5b8be0e464
commit 9ac38c2914
28 changed files with 1287 additions and 31 deletions
+4
View File
@@ -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)
+4 -2
View File
@@ -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
+19 -4
View File
@@ -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
}
+2
View File
@@ -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)
+43
View File
@@ -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)
}
+71
View File
@@ -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")
}