Compare commits

..

17 Commits

Author SHA1 Message Date
0c89dced36 chore: final cleanup for video monitoring 2026-05-07 15:34:25 +08:00
d804324e67 fix: correct proxy URL path in monitor page 2026-05-07 15:33:32 +08:00
f5df6b6da0 fix: use unit name for HLS path, matching graph name 2026-05-07 15:19:11 +08:00
d730413cba feat: proxy HLS through backend to eliminate CORS issues 2026-05-07 15:03:52 +08:00
3930f014df feat: use hls.js for HLS playback 2026-05-07 14:59:31 +08:00
cbc5f90b84 fix: use video tag with HLS URL, add open-in-new-window link 2026-05-07 14:43:17 +08:00
531581eb04 fix: use iframe hls_player for video wall 2026-05-07 14:41:22 +08:00
35726388ab feat: auto-load all device channels as video wall 2026-05-07 14:26:06 +08:00
3a77449b4f fix: show device list directly on monitor page 2026-05-07 14:23:14 +08:00
d40b977e90 fix: proxy preview channels through backend to avoid CORS 2026-05-07 14:20:51 +08:00
35c6b5c091 fix: remove stray t from nav links 2026-05-07 14:19:57 +08:00
a289882a1c feat: video monitor page with HLS preview 2026-05-07 14:16:21 +08:00
46eb031b8f fix: extract target info from nested detections 2026-05-07 12:34:47 +08:00
2c97cbc26e fix: alarm center nav link 2026-05-07 12:30:23 +08:00
f8e6b6158b fix: alarm collector handles real media-server format 2026-05-07 12:26:01 +08:00
4c90ef88c9 feat: alarm center page with recent alarm records 2026-05-07 10:31:03 +08:00
a7bd5e1309 feat: alarm collector service - poll devices for alarm records 2026-05-07 10:27:35 +08:00
8 changed files with 458 additions and 4 deletions

View File

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

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

View File

