feat(hpe-ilo): parse AHS files, fix event logs export, add logs CSV export

- HPE iLO AHS parser: handle truncated last entry gracefully, recognize
  Alletra product line, expand event type/severity inference, trim iLO
  frame separators from event messages
- Fix event_logs always 0 in Reanimator export: normalizeEventLogSource
  now maps "HPE iLO" → "bmc"
- Fix chart JS not loading in LOGPile: rewriteChartStaticPaths now also
  rewrites src="/static/view.js" → /chart/static/view.js
- Add "Logs Export" button (CSV, semicolon-delimited, UTF-8 BOM) and
  remove PDF button
- Fix collector test broken by pciids rename of Intel VMD device
- Update submodules: chart v2.7, pciids, bible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-06-19 15:11:46 +03:00
parent cd864c3d6c
commit 3e3c48bc08
14 changed files with 2190 additions and 777 deletions

2
bible

Submodule bible updated: d2600f1279...1977730d93

View File

@@ -244,7 +244,7 @@ func isReplayStorageServiceEndpoint(doc map[string]interface{}, dev models.PCIeD
if strings.Contains(name, "pcie switch management endpoint") { if strings.Contains(name, "pcie switch management endpoint") {
return true return true
} }
if strings.Contains(name, "volume management device nvme raid controller") { if strings.Contains(name, "volume management device") {
return true return true
} }
return false return false

View File

@@ -174,3 +174,42 @@ func firstNonEmptyString(values ...string) string {
} }
return "" return ""
} }
// ExportLogsCSV writes all recognized events as a semicolon-delimited UTF-8 CSV readable in Excel.
func ExportLogsCSV(w io.Writer, result *models.AnalysisResult) error {
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return err
}
writer := csv.NewWriter(w)
writer.Comma = ';'
defer writer.Flush()
if err := writer.Write([]string{"timestamp", "source", "severity", "sensor_type", "sensor_name", "event_type", "id", "description", "raw_data"}); err != nil {
return err
}
if result == nil {
return nil
}
for _, e := range result.Events {
ts := ""
if !e.Timestamp.IsZero() {
ts = e.Timestamp.UTC().Format("2006-01-02T15:04:05Z")
}
if err := writer.Write([]string{
ts,
e.Source,
string(e.Severity),
e.SensorType,
e.SensorName,
e.EventType,
e.ID,
e.Description,
e.RawData,
}); err != nil {
return err
}
}
return nil
}

View File

