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

View File

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

View File

@@ -174,3 +174,42 @@ func firstNonEmptyString(values ...string) string {
}
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)) {
case "redfish":
return "redfish"
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller":
case "sel", "bmc", "ipmi", "idrac", "lifecycle controller", "lifecyclecontroller", "hpe ilo", "ilo":
return "bmc"
case "system", "syslog", "smart", "zfs", "file", "gpu", "dmi", "nvidia driver", "gpu field diagnostics", "fan", "memory", "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")
start := offset + ahsHeaderSize
end := start + size
truncated := false
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]...)
@@ -235,6 +237,9 @@ func parseAHSContainer(data []byte) ([]ahsEntry, error) {
Content: content,
Compressed: compressed,
})
if truncated {
break
}
offset = end
}
@@ -992,7 +997,7 @@ func parseEvents(tokens []string) []models.Event {
break
}
if looksLikeEventMessage(tokens[j]) {
message = tokens[j]
message = trimEventJunk(tokens[j])
break
}
}
@@ -1173,7 +1178,7 @@ func looksLikeServerModel(v string) bool {
return false
}
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 {
@@ -1464,7 +1469,19 @@ func fabricIDFromPath(path string) string {
func inferSeverity(message string) models.Severity {
lower := strings.ToLower(message)
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
default:
return models.SeverityInfo
@@ -1478,21 +1495,73 @@ func inferEventType(message string) string {
return "Login"
case strings.Contains(lower, "logout"):
return "Logout"
case strings.Contains(lower, "network"):
case strings.Contains(lower, "network"), strings.Contains(lower, "link"):
return "Network"
case strings.Contains(lower, "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:
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 {
if len(v) < 8 || strings.HasPrefix(v, "src/") || strings.HasPrefix(v, "PciRoot(") {
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)
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 {

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) {
path := filepath.Join("..", "..", "..", "..", "example", "HPE_CZ2D1X0GS3_20260330.ahs")
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)
}
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") {
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 {
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) {
@@ -1232,6 +1234,13 @@ func (s *Server) handleExportCSV(w http.ResponseWriter, r *http.Request) {
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) {
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/json", s.handleExportJSON)
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("GET /api/convert/{id}", s.handleConvertStatus)
s.mux.HandleFunc("GET /api/convert/{id}/download", s.handleConvertDownload)