diff --git a/control/internal/service/device_video_sources.go b/control/internal/service/device_video_sources.go new file mode 100644 index 0000000..d722009 --- /dev/null +++ b/control/internal/service/device_video_sources.go @@ -0,0 +1,82 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + + "safesight-control/internal/models" +) + +// VideoSourceStatus 设备上一个视频源(input_rtsp 节点)的运行状态。 +// 数据来自 edge /v1/graphs 节点快照的 custom_metrics(media 端 input_rtsp 上报)。 +type VideoSourceStatus struct { + GraphName string `json:"graph_name"` + NodeID string `json:"node_id"` + URL string `json:"url"` + Streaming bool `json:"streaming"` + Online bool `json:"online"` + InputFPS float64 `json:"input_fps"` + FailCount int64 `json:"fail_count"` + LastError string `json:"last_error"` + LastErrorAtMS int64 `json:"last_error_at_ms"` + LastOKAtMS int64 `json:"last_ok_at_ms"` +} + +// FetchVideoSourceStatus 调用 edge agent /v1/graphs,解析所有 input_rtsp 节点的 +// 拉流状态(在线/离线/失败原因)。设备离线或接口不可用时返回 error。 +func FetchVideoSourceStatus(agent *AgentClient, device *models.Device) ([]VideoSourceStatus, error) { + if agent == nil || device == nil || strings.TrimSpace(device.IP) == "" || device.AgentPort <= 0 { + return nil, nil + } + body, status, err := agent.Do("GET", device.IP, device.AgentPort, "/v1/graphs", nil) + if err != nil { + return nil, err + } + if status != 200 { + return nil, fmt.Errorf("agent returned status %d", status) + } + var graphs []struct { + Name string `json:"name"` + Running bool `json:"running"` + Nodes []struct { + ID string `json:"id"` + Type string `json:"type"` + InputFPS float64 `json:"input_fps"` + CustomMetrics struct { + Type string `json:"type"` + URL string `json:"url"` + Streaming bool `json:"streaming"` + FailCount float64 `json:"fail_count"` + LastError string `json:"last_error"` + LastErrorAtMS float64 `json:"last_error_at_ms"` + LastOKAtMS float64 `json:"last_ok_at_ms"` + } `json:"custom_metrics"` + } `json:"nodes"` + } + if err := json.Unmarshal(body, &graphs); err != nil { + return nil, err + } + + var out []VideoSourceStatus + for _, g := range graphs { + for _, n := range g.Nodes { + if n.Type != "input_rtsp" { + continue + } + out = append(out, VideoSourceStatus{ + GraphName: g.Name, + NodeID: n.ID, + URL: n.CustomMetrics.URL, + Streaming: n.CustomMetrics.Streaming, + Online: n.CustomMetrics.Streaming, + InputFPS: n.InputFPS, + FailCount: int64(n.CustomMetrics.FailCount), + LastError: n.CustomMetrics.LastError, + LastErrorAtMS: int64(n.CustomMetrics.LastErrorAtMS), + LastOKAtMS: int64(n.CustomMetrics.LastOKAtMS), + }) + } + } + return out, nil +} diff --git a/control/internal/web/ui.go b/control/internal/web/ui.go index 3c77571..e5ecf40 100644 --- a/control/internal/web/ui.go +++ b/control/internal/web/ui.go @@ -144,6 +144,8 @@ type PageData struct { DeviceResourceStatuses []service.InstalledResourceStatus DeviceVersion *service.DeviceVersionInfo DeviceVersionErr string + VideoSources []service.VideoSourceStatus + VideoSourceErr string Templates []service.Template Template *service.Template AssetTab string @@ -2024,6 +2026,11 @@ func (u *UI) deviceDetailPageData(dev *models.Device) PageData { } else { data.DeviceVersionErr = err.Error() } + if vs, err := service.FetchVideoSourceStatus(u.agent, dev); err == nil { + data.VideoSources = vs + } else { + data.VideoSourceErr = err.Error() + } if items, err := service.FetchInstalledModelStatuses(u.agent, dev); err == nil { data.DeviceModelStatuses = items } diff --git a/control/internal/web/ui/assets/style.css b/control/internal/web/ui/assets/style.css index 127152f..b36a477 100644 --- a/control/internal/web/ui/assets/style.css +++ b/control/internal/web/ui/assets/style.css @@ -478,3 +478,9 @@ body[data-mode=expert] .mode-track{background:var(--primary-strong);border-color .mode-thumb{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--sidebar-text);transition:transform .16s ease} body[data-mode=expert] .mode-thumb{transform:translateX(16px)} body:not([data-mode=expert]) .expert-only{display:none} + +/* 视频源状态(设备详情页) */ +.video-source-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:12px} +.video-source-item{padding:10px 12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface-soft)} +.video-source-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px} +.video-source-error{margin-top:6px;padding:6px 8px;border-radius:var(--radius);background:rgba(220,53,69,.08);color:var(--danger);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} diff --git a/control/internal/web/ui/templates/device.html b/control/internal/web/ui/templates/device.html index b6f8fa6..3311e50 100644 --- a/control/internal/web/ui/templates/device.html +++ b/control/internal/web/ui/templates/device.html @@ -115,6 +115,23 @@
媒体服务{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}{{if .ConfigStatus.MediaServer.LastError}}{{.ConfigStatus.MediaServer.LastError}}{{else}}状态未知{{end}}{{end}}
+ {{if .VideoSources}} +
+ {{range .VideoSources}} +
+
+ {{.GraphName}} + {{if .Streaming}}在线{{else}}离线{{end}} +
+
{{.URL}}
+ {{if .LastError}}
⚠ {{.LastError}}
{{end}} + {{if not .LastError}}
帧率 {{printf "%.1f" .InputFPS}} fps{{if gt .FailCount 0}} · 历史失败 {{.FailCount}} 次{{end}}
{{end}} +
+ {{end}} +
+ {{else if .VideoSourceErr}} +
视频源状态获取失败:{{.VideoSourceErr}}
+ {{end}}