@@ -1235,7 +1235,7 @@ func normalizeEventLogSource(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) { switch strings.ToLower(strings.TrimSpace(source)) {
case "redfish": case "redfish":
return "redfish" return "redfish"
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller": case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller", "hpe ilo", "ilo":
return "bmc" return "bmc"
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host": case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "host":
return "host" return "host"

View File

@@ -214,8 +214,10 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
name := strings.TrimRight(string(data[offset+20:offset+52]), "\x00") name := strings.TrimRight(string(data[offset+20:offset+52]), "\x00")
start := offset + ahsHeaderSize start := offset + ahsHeaderSize
end := start + size end := start + size
truncated := false
if size < 0 || end > len(data) { if size < 0 || end > len(data) {
return nil, fmt.Errorf("invalid payload size for %q", name) end = len(data)
truncated = true
} }
payload := append([]byte(nil), data[start:end]...) payload := append([]byte(nil), data[start:end]...)
@@ -235,6 +237,9 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
Content: content, Content: content,
Compressed: compressed, Compressed: compressed,
}) })
if truncated {
break
}
offset = end offset = end
} }
@@ -992,7 +997,7 @@ func parseEvents(tokens []string) []models.Event {
break break
} }
if looksLikeEventMessage(tokens[j]) { if looksLikeEventMessage(tokens[j]) {
message = tokens[j] message = trimEventJunk(tokens[j])
break break
} }
} }
@@ -1173,7 +1178,7 @@ func looksLikeServerModel(v string) bool {
return false return false
} }
lower := strings.ToLower(v) lower := strings.ToLower(v)
return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline") return strings.Contains(lower, "proliant") || strings.Contains(lower, "apollo") || strings.Contains(lower, "synergy") || strings.Contains(lower, "edgeline") || strings.Contains(lower, "alletra")
} }
func looksLikeCPUVendor(v string) bool { func looksLikeCPUVendor(v string) bool {
@@ -1464,7 +1469,19 @@ func fabricIDFromPath(path string) string {
func inferSeverity(message string) models.Severity { func inferSeverity(message string) models.Severity {
lower := strings.ToLower(message) lower := strings.ToLower(message)
switch { switch {
case strings.Contains(lower, " down"), strings.Contains(lower, "warning"), strings.Contains(lower, "fail"), strings.Contains(lower, "error"): case strings.Contains(lower, "critical"):
return models.SeverityCritical
case strings.Contains(lower, " down"),
strings.Contains(lower, "warning"),
strings.Contains(lower, "fail"),
strings.Contains(lower, "error"),
strings.Contains(lower, "server reset"),
strings.Contains(lower, "server power"),
strings.Contains(lower, "power restored"),
strings.Contains(lower, "ilo reset"),
strings.Contains(lower, "ilo restarted"),
strings.Contains(lower, "pcr measurements"),
strings.Contains(lower, "hardware data received from uefi"):
return models.SeverityWarning return models.SeverityWarning
default: default:
return models.SeverityInfo return models.SeverityInfo
@@ -1478,21 +1495,73 @@ func inferEventType(message string) string {
return "Login" return "Login"
case strings.Contains(lower, "logout"): case strings.Contains(lower, "logout"):
return "Logout" return "Logout"
case strings.Contains(lower, "network"): case strings.Contains(lower, "network"), strings.Contains(lower, "link"):
return "Network" return "Network"
case strings.Contains(lower, "license"): case strings.Contains(lower, "license"):
return "License" return "License"
case strings.Contains(lower, "backup operation"), strings.Contains(lower, "remote console"):
return "Management"
case strings.Contains(lower, "server power"), strings.Contains(lower, "power restored"), strings.Contains(lower, "power off"), strings.Contains(lower, "server reset"), strings.Contains(lower, "ilo reset"), strings.Contains(lower, "ilo restarted"):
return "Power"
case strings.Contains(lower, "storage"), strings.Contains(lower, "volume"), strings.Contains(lower, "drive"), strings.Contains(lower, "firmware"):
return "Hardware"
case strings.Contains(lower, "certificate"), strings.Contains(lower, "pcr measurements"), strings.Contains(lower, "hardware data"), strings.Contains(lower, "security"):
return "Security"
default: default:
return "Event" return "Event"
} }
} }
// trimEventJunk strips trailing single-byte frame markers written by iLO into
// binary .zbb log records. These markers are printable ASCII (letters, *, +, ')
// that appear immediately after the sentence-ending punctuation or a digit.
func trimEventJunk(s string) string {
if len(s) < 3 {
return s
}
last := s[len(s)-1]
prev := s[len(s)-2]
isJunk := (last >= 'A' && last <= 'Z') || (last >= 'a' && last <= 'z') ||
last == '*' || last == '+' || last == '\''
prevIsBoundary := prev == '.' || prev == '!' || prev == '"' || prev == ')' ||
(prev >= '0' && prev <= '9')
if isJunk && prevIsBoundary {
return s[:len(s)-1]
}
return s
}
func looksLikeEventMessage(v string) bool { func looksLikeEventMessage(v string) bool {
if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") { if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") {
return false return false
} }
// JSON document accidentally extracted — skip
if strings.HasPrefix(v, "{") || strings.HasPrefix(v, "[") {
return false
}
// Numbered list items (e.g. "2.Perform the iLO reset.") are instructions, not events
if len(v) > 2 && v[0] >= '1' && v[0] <= '9' && v[1] == '.' {
return false
}
lower := strings.ToLower(v) lower := strings.ToLower(v)
return strings.Contains(lower, "login") || strings.Contains(lower, "logout") || strings.Contains(lower, "link") || strings.Contains(lower, "license") || strings.Contains(lower, "security state") return strings.Contains(lower, "login") ||
strings.Contains(lower, "logout") ||
strings.Contains(lower, "link") ||
strings.Contains(lower, "license") ||
strings.Contains(lower, "security state") ||
strings.Contains(lower, "server power") ||
strings.Contains(lower, "server reset") ||
strings.Contains(lower, "power restored") ||
strings.Contains(lower, "power off") ||
strings.Contains(lower, "storage") ||
strings.Contains(lower, "firmware") ||
strings.Contains(lower, "certificate") ||
strings.Contains(lower, "backup operation") ||
strings.Contains(lower, "pcr measurements") ||
strings.Contains(lower, "hardware data") ||
strings.Contains(lower, "ilo reset") ||
strings.Contains(lower, "ilo restarted") ||
strings.Contains(lower, "remote console")
} }
func sanitizeModel(v string) string { func sanitizeModel(v string) string {

View File

@@ -153,6 +153,29 @@ func TestParseAHSInventory(t *testing.T) {
} }
} }
func TestParseAHSTruncatedEntry(t *testing.T) {
p := &Parser{}
// Build archive where the last entry's declared size exceeds available data.
archive := makeAHSArchive(t, []ahsTestEntry{
{Name: "CUST_INFO.DAT", Payload: []byte("HPE\x00ProLiant DL380 Gen11\x00CZ2D1X0GS3\x00P52560-421")},
{Name: "0000150-2025-11-27.zbb", Payload: []byte("some content")},
})
// Corrupt the size field of the second entry to exceed len(archive).
secondHeaderOffset := ahsHeaderSize + len([]byte("HPE\x00ProLiant DL380 Gen11\x00CZ2D1X0GS3\x00P52560-421"))
binary.LittleEndian.PutUint32(archive[secondHeaderOffset+8:secondHeaderOffset+12], 0xFFFFFFFF)
result, err := p.Parse([]parser.ExtractedFile{{
Path: "HPE_CZ2D1X0GS3_20251127.ahs",
Content: archive,
}})
if err != nil {
t.Fatalf("expected graceful handling of truncated entry, got error: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
}
func TestParseExampleAHS(t *testing.T) { func TestParseExampleAHS(t *testing.T) {
path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs") path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs")
content, err := os.ReadFile(path) content, err := os.ReadFile(path)

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,10 @@ func TestHandleChartCurrent_RendersCurrentReanimatorSnapshot(t *testing.T) {
t.Fatalf("expected chart title in body, got %q", body) t.Fatalf("expected chart title in body, got %q", body)
} }
if !strings.Contains(body, `/chart/static/view.css`) { if !strings.Contains(body, `/chart/static/view.css`) {
t.Fatalf("expected rewritten chart static path, got %q", body) t.Fatalf("expected rewritten chart css path, got %q", body)
}
if !strings.Contains(body, `/chart/static/view.js`) {
t.Fatalf("expected rewritten chart js path, got %q", body)
} }
if !strings.Contains(body, "Snapshot Metadata") { if !strings.Contains(body, "Snapshot Metadata") {
t.Fatalf("expected rendered chart output, got %q", body) t.Fatalf("expected rendered chart output, got %q", body)

View File

@@ -137,7 +137,9 @@ func chartTitle(result *models.AnalysisResult) string {
} }
func rewriteChartStaticPaths(html []byte) []byte { func rewriteChartStaticPaths(html []byte) []byte {
return bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`)) html = bytes.ReplaceAll(html, []byte(`href="/static/view.css"`), []byte(`href="/chart/static/view.css"`))
html = bytes.ReplaceAll(html, []byte(`src="/static/view.js"`), []byte(`src="/chart/static/view.js"`))
return html
} }
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
@@ -1232,6 +1234,13 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
exp.ExportCSV(w) exp.ExportCSV(w)
} }
func (s *Server) handleExportLogsCSV(w http.ResponseWriter, r *http.Request) {
result := s.GetResult()
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", exportFilename(result, "logs.csv")))
exporter.ExportLogsCSV(w, result)
}
func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) { func (s *Server) handleExportJSON(w http.ResponseWriter, r *http.Request) {
result := s.GetResult() result := s.GetResult()

View File

@@ -91,6 +91,7 @@ func (s *Server) setupRoutes() {
s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV) s.mux.HandleFunc("GET /api/export/csv", s.handleExportCSV)
s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON) s.mux.HandleFunc("GET /api/export/json", s.handleExportJSON)
s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator) s.mux.HandleFunc("GET /api/export/reanimator", s.handleExportReanimator)
s.mux.HandleFunc("GET /api/export/logs-csv", s.handleExportLogsCSV)
s.mux.HandleFunc("POST /api/convert", s.handleConvertReanimatorBatch) s.mux.HandleFunc("POST /api/convert", s.handleConvertReanimatorBatch)
s.mux.HandleFunc("GET /api/convert/{id}", s.handleConvertStatus) s.mux.HandleFunc("GET /api/convert/{id}", s.handleConvertStatus)
s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload) s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload)

View File

@@ -1410,7 +1410,7 @@ async function loadData(vendor, filename) {
document.getElementById('clear-btn').classList.remove('hidden'); document.getElementById('clear-btn').classList.remove('hidden');
document.getElementById('header-raw-btn').classList.remove('hidden'); document.getElementById('header-raw-btn').classList.remove('hidden');
document.getElementById('header-reanimator-btn').classList.remove('hidden'); document.getElementById('header-reanimator-btn').classList.remove('hidden');
document.getElementById('header-pdf-btn').classList.remove('hidden'); document.getElementById('header-logs-csv-btn').classList.remove('hidden');
document.getElementById('header-log-meta').classList.remove('hidden'); document.getElementById('header-log-meta').classList.remove('hidden');
loadAuditViewer(); loadAuditViewer();
@@ -1510,10 +1510,6 @@ function exportData(format) {
window.location.href = `/api/export/${format}`; window.location.href = `/api/export/${format}`;
} }
function printReport() {
window.open('/chart/current?print=true', '_blank');
}
// Clear data // Clear data
async function clearData() { async function clearData() {
try { try {
@@ -1523,7 +1519,7 @@ async function clearData() {
document.getElementById('clear-btn').classList.add('hidden'); document.getElementById('clear-btn').classList.add('hidden');
document.getElementById('header-raw-btn').classList.add('hidden'); document.getElementById('header-raw-btn').classList.add('hidden');
document.getElementById('header-reanimator-btn').classList.add('hidden'); document.getElementById('header-reanimator-btn').classList.add('hidden');
document.getElementById('header-pdf-btn').classList.add('hidden'); document.getElementById('header-logs-csv-btn').classList.add('hidden');
document.getElementById('header-log-meta').classList.add('hidden'); document.getElementById('header-log-meta').classList.add('hidden');
document.getElementById('upload-status').textContent = ''; document.getElementById('upload-status').textContent = '';
const frame = document.getElementById('audit-viewer-frame'); const frame = document.getElementById('audit-viewer-frame');

View File

@@ -18,7 +18,7 @@
<button id="clear-btn" class="header-action hidden" onclick="clearData()">Clear Data</button> <button id="clear-btn" class="header-action hidden" onclick="clearData()">Clear Data</button>
<button id="header-raw-btn" class="header-action hidden" onclick="exportData('json')">Raw Data</button> <button id="header-raw-btn" class="header-action hidden" onclick="exportData('json')">Raw Data</button>
<button id="header-reanimator-btn" class="header-action hidden" onclick="exportData('reanimator')">Reanimator</button> <button id="header-reanimator-btn" class="header-action hidden" onclick="exportData('reanimator')">Reanimator</button>
<button id="header-pdf-btn" class="header-action hidden" onclick="printReport()">PDF</button> <button id="header-logs-csv-btn" class="header-action hidden" onclick="exportData('logs-csv')">Logs Export</button>
<button id="restart-btn" class="header-action" onclick="restartApp()">Restart</button> <button id="restart-btn" class="header-action" onclick="restartApp()">Restart</button>
<button id="exit-btn" class="header-action" onclick="exitApp()">Exit</button> <button id="exit-btn" class="header-action" onclick="exitApp()">Exit</button>
</div> </div>