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