7 Commits
v1.16 ... v1.17

Author SHA1 Message Date
Mikhail Chusavitin
f3836a34cc chore: update chart submodule to v2.0 and refresh pci.ids (2026-05-21)
chart: feat(viewer): replace severity dropdown with per-column header filters
pci.ids: 2026-02-17 → 2026-05-21

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:37:26 +03:00
Mikhail Chusavitin
ba9a52a61a fix(ui): parse-errors panel full width
Removed max-width/padding constraints — panel now stretches to grid
column width like the viewer-panel above it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:32:47 +03:00
Mikhail Chusavitin
27373aa104 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>
2026-05-21 14:30:01 +03:00
Mikhail Chusavitin
4f7b5b826a fix(inspur): fix PSU section regex when PCIE section precedes Network
The PSU regex used "RESTful Network" as its end anchor, but in standard
Inspur component.log layout the PCIE Device section sits between PSU and
Network Adapter. The lazy [\s\S]*? captured across the PCIE error block,
producing invalid JSON and silently dropping all PSU data.

Changed anchor to RESTful (?:PCIE|Network) — matches whichever section
immediately follows PSU in a given archive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:21:13 +03:00
Mikhail Chusavitin
dfd64550cf fix(inspur): infer DIMM size from part number when BMC reports size=0
When BMC firmware fails to read capacity for a present DIMM, size_mb stays
0. If another DIMM with the same part number in the same batch has a known
size, use it to fill the gap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:18:15 +03:00
Mikhail Chusavitin
9505303d1d fix(inspur): show microcode version for every CPU, not just the first
Dedup by version caused CPU1 Microcode to be omitted when both CPUs run
the same version, leaving the firmware column blank for the second socket.
Each CPU gets its own firmware entry keyed by index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:15:28 +03:00
Mikhail Chusavitin
f2c04cf0e8 fix(inspur): parse CPUs from component.log and fix DIMM present detection
Two bugs in onekeylog archives that lack asset.json:

- CPU count was always 0: ParseComponentLog never parsed the "RESTful CPU
  info" section. Added parseCPUInfo as a fallback when hw.CPUs is empty
  (asset.json remains the primary source when present). Also worked around
  a Go JSON case-insensitive collision between "proc_id" (int) and
  "PROC_ID" (string CPUID) by adding an explicit PROC_ID field with an
  exact-case tag.

- Only 1 of 2 DIMMs shown: Present condition required mem_mod_size > 0,
  but some BMC firmware reports size=0 for a physically installed module
  while still providing serial and part number. Now treats a DIMM as
  present when status=1 and any of size/serial/partnum is non-empty.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 14:13:55 +03:00
12 changed files with 1628 additions and 502 deletions

View File

@@ -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

View File

