Compare commits
5 Commits
master
...
p0-dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
| 46eb031b8f | |||
| 2c97cbc26e | |||
| f8e6b6158b | |||
| 4c90ef88c9 | |||
| a7bd5e1309 |
@ -68,6 +68,8 @@ func main() {
|
||||
if err := taskSvc.LoadPersistedTasks(); err != nil {
|
||||
log.Printf("load persisted tasks: %v", err)
|
||||
}
|
||||
alarmCollector := service.NewAlarmCollector(store.DB(), agentClient, regSvc)
|
||||
alarmCollector.Start()
|
||||
tplSvc := service.NewTemplateService(cfg)
|
||||
h := api.NewHandler(discoSvc, regSvc, agentClient, taskSvc, tplSvc)
|
||||
|
||||
@ -97,6 +99,7 @@ func main() {
|
||||
ui.SetAuditRepo(auditRepo)
|
||||
ui.SetDBPath(cfg.DBPathOrDefault())
|
||||
ui.SetResourcesRepo(resourcesRepo)
|
||||
ui.SetAlarmCollector(alarmCollector)
|
||||
uiRouter, err := ui.Routes()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to init ui routes: %v", err)
|
||||
|
||||
211
internal/service/alarm_collector.go
Normal file
211
internal/service/alarm_collector.go
Normal file
@ -0,0 +1,211 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"3588AdminBackend/internal/models"
|
||||
)
|
||||
|
||||
type AlarmRecord struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Channel string `json:"channel"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
RuleName string `json:"rule_name"`
|
||||
RuleType string `json:"rule_type"`
|
||||
ObjectLabel string `json:"object_label"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
SnapshotURL string `json:"snapshot_url"`
|
||||
ClipURL string `json:"clip_url"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
CollectedAt string `json:"collected_at"`
|
||||
}
|
||||
|
||||
type AlarmCollector struct {
|
||||
db *sql.DB
|
||||
agent *AgentClient
|
||||
registry *RegistryService
|
||||
mu sync.Mutex
|
||||
lastID string // last alarm ID seen, to avoid duplicates
|
||||
}
|
||||
|
||||
func NewAlarmCollector(db *sql.DB, agent *AgentClient, registry *RegistryService) *AlarmCollector {
|
||||
return &AlarmCollector{db: db, agent: agent, registry: registry}
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) Start() {
|
||||
go c.poll()
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) poll() {
|
||||
// Initial delay to let the system settle
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
c.collectFromDevices()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) collectFromDevices() {
|
||||
if c.agent == nil || c.registry == nil {
|
||||
return
|
||||
}
|
||||
devices := c.registry.GetDevices()
|
||||
for _, dev := range devices {
|
||||
if dev == nil || !dev.Online {
|
||||
continue
|
||||
}
|
||||
alarms, err := c.fetchDeviceAlarms(dev)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, alarm := range alarms {
|
||||
alarm.DeviceID = dev.DeviceID
|
||||
alarm.CollectedAt = time.Now().Format(time.RFC3339)
|
||||
c.saveAlarm(alarm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) fetchDeviceAlarms(dev *models.Device) ([]AlarmRecord, error) {
|
||||
c.mu.Lock()
|
||||
lastID := c.lastID
|
||||
c.mu.Unlock()
|
||||
|
||||
url := "/v1/alarms/recent?limit=100"
|
||||
body, status, err := c.agent.Do("GET", dev.IP, dev.AgentPort, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != 200 {
|
||||
return nil, nil // agent may not support alarms yet
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Alarms []map[string]any `json:"alarms"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newLastID := lastID
|
||||
alarms := make([]AlarmRecord, 0)
|
||||
for _, a := range resp.Alarms {
|
||||
id, _ := a["id"].(string)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if id == lastID {
|
||||
break // reached previously seen alarms
|
||||
}
|
||||
// Extract fields from whatever format media-server sends
|
||||
channel, _ := a["channel"].(string)
|
||||
if channel == "" {
|
||||
channel, _ = a["node_id"].(string) // media-server uses node_id
|
||||
}
|
||||
ruleName, _ := a["rule_name"].(string)
|
||||
ruleType, _ := a["rule_type"].(string)
|
||||
objectLabel, _ := a["object_label"].(string)
|
||||
snapshotURL, _ := a["snapshot_url"].(string)
|
||||
clipURL, _ := a["clip_url"].(string)
|
||||
// Timestamp can be string (RFC3339) or number (unix millis)
|
||||
var ts string
|
||||
switch v := a["timestamp"].(type) {
|
||||
case string:
|
||||
ts = v
|
||||
case float64:
|
||||
ts = time.UnixMilli(int64(v)).Format(time.RFC3339)
|
||||
}
|
||||
confidence, _ := a["confidence"].(float64)
|
||||
if confidence == 0 {
|
||||
if score, ok := a["score"].(float64); ok {
|
||||
confidence = score
|
||||
}
|
||||
}
|
||||
// Extract from nested detections if top-level fields are empty
|
||||
if dets, ok := a["detections"].([]any); ok && len(dets) > 0 {
|
||||
if objectLabel == "" {
|
||||
objectLabel = fmt.Sprintf("%d 个检测目标", len(dets))
|
||||
}
|
||||
if confidence == 0 {
|
||||
if d0, ok := dets[0].(map[string]any); ok {
|
||||
if s, ok := d0["score"].(float64); ok {
|
||||
confidence = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
durationMs, _ := a["duration_ms"].(float64)
|
||||
alarms = append(alarms, AlarmRecord{
|
||||
ID: id,
|
||||
Timestamp: ts,
|
||||
Channel: channel,
|
||||
RuleName: ruleName,
|
||||
RuleType: ruleType,
|
||||
ObjectLabel: objectLabel,
|
||||
Confidence: confidence,
|
||||
SnapshotURL: snapshotURL,
|
||||
ClipURL: clipURL,
|
||||
DurationMs: int64(durationMs),
|
||||
})
|
||||
if newLastID == "" {
|
||||
newLastID = id
|
||||
}
|
||||
}
|
||||
|
||||
if newLastID != "" {
|
||||
c.mu.Lock()
|
||||
c.lastID = newLastID
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return alarms, nil
|
||||
}
|
||||
|
||||
func (c *AlarmCollector) saveAlarm(alarm AlarmRecord) {
|
||||
if c.db == nil {
|
||||
return
|
||||
}
|
||||
_, err := c.db.Exec(`
|
||||
INSERT INTO alarm_records(id, device_id, channel, timestamp, rule_name, rule_type, object_label, confidence, snapshot_url, clip_url, duration_ms, collected_at)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
`, alarm.ID, alarm.DeviceID, alarm.Channel, alarm.Timestamp, alarm.RuleName, alarm.RuleType, alarm.ObjectLabel, alarm.Confidence, alarm.SnapshotURL, alarm.ClipURL, alarm.DurationMs, alarm.CollectedAt)
|
||||
if err != nil {
|
||||
log.Printf("alarm collector: save error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// GetRecent returns the most recent N alarm records, newest first.
|
||||
func (c *AlarmCollector) GetRecent(limit int) []AlarmRecord {
|
||||
if c.db == nil || limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
@ -77,6 +77,20 @@ CREATE TABLE IF NOT EXISTS standard_resources (
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS alarm_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
channel TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
rule_name TEXT NOT NULL,
|
||||
rule_type TEXT NOT NULL,
|
||||
object_label TEXT NOT NULL DEFAULT '',
|
||||
confidence REAL NOT NULL DEFAULT 0,
|
||||
snapshot_url TEXT NOT NULL DEFAULT '',
|
||||
clip_url TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
collected_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS scene_templates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
|
||||
@ -33,6 +33,7 @@ type UI struct {
|
||||
auditRepo *storage.AuditLogsRepo
|
||||
dbPath string
|
||||
resourcesRepo *storage.ResourcesRepo
|
||||
alarmCollector *service.AlarmCollector
|
||||
|
||||
tpl *template.Template
|
||||
}
|
||||
@ -78,6 +79,7 @@ type PageData struct {
|
||||
ModelStatusBoard *service.ModelStatusBoard
|
||||
StandardResources []storage.StandardResourceRecord
|
||||
ResourceStatusBoard *service.ResourceStatusBoard
|
||||
AlarmRecords []service.AlarmRecord
|
||||
Templates []service.Template
|
||||
Template *service.Template
|
||||
AssetTab string
|
||||
@ -501,6 +503,13 @@ func (u *UI) SetResourcesRepo(repo *storage.ResourcesRepo) {
|
||||
u.resourcesRepo = repo
|
||||
}
|
||||
|
||||
func (u *UI) SetAlarmCollector(ac *service.AlarmCollector) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
u.alarmCollector = ac
|
||||
}
|
||||
|
||||
func tablerIconSVG(name string) string {
|
||||
icons := map[string]string{
|
||||
"devices": `<svg xmlns="http://www.w3.org/2000/svg" class="icon ui-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><rect x="3" y="4" width="18" height="12" rx="1"/><path d="M7 20h10"/><path d="M9 16v4"/><path d="M15 16v4"/></svg>`,
|
||||
@ -651,6 +660,7 @@ func (u *UI) Routes() (chi.Router, error) {
|
||||
r.Post("/models/sync", u.actionModelSync)
|
||||
r.Post("/resources/sync", u.actionResourceSync)
|
||||
r.Get("/diagnostics", u.pageDiagnostics)
|
||||
r.Get("/alarms", u.pageAlarms)
|
||||
r.Get("/recognition", u.pageRecognition)
|
||||
r.Get("/logs", u.pageLogs)
|
||||
|
||||
@ -1434,6 +1444,15 @@ func (u *UI) pageDiagnostics(w http.ResponseWriter, r *http.Request) {
|
||||
u.render(w, r, "diagnostics", data)
|
||||
}
|
||||
|
||||
func (u *UI) pageAlarms(w http.ResponseWriter, r *http.Request) {
|
||||
u.ensureDevicesLoaded()
|
||||
data := PageData{Title: "告警中心", Devices: u.registry.GetDevices()}
|
||||
if u.alarmCollector != nil {
|
||||
data.AlarmRecords = u.alarmCollector.GetRecent(100)
|
||||
}
|
||||
u.render(w, r, "alarms", data)
|
||||
}
|
||||
|
||||
func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) {
|
||||
u.ensureDevicesLoaded()
|
||||
data := PageData{Title: "资源管理", Devices: u.registry.GetDevices()}
|
||||
|
||||
59
internal/web/ui/templates/alarms.html
Normal file
59
internal/web/ui/templates/alarms.html
Normal file
@ -0,0 +1,59 @@
|
||||
{{define "alarms"}}
|
||||
<div class="card">
|
||||
<div class="section-title">
|
||||
<div>
|
||||
<h2 class="title-with-icon">{{icon "bell"}}<span>告警中心</span></h2>
|
||||
<div class="form-hint">来自所有设备的实时告警记录,按时间倒序排列。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
{{if .AlarmRecords}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>设备</th>
|
||||
<th>通道</th>
|
||||
<th>规则</th>
|
||||
<th>目标</th>
|
||||
<th>置信度</th>
|
||||
<th>截图</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .AlarmRecords}}
|
||||
<tr>
|
||||
<td class="mono small">{{.Timestamp}}</td>
|
||||
<td>
|
||||
<a href="/ui/devices/{{.DeviceID}}">{{.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 .SnapshotURL}}
|
||||
<a href="{{.SnapshotURL}}" target="_blank" class="btn ghost">查看截图</a>
|
||||
{{else}}-{{end}}
|
||||
{{if .ClipURL}}
|
||||
<a href="{{.ClipURL}}" target="_blank" class="btn ghost">视频片段</a>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<div class="empty-title">暂无告警记录</div>
|
||||
<div class="muted">设备尚未上报告警,或告警收集服务尚未启动。</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@ -35,6 +35,7 @@
|
||||
<div class="nav-group-items">
|
||||
<a class="nav-subitem" href="/ui/models"><span class="nav-icon nav-subicon">{{icon "assets"}}</span><span>模型管理</span></a>
|
||||
<a class="nav-subitem" href="/ui/resources"><span class="nav-icon nav-subicon">{{icon "template"}}</span><span>资源管理</span></a>
|
||||
<a class="nav-subitem" href="/ui/alarms"><span class="nav-icon nav-subicon">{{icon "bell"}}</span><span>告警中心</span></a>
|
||||
<a class="nav-subitem" href="/ui/diagnostics"><span class="nav-icon nav-subicon">{{icon "logs"}}</span><span>日志审计</span></a>
|
||||
<a class="nav-subitem" href="/ui/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
|
||||
</div>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user