feat: surface BMC collection errors in parse-errors panel and event log
When Inspur component.log sections return {"error":"...","code":N} instead
of hardware data, the parser now:
- stores them in AnalysisResult.CollectionErrors (new model field)
- mirrors each one into result.Events with Source="BMC/<section>"
so the chart viewer event table shows the specific BMC module
- feeds them into /api/parse-errors as bmc_collection_error entries
UI adds a collapsible "Collection diagnostics" panel below the chart
iframe (outside /chart) that appears when /api/parse-errors returns
any items; resets on data clear.
Affected sections in this dump: HDD (1458), PCIe Devices (1458),
Network Adapters (1458), Disk Backplane.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,11 +16,21 @@ type AnalysisResult struct {
|
||||
SourceTimezone string `json:"source_timezone,omitempty"` // Source timezone/offset used during collection (e.g. +08:00)
|
||||
CollectedAt time.Time `json:"collected_at,omitempty"` // Collection/upload timestamp
|
||||
InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"` // Redfish inventory last modified (InventoryData/Status)
|
||||
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
|
||||
Events []Event `json:"events"`
|
||||
FRU []FRUInfo `json:"fru"`
|
||||
Sensors []SensorReading `json:"sensors"`
|
||||
Hardware *HardwareConfig `json:"hardware"`
|
||||
RawPayloads map[string]any `json:"raw_payloads,omitempty"` // Additional source payloads (e.g. Redfish tree)
|
||||
CollectionErrors []CollectionError `json:"collection_errors,omitempty"` // BMC-reported failures to collect specific sections
|
||||
Events []Event `json:"events"`
|
||||
FRU []FRUInfo `json:"fru"`
|
||||
Sensors []SensorReading `json:"sensors"`
|
||||
Hardware *HardwareConfig `json:"hardware"`
|
||||
}
|
||||
|
||||
// CollectionError represents a BMC-reported failure to collect a specific data section.
|
||||
// Populated by vendor parsers when the source explicitly returns an error response
|
||||
// instead of structured data (e.g. {"error":"...","code":1458} in Inspur component.log).
|
||||
type CollectionError struct {
|
||||
Section string `json:"section"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// Event represents a single log event
|
||||
|
||||
46
internal/parser/vendors/inspur/component.go
vendored
46
internal/parser/vendors/inspur/component.go
vendored
@@ -56,6 +56,52 @@ func ParseComponentLogEvents(content []byte) []models.Event {
|
||||
return events
|
||||
}
|
||||
|
||||
// ParseComponentLogCollectionErrors detects BMC-reported collection failures in component.log.
|
||||
// When a RESTful section returns {"error":"...","code":N} instead of structured data,
|
||||
// the BMC itself failed to collect that subsystem — the parser emits a CollectionError
|
||||
// so the UI can surface it explicitly rather than showing an empty section.
|
||||
func ParseComponentLogCollectionErrors(content []byte) []models.CollectionError {
|
||||
type bmcErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
// Map of section name (for display) → regex that captures its JSON payload.
|
||||
// Sections that return arrays use \[ ... \]; object sections use \{ ... \}.
|
||||
// We only probe sections that are expected to have structured hardware data.
|
||||
sections := []struct {
|
||||
name string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
{"HDD", regexp.MustCompile(`RESTful HDD info:\s*(\{[^\n]*\})`)},
|
||||
{"PCIe Devices", regexp.MustCompile(`RESTful PCIE Device info:\s*(\{[^\n]*\})`)},
|
||||
{"Network Adapters", regexp.MustCompile(`RESTful Network Adapter info:\s*(\{[^\n]*\})`)},
|
||||
{"Disk Backplane", regexp.MustCompile(`RESTful diskbackplane info:\s*(\{[^\n]*\})`)},
|
||||
}
|
||||
|
||||
text := string(content)
|
||||
var out []models.CollectionError
|
||||
for _, s := range sections {
|
||||
m := s.re.FindStringSubmatch(text)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
var errResp bmcErrorResponse
|
||||
if err := json.Unmarshal([]byte(m[1]), &errResp); err != nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(errResp.Error) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, models.CollectionError{
|
||||
Section: s.name,
|
||||
Message: errResp.Error,
|
||||
Code: errResp.Code,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseComponentLogSensors extracts sensor readings from component.log JSON sections.
|
||||
func ParseComponentLogSensors(content []byte) []models.SensorReading {
|
||||
text := string(content)
|
||||
|
||||
22
internal/parser/vendors/inspur/parser.go
vendored
22
internal/parser/vendors/inspur/parser.go
vendored
@@ -16,7 +16,7 @@ import (
|
||||
|
||||
// parserVersion - version of this parser module
|
||||
// IMPORTANT: Increment this version when making changes to parser logic!
|
||||
const parserVersion = "1.9"
|
||||
const parserVersion = "2.0"
|
||||
|
||||
func init() {
|
||||
parser.Register(&Parser{})
|
||||
@@ -163,6 +163,26 @@ func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, er
|
||||
// (fan RPM, backplane temperature, PSU summary power, etc.).
|
||||
componentSensors := ParseComponentLogSensors(f.Content)
|
||||
result.Sensors = mergeSensorReadings(result.Sensors, componentSensors)
|
||||
|
||||
// Record sections where BMC itself returned an error instead of data,
|
||||
// and mirror each one into the Events stream so they appear in the log viewer.
|
||||
// Source is set to "BMC/<section>" so the viewer can show the specific module.
|
||||
for _, ce := range ParseComponentLogCollectionErrors(f.Content) {
|
||||
result.CollectionErrors = append(result.CollectionErrors, ce)
|
||||
desc := ce.Message
|
||||
if ce.Code != 0 {
|
||||
desc = fmt.Sprintf("%s (code %d)", ce.Message, ce.Code)
|
||||
}
|
||||
result.Events = append(result.Events, models.Event{
|
||||
ID: fmt.Sprintf("bmc_collection_error_%s", strings.ToLower(strings.ReplaceAll(ce.Section, " ", "_"))),
|
||||
Timestamp: time.Time{}, // no timestamp available
|
||||
Source: fmt.Sprintf("BMC/%s", ce.Section),
|
||||
SensorType: "bmc_collection_error",
|
||||
EventType: "Collection Error",
|
||||
Severity: models.SeverityWarning,
|
||||
Description: desc,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich runtime component data from Redis snapshot (serials, FW, telemetry),
|
||||
|
||||
@@ -854,6 +854,28 @@ func (s *Server) handleGetParseErrors(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// BMC-reported collection failures surfaced by vendor parsers.
|
||||
if result != nil {
|
||||
for _, ce := range result.CollectionErrors {
|
||||
msg := strings.TrimSpace(ce.Message)
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
detail := ""
|
||||
if ce.Code != 0 {
|
||||
detail = fmt.Sprintf("code %d", ce.Code)
|
||||
}
|
||||
add(parseErrorEntry{
|
||||
Source: "bmc",
|
||||
Category: "bmc_collection_error",
|
||||
Severity: "warning",
|
||||
Path: ce.Section,
|
||||
Message: msg,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Severity != items[j].Severity {
|
||||
// error > warning > info
|
||||
|
||||
@@ -933,3 +933,73 @@ code {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Parse / collection errors panel ───────────────────────────────────── */
|
||||
.parse-errors-section {
|
||||
margin: 0 auto;
|
||||
max-width: 1200px;
|
||||
padding: 0 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.parse-errors-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: .55rem .75rem;
|
||||
background: var(--warn-bg);
|
||||
border: 1px solid #f0e0c0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
font-size: .85rem;
|
||||
color: var(--warn-fg);
|
||||
}
|
||||
|
||||
.parse-errors-toggle {
|
||||
font-size: .75rem;
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.parse-errors-body {
|
||||
border: 1px solid #f0e0c0;
|
||||
border-top: none;
|
||||
border-radius: 0 0 6px 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.parse-errors-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: .82rem;
|
||||
}
|
||||
|
||||
.parse-errors-table th {
|
||||
background: var(--surface-3);
|
||||
padding: .4rem .65rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.parse-errors-table td {
|
||||
padding: .38rem .65rem;
|
||||
border-bottom: 1px solid var(--border-lite);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.parse-errors-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.parse-error-row.parse-error-error td:first-child {
|
||||
color: var(--crit-fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.parse-error-row.parse-error-warning td:first-child {
|
||||
color: #7a5200;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1413,6 +1413,62 @@ async function loadData(vendor, filename) {
|
||||
document.getElementById('header-log-meta').classList.remove('hidden');
|
||||
|
||||
loadAuditViewer();
|
||||
loadParseErrors();
|
||||
}
|
||||
|
||||
async function loadParseErrors() {
|
||||
const section = document.getElementById('parse-errors-section');
|
||||
const rows = document.getElementById('parse-errors-rows');
|
||||
const title = document.getElementById('parse-errors-title');
|
||||
if (!section || !rows) return;
|
||||
|
||||
let data;
|
||||
try {
|
||||
const resp = await fetch('/api/parse-errors');
|
||||
if (!resp.ok) return;
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = (data && data.items) ? data.items : [];
|
||||
if (items.length === 0) {
|
||||
section.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const errorCount = items.filter(i => i.severity === 'error').length;
|
||||
const warnCount = items.filter(i => i.severity === 'warning').length;
|
||||
const parts = [];
|
||||
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? 's' : ''}`);
|
||||
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? 's' : ''}`);
|
||||
const otherCount = items.length - errorCount - warnCount;
|
||||
if (otherCount > 0) parts.push(`${otherCount} notice${otherCount > 1 ? 's' : ''}`);
|
||||
title.textContent = `Collection diagnostics — ${parts.join(', ')}`;
|
||||
|
||||
rows.innerHTML = '';
|
||||
for (const item of items) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = `parse-error-row parse-error-${item.severity || 'info'}`;
|
||||
tr.innerHTML =
|
||||
`<td>${escapeHtml(item.source || '')}</td>` +
|
||||
`<td>${escapeHtml(item.path || item.category || '')}</td>` +
|
||||
`<td>${escapeHtml(item.message || '')}</td>` +
|
||||
`<td>${escapeHtml(item.detail || '')}</td>`;
|
||||
rows.appendChild(tr);
|
||||
}
|
||||
|
||||
section.classList.remove('hidden');
|
||||
}
|
||||
|
||||
let parseErrorsCollapsed = false;
|
||||
function toggleParseErrors() {
|
||||
const body = document.getElementById('parse-errors-body');
|
||||
const toggle = document.getElementById('parse-errors-toggle');
|
||||
if (!body) return;
|
||||
parseErrorsCollapsed = !parseErrorsCollapsed;
|
||||
body.style.display = parseErrorsCollapsed ? 'none' : '';
|
||||
toggle.textContent = parseErrorsCollapsed ? '▼' : '▲';
|
||||
}
|
||||
|
||||
function loadAuditViewer() {
|
||||
@@ -1468,6 +1524,15 @@ async function clearData() {
|
||||
if (frame) {
|
||||
frame.src = 'about:blank';
|
||||
}
|
||||
const parseErrSection = document.getElementById('parse-errors-section');
|
||||
if (parseErrSection) parseErrSection.classList.add('hidden');
|
||||
const parseErrRows = document.getElementById('parse-errors-rows');
|
||||
if (parseErrRows) parseErrRows.innerHTML = '';
|
||||
parseErrorsCollapsed = false;
|
||||
const parseErrBody = document.getElementById('parse-errors-body');
|
||||
if (parseErrBody) parseErrBody.style.display = '';
|
||||
const parseErrToggle = document.getElementById('parse-errors-toggle');
|
||||
if (parseErrToggle) parseErrToggle.textContent = '▲';
|
||||
} catch (err) {
|
||||
console.error('Failed to clear data:', err);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,25 @@
|
||||
</iframe>
|
||||
</div>
|
||||
</section>
|
||||
<section id="parse-errors-section" class="parse-errors-section hidden">
|
||||
<div class="parse-errors-header" onclick="toggleParseErrors()">
|
||||
<span id="parse-errors-title">Collection warnings</span>
|
||||
<span id="parse-errors-toggle" class="parse-errors-toggle">▲</span>
|
||||
</div>
|
||||
<div id="parse-errors-body" class="parse-errors-body">
|
||||
<table class="parse-errors-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Source</th>
|
||||
<th>Section</th>
|
||||
<th>Message</th>
|
||||
<th>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="parse-errors-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user