feat: alarm page date filter, pagination, auto-cleanup

- 告警页增加日期范围筛选(默认今天),50 条/页分页
- AlarmCollector.GetFiltered 支持 from/to/limit/offset
- alarm_retention_days 配置项(默认 30 天),超期自动清理
This commit is contained in:
tian 2026-05-12 16:58:56 +08:00
parent 65eb988905
commit 74ca578483
6 changed files with 140 additions and 10 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -32,6 +32,7 @@ type AlarmCollector struct {
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 {

View File

@ -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)
}

View File

@ -9,6 +9,14 @@
</div>
<div class="card">
<form method="get" action="/ui/alarms" style="display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap">
<input type="date" name="from" value="{{.SelectedFrom}}" style="width:150px;font-size:12px">
<span class="muted small"></span>
<input type="date" name="to" value="{{.SelectedTo}}" style="width:150px;font-size:12px">
<button class="btn" style="font-size:12px;padding:4px 12px">筛选</button>
<a href="/ui/alarms" class="btn ghost" style="font-size:11px">今天</a>
</form>
<div class="table-wrap">
{{if .AlarmRecords}}
<table>
@ -28,14 +36,14 @@
<tr>
<td class="mono small">{{.Timestamp}}</td>
<td>
<a href="/ui/devices/{{.DeviceID}}">{{.DeviceID}}</a>
<a href="/ui/devices/{{.DeviceID}}">{{shortID .DeviceID}}</a>
</td>
<td>{{.Channel}}</td>
<td>
<span class="pill warn">{{.RuleName}}</span>
</td>
<td>{{if .ObjectLabel}}{{.ObjectLabel}}{{else}}-{{end}}</td>
<td>{{if .Confidence}}{{.Confidence}}{{else}}-{{end}}</td>
<td>{{if .Confidence}}{{printf "%.2f" .Confidence}}{{else}}-{{end}}</td>
<td>
{{if .SnapshotURL}}
<a href="{{.SnapshotURL}}" target="_blank" class="btn ghost">查看截图</a>
@ -48,10 +56,20 @@
{{end}}
</tbody>
</table>
{{if gt .TotalAlarmPages 1}}
<div style="margin-top:12px;display:flex;gap:6px;align-items:center">
<span class="muted small">{{.TotalAlarmCount}} 条记录,{{.TotalAlarmPages}} 页</span>
{{$from := $.SelectedFrom}}{{$to := $.SelectedTo}}
{{range $p := loop .TotalAlarmPages}}
{{$pn := add $p 1}}
<a href="/ui/alarms?from={{$from}}&to={{$to}}&page={{$pn}}" class="btn {{if eq $pn $.CurrentPage}}primary{{else}}ghost{{end}}" style="font-size:11px;padding:2px 8px">{{$pn}}</a>
{{end}}
</div>
{{end}}
{{else}}
<div class="empty-state">
<div class="empty-title">暂无告警记录</div>
<div class="muted">设备尚未上报告警,或告警收集服务尚未启动。</div>
<div class="muted">所选日期范围内没有告警,或设备尚未上报。</div>
</div>
{{end}}
</div>

View File

@ -10,5 +10,6 @@
"log_dir": "data/logs",
"device_aliases": {
"d12a4719c91641df91b76ab271280797": "盒子A"
}
},
"alarm_retention_days": 30
}