Improve Redfish raw replay recovery and GUI diagnostics

This commit is contained in:
Mikhail Chusavitin
2026-02-25 12:16:31 +03:00
parent 66fb90233f
commit a4a1a19a94
5 changed files with 231 additions and 39 deletions
+28 -7
View File
@@ -88,15 +88,19 @@ func (c *RedfishConnector) Collect(ctx context.Context, req Request, emit Progre
emit(Progress{Status: "running", Progress: 90, Message: "Redfish: сбор расширенного snapshot..."}) emit(Progress{Status: "running", Progress: 90, Message: "Redfish: сбор расширенного snapshot..."})
} }
c.debugSnapshotf("snapshot crawl start host=%s port=%d", req.Host, req.Port) c.debugSnapshotf("snapshot crawl start host=%s port=%d", req.Host, req.Port)
rawTree := c.collectRawRedfishTree(ctx, client, req, baseURL, redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths), emit) rawTree, fetchErrors := c.collectRawRedfishTree(ctx, client, req, baseURL, redfishSnapshotPrioritySeeds(systemPaths, chassisPaths, managerPaths), emit)
c.debugSnapshotf("snapshot crawl done docs=%d", len(rawTree)) c.debugSnapshotf("snapshot crawl done docs=%d", len(rawTree))
if emit != nil { if emit != nil {
emit(Progress{Status: "running", Progress: 99, Message: "Redfish: анализ raw snapshot..."}) emit(Progress{Status: "running", Progress: 99, Message: "Redfish: анализ raw snapshot..."})
} }
// Unified tunnel: live collection and raw import go through the same analyzer over redfish_tree. rawPayloads := map[string]any{
return ReplayRedfishFromRawPayloads(map[string]any{
"redfish_tree": rawTree, "redfish_tree": rawTree,
}, nil) }
if len(fetchErrors) > 0 {
rawPayloads["redfish_fetch_errors"] = fetchErrors
}
// Unified tunnel: live collection and raw import go through the same analyzer over redfish_tree.
return ReplayRedfishFromRawPayloads(rawPayloads, nil)
} }
func (c *RedfishConnector) httpClient(req Request) *http.Client { func (c *RedfishConnector) httpClient(req Request) *http.Client {
@@ -444,7 +448,7 @@ func (c *RedfishConnector) collectPCIeDevices(ctx context.Context, client *http.
for _, doc := range memberDocs { for _, doc := range memberDocs {
functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, doc) functionDocs := c.getLinkedPCIeFunctions(ctx, client, req, baseURL, doc)
dev := parsePCIeDevice(doc, functionDocs) dev := parsePCIeDevice(doc, functionDocs)
key := firstNonEmpty(dev.SerialNumber, dev.BDF, dev.Slot+"|"+dev.DeviceClass) key := firstNonEmpty(dev.BDF, dev.SerialNumber, dev.Slot+"|"+dev.DeviceClass)
if key == "" { if key == "" {
continue continue
} }
@@ -506,12 +510,13 @@ func (c *RedfishConnector) discoverMemberPaths(ctx context.Context, client *http
return nil return nil
} }
func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *http.Client, req Request, baseURL string, seedPaths []string, emit ProgressFn) map[string]interface{} { func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *http.Client, req Request, baseURL string, seedPaths []string, emit ProgressFn) (map[string]interface{}, []map[string]interface{}) {
maxDocuments := redfishSnapshotMaxDocuments() maxDocuments := redfishSnapshotMaxDocuments()
const workers = 6 const workers = 6
const heartbeatInterval = 5 * time.Second const heartbeatInterval = 5 * time.Second
out := make(map[string]interface{}, maxDocuments) out := make(map[string]interface{}, maxDocuments)
fetchErrors := make(map[string]string)
seen := make(map[string]struct{}, maxDocuments) seen := make(map[string]struct{}, maxDocuments)
rootCounts := make(map[string]int) rootCounts := make(map[string]int)
var mu sync.Mutex var mu sync.Mutex
@@ -604,6 +609,11 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
} }
n := atomic.AddInt32(&processed, 1) n := atomic.AddInt32(&processed, 1)
if err != nil { if err != nil {
mu.Lock()
if _, ok := fetchErrors[current]; !ok {
fetchErrors[current] = err.Error()
}
mu.Unlock()
c.debugSnapshotf("worker=%d fetch error path=%s err=%v", workerID, current, err) c.debugSnapshotf("worker=%d fetch error path=%s err=%v", workerID, current, err)
if emit != nil && shouldReportSnapshotFetchError(err) { if emit != nil && shouldReportSnapshotFetchError(err) {
emit(Progress{ emit(Progress{
@@ -677,7 +687,18 @@ func (c *RedfishConnector) collectRawRedfishTree(ctx context.Context, client *ht
}) })
} }
return out errorList := make([]map[string]interface{}, 0, len(fetchErrors))
for p, msg := range fetchErrors {
errorList = append(errorList, map[string]interface{}{
"path": p,
"error": msg,
})
}
sort.Slice(errorList, func(i, j int) bool {
return asString(errorList[i]["path"]) < asString(errorList[j]["path"])
})
return out, errorList
} }
func (c *RedfishConnector) probeSupermicroNVMeDiskBays(ctx context.Context, client *http.Client, req Request, baseURL, backplanePath string) []map[string]interface{} { func (c *RedfishConnector) probeSupermicroNVMeDiskBays(ctx context.Context, client *http.Client, req Request, baseURL, backplanePath string) []map[string]interface{} {
+58 -6
View File
@@ -2,6 +2,8 @@ package collector
import ( import (
"fmt" "fmt"
"sort"
"strings"
"git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/models"
) )
@@ -67,9 +69,7 @@ func ReplayRedfishFromRawPayloads(rawPayloads map[string]any, emit ProgressFn) (
Events: make([]models.Event, 0), Events: make([]models.Event, 0),
FRU: make([]models.FRUInfo, 0), FRU: make([]models.FRUInfo, 0),
Sensors: make([]models.SensorReading, 0), Sensors: make([]models.SensorReading, 0),
RawPayloads: map[string]any{ RawPayloads: cloneRawPayloads(rawPayloads),
"redfish_tree": tree,
},
Hardware: &models.HardwareConfig{ Hardware: &models.HardwareConfig{
BoardInfo: parseBoardInfo(systemDoc), BoardInfo: parseBoardInfo(systemDoc),
CPUs: parseCPUs(processors), CPUs: parseCPUs(processors),
@@ -115,11 +115,11 @@ func (r redfishSnapshotReader) getJSON(requestPath string) (map[string]interface
func (r redfishSnapshotReader) getCollectionMembers(collectionPath string) ([]map[string]interface{}, error) { func (r redfishSnapshotReader) getCollectionMembers(collectionPath string) ([]map[string]interface{}, error) {
collection, err := r.getJSON(collectionPath) collection, err := r.getJSON(collectionPath)
if err != nil { if err != nil {
return nil, err return r.fallbackCollectionMembers(collectionPath, err)
} }
refs, ok := collection["Members"].([]interface{}) refs, ok := collection["Members"].([]interface{})
if !ok || len(refs) == 0 { if !ok || len(refs) == 0 {
return []map[string]interface{}{}, nil return r.fallbackCollectionMembers(collectionPath, nil)
} }
out := make([]map[string]interface{}, 0, len(refs)) out := make([]map[string]interface{}, 0, len(refs))
for _, refAny := range refs { for _, refAny := range refs {
@@ -137,9 +137,61 @@ func (r redfishSnapshotReader) getCollectionMembers(collectionPath string) ([]ma
} }
out = append(out, doc) out = append(out, doc)
} }
if len(out) == 0 {
return r.fallbackCollectionMembers(collectionPath, nil)
}
return out, nil return out, nil
} }
func (r redfishSnapshotReader) fallbackCollectionMembers(collectionPath string, originalErr error) ([]map[string]interface{}, error) {
prefix := strings.TrimSuffix(normalizeRedfishPath(collectionPath), "/") + "/"
if prefix == "/" {
if originalErr != nil {
return nil, originalErr
}
return []map[string]interface{}{}, nil
}
paths := make([]string, 0)
for key := range r.tree {
p := normalizeRedfishPath(key)
if !strings.HasPrefix(p, prefix) {
continue
}
rest := strings.TrimPrefix(p, prefix)
if rest == "" || strings.Contains(rest, "/") {
continue
}
paths = append(paths, p)
}
if len(paths) == 0 {
if originalErr != nil {
return nil, originalErr
}
return []map[string]interface{}{}, nil
}
sort.Strings(paths)
out := make([]map[string]interface{}, 0, len(paths))
for _, p := range paths {
doc, err := r.getJSON(p)
if err != nil {
continue
}
out = append(out, doc)
}
return out, nil
}
func cloneRawPayloads(src map[string]any) map[string]any {
if len(src) == 0 {
return nil
}
dst := make(map[string]any, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}
func (r redfishSnapshotReader) discoverMemberPaths(collectionPath, fallbackPath string) []string { func (r redfishSnapshotReader) discoverMemberPaths(collectionPath, fallbackPath string) []string {
collection, err := r.getJSON(collectionPath) collection, err := r.getJSON(collectionPath)
if err == nil { if err == nil {
@@ -482,7 +534,7 @@ func (r redfishSnapshotReader) collectPCIeDevices(systemPaths, chassisPaths []st
for _, doc := range memberDocs { for _, doc := range memberDocs {
functionDocs := r.getLinkedPCIeFunctions(doc) functionDocs := r.getLinkedPCIeFunctions(doc)
dev := parsePCIeDevice(doc, functionDocs) dev := parsePCIeDevice(doc, functionDocs)
key := firstNonEmpty(dev.SerialNumber, dev.BDF, dev.Slot+"|"+dev.DeviceClass) key := firstNonEmpty(dev.BDF, dev.SerialNumber, dev.Slot+"|"+dev.DeviceClass)
if key == "" { if key == "" {
continue continue
} }
+62
View File
@@ -239,6 +239,68 @@ func TestParsePCIeDeviceSlot_EmptyMapFallsBackToID(t *testing.T) {
} }
} }
func TestReplayRedfishFromRawPayloads_FallbackCollectionMembersByPrefix(t *testing.T) {
raw := map[string]any{
"redfish_tree": map[string]interface{}{
"/redfish/v1": map[string]interface{}{
"Systems": map[string]interface{}{"@odata.id": "/redfish/v1/Systems"},
"Chassis": map[string]interface{}{"@odata.id": "/redfish/v1/Chassis"},
"Managers": map[string]interface{}{"@odata.id": "/redfish/v1/Managers"},
},
"/redfish/v1/Systems": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Systems/1"},
},
},
"/redfish/v1/Systems/1": map[string]interface{}{
"Manufacturer": "Supermicro",
"Model": "SYS-TEST",
"SerialNumber": "SYS123",
},
// Intentionally missing /redfish/v1/Systems/1/Processors collection.
"/redfish/v1/Systems/1/Processors/CPU1": map[string]interface{}{
"Id": "CPU1",
"Model": "Xeon Gold",
"TotalCores": 32,
"TotalThreads": 64,
},
"/redfish/v1/Chassis": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Chassis/1"},
},
},
"/redfish/v1/Chassis/1": map[string]interface{}{
"Id": "1",
},
"/redfish/v1/Managers": map[string]interface{}{
"Members": []interface{}{
map[string]interface{}{"@odata.id": "/redfish/v1/Managers/1"},
},
},
"/redfish/v1/Managers/1": map[string]interface{}{
"Id": "1",
},
},
"redfish_fetch_errors": []map[string]interface{}{
{"path": "/redfish/v1/Systems/1/Processors", "error": "status 500"},
},
}
got, err := ReplayRedfishFromRawPayloads(raw, nil)
if err != nil {
t.Fatalf("replay failed: %v", err)
}
if got.Hardware == nil {
t.Fatalf("expected hardware")
}
if len(got.Hardware.CPUs) != 1 {
t.Fatalf("expected one CPU via prefix fallback, got %d", len(got.Hardware.CPUs))
}
if _, ok := got.RawPayloads["redfish_fetch_errors"]; !ok {
t.Fatalf("expected raw payloads to preserve redfish_fetch_errors")
}
}
func TestEnrichNICFromPCIeFunctions(t *testing.T) { func TestEnrichNICFromPCIeFunctions(t *testing.T) {
nic := parseNIC(map[string]interface{}{ nic := parseNIC(map[string]interface{}{
"Id": "1", "Id": "1",
+5
View File
@@ -319,6 +319,11 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
"target_host": result.TargetHost, "target_host": result.TargetHost,
"collected_at": result.CollectedAt, "collected_at": result.CollectedAt,
} }
if result.RawPayloads != nil {
if fetchErrors, ok := result.RawPayloads["redfish_fetch_errors"]; ok {
response["redfish_fetch_errors"] = fetchErrors
}
}
if result.Hardware == nil { if result.Hardware == nil {
response["hardware"] = map[string]interface{}{} response["hardware"] = map[string]interface{}{}
+52
View File
@@ -646,6 +646,8 @@ function renderConfig(data) {
const config = data.hardware || data; const config = data.hardware || data;
const spec = data.specification; const spec = data.specification;
const redfishFetchErrors = Array.isArray(data.redfish_fetch_errors) ? data.redfish_fetch_errors : [];
const visibleRedfishFetchErrors = filterVisibleRedfishFetchErrors(redfishFetchErrors);
const devices = Array.isArray(config.devices) ? config.devices : []; const devices = Array.isArray(config.devices) ? config.devices : [];
const volumes = Array.isArray(config.volumes) ? config.volumes : []; const volumes = Array.isArray(config.volumes) ? config.volumes : [];
@@ -688,6 +690,32 @@ function renderConfig(data) {
// Specification tab // Specification tab
html += '<div class="config-tab-content active" id="config-spec">'; html += '<div class="config-tab-content active" id="config-spec">';
const partialInventory = detectPartialRedfishInventory({
cpus,
memory,
redfishFetchErrors
});
if (partialInventory) {
html += `<div class="spec-section">
<h3>Частичный инвентарь</h3>
<p class="no-data" style="margin-top: 0;">${escapeHtml(partialInventory)}</p>
</div>`;
}
if (visibleRedfishFetchErrors.length > 0) {
html += `<div class="spec-section">
<h3>Redfish fetch errors (${visibleRedfishFetchErrors.length})</h3>
<p class="no-data" style="margin-top: 0;">Сохранено в raw snapshot для последующего анализа в GUI.</p>
<table class="config-table"><thead><tr><th>Endpoint</th><th>Ошибка</th></tr></thead><tbody>`;
visibleRedfishFetchErrors.forEach(item => {
const path = item && typeof item === 'object' ? (item.path || '-') : '-';
const err = item && typeof item === 'object' ? (item.error || '-') : String(item || '-');
html += `<tr>
<td><code>${escapeHtml(String(path))}</code></td>
<td>${escapeHtml(String(err))}</td>
</tr>`;
});
html += '</tbody></table></div>';
}
if (spec && spec.length > 0) { if (spec && spec.length > 0) {
html += '<div class="spec-section"><h3>Спецификация сервера</h3><ul class="spec-list">'; html += '<div class="spec-section"><h3>Спецификация сервера</h3><ul class="spec-list">';
spec.forEach(item => { spec.forEach(item => {
@@ -1319,6 +1347,30 @@ function escapeHtml(text) {
return div.innerHTML; return div.innerHTML;
} }
function filterVisibleRedfishFetchErrors(items) {
if (!Array.isArray(items)) return [];
return items.filter(item => {
const message = String(item && typeof item === 'object' ? (item.error || '') : item || '').toLowerCase();
return !(
message.startsWith('status 404 ') ||
message.startsWith('status 405 ') ||
message.startsWith('status 410 ') ||
message.startsWith('status 501 ')
);
});
}
function detectPartialRedfishInventory({ cpus, memory, redfishFetchErrors }) {
const errors = Array.isArray(redfishFetchErrors) ? redfishFetchErrors : [];
const paths = errors.map(item => String(item && typeof item === 'object' ? (item.path || '') : '')).filter(Boolean);
const cpuMissing = (!Array.isArray(cpus) || cpus.length === 0) && paths.some(p => /\/Systems\/[^/]+\/Processors(\/)?$/i.test(p));
const memMissing = (!Array.isArray(memory) || memory.length === 0) && paths.some(p => /\/Systems\/[^/]+\/Memory(\/)?$/i.test(p));
if (!cpuMissing && !memMissing) return '';
if (cpuMissing && memMissing) return 'Не удалось восстановить CPU и Memory: Redfish endpoint\'ы /Processors и /Memory были недоступны во время сбора.';
if (cpuMissing) return 'CPU-инвентарь неполный: Redfish endpoint /Processors был недоступен во время сбора.';
return 'Memory-инвентарь неполный: Redfish endpoint /Memory был недоступен во время сбора.';
}
function calculateCPUToPCIeBalance(inventoryRows, cpus) { function calculateCPUToPCIeBalance(inventoryRows, cpus) {
const laneByCPU = new Map(); const laneByCPU = new Map();
const cpuIndexes = new Set(); const cpuIndexes = new Set();