feat(control): 视频源状态检测与展示

- 新增 service.FetchVideoSourceStatus: 调 agent /v1/graphs 解析
  input_rtsp 节点 (custom_metrics: streaming/last_error/fail_count)
- 设备详情页运行与服务区块展示每个视频源:
  在线/离线 + 失败原因(如 RTSP 打开失败: 404) + 帧率 + 历史失败次数
- 视频源离线时红色提示,与媒体服务状态区分
This commit is contained in:
tian 2026-08-02 12:20:13 +08:00
parent dca065e6cc
commit c92ad41bd1
4 changed files with 112 additions and 0 deletions

View File

@ -0,0 +1,82 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"safesight-control/internal/models"
)
// VideoSourceStatus 设备上一个视频源input_rtsp 节点)的运行状态。
// 数据来自 edge /v1/graphs 节点快照的 custom_metricsmedia 端 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
}

View File

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

View File

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

View File

@ -115,6 +115,23 @@
<div class="info-list compact-list">
<div><span>媒体服务</span><strong>{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}{{if .ConfigStatus.MediaServer.LastError}}{{.ConfigStatus.MediaServer.LastError}}{{else}}状态未知{{end}}{{end}}</strong></div>
</div>
{{if .VideoSources}}
<div class="video-source-list">
{{range .VideoSources}}
<div class="video-source-item">
<div class="video-source-head">
<strong>{{.GraphName}}</strong>
{{if .Streaming}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}
</div>
<div class="mono small muted" title="{{.URL}}">{{.URL}}</div>
{{if .LastError}}<div class="video-source-error" title="{{.LastError}}">⚠ {{.LastError}}</div>{{end}}
{{if not .LastError}}<div class="muted small">帧率 {{printf "%.1f" .InputFPS}} fps{{if gt .FailCount 0}} · 历史失败 {{.FailCount}} 次{{end}}</div>{{end}}
</div>
{{end}}
</div>
{{else if .VideoSourceErr}}
<div class="muted small" style="padding:8px 0">视频源状态获取失败:{{.VideoSourceErr}}</div>
{{end}}
<div class="actions service-actions-row">
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_start" />