package easy_bee import ( "encoding/json" "fmt" "strings" "time" "git.mchus.pro/mchus/logpile/internal/models" "git.mchus.pro/mchus/logpile/internal/parser" ) const parserVersion = "1.0" func init() { parser.Register(&Parser{}) } // Parser imports support bundles produced by reanimator-easy-bee. // These archives embed a ready-to-use hardware snapshot in export/bee-audit.json. type Parser struct{} func (p *Parser) Name() string { return "Reanimator Easy Bee Parser" } func (p *Parser) Vendor() string { return "easy_bee" } func (p *Parser) Version() string { return parserVersion } func (p *Parser) Detect(files []parser.ExtractedFile) int { confidence := 0 hasManifest := false hasBeeAudit := false hasRuntimeHealth := false hasTechdump := false hasBundlePrefix := false for _, f := range files { path := strings.ToLower(strings.TrimSpace(f.Path)) content := strings.ToLower(string(f.Content)) if !hasBundlePrefix && strings.Contains(path, "bee-support-") { hasBundlePrefix = true confidence += 5 } if (strings.HasSuffix(path, "/manifest.txt") || path == "manifest.txt") && strings.Contains(content, "bee_version=") { hasManifest = true confidence += 35 if strings.Contains(content, "export_dir=") { confidence += 10 } } if strings.HasSuffix(path, "/export/bee-audit.json") || path == "bee-audit.json" { hasBeeAudit = true confidence += 55 } if hasBundlePrefix && (strings.HasSuffix(path, "/export/runtime-health.json") || path == "runtime-health.json") { hasRuntimeHealth = true confidence += 10 } if hasBundlePrefix && !hasTechdump && strings.Contains(path, "/export/techdump/") { hasTechdump = true confidence += 10 } } if hasManifest && hasBeeAudit { return 100 } if hasBeeAudit && (hasRuntimeHealth || hasTechdump) { confidence += 10 } if confidence > 100 { return 100 } return confidence } func (p *Parser) Parse(files []parser.ExtractedFile) (*models.AnalysisResult, error) { snapshotFile := findSnapshotFile(files) if snapshotFile == nil { return nil, fmt.Errorf("easy-bee snapshot not found") } var snapshot beeSnapshot if err := json.Unmarshal(snapshotFile.Content, &snapshot); err != nil { return nil, fmt.Errorf("decode %s: %w", snapshotFile.Path, err) } manifest := parseManifest(files) result := &models.AnalysisResult{ SourceType: strings.TrimSpace(snapshot.SourceType), Protocol: strings.TrimSpace(snapshot.Protocol), TargetHost: firstNonEmpty(snapshot.TargetHost, manifest.Host), SourceTimezone: strings.TrimSpace(snapshot.SourceTimezone), CollectedAt: chooseCollectedAt(snapshot, manifest), InventoryLastModifiedAt: snapshot.InventoryLastModifiedAt, RawPayloads: snapshot.RawPayloads, Events: make([]models.Event, 0), FRU: append([]models.FRUInfo(nil), snapshot.FRU...), Sensors: make([]models.SensorReading, 0), Hardware: &models.HardwareConfig{ Firmware: append([]models.FirmwareInfo(nil), snapshot.Hardware.Firmware...), BoardInfo: snapshot.Hardware.Board, Devices: append([]models.HardwareDevice(nil), snapshot.Hardware.Devices...), CPUs: append([]models.CPU(nil), snapshot.Hardware.CPUs...), Memory: append([]models.MemoryDIMM(nil), snapshot.Hardware.Memory...), Storage: append([]models.Storage(nil), snapshot.Hardware.Storage...), Volumes: append([]models.StorageVolume(nil), snapshot.Hardware.Volumes...), PCIeDevices: normalizePCIeDevices(snapshot.Hardware.PCIeDevices), GPUs: append([]models.GPU(nil), snapshot.Hardware.GPUs...), NetworkCards: append([]models.NIC(nil), snapshot.Hardware.NetworkCards...), NetworkAdapters: normalizeNetworkAdapters(snapshot.Hardware.NetworkAdapters), PowerSupply: append([]models.PSU(nil), snapshot.Hardware.PowerSupply...), }, } result.Events = append(result.Events, snapshot.Events...) result.Events = append(result.Events, convertRuntimeToEvents(snapshot.Runtime, result.CollectedAt)...) result.Events = append(result.Events, convertEventLogs(snapshot.Hardware.EventLogs)...) result.Sensors = append(result.Sensors, snapshot.Sensors...) result.Sensors = append(result.Sensors, flattenSensorGroups(snapshot.Hardware.Sensors)...) if len(result.FRU) == 0 { if boardFRU, ok := buildBoardFRU(snapshot.Hardware.Board); ok { result.FRU = append(result.FRU, boardFRU) } } if result.Hardware == nil || (result.Hardware.BoardInfo.SerialNumber == "" && len(result.Hardware.CPUs) == 0 && len(result.Hardware.Memory) == 0 && len(result.Hardware.Storage) == 0 && len(result.Hardware.PCIeDevices) == 0 && len(result.Hardware.Devices) == 0) { return nil, fmt.Errorf("unsupported easy-bee snapshot format") } return result, nil } type beeSnapshot struct { SourceType string `json:"source_type,omitempty"` Protocol string `json:"protocol,omitempty"` TargetHost string `json:"target_host,omitempty"` SourceTimezone string `json:"source_timezone,omitempty"` CollectedAt time.Time `json:"collected_at,omitempty"` InventoryLastModifiedAt time.Time `json:"inventory_last_modified_at,omitempty"` RawPayloads map[string]any `json:"raw_payloads,omitempty"` Events []models.Event `json:"events,omitempty"` FRU []models.FRUInfo `json:"fru,omitempty"` Sensors []models.SensorReading `json:"sensors,omitempty"` Hardware beeHardware `json:"hardware"` Runtime beeRuntime `json:"runtime,omitempty"` } type beeHardware struct { Board models.BoardInfo `json:"board"` Firmware []models.FirmwareInfo `json:"firmware,omitempty"` Devices []models.HardwareDevice `json:"devices,omitempty"` CPUs []models.CPU `json:"cpus,omitempty"` Memory []models.MemoryDIMM `json:"memory,omitempty"` Storage []models.Storage `json:"storage,omitempty"` Volumes []models.StorageVolume `json:"volumes,omitempty"` PCIeDevices []models.PCIeDevice `json:"pcie_devices,omitempty"` GPUs []models.GPU `json:"gpus,omitempty"` NetworkCards []models.NIC `json:"network_cards,omitempty"` NetworkAdapters []models.NetworkAdapter `json:"network_adapters,omitempty"` PowerSupply []models.PSU `json:"power_supplies,omitempty"` Sensors beeSensorGroups `json:"sensors,omitempty"` EventLogs []beeEventLog `json:"event_logs,omitempty"` } type beeSensorGroups struct { Fans []beeFanSensor `json:"fans,omitempty"` Power []beePowerSensor `json:"power,omitempty"` Temperatures []beeTemperatureSensor `json:"temperatures,omitempty"` Other []beeOtherSensor `json:"other,omitempty"` } type beeFanSensor struct { Name string `json:"name"` Location string `json:"location,omitempty"` RPM int `json:"rpm,omitempty"` Status string `json:"status,omitempty"` } type beePowerSensor struct { Name string `json:"name"` Location string `json:"location,omitempty"` VoltageV float64 `json:"voltage_v,omitempty"` CurrentA float64 `json:"current_a,omitempty"` PowerW float64 `json:"power_w,omitempty"` Status string `json:"status,omitempty"` } type beeTemperatureSensor struct { Name string `json:"name"` Location string `json:"location,omitempty"` Celsius float64 `json:"celsius,omitempty"` ThresholdWarningCelsius float64 `json:"threshold_warning_celsius,omitempty"` ThresholdCriticalCelsius float64 `json:"threshold_critical_celsius,omitempty"` Status string `json:"status,omitempty"` } type beeOtherSensor struct { Name string `json:"name"` Location string `json:"location,omitempty"` Value float64 `json:"value,omitempty"` Unit string `json:"unit,omitempty"` Status string `json:"status,omitempty"` } type beeRuntime struct { Status string `json:"status,omitempty"` CheckedAt time.Time `json:"checked_at,omitempty"` NetworkStatus string `json:"network_status,omitempty"` Issues []beeRuntimeIssue `json:"issues,omitempty"` Services []beeRuntimeStatus `json:"services,omitempty"` Interfaces []beeInterface `json:"interfaces,omitempty"` } type beeRuntimeIssue struct { Code string `json:"code,omitempty"` Severity string `json:"severity,omitempty"` Description string `json:"description,omitempty"` } type beeRuntimeStatus struct { Name string `json:"name,omitempty"` Status string `json:"status,omitempty"` } type beeInterface struct { Name string `json:"name,omitempty"` State string `json:"state,omitempty"` IPv4 []string `json:"ipv4,omitempty"` Outcome string `json:"outcome,omitempty"` } type beeEventLog struct { Source string `json:"source,omitempty"` EventTime string `json:"event_time,omitempty"` Severity string `json:"severity,omitempty"` MessageID string `json:"message_id,omitempty"` Message string `json:"message,omitempty"` RawPayload map[string]any `json:"raw_payload,omitempty"` } type manifestMetadata struct { Host string GeneratedAtUTC time.Time } func findSnapshotFile(files []parser.ExtractedFile) *parser.ExtractedFile { for i := range files { path := strings.ToLower(strings.TrimSpace(files[i].Path)) if strings.HasSuffix(path, "/export/bee-audit.json") || path == "bee-audit.json" { return &files[i] } } for i := range files { path := strings.ToLower(strings.TrimSpace(files[i].Path)) if strings.HasSuffix(path, ".json") && strings.Contains(path, "reanimator") { return &files[i] } } return nil } func parseManifest(files []parser.ExtractedFile) manifestMetadata { var meta manifestMetadata for _, f := range files { path := strings.ToLower(strings.TrimSpace(f.Path)) if !(strings.HasSuffix(path, "/manifest.txt") || path == "manifest.txt") { continue } lines := strings.Split(string(f.Content), "\n") for _, line := range lines { key, value, ok := strings.Cut(strings.TrimSpace(line), "=") if !ok { continue } switch strings.TrimSpace(key) { case "host": meta.Host = strings.TrimSpace(value) case "generated_at_utc": if ts, err := time.Parse(time.RFC3339, strings.TrimSpace(value)); err == nil { meta.GeneratedAtUTC = ts.UTC() } } } break } return meta } func chooseCollectedAt(snapshot beeSnapshot, manifest manifestMetadata) time.Time { switch { case !snapshot.CollectedAt.IsZero(): return snapshot.CollectedAt.UTC() case !snapshot.Runtime.CheckedAt.IsZero(): return snapshot.Runtime.CheckedAt.UTC() case !manifest.GeneratedAtUTC.IsZero(): return manifest.GeneratedAtUTC.UTC() default: return time.Time{} } } func convertRuntimeToEvents(runtime beeRuntime, fallback time.Time) []models.Event { events := make([]models.Event, 0) ts := runtime.CheckedAt if ts.IsZero() { ts = fallback } if status := strings.TrimSpace(runtime.Status); status != "" { desc := "Bee runtime status: " + status if networkStatus := strings.TrimSpace(runtime.NetworkStatus); networkStatus != "" { desc += " (network: " + networkStatus + ")" } events = append(events, models.Event{ Timestamp: ts, Source: "Bee Runtime", EventType: "Runtime Status", Severity: mapSeverity(status), Description: desc, }) } for _, issue := range runtime.Issues { desc := strings.TrimSpace(issue.Description) if desc == "" { desc = "Bee runtime issue" } events = append(events, models.Event{ Timestamp: ts, Source: "Bee Runtime", EventType: "Runtime Issue", Severity: mapSeverity(issue.Severity), Description: desc, RawData: strings.TrimSpace(issue.Code), }) } for _, svc := range runtime.Services { status := strings.TrimSpace(svc.Status) if status == "" || strings.EqualFold(status, "active") { continue } events = append(events, models.Event{ Timestamp: ts, Source: "systemd", EventType: "Service Status", Severity: mapSeverity(status), Description: fmt.Sprintf("%s is %s", strings.TrimSpace(svc.Name), status), }) } for _, iface := range runtime.Interfaces { state := strings.TrimSpace(iface.State) outcome := strings.TrimSpace(iface.Outcome) if state == "" && outcome == "" { continue } if strings.EqualFold(state, "up") && strings.EqualFold(outcome, "lease_acquired") { continue } desc := fmt.Sprintf("interface %s state=%s outcome=%s", strings.TrimSpace(iface.Name), state, outcome) events = append(events, models.Event{ Timestamp: ts, Source: "network", EventType: "Interface Status", Severity: models.SeverityWarning, Description: strings.TrimSpace(desc), }) } return events } func convertEventLogs(items []beeEventLog) []models.Event { events := make([]models.Event, 0, len(items)) for _, item := range items { message := strings.TrimSpace(item.Message) if message == "" { continue } ts := parseEventTime(item.EventTime) rawData := strings.TrimSpace(item.MessageID) events = append(events, models.Event{ Timestamp: ts, Source: firstNonEmpty(strings.TrimSpace(item.Source), "Reanimator"), EventType: "Event Log", Severity: mapSeverity(item.Severity), Description: message, RawData: rawData, }) } return events } func parseEventTime(raw string) time.Time { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{} } layouts := []string{time.RFC3339Nano, time.RFC3339} for _, layout := range layouts { if ts, err := time.Parse(layout, raw); err == nil { return ts.UTC() } } return time.Time{} } func flattenSensorGroups(groups beeSensorGroups) []models.SensorReading { result := make([]models.SensorReading, 0, len(groups.Fans)+len(groups.Power)+len(groups.Temperatures)+len(groups.Other)) for _, fan := range groups.Fans { result = append(result, models.SensorReading{ Name: sensorName(fan.Name, fan.Location), Type: "fan", Value: float64(fan.RPM), Unit: "RPM", Status: strings.TrimSpace(fan.Status), }) } for _, power := range groups.Power { name := sensorName(power.Name, power.Location) status := strings.TrimSpace(power.Status) if power.PowerW != 0 { result = append(result, models.SensorReading{ Name: name, Type: "power", Value: power.PowerW, Unit: "W", Status: status, }) } if power.VoltageV != 0 { result = append(result, models.SensorReading{ Name: name + " Voltage", Type: "voltage", Value: power.VoltageV, Unit: "V", Status: status, }) } if power.CurrentA != 0 { result = append(result, models.SensorReading{ Name: name + " Current", Type: "current", Value: power.CurrentA, Unit: "A", Status: status, }) } } for _, temp := range groups.Temperatures { result = append(result, models.SensorReading{ Name: sensorName(temp.Name, temp.Location), Type: "temperature", Value: temp.Celsius, Unit: "C", Status: strings.TrimSpace(temp.Status), }) } for _, other := range groups.Other { result = append(result, models.SensorReading{ Name: sensorName(other.Name, other.Location), Type: "other", Value: other.Value, Unit: strings.TrimSpace(other.Unit), Status: strings.TrimSpace(other.Status), }) } return result } func sensorName(name, location string) string { name = strings.TrimSpace(name) location = strings.TrimSpace(location) if name == "" { return location } if location == "" { return name } return name + " [" + location + "]" } func normalizePCIeDevices(items []models.PCIeDevice) []models.PCIeDevice { out := append([]models.PCIeDevice(nil), items...) for i := range out { slot := strings.TrimSpace(out[i].Slot) if out[i].BDF == "" && looksLikeBDF(slot) { out[i].BDF = slot } if out[i].Slot == "" && out[i].BDF != "" { out[i].Slot = out[i].BDF } } return out } func normalizeNetworkAdapters(items []models.NetworkAdapter) []models.NetworkAdapter { out := append([]models.NetworkAdapter(nil), items...) for i := range out { slot := strings.TrimSpace(out[i].Slot) if out[i].BDF == "" && looksLikeBDF(slot) { out[i].BDF = slot } if out[i].Slot == "" && out[i].BDF != "" { out[i].Slot = out[i].BDF } } return out } func looksLikeBDF(value string) bool { value = strings.TrimSpace(value) if len(value) != len("0000:00:00.0") { return false } for i, r := range value { switch i { case 4, 7: if r != ':' { return false } case 10: if r != '.' { return false } default: if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { return false } } } return true } func buildBoardFRU(board models.BoardInfo) (models.FRUInfo, bool) { if strings.TrimSpace(board.SerialNumber) == "" && strings.TrimSpace(board.Manufacturer) == "" && strings.TrimSpace(board.ProductName) == "" && strings.TrimSpace(board.PartNumber) == "" { return models.FRUInfo{}, false } return models.FRUInfo{ Description: "System Board", Manufacturer: strings.TrimSpace(board.Manufacturer), ProductName: strings.TrimSpace(board.ProductName), SerialNumber: strings.TrimSpace(board.SerialNumber), PartNumber: strings.TrimSpace(board.PartNumber), }, true } func mapSeverity(raw string) models.Severity { switch strings.ToLower(strings.TrimSpace(raw)) { case "critical", "crit", "error", "failed", "failure": return models.SeverityCritical case "warning", "warn", "partial", "degraded", "inactive", "activating", "deactivating": return models.SeverityWarning default: return models.SeverityInfo } } func firstNonEmpty(values ...string) string { for _, value := range values { value = strings.TrimSpace(value) if value != "" { return value } } return "" }