safesight/control/internal/web/ui/templates/device.html
tian c92ad41bd1 feat(control): 视频源状态检测与展示
- 新增 service.FetchVideoSourceStatus: 调 agent /v1/graphs 解析
  input_rtsp 节点 (custom_metrics: streaming/last_error/fail_count)
- 设备详情页运行与服务区块展示每个视频源:
  在线/离线 + 失败原因(如 RTSP 打开失败: 404) + 帧率 + 历史失败次数
- 视频源离线时红色提示,与媒体服务状态区分
2026-08-02 12:20:13 +08:00

318 lines
17 KiB
HTML

{{define "device"}}
{{template "device_header" .}}
<div class="card">
<div class="section-title">
<div>
<h2>设备工作台</h2>
<div class="muted small">单设备查看、服务控制、设备分配应用、模型资源和日志指标都收敛在这里完成。</div>
</div>
<div class="actions">
<a class="btn ghost" href="/devices">返回设备列表</a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/config-preview"><span>预览设备分配</span></a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/config-versions"><span>配置历史</span></a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
</div>
</div>
<div class="summary-strip control-summary">
<div class="summary-chip">
<div class="summary-chip-label">设备状态</div>
<div class="summary-chip-value">{{if .Device.Online}}在线{{else}}离线{{end}}</div>
</div>
<div class="summary-chip">
<div class="summary-chip-label">当前配置</div>
<div class="summary-chip-value">{{if and .ConfigStatus .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else if .PersistedConfig}}{{.PersistedConfig.ConfigID}}{{else}}待读取{{end}}</div>
</div>
<div class="summary-chip">
<div class="summary-chip-label">服务状态</div>
<div class="summary-chip-value">{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}运行中{{else}}状态未知{{end}}</div>
</div>
</div>
</div>
<div class="card">
<div class="actions compact">
<a class="btn ghost" href="#device-overview">概览</a>
<a class="btn ghost" href="#device-runtime">运行与服务</a>
<a class="btn ghost" href="#device-config">设备分配</a>
<a class="btn ghost" href="#device-models">模型与资源</a>
<a class="btn ghost" href="#device-observability">日志与指标</a>
</div>
</div>
<div class="detail-grid" id="device-overview">
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "overview"}}<span>概览</span></h2>
</div>
{{if .Device.Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}
</div>
<div class="info-list">
<div><span>设备名称</span><strong style="display:flex;align-items:center;gap:6px"><span>{{displayDeviceName .Device .ConfigStatus}}</span><span class="btn-icon" style="font-size:12px;cursor:pointer;color:var(--primary);border:1px solid var(--border);border-radius:3px;padding:0 5px;line-height:18px" onclick="editDeviceName(this,'{{.Device.DeviceID}}')"></span></strong></div>
<div><span>设备 ID</span><strong class="mono">{{.Device.DeviceID}}</strong></div>
<div><span>管理地址</span><strong class="mono">{{.Device.IP}}:{{.Device.AgentPort}}</strong></div>
<div><span>视频端口</span><strong class="mono">{{.Device.MediaPort}}</strong></div>
<div><span>最后心跳</span><strong>{{ago .Device.LastSeenMs}}</strong></div>
<div><span>Agent 版本</span><strong class="mono">{{if .Device.Version}}V{{.Device.Version}}{{else}}-{{end}}</strong></div>
{{if .DeviceVersion}}
<div><span>引擎版本</span><strong class="mono">{{if .DeviceVersion.MediaSupported}}{{if .DeviceVersion.MediaVersion}}{{.DeviceVersion.MediaVersion}}{{else}}运行中(版本未知){{end}}{{else}}未启用{{end}}</strong></div>
<div><span>构建信息</span><strong class="mono" style="font-size:12px">{{if .DeviceVersion.AgentBuildID}}{{.DeviceVersion.AgentBuildID}}{{else}}-{{end}}{{if .DeviceVersion.AgentGitSHA}} <span class="muted">({{.DeviceVersion.AgentGitSHA}})</span>{{end}}</strong></div>
<div><span>模型</span><strong class="mono">{{.DeviceVersion.ModelCount}} 个</strong></div>
{{else if .DeviceVersionErr}}
<div><span>引擎版本</span><strong class="mono" style="color:var(--danger)">获取失败</strong></div>
{{end}}
<div><span>当前场景模板</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else if .PersistedConfig}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
<div><span>通道名</span><strong>{{if and .ConfigStatus .ConfigStatus.Metadata.InstanceName}}{{.ConfigStatus.Metadata.InstanceName}}{{else if .Device.InstanceName}}{{.Device.InstanceName}}{{else}}-{{end}}</strong></div>
</div>
</div>
<div class="card">
<div class="section-title">
<div>
<h2 class="title-with-icon">{{icon "config"}}<span>当前运行配置</span></h2>
</div>
{{if .ConfigStatus}}{{if .ConfigStatus.OK}}<span class="pill ok">已读取</span>{{else}}<span class="pill warn">异常</span>{{end}}{{else}}<span class="pill bad">未读取</span>{{end}}
</div>
{{if .ConfigStatus}}
<div class="info-list">
<div><span>配置 ID</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigID}}{{.ConfigStatus.Metadata.ConfigID}}{{else}}未标记{{end}}</strong></div>
<div><span>配置版本</span><strong class="mono">{{if .ConfigStatus.Metadata.ConfigVersion}}{{.ConfigStatus.Metadata.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
<div><span>模板</span><strong>{{if .ConfigStatus.Metadata.Template}}{{.ConfigStatus.Metadata.Template}}{{else}}-{{end}}</strong></div>
<div><span>场景模板</span><strong>{{if .ConfigStatus.Metadata.Profile}}{{.ConfigStatus.Metadata.Profile}}{{else}}-{{end}}</strong></div>
<div><span>调试参数</span><strong class="mono">{{if .ConfigStatus.Metadata.Overlays}}{{range $i, $name := .ConfigStatus.Metadata.Overlays}}{{if $i}}, {{end}}{{$name}}{{end}}{{else}}-{{end}}</strong></div>
<div><span>配置文件</span><strong class="mono">{{.ConfigStatus.ConfigPath}}</strong></div>
<div><span>配置 SHA</span><strong class="mono">{{shortHash .ConfigStatus.Sha256}}</strong></div>
</div>
{{else}}
{{if .PersistedConfig}}
<div class="info-list">
<div><span>配置 ID</span><strong class="mono">{{if .PersistedConfig.ConfigID}}{{.PersistedConfig.ConfigID}}{{else}}未标记{{end}}</strong></div>
<div><span>配置版本</span><strong class="mono">{{if .PersistedConfig.ConfigVersion}}{{.PersistedConfig.ConfigVersion}}{{else}}未标记{{end}}</strong></div>
<div><span>模板</span><strong>{{if .PersistedConfig.TemplateName}}{{.PersistedConfig.TemplateName}}{{else}}-{{end}}</strong></div>
<div><span>场景模板</span><strong>{{if .PersistedConfig.ProfileName}}{{.PersistedConfig.ProfileName}}{{else}}-{{end}}</strong></div>
<div><span>调试参数</span><strong class="mono">{{if .PersistedConfig.OverlaysJSON}}{{.PersistedConfig.OverlaysJSON}}{{else}}-{{end}}</strong></div>
<div><span>最近下发任务</span><strong class="mono">{{if .PersistedConfig.LastAppliedTaskID}}{{.PersistedConfig.LastAppliedTaskID}}{{else}}-{{end}}</strong></div>
</div>
{{else}}
<div class="empty-state compact">
<div class="empty-title">暂未读到配置状态</div>
<div class="muted">{{if .ConfigStatusErr}}{{.ConfigStatusErr}}{{else}}设备未返回配置摘要。{{end}}</div>
</div>
{{end}}
{{end}}
</div>
</div>
<div class="control-grid" id="device-runtime">
<section class="panel-block">
<div class="panel-head">
<div>
<h3 class="title-with-icon">{{icon "service"}}<span>运行与服务</span></h3>
</div>
{{if and .ConfigStatus .ConfigStatus.MediaServer.Running}}<span class="pill ok">运行中</span>{{else}}<span class="pill warn">状态未知</span>{{end}}
</div>
<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" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="secondary">启动服务</button>
</form>
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_restart" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="primary">重启服务</button>
</form>
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="media_stop" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="danger">停止服务</button>
</form>
</div>
</section>
<section class="panel-block" id="device-config">
<div class="panel-head">
<div>
<h3 class="title-with-icon">{{icon "apply"}}<span>设备分配</span></h3>
</div>
</div>
{{if .DeviceAssignment}}
<details class="scene-summary-details">
<summary>查看当前设备分配详情</summary>
<div class="info-list compact-list">
<div><span>场景模板</span><strong class="mono">{{.DeviceAssignment.ProfileName}}</strong></div>
<div><span>识别单元</span><strong>{{.DeviceAssignment.RecognitionCount}} 路</strong></div>
<div><span>说明</span><strong>{{if .DeviceAssignment.Description}}{{.DeviceAssignment.Description}}{{else}}-{{end}}</strong></div>
</div>
</details>
{{else}}
<div class="empty-state compact">
<div class="empty-title">还没有设备分配</div>
<div class="muted">请先到“设备分配”页面为这台设备指定识别单元。</div>
</div>
{{end}}
<div class="actions scene-actions-row">
<form method="post" action="/devices/{{.Device.DeviceID}}/plan-apply">
<button type="submit" class="primary">下发当前设备分配</button>
</form>
<a class="btn secondary" href="/devices/{{.Device.DeviceID}}/config-preview"><span>预览当前设备分配</span></a>
<form method="post" action="/devices/{{.Device.DeviceID}}/action">
<input type="hidden" name="action" value="rollback" />
<input type="hidden" name="return_to" value="config" />
<button type="submit" class="secondary">回滚上一版</button>
</form>
</div>
</section>
<section class="panel-block" id="device-overlays">
<div class="panel-head">
<div>
<h3 class="title-with-icon">{{icon "overlay"}}<span>调试参数覆盖</span></h3>
</div>
</div>
{{if .AssetOverlays}}
<form method="POST" action="/devices/{{.Device.DeviceID}}/overlays">
{{range .AssetOverlays}}
<label class="overlay-check-row">
<input type="checkbox" name="overlay" value="{{.Name}}" {{if hasString $.SelectedOverlays .Name}}checked{{end}}>
<span class="mono" style="min-width:170px">{{.Name}}</span>
{{if .Description}}<span class="muted small">{{.Description}}</span>{{end}}
</label>
{{end}}
<div style="margin-top:12px">
<button type="submit" class="btn primary">保存覆盖参数</button>
</div>
</form>
{{else}}
<div class="empty-state compact">
<div class="muted">暂无可用的调试参数覆盖。在 <a href="/assets/overlays">调试参数管理</a> 中创建。</div>
</div>
{{end}}
</section>
<section class="panel-block" id="device-models">
<div class="panel-head">
<div>
<h3 class="title-with-icon">{{icon "assets"}}<span>模型与资源</span></h3>
</div>
<a class="btn secondary" href="/models">模型管理</a>
</div>
{{if .StandardModels}}
<div class="info-list compact-list" style="margin-bottom:0">
{{range $m := .StandardModels}}
{{$status := "missing"}}
{{range $d := $.DeviceModelStatuses}}{{if eq $d.Name $m.Name}}{{if eq $d.SHA256 $m.SHA256}}{{$status = "ok"}}{{else}}{{$status = "mismatch"}}{{end}}{{end}}{{end}}
<div style="display:flex;justify-content:space-between;align-items:center"><span class="mono" title="{{$m.Name}}" style="margin-bottom:0">{{$m.Name}}</span>
<strong style="flex-shrink:0">{{if eq $status "ok"}}<span class="pill ok">一致</span>{{else if eq $status "mismatch"}}<span class="pill warn">不一致</span>{{else}}<span class="pill bad">缺失</span>{{end}}</strong></div>
{{end}}
</div>
{{else if .DeviceModelStatuses}}
<div class="info-list compact-list" style="margin-bottom:0">
{{range $m := .DeviceModelStatuses}}
<div style="display:flex;justify-content:space-between;align-items:center"><span class="mono" title="{{$m.Name}}" style="margin-bottom:0">{{$m.Name}}</span>
<strong style="flex-shrink:0"><span class="pill ok">{{$m.SHA256}}</span></strong></div>
{{end}}
</div>
{{end}}
{{if .StandardResources}}
<div class="info-list compact-list">
{{range $r := .StandardResources}}
{{$status := "missing"}}
{{range $d := $.DeviceResourceStatuses}}{{if eq $d.Name $r.Name}}{{if eq $d.SHA256 $r.SHA256}}{{$status = "ok"}}{{else}}{{$status = "mismatch"}}{{end}}{{end}}{{end}}
<div style="display:flex;justify-content:space-between;align-items:center"><span class="mono" title="{{$r.Name}}" style="margin-bottom:0">{{$r.Name}} <span class="muted small">({{resourceTypeLabel $r.ResourceType}})</span></span>
<strong style="flex-shrink:0">{{if eq $status "ok"}}<span class="pill ok">一致</span>{{else if eq $status "mismatch"}}<span class="pill warn">不一致</span>{{else}}<span class="pill bad">缺失</span>{{end}}</strong></div>
{{end}}
</div>
{{else if .DeviceResourceStatuses}}
<div class="info-list compact-list">
{{range $r := .DeviceResourceStatuses}}
<div style="display:flex;justify-content:space-between;align-items:center"><span class="mono" title="{{$r.Name}}" style="margin-bottom:0">{{$r.Name}} <span class="muted small">({{resourceTypeLabel $r.ResourceType}})</span></span>
<strong style="flex-shrink:0"><span class="pill ok">{{$r.SHA256}}</span></strong></div>
{{end}}
</div>
{{end}}
{{if not (or .StandardModels .StandardResources .DeviceModelStatuses .DeviceResourceStatuses)}}
<div class="info-list compact-list">
<div><span>模型入口</span><strong>通过模型管理页上传到设备</strong></div>
<div><span>人脸库</span><strong>通过资源管理与基础配置维护</strong></div>
</div>
{{end}}
</section>
<section class="panel-block" id="device-observability">
<div class="panel-head">
<div>
<h3 class="title-with-icon">{{icon "result"}}<span>日志与指标</span></h3>
</div>
</div>
<div class="actions stack">
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/logs?limit=200">诊断日志</a>
<a class="btn ghost" href="/devices/{{.Device.DeviceID}}/graphs">运行指标</a>
<a class="btn ghost" href="/api">高级调试</a>
</div>
{{if .ResultTitle}}<div class="muted small" style="margin-top:12px">{{.ResultTitle}}</div>{{end}}
{{if .Message}}<div class="msg">{{.Message}}</div>{{end}}
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
{{if .RawText}}<pre style="margin-top:10px">{{.RawText}}</pre>{{end}}
</section>
</div>
<script>
function editDeviceName(el, deviceID) {
var strong = el.parentNode;
var nameSpan = strong.querySelector('span:first-child');
var current = nameSpan.textContent.trim();
var input = document.createElement('input');
input.value = current;
input.style.cssText = 'width:200px;padding:2px 6px;font-size:inherit;font-weight:400;border:1px solid var(--primary);border-radius:4px';
nameSpan.style.display = 'none';
el.style.display = 'none';
strong.insertBefore(input, strong.firstChild);
input.focus();
input.select();
function save() {
var name = input.value.trim();
input.remove();
nameSpan.style.display = '';
el.style.display = '';
if (!name || name === current) return;
var form = document.createElement('form');
form.method = 'POST';
form.action = '/devices/' + deviceID + '/alias';
form.style.display = 'none';
var inp = document.createElement('input');
inp.name = 'device_alias';
inp.value = name;
form.appendChild(inp);
document.body.appendChild(form);
form.submit();
}
input.addEventListener('blur', save);
input.addEventListener('keydown', function(e) { if (e.key === 'Enter') save(); if (e.key === 'Escape') { input.remove(); nameSpan.style.display = ''; el.style.display = ''; } });
}
</script>
{{end}}