diff --git a/internal/web/ui.go b/internal/web/ui.go index 01f3e83..f564f20 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -595,6 +595,7 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic return "" } }, + "alarmChannelSource": alarmChannelSource, "statusLabel": func(v string) string { switch strings.TrimSpace(v) { case "unacknowledged": @@ -2734,13 +2735,26 @@ func (u *UI) apiAlarmExport(w http.ResponseWriter, r *http.Request) { } records := u.alarmCollector.ExportFiltered(from, to, deviceID, ruleType) - // Lookup device info for names and IPs + // Device name/IP lookup deviceMap := map[string]*models.Device{} for _, d := range u.registry.GetDevices() { if d != nil { deviceMap[d.DeviceID] = d } } + // Channel→source lookup (channel = recognition unit name) + channelSources := map[string]string{} + if u.preview != nil { + if assignments, _ := u.preview.ListDeviceAssignments(); len(assignments) > 0 { + for _, a := range assignments { + for _, ref := range a.RecognitionUnits { + if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil && unit.VideoSourceRef != "" { + channelSources[unit.Name] = unit.VideoSourceRef + } + } + } + } + } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="alarms_%s_%s.csv"`, from, to)) @@ -2750,27 +2764,11 @@ func (u *UI) apiAlarmExport(w http.ResponseWriter, r *http.Request) { for _, a := range records { devName := a.DeviceID devIP := "" - sourceName := a.Channel if dev, ok := deviceMap[a.DeviceID]; ok { devName = dev.DisplayName() devIP = dev.IP } - // Resolve channel to video source name - if u.preview != nil { - if assignment, err := u.preview.GetDeviceAssignment(a.DeviceID); err == nil && assignment != nil { - for _, ref := range assignment.RecognitionUnits { - if unit, err := u.preview.GetRecognitionUnit(ref); err == nil && unit != nil { - if unit.Name == a.Channel && unit.VideoSourceRef != "" { - sourceName = unit.VideoSourceRef - break - } - } - } - } - } - if sourceName == "" { - sourceName = a.Channel - } + sourceName := alarmChannelSource(a.Channel, channelSources) fmt.Fprintf(w, "%s,%s,%s,%s,%s,%s,%s\n", escapeCSV(formatAlarmTime(a.Timestamp)), escapeCSV(devName), escapeCSV(devIP), escapeCSV(sourceName), escapeCSV(alarmRuleLabel(a.RuleName)), escapeCSV(a.ObjectLabel), @@ -2817,6 +2815,22 @@ func alarmStatusLabel(v string) string { } } +// alarmChannelSource resolves an alarm channel (e.g. "cam1_alarm_violation") to its video source name. +// The channel format is {instanceName}_alarm_{type}, where instanceName matches the recognition unit. +func alarmChannelSource(channel string, sources map[string]string) string { + // Try exact match first + if src, ok := sources[channel]; ok { + return src + } + // Strip suffix: cam1_alarm_violation → cam1 + if idx := strings.LastIndex(channel, "_alarm_"); idx > 0 { + if src, ok := sources[channel[:idx]]; ok { + return src + } + } + return channel +} + func formatAlarmTime(t string) string { // 2026-07-22T13:03:20+08:00 → 2026-07-22 13:03:20 s := strings.TrimSpace(t) diff --git a/internal/web/ui/templates/alarms.html b/internal/web/ui/templates/alarms.html index 8f6fb9d..c3838e0 100644 --- a/internal/web/ui/templates/alarms.html +++ b/internal/web/ui/templates/alarms.html @@ -62,7 +62,7 @@ {{index $.AlarmDeviceNames .DeviceID}}