feat: alarm page date filter, pagination, auto-cleanup
- 告警页增加日期范围筛选(默认今天),50 条/页分页 - AlarmCollector.GetFiltered 支持 from/to/limit/offset - alarm_retention_days 配置项(默认 30 天),超期自动清理
This commit is contained in:
parent
65eb988905
commit
74ca578483
@ -74,6 +74,9 @@ func main() {
|
|||||||
log.Printf("load persisted tasks: %v", err)
|
log.Printf("load persisted tasks: %v", err)
|
||||||
}
|
}
|
||||||
alarmCollector := service.NewAlarmCollector(store.DB(), agentClient, regSvc)
|
alarmCollector := service.NewAlarmCollector(store.DB(), agentClient, regSvc)
|
||||||
|
if cfg.AlarmRetentionDays > 0 {
|
||||||
|
alarmCollector.SetRetention(cfg.AlarmRetentionDays)
|
||||||
|
}
|
||||||
alarmCollector.Start()
|
alarmCollector.Start()
|
||||||
tplSvc := service.NewTemplateService(cfg)
|
tplSvc := service.NewTemplateService(cfg)
|
||||||
h := api.NewHandler(discoSvc, regSvc, agentClient, taskSvc, tplSvc)
|
h := api.NewHandler(discoSvc, regSvc, agentClient, taskSvc, tplSvc)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ type Config struct {
|
|||||||
DBPath string `json:"db_path,omitempty"`
|
DBPath string `json:"db_path,omitempty"`
|
||||||
LogDir string `json:"log_dir,omitempty"`
|
LogDir string `json:"log_dir,omitempty"`
|
||||||
MediaRepoPath string `json:"media_repo_path,omitempty"` // explicit import-only source; not used for runtime rendering
|
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"`
|
DeviceAliases map[string]string `json:"device_aliases,omitempty"`
|
||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ type AlarmCollector struct {
|
|||||||
registry *RegistryService
|
registry *RegistryService
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastID string // last alarm ID seen, to avoid duplicates
|
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 {
|
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() {
|
func (c *AlarmCollector) Start() {
|
||||||
go c.poll()
|
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() {
|
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.
|
// GetRecent returns the most recent N alarm records, newest first.
|
||||||
func (c *AlarmCollector) GetRecent(limit int) []AlarmRecord {
|
func (c *AlarmCollector) GetRecent(limit int) []AlarmRecord {
|
||||||
if c.db == nil || limit <= 0 {
|
if c.db == nil || limit <= 0 {
|
||||||
|
|||||||
@ -83,6 +83,11 @@ type PageData struct {
|
|||||||
StandardResources []storage.StandardResourceRecord
|
StandardResources []storage.StandardResourceRecord
|
||||||
ResourceStatusBoard *service.ResourceStatusBoard
|
ResourceStatusBoard *service.ResourceStatusBoard
|
||||||
AlarmRecords []service.AlarmRecord
|
AlarmRecords []service.AlarmRecord
|
||||||
|
TotalAlarmCount int
|
||||||
|
TotalAlarmPages int
|
||||||
|
CurrentPage int
|
||||||
|
SelectedFrom string
|
||||||
|
SelectedTo string
|
||||||
TodayAlarmCount int
|
TodayAlarmCount int
|
||||||
DeviceMetrics []DeviceMetric
|
DeviceMetrics []DeviceMetric
|
||||||
ConsoleDevices []ConsoleDeviceData
|
ConsoleDevices []ConsoleDeviceData
|
||||||
@ -491,6 +496,9 @@ func NewUI(discovery *service.DiscoveryService, registry *service.RegistryServic
|
|||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
},
|
},
|
||||||
|
"add": func(a, b int) int {
|
||||||
|
return a + b
|
||||||
|
},
|
||||||
}).ParseFS(uiFS, "ui/templates/*.html")
|
}).ParseFS(uiFS, "ui/templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (u *UI) pageAlarms(w http.ResponseWriter, r *http.Request) {
|
||||||
u.ensureDevicesLoaded()
|
u.ensureDevicesLoaded()
|
||||||
data := PageData{Title: "告警中心", Devices: u.registry.GetDevices()}
|
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 {
|
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)
|
u.render(w, r, "alarms", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<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">
|
<div class="table-wrap">
|
||||||
{{if .AlarmRecords}}
|
{{if .AlarmRecords}}
|
||||||
<table>
|
<table>
|
||||||
@ -28,14 +36,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="mono small">{{.Timestamp}}</td>
|
<td class="mono small">{{.Timestamp}}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="/ui/devices/{{.DeviceID}}">{{.DeviceID}}</a>
|
<a href="/ui/devices/{{.DeviceID}}">{{shortID .DeviceID}}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>{{.Channel}}</td>
|
<td>{{.Channel}}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="pill warn">{{.RuleName}}</span>
|
<span class="pill warn">{{.RuleName}}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{if .ObjectLabel}}{{.ObjectLabel}}{{else}}-{{end}}</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>
|
<td>
|
||||||
{{if .SnapshotURL}}
|
{{if .SnapshotURL}}
|
||||||
<a href="{{.SnapshotURL}}" target="_blank" class="btn ghost">查看截图</a>
|
<a href="{{.SnapshotURL}}" target="_blank" class="btn ghost">查看截图</a>
|
||||||
@ -48,10 +56,20 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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}}
|
{{else}}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-title">暂无告警记录</div>
|
<div class="empty-title">暂无告警记录</div>
|
||||||
<div class="muted">设备尚未上报告警,或告警收集服务尚未启动。</div>
|
<div class="muted">所选日期范围内没有告警,或设备尚未上报。</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,5 +10,6 @@
|
|||||||
"log_dir": "data/logs",
|
"log_dir": "data/logs",
|
||||||
"device_aliases": {
|
"device_aliases": {
|
||||||
"d12a4719c91641df91b76ab271280797": "盒子A"
|
"d12a4719c91641df91b76ab271280797": "盒子A"
|
||||||
}
|
},
|
||||||
|
"alarm_retention_days": 30
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user