diff --git a/cmd/managerd/main.go b/cmd/managerd/main.go index 00dbdb0..9cebf3e 100644 --- a/cmd/managerd/main.go +++ b/cmd/managerd/main.go @@ -74,6 +74,9 @@ func main() { log.Printf("load persisted tasks: %v", err) } alarmCollector := service.NewAlarmCollector(store.DB(), agentClient, regSvc) + if cfg.AlarmRetentionDays > 0 { + alarmCollector.SetRetention(cfg.AlarmRetentionDays) + } alarmCollector.Start() tplSvc := service.NewTemplateService(cfg) h := api.NewHandler(discoSvc, regSvc, agentClient, taskSvc, tplSvc) diff --git a/internal/config/config.go b/internal/config/config.go index 59d159b..9cefaf6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,7 @@ type Config struct { DBPath string `json:"db_path,omitempty"` LogDir string `json:"log_dir,omitempty"` MediaRepoPath string `json:"media_repo_path,omitempty"` // explicit import-only source; not used for runtime rendering + AlarmRetentionDays int `json:"alarm_retention_days"` // auto-delete alarms older than N days, 0=keep forever DeviceAliases map[string]string `json:"device_aliases,omitempty"` path string } diff --git a/internal/service/alarm_collector.go b/internal/service/alarm_collector.go index 3ef453f..9c688be 100644 --- a/internal/service/alarm_collector.go +++ b/internal/service/alarm_collector.go @@ -27,11 +27,12 @@ type AlarmRecord struct { } type AlarmCollector struct { - db *sql.DB - agent *AgentClient - registry *RegistryService - mu sync.Mutex - lastID string // last alarm ID seen, to avoid duplicates + db *sql.DB + agent *AgentClient + registry *RegistryService + mu sync.Mutex + lastID string // last alarm ID seen, to avoid duplicates + retentionDays int // auto-delete alarms older than N days, 0=keep } func NewAlarmCollector(db *sql.DB, agent *AgentClient, registry *RegistryService) *AlarmCollector { @@ -40,6 +41,32 @@ func NewAlarmCollector(db *sql.DB, agent *AgentClient, registry *RegistryService func (c *AlarmCollector) Start() { go c.poll() + go c.cleanupLoop() +} + +// SetRetention configures alarm retention in days. 0 = keep forever. +func (c *AlarmCollector) SetRetention(days int) { + c.retentionDays = days +} + +func (c *AlarmCollector) cleanupLoop() { + c.cleanupOldAlarms() + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for range ticker.C { + c.cleanupOldAlarms() + } +} + +func (c *AlarmCollector) cleanupOldAlarms() { + if c.db == nil || c.retentionDays <= 0 { + return + } + cutoff := time.Now().AddDate(0, 0, -c.retentionDays).Format(time.RFC3339) + _, err := c.db.Exec(`DELETE FROM alarm_records WHERE collected_at < ?`, cutoff) + if err != nil { + log.Printf("alarm cleanup: %v", err) + } } func (c *AlarmCollector) poll() { @@ -183,6 +210,46 @@ ON CONFLICT(id) DO NOTHING } } +// GetFiltered returns alarm records within a date range, with pagination. +// Returns records and total count (ignoring limit/offset for count). +func (c *AlarmCollector) GetFiltered(from, to string, limit, offset int) ([]AlarmRecord, int) { + if c.db == nil { + return nil, 0 + } + fromTS := from + "T00:00:00" + toTS := to + "T23:59:59" + + // Count total + var total int + c.db.QueryRow(` +SELECT COUNT(*) FROM alarm_records +WHERE timestamp >= ? AND timestamp <= ? +`, fromTS, toTS).Scan(&total) + + // Fetch page + rows, err := c.db.Query(` +SELECT id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at +FROM alarm_records +WHERE timestamp >= ? AND timestamp <= ? +ORDER BY timestamp DESC +LIMIT ? OFFSET ? +`, fromTS, toTS, limit, offset) + if err != nil { + return nil, total + } + defer rows.Close() + + var alarms []AlarmRecord + for rows.Next() { + var a AlarmRecord + if err := rows.Scan(&a.ID, &a.DeviceID, &a.Channel, &a.Timestamp, &a.RuleName, &a.RuleType, &a.ObjectLabel, &a.Confidence, &a.SnapshotURL, &a.ClipURL, &a.DurationMs, &a.CollectedAt); err != nil { + continue + } + alarms = append(alarms, a) + } + return alarms, total +} + // GetRecent returns the most recent N alarm records, newest first. func (c *AlarmCollector) GetRecent(limit int) []AlarmRecord { if c.db == nil || limit <= 0 { diff --git a/internal/web/ui.go b/internal/web/ui.go index 9ee5f23..c95a973 100644 --- a/internal/web/ui.go +++ b/internal/web/ui.go @@ -83,6 +83,11 @@ type PageData struct { StandardResources []storage.StandardResourceRecord ResourceStatusBoard *service.ResourceStatusBoard AlarmRecords []service.AlarmRecord + TotalAlarmCount int + TotalAlarmPages int + CurrentPage int + SelectedFrom string + SelectedTo string TodayAlarmCount int DeviceMetrics []DeviceMetric ConsoleDevices []ConsoleDeviceData @@ -491,6 +496,9 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic } return v }, + "add": func(a, b int) int { + return a + b + }, }).ParseFS(uiFS, "ui/templates/*.html") if err != nil { return nil, err @@ -1897,8 +1905,40 @@ func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) { func (u *UI) pageAlarms(w http.ResponseWriter, r *http.Request) { u.ensureDevicesLoaded() data := PageData{Title: "告警中心", Devices: u.registry.GetDevices()} + + // Parse date filter: ?date=2026-05-12 or ?from=...&to=... (default: today) + q := r.URL.Query() + today := time.Now().Format("2006-01-02") + from := strings.TrimSpace(q.Get("from")) + to := strings.TrimSpace(q.Get("to")) + date := strings.TrimSpace(q.Get("date")) + if date != "" { + from = date + to = date + } + if from == "" { + from = today + } + if to == "" { + to = today + } + // Parse page: ?page=N (default 1, 50 per page) + page := 1 + if p, err := strconv.Atoi(q.Get("page")); err == nil && p > 0 { + page = p + } + perPage := 50 + + data.SelectedFrom = from + data.SelectedTo = to + data.CurrentPage = page + if u.alarmCollector != nil { - data.AlarmRecords = u.alarmCollector.GetRecent(100) + data.AlarmRecords, data.TotalAlarmCount = u.alarmCollector.GetFiltered(from, to, perPage, (page-1)*perPage) + } + // Calculate total pages + if data.TotalAlarmCount > 0 { + data.TotalAlarmPages = (data.TotalAlarmCount + perPage - 1) / perPage } u.render(w, r, "alarms", data) } diff --git a/internal/web/ui/templates/alarms.html b/internal/web/ui/templates/alarms.html index eaaa186..79ed051 100644 --- a/internal/web/ui/templates/alarms.html +++ b/internal/web/ui/templates/alarms.html @@ -9,6 +9,14 @@
+
+ + + + + 今天 +
+
{{if .AlarmRecords}} @@ -28,14 +36,14 @@ - +
{{.Timestamp}} - {{.DeviceID}} + {{shortID .DeviceID}} {{.Channel}} {{.RuleName}} {{if .ObjectLabel}}{{.ObjectLabel}}{{else}}-{{end}}{{if .Confidence}}{{.Confidence}}{{else}}-{{end}}{{if .Confidence}}{{printf "%.2f" .Confidence}}{{else}}-{{end}} {{if .SnapshotURL}} 查看截图 @@ -48,10 +56,20 @@ {{end}}
+ {{if gt .TotalAlarmPages 1}} +
+ {{.TotalAlarmCount}} 条记录,{{.TotalAlarmPages}} 页 + {{$from := $.SelectedFrom}}{{$to := $.SelectedTo}} + {{range $p := loop .TotalAlarmPages}} + {{$pn := add $p 1}} + {{$pn}} + {{end}} +
+ {{end}} {{else}}
暂无告警记录
-
设备尚未上报告警,或告警收集服务尚未启动。
+
所选日期范围内没有告警,或设备尚未上报。
{{end}}
diff --git a/managerd.json b/managerd.json index 696cc1f..8c21f99 100644 --- a/managerd.json +++ b/managerd.json @@ -10,5 +10,6 @@ "log_dir": "data/logs", "device_aliases": { "d12a4719c91641df91b76ab271280797": "盒子A" - } + }, + "alarm_retention_days": 30 }