Compare commits
17 Commits
master
...
p0-dashboa
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c89dced36 | |||
| d804324e67 | |||
| f5df6b6da0 | |||
| d730413cba | |||
| 3930f014df | |||
| cbc5f90b84 | |||
| 531581eb04 | |||
| 35726388ab | |||
| 3a77449b4f | |||
| d40b977e90 | |||
| 35c6b5c091 | |||
| a289882a1c | |||
| 46eb031b8f | |||
| 2c97cbc26e | |||
| f8e6b6158b | |||
| 4c90ef88c9 | |||
| a7bd5e1309 |
@ -68,6 +68,8 @@ func main() {
|
|||||||
if err := taskSvc.LoadPersistedTasks(); err != nil {
|
if err := taskSvc.LoadPersistedTasks(); err != nil {
|
||||||
log.Printf("load persisted tasks: %v", err)
|
log.Printf("load persisted tasks: %v", err)
|
||||||
}
|
}
|
||||||
|
alarmCollector := service.NewAlarmCollector(store.DB(), agentClient, regSvc)
|
||||||
|
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)
|
||||||
|
|
||||||
@ -97,6 +99,7 @@ func main() {
|
|||||||
ui.SetAuditRepo(auditRepo)
|
ui.SetAuditRepo(auditRepo)
|
||||||
ui.SetDBPath(cfg.DBPathOrDefault())
|
ui.SetDBPath(cfg.DBPathOrDefault())
|
||||||
ui.SetResourcesRepo(resourcesRepo)
|
ui.SetResourcesRepo(resourcesRepo)
|
||||||
|
ui.SetAlarmCollector(alarmCollector)
|
||||||
uiRouter, err := ui.Routes()
|
uiRouter, err := ui.Routes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to init ui routes: %v", err)
|
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
|
||||||
|
}
|
||||||
@ -357,7 +357,7 @@ func (s *ConfigPreviewService) GetProfileAsset(name string) (*ConfigProfileAsset
|
|||||||
VideoSourceRef: unit.VideoSourceRef,
|
VideoSourceRef: unit.VideoSourceRef,
|
||||||
DisplayName: unit.DisplayName,
|
DisplayName: unit.DisplayName,
|
||||||
SiteName: unit.SiteName,
|
SiteName: unit.SiteName,
|
||||||
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
|
PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8",
|
||||||
PublishRTSPPort: unit.RTSPPort,
|
PublishRTSPPort: unit.RTSPPort,
|
||||||
PublishRTSPPath: "/live/" + channel,
|
PublishRTSPPath: "/live/" + channel,
|
||||||
ChannelNo: channel,
|
ChannelNo: channel,
|
||||||
@ -533,7 +533,7 @@ func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName str
|
|||||||
DisplayName: strings.TrimSpace(unit.DisplayName),
|
DisplayName: strings.TrimSpace(unit.DisplayName),
|
||||||
SiteName: strings.TrimSpace(unit.SiteName),
|
SiteName: strings.TrimSpace(unit.SiteName),
|
||||||
ChannelNo: channel,
|
ChannelNo: channel,
|
||||||
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
|
PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8",
|
||||||
PublishRTSPPort: rtspPort,
|
PublishRTSPPort: rtspPort,
|
||||||
PublishRTSPPath: "/live/" + channel,
|
PublishRTSPPath: "/live/" + channel,
|
||||||
InputBindings: map[string]InputBindingEditor{
|
InputBindings: map[string]InputBindingEditor{
|
||||||
@ -541,7 +541,7 @@ func recognitionUnitToInstanceEditor(unit RecognitionUnitAsset, templateName str
|
|||||||
},
|
},
|
||||||
OutputBindings: map[string]OutputBindingEditor{
|
OutputBindings: map[string]OutputBindingEditor{
|
||||||
"stream_output_main": {
|
"stream_output_main": {
|
||||||
PublishHLSPath: "./web/hls/" + channel + "/index.m3u8",
|
PublishHLSPath: "./web/hls/" + unit.Name + "/index.m3u8",
|
||||||
PublishRTSPPort: rtspPort,
|
PublishRTSPPort: rtspPort,
|
||||||
PublishRTSPPath: "/live/" + channel,
|
PublishRTSPPath: "/live/" + channel,
|
||||||
ChannelNo: channel,
|
ChannelNo: channel,
|
||||||
@ -570,7 +570,7 @@ func recognitionUnitToRecord(unit RecognitionUnitAsset) storage.RecognitionUnitR
|
|||||||
},
|
},
|
||||||
"output_bindings": map[string]any{
|
"output_bindings": map[string]any{
|
||||||
"stream_output_main": 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_port": rtspPort,
|
||||||
"publish_rtsp_path": "/live/" + channel,
|
"publish_rtsp_path": "/live/" + channel,
|
||||||
"channel_no": channel,
|
"channel_no": channel,
|
||||||
|
|||||||
@ -77,6 +77,20 @@ CREATE TABLE IF NOT EXISTS standard_resources (
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_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 (
|
CREATE TABLE IF NOT EXISTS scene_templates (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
|||||||
@ -33,6 +33,7 @@ type UI struct {
|
|||||||
auditRepo *storage.AuditLogsRepo
|
auditRepo *storage.AuditLogsRepo
|
||||||
dbPath string
|
dbPath string
|
||||||
resourcesRepo *storage.ResourcesRepo
|
resourcesRepo *storage.ResourcesRepo
|
||||||
|
alarmCollector *service.AlarmCollector
|
||||||
|
|
||||||
tpl *template.Template
|
tpl *template.Template
|
||||||
}
|
}
|
||||||
@ -78,6 +79,7 @@ type PageData struct {
|
|||||||
ModelStatusBoard *service.ModelStatusBoard
|
ModelStatusBoard *service.ModelStatusBoard
|
||||||
StandardResources []storage.StandardResourceRecord
|
StandardResources []storage.StandardResourceRecord
|
||||||
ResourceStatusBoard *service.ResourceStatusBoard
|
ResourceStatusBoard *service.ResourceStatusBoard
|
||||||
|
AlarmRecords []service.AlarmRecord
|
||||||
Templates []service.Template
|
Templates []service.Template
|
||||||
Template *service.Template
|
Template *service.Template
|
||||||
AssetTab string
|
AssetTab string
|
||||||
@ -501,6 +503,13 @@ func (u *UI) SetResourcesRepo(repo *storage.ResourcesRepo) {
|
|||||||
u.resourcesRepo = repo
|
u.resourcesRepo = repo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *UI) SetAlarmCollector(ac *service.AlarmCollector) {
|
||||||
|
if u == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u.alarmCollector = ac
|
||||||
|
}
|
||||||
|
|
||||||
func tablerIconSVG(name string) string {
|
func tablerIconSVG(name string) string {
|
||||||
icons := map[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>`,
|
"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("/models/sync", u.actionModelSync)
|
||||||
r.Post("/resources/sync", u.actionResourceSync)
|
r.Post("/resources/sync", u.actionResourceSync)
|
||||||
r.Get("/diagnostics", u.pageDiagnostics)
|
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("/recognition", u.pageRecognition)
|
||||||
r.Get("/logs", u.pageLogs)
|
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)
|
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) {
|
func (u *UI) pageResources(w http.ResponseWriter, r *http.Request) {
|
||||||
u.ensureDevicesLoaded()
|
u.ensureDevicesLoaded()
|
||||||
data := PageData{Title: "资源管理", Devices: u.registry.GetDevices()}
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
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,8 @@
|
|||||||
<div class="nav-group-items">
|
<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/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/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/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>
|
<a class="nav-subitem" href="/ui/system"><span class="nav-icon nav-subicon">{{icon "heartbeat"}}</span><span>系统状态</span></a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
84
internal/web/ui/templates/monitor.html
Normal file
84
internal/web/ui/templates/monitor.html
Normal 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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""); }
|
||||||
|
loadAll();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
Loading…
Reference in New Issue
Block a user