@ -357,7 +357,7 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
VideoSourceRef: unit.VideoSourceRef,
DisplayName: unit.DisplayName,
SiteName: unit.SiteName,
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8",
PublishRTSPPort: unit.RTSPPort,
PublishRTSPPath: "/live/" + channel,
ChannelNo: channel,
@ -533,7 +533,7 @@ func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName str
DisplayName: strings.TrimSpace(unit.DisplayName),
SiteName: strings.TrimSpace(unit.SiteName),
ChannelNo: channel,
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8",
PublishRTSPPort: rtspPort,
PublishRTSPPath: "/live/" + channel,
InputBindings: map[string]InputBindingEditor{
@ -541,7 +541,7 @@ func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName str
},
OutputBindings: map[string]OutputBindingEditor{
"stream_output_main": {
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8",
PublishRTSPPort: rtspPort,
PublishRTSPPath: "/live/" + channel,
ChannelNo: channel,
@ -570,7 +570,7 @@ func recognitionUnitToRecord(unit RecognitionUnitAsset) storage.RecognitionUnitR
},
"output_bindings": map[string]any{
"stream_output_main": map[string]any{
"publish_hls_path": "./web/hls/" + channel + "/index.m3u8",
"publish_hls_path": "./web/hls/" + unit.Name + "/index.m3u8",
"publish_rtsp_port": rtspPort,
"publish_rtsp_path": "/live/" + channel,
"channel_no": channel,

View File

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

View File

@ -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,10 @@ 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("/monitor", u.pageMonitor)
r.Get("/hls/*", u.proxyHLS)
r.Get("/api/monitor/channels", u.apiMonitorChannels)
r.Get("/recognition", u.pageRecognition)
r.Get("/logs", u.pageLogs)
@ -1434,6 +1447,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()}
@ -3844,3 +3866,62 @@ func (u *UI) actionDeviceFaceGalleryReload(w http.ResponseWriter, r *http.Reques
}
u.render(w, r, "config_ui", data)
}
func (u *UI) pageMonitor(w http.ResponseWriter, r *http.Request) {
u.ensureDevicesLoaded()
u.render(w, r, "monitor", PageData{Title: "视频监控", Devices: u.registry.GetDevices()})
}
func (u *UI) apiMonitorChannels(w http.ResponseWriter, r *http.Request) {
deviceID := r.URL.Query().Get("device_id")
if deviceID == "" {
http.Error(w, "missing device_id", http.StatusBadRequest)
return
}
dev, ok := u.findDevice(deviceID)
if !ok {
http.Error(w, "device not found", http.StatusNotFound)
return
}
body, code, err := u.agent.Do("GET", dev.IP, dev.AgentPort, "/v1/preview/channels", nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write(body)
}
func (u *UI) proxyHLS(w http.ResponseWriter, r *http.Request) {
// URL format: /hls/{deviceID}/{hls_path}
path := strings.TrimPrefix(r.URL.Path, "/hls/")
idx := strings.Index(path, "/")
if idx < 0 {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
deviceID := path[:idx]
hlsPath := path[idx+1:]
dev, ok := u.findDevice(deviceID)
if !ok {
http.Error(w, "device not found", http.StatusNotFound)
return
}
body, code, err := u.agent.Do("GET", dev.IP, dev.MediaPort, "/"+hlsPath, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
// Set HLS-friendly headers
if strings.HasSuffix(hlsPath, ".m3u8") {
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Access-Control-Allow-Origin", "*")
} else if strings.HasSuffix(hlsPath, ".ts") {
w.Header().Set("Content-Type", "video/mp2t")
w.Header().Set("Access-Control-Allow-Origin", "*")
}
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(code)
w.Write(body)
}

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

View File

@ -35,6 +35,8 @@
<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/monitor"><span class="nav-icon nav-subicon">{{icon "devices"}}</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>

View File

@ -0,0 +1,84 @@
{{define "monitor"}}
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "devices"}}<span>视频监控</span></h2>
<div class="form-hint">所有在线设备的实时画面</div>
</div>
<div class="actions compact">
<button class="btn ghost" type="button" onclick="loadAll()">刷新</button>
</div>
</div>
</div>
<div id="video-wall" style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin-top:16px">
<div class="card muted" style="text-align:center;grid-column:1/-1">加载中…</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script>
function loadAll() {
var wall = document.getElementById("video-wall");
wall.innerHTML = '<div class="card muted" style="text-align:center;grid-column:1/-1">加载中…</div>';
var devices = [
{{range .Devices}}{{if .Online}}
{id:"{{.DeviceID}}",name:"{{.DisplayName}}"},
{{end}}{{end}}
];
if (devices.length === 0) {
wall.innerHTML = '<div class="card muted" style="text-align:center;grid-column:1/-1">暂无在线设备</div>';
return;
}
var promises = devices.map(function(dev) {
return fetch('/ui/api/monitor/channels?device_id=' + encodeURIComponent(dev.id))
.then(function(r) { return r.json(); })
.then(function(data) { return (data.channels||[]).map(function(ch) { ch._dev = dev.name; ch._devId = dev.id; return ch; }); })
.catch(function() { return []; });
});
Promise.all(promises).then(function(results) {
var all = [];
results.forEach(function(chs) { all = all.concat(chs); });
if (all.length === 0) {
wall.innerHTML = '<div class="card muted" style="text-align:center;grid-column:1/-1">没有可预览的通道</div>';
return;
}
var cols = all.length <= 2 ? all.length : 2;
wall.style.gridTemplateColumns = "repeat(" + cols + ",minmax(0,1fr))";
var html = "";
all.forEach(function(ch, i) {
var proxyUrl = '/ui/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
html += '<div class="card" style="padding:8px">';
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">';
html += '<span style="font-size:12px;font-weight:500">' + esc(ch._dev) + ' · ' + esc(ch.name) + '</span>';
html += '</div>';
if (ch.hls_url) {
html += '<video id="v' + i + '" controls autoplay muted style="width:100%;max-height:340px;background:#000"></video>';
} else {
html += '<div class="muted" style="height:200px;display:flex;align-items:center;justify-content:center">无预览地址</div>';
}
html += '</div>';
});
wall.innerHTML = html;
// Initialize HLS players
all.forEach(function(ch, i) {
if (!ch.hls_url || !ch._devId) return;
var proxyUrl = '/ui/hls/' + ch._devId + '/hls/' + ch.name + '/index.m3u8';
var video = document.getElementById('v' + i);
if (!video) return;
if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(proxyUrl);
hls.attachMedia(video);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = ch.hls_url;
}
});
});
}
function esc(s) { return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
loadAll();
</script>
{{end}}