Event ingestion now uses the source syslog PRI and an explicit-offset timezone timeline instead of assuming host-local time. CPU PPIN is exported as the source-backed CPU serial, and an active NVMe fault SEL event promotes the matching drive's storage status. See ADL-056, ADL-057. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
294 lines
7.9 KiB
Go
294 lines
7.9 KiB
Go
package inspur
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"git.mchus.pro/mchus/logpile/internal/models"
|
|
"git.mchus.pro/mchus/logpile/internal/parser"
|
|
)
|
|
|
|
// ParseSELList parses selelist.csv file with SEL events
|
|
// Format: ID, Date (MM/DD/YYYY), Time (HH:MM:SS), Sensor, Event, Status
|
|
// Example: 1,04/18/2025,09:31:18,Event Logging Disabled SEL_Status,Log area reset/cleared,Asserted
|
|
func ParseSELList(content []byte) []models.Event {
|
|
return ParseSELListWithLocation(content, parser.DefaultArchiveLocation())
|
|
}
|
|
|
|
// ParseSELListWithLocation parses selelist.csv using provided source timezone
|
|
// for timestamps that don't contain an explicit offset.
|
|
func ParseSELListWithLocation(content []byte, location *time.Location) []models.Event {
|
|
return parseSELList(content, func(local time.Time) *time.Location { return location })
|
|
}
|
|
|
|
func parseSELListWithResolver(content []byte, resolver *inspurTimezoneResolver) []models.Event {
|
|
return parseSELList(content, resolver.locationFor)
|
|
}
|
|
|
|
func parseSELList(content []byte, locationFor func(time.Time) *time.Location) []models.Event {
|
|
var events []models.Event
|
|
|
|
text := string(content)
|
|
lines := strings.Split(text, "\n")
|
|
|
|
// Skip header line(s) if present
|
|
startIdx := 0
|
|
for i, line := range lines {
|
|
if strings.Contains(strings.ToLower(line), "sel elist") {
|
|
startIdx = i + 1
|
|
break
|
|
}
|
|
}
|
|
|
|
// Parse CSV data
|
|
for i := startIdx; i < len(lines); i++ {
|
|
line := strings.TrimSpace(lines[i])
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Parse CSV line
|
|
r := csv.NewReader(strings.NewReader(line))
|
|
records, err := r.Read()
|
|
if err != nil || len(records) < 6 {
|
|
continue
|
|
}
|
|
|
|
eventID := strings.TrimSpace(records[0])
|
|
dateStr := strings.TrimSpace(records[1])
|
|
timeStr := strings.TrimSpace(records[2])
|
|
sensorStr := strings.TrimSpace(records[3])
|
|
eventDesc := strings.TrimSpace(records[4])
|
|
status := strings.TrimSpace(records[5])
|
|
|
|
// Parse timestamp: MM/DD/YYYY HH:MM:SS
|
|
timestamp := parseSELTimestampWithResolver(dateStr, timeStr, locationFor)
|
|
|
|
// Extract sensor type and name
|
|
sensorType, sensorName := parseSensorInfo(sensorStr)
|
|
|
|
// Determine severity
|
|
severity := determineSELSeverity(sensorStr, eventDesc, status)
|
|
|
|
// Build full description
|
|
description := buildSELDescription(eventDesc, status)
|
|
|
|
events = append(events, models.Event{
|
|
ID: eventID,
|
|
Timestamp: timestamp,
|
|
Source: "SEL",
|
|
SensorType: sensorType,
|
|
SensorName: sensorName,
|
|
EventType: eventDesc,
|
|
Severity: severity,
|
|
Description: description,
|
|
RawData: line,
|
|
})
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
func parseSELTimestampWithResolver(dateStr, timeStr string, locationFor func(time.Time) *time.Location) time.Time {
|
|
timestampStr := dateStr + " " + timeStr
|
|
local, err := time.ParseInLocation("01/02/2006 15:04:05", timestampStr, time.UTC)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
location := parser.DefaultArchiveLocation()
|
|
if locationFor != nil {
|
|
if resolved := locationFor(local); resolved != nil {
|
|
location = resolved
|
|
}
|
|
}
|
|
return time.Date(local.Year(), local.Month(), local.Day(), local.Hour(), local.Minute(), local.Second(), 0, location)
|
|
}
|
|
|
|
// parseSELTimestamp parses MM/DD/YYYY and HH:MM:SS into time.Time
|
|
func parseSELTimestamp(dateStr, timeStr string, location *time.Location) time.Time {
|
|
return parseSELTimestampWithResolver(dateStr, timeStr, func(time.Time) *time.Location { return location })
|
|
}
|
|
|
|
// parseSensorInfo extracts sensor type and name from sensor string
|
|
// Example: "Event Logging Disabled SEL_Status" -> ("sel", "SEL_Status")
|
|
// Example: "Power Supply PSU0_Status" -> ("power_supply", "PSU0_Status")
|
|
func parseSensorInfo(sensorStr string) (sensorType, sensorName string) {
|
|
parts := strings.Fields(sensorStr)
|
|
if len(parts) == 0 {
|
|
return "unknown", sensorStr
|
|
}
|
|
|
|
// Last part is usually the sensor name
|
|
sensorName = parts[len(parts)-1]
|
|
|
|
// First parts form the sensor type
|
|
if len(parts) > 1 {
|
|
sensorType = strings.ToLower(strings.Join(parts[:len(parts)-1], "_"))
|
|
} else {
|
|
sensorType = "system"
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// determineSELSeverity determines event severity based on sensor and event description
|
|
func determineSELSeverity(sensorStr, eventDesc, status string) models.Severity {
|
|
lowerSensor := strings.ToLower(sensorStr)
|
|
lowerEvent := strings.ToLower(eventDesc)
|
|
lowerStatus := strings.ToLower(status)
|
|
if strings.Contains(lowerStatus, "deassert") {
|
|
return models.SeverityInfo
|
|
}
|
|
|
|
// Critical indicators
|
|
criticalKeywords := []string{
|
|
"critical", "failure", "fault", "error",
|
|
"ac lost", "predictive failure", "redundancy lost",
|
|
"going high", "going low", "transition to critical",
|
|
}
|
|
|
|
for _, keyword := range criticalKeywords {
|
|
if strings.Contains(lowerSensor, keyword) ||
|
|
strings.Contains(lowerEvent, keyword) ||
|
|
strings.Contains(lowerStatus, keyword) {
|
|
return models.SeverityCritical
|
|
}
|
|
}
|
|
|
|
// Warning indicators
|
|
warningKeywords := []string{
|
|
"warning", "disabled", "non-recoverable",
|
|
"device removed", "device absent",
|
|
}
|
|
|
|
for _, keyword := range warningKeywords {
|
|
if strings.Contains(lowerSensor, keyword) ||
|
|
strings.Contains(lowerEvent, keyword) ||
|
|
strings.Contains(lowerStatus, keyword) {
|
|
return models.SeverityWarning
|
|
}
|
|
}
|
|
|
|
// Info indicators (normal operations)
|
|
infoKeywords := []string{
|
|
"presence detected", "device present", "asserted",
|
|
"initiated by", "state asserted", "s0/g0: working",
|
|
"power button pressed",
|
|
}
|
|
|
|
for _, keyword := range infoKeywords {
|
|
if strings.Contains(lowerEvent, keyword) ||
|
|
strings.Contains(lowerStatus, keyword) {
|
|
return models.SeverityInfo
|
|
}
|
|
}
|
|
|
|
// Default to info
|
|
return models.SeverityInfo
|
|
}
|
|
|
|
// dedupSELEvents removes exact SEL repeats and equivalent IDL/SEL copies at
|
|
// the same normalized instant. IDL is preferred because it carries an
|
|
// explicit timezone offset and usually a richer component-prefixed message.
|
|
func dedupSELEvents(events []models.Event) []models.Event {
|
|
if len(events) == 0 {
|
|
return events
|
|
}
|
|
out := make([]models.Event, 0, len(events))
|
|
bySecond := make(map[int64][]int)
|
|
for _, e := range events {
|
|
if e.Timestamp.IsZero() {
|
|
out = append(out, e)
|
|
continue
|
|
}
|
|
|
|
second := e.Timestamp.Unix()
|
|
duplicate := false
|
|
for _, idx := range bySecond[second] {
|
|
previous := out[idx]
|
|
if !equivalentInspurEvent(previous, e) {
|
|
continue
|
|
}
|
|
if previous.Source == "SEL" && e.Source != "SEL" {
|
|
out[idx] = e
|
|
}
|
|
duplicate = true
|
|
break
|
|
}
|
|
if duplicate {
|
|
continue
|
|
}
|
|
bySecond[second] = append(bySecond[second], len(out))
|
|
out = append(out, e)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func equivalentInspurEvent(a, b models.Event) bool {
|
|
if a.Source != "SEL" && b.Source != "SEL" {
|
|
return false
|
|
}
|
|
if a.Severity != b.Severity || eventDirection(a) != eventDirection(b) {
|
|
return false
|
|
}
|
|
left := normalizeEventDescription(a.Description)
|
|
right := normalizeEventDescription(b.Description)
|
|
if left == "" || right == "" {
|
|
return false
|
|
}
|
|
if left == right {
|
|
return true
|
|
}
|
|
shorter, longer := left, right
|
|
if len(shorter) > len(longer) {
|
|
shorter, longer = longer, shorter
|
|
}
|
|
return len(shorter) >= 12 && strings.Contains(longer, shorter)
|
|
}
|
|
|
|
func eventDirection(e models.Event) string {
|
|
value := strings.ToLower(e.EventType + " " + e.RawData)
|
|
if strings.Contains(value, "deassert") {
|
|
return "deassert"
|
|
}
|
|
if strings.Contains(value, "assert") {
|
|
return "assert"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeEventDescription(value string) string {
|
|
value = strings.ToLower(value)
|
|
value = strings.Map(func(r rune) rune {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
return r
|
|
}
|
|
return ' '
|
|
}, value)
|
|
return strings.Join(strings.Fields(value), " ")
|
|
}
|
|
|
|
func sortInspurEvents(events []models.Event) {
|
|
sort.SliceStable(events, func(i, j int) bool {
|
|
left, right := events[i].Timestamp, events[j].Timestamp
|
|
if left.IsZero() {
|
|
return false
|
|
}
|
|
if right.IsZero() {
|
|
return true
|
|
}
|
|
return left.Before(right)
|
|
})
|
|
}
|
|
|
|
// buildSELDescription builds human-readable description
|
|
func buildSELDescription(eventDesc, status string) string {
|
|
if status == "Asserted" || status == "Deasserted" {
|
|
return eventDesc
|
|
}
|
|
return eventDesc + " (" + status + ")"
|
|
}
|