@@ -117,7 +117,6 @@ func ParseAssetJSON(content []byte, pcieSlotDeviceNames map[int]string, pcieSlot
}
// Parse CPU info
seenMicrocode := make(map[string]bool)
for i, cpu := range asset.CpuInfo {
config.CPUs = append(config.CPUs, models.CPU{
Socket: i,
@@ -133,13 +132,11 @@ func ParseAssetJSON(content []byte, pcieSlotDeviceNames map[int]string, pcieSlot
PPIN: cpu.PPIN,
})
// Add CPU microcode to firmware list (deduplicated)
if cpu.MicroCodeVer != "" && !seenMicrocode[cpu.MicroCodeVer] {
if cpu.MicroCodeVer != "" {
config.Firmware = append(config.Firmware, models.FirmwareInfo{
DeviceName: fmt.Sprintf("CPU%d Microcode", i),
Version: cpu.MicroCodeVer,
})
seenMicrocode[cpu.MicroCodeVer] = true
}
}

View File

@@ -19,6 +19,11 @@ func ParseComponentLog(content []byte, hw *models.HardwareConfig) {
text := string(content)
// Parse RESTful CPU info — fallback when asset.json is absent
if len(hw.CPUs) == 0 {
parseCPUInfo(text, hw)
}
// Parse RESTful Memory info (detailed memory data)
parseMemoryInfo(text, hw)
@@ -51,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)
@@ -61,6 +112,68 @@ func ParseComponentLogSensors(content []byte) []models.SensorReading {
return out
}
// CPURESTInfo represents the RESTful CPU info structure in component.log
type CPURESTInfo struct {
Processors []struct {
ProcID int `json:"proc_id"`
CPUID string `json:"PROC_ID"` // uppercase key — prevents case-insensitive collision with proc_id
Manufacturer string `json:"Manufacturer"`
MaxSpeedMHz int `json:"MaxSpeedMHz"`
ConfigStatus int `json:"configStatus"`
ProcName string `json:"proc_name"`
ProcStatus int `json:"proc_status"`
ProcSpeed int `json:"proc_speed"`
CoreCount int `json:"proc_core_count"`
ThreadCount int `json:"proc_thread_count"`
TDP int `json:"proc_tdp"`
L1Cache int `json:"proc_l1cache_size"`
L2Cache int `json:"proc_l2cache_size"`
L3Cache int `json:"proc_l3cache_size"`
MicroCode string `json:"micro_code"`
PPIN string `json:"ppin"`
Status string `json:"status"`
} `json:"processors"`
}
func parseCPUInfo(text string, hw *models.HardwareConfig) {
re := regexp.MustCompile(`RESTful CPU info:\s*(\{[\s\S]*?\})\s*RESTful Memory`)
match := re.FindStringSubmatch(text)
if match == nil {
return
}
jsonStr := strings.ReplaceAll(match[1], "\n", "")
var cpuInfo CPURESTInfo
if err := json.Unmarshal([]byte(jsonStr), &cpuInfo); err != nil {
return
}
for _, proc := range cpuInfo.Processors {
if proc.ProcStatus != 1 && proc.ConfigStatus != 1 {
continue
}
hw.CPUs = append(hw.CPUs, models.CPU{
Socket: proc.ProcID,
Model: strings.TrimSpace(proc.ProcName),
Cores: proc.CoreCount,
Threads: proc.ThreadCount,
FrequencyMHz: proc.ProcSpeed,
MaxFreqMHz: proc.MaxSpeedMHz,
L1CacheKB: proc.L1Cache,
L2CacheKB: proc.L2Cache,
L3CacheKB: proc.L3Cache,
TDP: proc.TDP,
PPIN: proc.PPIN,
})
if proc.MicroCode != "" {
hw.Firmware = append(hw.Firmware, models.FirmwareInfo{
DeviceName: fmt.Sprintf("CPU%d Microcode", proc.ProcID),
Version: proc.MicroCode,
})
}
}
}
// MemoryRESTInfo represents the RESTful Memory info structure
type MemoryRESTInfo struct {
MemModules []struct {
@@ -112,9 +225,10 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
}
for _, mem := range memInfo.MemModules {
item := models.MemoryDIMM{
Slot: mem.MemModSlot,
Location: mem.MemModSlot,
Present: mem.MemModStatus == 1 && mem.MemModSize > 0,
Slot: mem.MemModSlot,
Location: mem.MemModSlot,
// status=1 with a known serial/part is definitely present even if BMC reports size=0
Present: mem.MemModStatus == 1 && (mem.MemModSize > 0 || strings.TrimSpace(mem.MemModSerial) != "" || strings.TrimSpace(mem.MemModPartNum) != ""),
SizeMB: mem.MemModSize * 1024, // Convert GB to MB
Type: mem.MemModType,
Technology: strings.TrimSpace(mem.MemModTechnology),
@@ -136,6 +250,25 @@ func parseMemoryInfo(text string, hw *models.HardwareConfig) {
}
merged = append(merged, item)
}
// If a present DIMM has size=0 (BMC firmware glitch), infer size from
// another present DIMM with the same part number in the same batch.
partSize := make(map[string]int)
for _, m := range merged {
if m.Present && m.SizeMB > 0 && strings.TrimSpace(m.PartNumber) != "" {
partSize[strings.TrimSpace(m.PartNumber)] = m.SizeMB
}
}
for i := range merged {
if merged[i].Present && merged[i].SizeMB == 0 {
if pn := strings.TrimSpace(merged[i].PartNumber); pn != "" {
if sz, ok := partSize[pn]; ok {
merged[i].SizeMB = sz
}
}
}
}
hw.Memory = merged
}
@@ -163,7 +296,7 @@ type PSURESTInfo struct {
func parsePSUInfo(text string, hw *models.HardwareConfig) {
// Find RESTful PSU info section
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`)
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
match := re.FindStringSubmatch(text)
if match == nil {
return
@@ -793,7 +926,7 @@ func parseDiskBackplaneSensors(text string) []models.SensorReading {
}
func parsePSUSummarySensors(text string) []models.SensorReading {
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`)
re := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
match := re.FindStringSubmatch(text)
if match == nil {
return nil
@@ -941,7 +1074,7 @@ func extractComponentFirmware(text string, hw *models.HardwareConfig) {
// Skip extracting from component.log to avoid duplicates
// Extract PSU firmware from RESTful PSU info
rePSU := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful Network`)
rePSU := regexp.MustCompile(`RESTful PSU info:\s*(\{[\s\S]*?\})\s*RESTful (?:PCIE|Network)`)
if match := rePSU.FindStringSubmatch(text); match != nil {
jsonStr := strings.ReplaceAll(match[1], "\n", "")
var psuInfo PSURESTInfo

View File

@@ -0,0 +1,83 @@
package inspur
import (
"strings"
"testing"
"git.mchus.pro/mchus/logpile/internal/models"
)
const cpuMemComponentLog = `RESTful version info:
[]
RESTful CPU info:
{ "processors": [ { "proc_id": 0, "PROC_ID": "A6-06-06-00-FF-FB-EB-BF", "InstructionSet": "x86-64", "Manufacturer": "Intel(R) Corporation", "MaxSpeedMHz": 3100, "configStatus": 1, "proc_name": "Intel(R) Xeon(R) Gold 6330 CPU @ 2.00GHz", "proc_status": 1, "proc_speed": 2000, "proc_core_count": 28, "proc_used_core_count": 28, "proc_thread_count": 56, "proc_tdp": 205, "proc_l1cache_size": 80, "proc_l2cache_size": 1280, "proc_l3cache_size": 43008, "micro_code": "0x0D000410", "ppin": "47149E2253E81688", "status": "OK" }, { "proc_id": 1, "PROC_ID": "A6-06-06-00-FF-FB-EB-BF", "InstructionSet": "x86-64", "Manufacturer": "Intel(R) Corporation", "MaxSpeedMHz": 3100, "configStatus": 1, "proc_name": "Intel(R) Xeon(R) Gold 6330 CPU @ 2.00GHz", "proc_status": 1, "proc_speed": 2000, "proc_core_count": 28, "proc_thread_count": 56, "proc_tdp": 205, "proc_l1cache_size": 80, "proc_l2cache_size": 1280, "proc_l3cache_size": 43008, "micro_code": "0x0D000410", "ppin": "475AC1221D41F557", "status": "OK" } ] }
RESTful Memory info:
{ "mem_modules": [ { "mem_mod_id": 0, "config_status": 1, "mem_mod_slot": "CPU0_C0D0", "mem_mod_status": 1, "mem_mod_size": 32, "mem_mod_type": "DDR4", "mem_mod_technology": "Synchronous", "mem_mod_frequency": 3200, "mem_mod_current_frequency": 2933, "mem_mod_vendor": "Samsung", "mem_mod_part_num": "M393A4K40EB3-CWE", "mem_mod_serial_num": "S1440202433526FC12", "mem_mod_ranks": 2, "status": "OK" }, { "mem_mod_id": 16, "config_status": 1, "mem_mod_slot": "CPU1_C0D0", "mem_mod_status": 1, "mem_mod_size": 0, "mem_mod_type": "DDR4", "mem_mod_technology": "Synchronous", "mem_mod_frequency": 3200, "mem_mod_current_frequency": 2933, "mem_mod_vendor": "Samsung", "mem_mod_part_num": "M393A4K40EB3-CWE", "mem_mod_serial_num": "K0UX000401205D2037", "mem_mod_ranks": 2, "status": "OK" } ], "total_memory_count": 2, "present_memory_count": 2, "mem_total_mem_size": 32 }
RESTful HDD info:
[]
RESTful PSU info:
{ "power_supplies": [] }
RESTful Network Adapter info:
{ "sys_adapters": [] }
RESTful fan info:
{ "fans": [] }
RESTful diskbackplane info:
[]
BMC done
`
func TestParseCPUInfo_FromComponentLog(t *testing.T) {
hw := &models.HardwareConfig{}
ParseComponentLog([]byte(cpuMemComponentLog), hw)
if len(hw.CPUs) != 2 {
t.Fatalf("expected 2 CPUs, got %d", len(hw.CPUs))
}
if !strings.Contains(hw.CPUs[0].Model, "Gold 6330") {
t.Errorf("unexpected CPU model: %s", hw.CPUs[0].Model)
}
if hw.CPUs[0].Cores != 28 {
t.Errorf("expected 28 cores, got %d", hw.CPUs[0].Cores)
}
if hw.CPUs[0].PPIN != "47149E2253E81688" {
t.Errorf("unexpected PPIN: %s", hw.CPUs[0].PPIN)
}
if hw.CPUs[1].PPIN != "475AC1221D41F557" {
t.Errorf("unexpected CPU1 PPIN: %s", hw.CPUs[1].PPIN)
}
}
func TestParseMemoryInfo_PresentWithZeroSize(t *testing.T) {
hw := &models.HardwareConfig{}
ParseComponentLog([]byte(cpuMemComponentLog), hw)
presentCount := 0
for _, m := range hw.Memory {
if m.Present {
presentCount++
}
}
if presentCount != 2 {
t.Errorf("expected 2 present DIMMs, got %d", presentCount)
}
// Find CPU1_C0D0 (size=0 but serial present — size should be inferred from same part number)
found := false
for _, m := range hw.Memory {
if m.Slot == "CPU1_C0D0" {
found = true
if !m.Present {
t.Error("CPU1_C0D0 should be Present=true despite size=0")
}
if m.SerialNumber != "K0UX000401205D2037" {
t.Errorf("wrong serial: %s", m.SerialNumber)
}
if m.SizeMB != 32768 {
t.Errorf("expected SizeMB=32768 inferred from part number, got %d", m.SizeMB)
}
}
}
if !found {
t.Error("CPU1_C0D0 not found in memory list")
}
}

View File

@@ -16,7 +16,7 @@ import (
// parserVersion - version of this parser module
// IMPORTANT: Increment this version when making changes to parser logic!
const parserVersion = "1.8"
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),

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -933,3 +933,71 @@ code {
grid-template-columns: 1fr;
}
}
/* ── Parse / collection errors panel ───────────────────────────────────── */
.parse-errors-section {
overflow: hidden;
}
.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;
}

View File

@@ -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);
}

View File

@@ -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>