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:
2
bible
2
bible
Submodule bible updated: d2600f1279...1977730d93
Submodule internal/chart updated: 8105c7ec08...8c80591531
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
81
internal/parser/vendors/hpe_ilo_ahs/parser.go
vendored
81
internal/parser/vendors/hpe_ilo_ahs/parser.go
vendored
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
2787
internal/parser/vendors/pciids/pci.ids
vendored
2787
internal/parser/vendors/pciids/pci.ids
vendored
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
2
third_party/pciids
vendored
2
third_party/pciids
vendored
Submodule third_party/pciids updated: 9186887530...a18f209e39
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user