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

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