safesight-control/internal/web/ui/templates/devices.html
2026-04-19 10:17:48 +08:00

369 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{define "devices"}}
<div class="card">
<div class="section-title">
<div>
<h2>局域网扫描</h2>
<div class="muted small">扫描同网段内的视觉识别边缘设备,发现结果会自动加入设备列表。</div>
</div>
<a class="btn ghost" href="/ui/devices-add">新增设备</a>
</div>
<div class="row" style="gap:16px">
<form method="post" action="/ui/discovery/search" class="row">
<div>
<div class="muted small">扫描等待时间(毫秒)</div>
<input name="timeout_ms" value="1200" />
</div>
<div style="align-self:end">
<button type="submit">开始扫描</button>
</div>
</form>
</div>
</div>
<div class="card">
<h2>节点态势</h2>
<div class="stats">
<div class="stat"><div class="k">设备总数</div><div class="v">{{.DeviceCount}}</div><div class="hint">已纳管的边缘设备</div></div>
<div class="stat"><div class="k">在线节点</div><div class="v">{{.OnlineCount}}</div><div class="hint">可接收配置和任务</div></div>
<div class="stat"><div class="k">离线节点</div><div class="v">{{.OfflineCount}}</div><div class="hint">超过心跳窗口未更新</div></div>
<div class="stat"><div class="k">本次扫描</div><div class="v">{{.FoundCount}}</div><div class="hint">刚发现的节点</div></div>
</div>
</div>
{{if .Found}}
<div class="card">
<h2>本次扫描结果({{len .Found}}</h2>
<div class="muted small">发现结果已同步到下方设备列表。</div>
<div class="table-wrap" style="margin-top:10px">
<table>
<thead>
<tr><th>节点标识</th><th>节点名称</th><th>管理地址</th><th>视频服务端口</th><th>版本</th></tr>
</thead>
<tbody>
{{range .Found}}
<tr>
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{.DeviceID}}</a></td>
<td>{{.DeviceName}}</td>
<td class="mono">{{.IP}}:{{.AgentPort}}</td>
<td class="mono">{{.MediaPort}}</td>
<td class="mono">{{.Version}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}
<div class="card">
<h2>设备列表</h2>
<div class="row" style="margin-top:10px">
<div>
<div class="muted small">搜索节点、名称或 IP</div>
<input id="filter" placeholder="例如 edge-01 / 入口 / 192.168" />
</div>
<div class="muted small" style="align-self:end">在线状态由发现心跳和设备访问自动更新</div>
</div>
<form method="post" action="/ui/devices/batch-action" style="margin-top:10px">
<div class="row">
<div>
<div class="muted small">批量运维操作</div>
<select name="action">
<option value="media_start">启动视频分析服务</option>
<option value="media_restart">重启视频分析服务</option>
<option value="media_stop">停止视频分析服务</option>
<option value="reload">重载识别服务</option>
<option value="rollback">回滚识别配置</option>
</select>
</div>
<div>
<div class="muted small">识别方案(可选,仅启动/重启生效)</div>
<input name="config" placeholder="例如 cam1" />
</div>
<div style="align-self:end">
<button type="submit" onclick="return confirmBatchAction(event)">执行操作(已选 <span id="selectedCount">0</span></button>
</div>
</div>
<div id="batch-confirm-panel" class="confirm-panel" hidden>
<div>
<h3>确认批量操作</h3>
<div class="muted small" id="batch-confirm-summary">请确认目标节点和操作类型。</div>
<div class="confirm-list" id="batch-confirm-list"></div>
</div>
<div class="actions">
<button type="button" class="btn ghost" onclick="cancelBatchAction()">取消</button>
<button type="button" onclick="submitBatchAction()">确认执行</button>
</div>
</div>
<div class="table-wrap" style="margin-top:10px">
<table id="devices-table">
<thead>
<tr>
<th style="width:48px"><input id="selectAll" type="checkbox" title="全选(仅作用于当前筛选可见行)" /></th>
<th>节点标识</th><th>节点名称</th><th>管理地址</th><th>状态</th><th>视频分析服务</th><th>最后心跳</th><th>版本</th><th>操作</th>
</tr>
</thead>
<tbody>
{{range .Devices}}
<tr data-device-id="{{.DeviceID}}" data-device-name="{{.DeviceName}}" data-device-ip="{{.IP}}" data-device-agent-port="{{.AgentPort}}" data-device-media-port="{{.MediaPort}}" data-device-online="{{.Online}}" data-device-version="{{.Version}}" data-device-last-seen="{{ago .LastSeenMs}}">
<td><input class="dev-check" type="checkbox" name="device_id" value="{{.DeviceID}}" /></td>
<td><a class="mono" href="/ui/devices/{{.DeviceID}}">{{.DeviceID}}</a></td>
<td>{{.DeviceName}}</td>
<td class="mono">
{{.IP}}:{{.AgentPort}}
<div class="muted small">视频端口:{{.MediaPort}}</div>
</td>
<td>{{if .Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</td>
<td>
{{if .Online}}
<span class="pill warn" data-media-status="{{.DeviceID}}">待查询</span>
{{else}}
<span class="muted">-</span>
{{end}}
</td>
<td>
<div>{{ago .LastSeenMs}}</div>
<div class="muted small mono">{{.LastSeenMs}}</div>
</td>
<td class="mono">{{.Version}}</td>
<td><button type="button" class="btn ghost" onclick="openDeviceDrawer(this)">快速查看</button></td>
</tr>
{{end}}
</tbody>
</table>
</div>
</form>
</div>
<aside id="device-drawer" class="drawer" aria-hidden="true">
<div class="drawer-panel">
<div class="section-title">
<div>
<h2 id="drawer-title">节点摘要</h2>
<div class="muted small" id="drawer-subtitle"></div>
</div>
<button type="button" class="btn ghost" onclick="closeDeviceDrawer()">关闭</button>
</div>
<div class="drawer-grid">
<div><div class="muted small">在线状态</div><div id="drawer-online"></div></div>
<div><div class="muted small">管理地址</div><div class="mono" id="drawer-address"></div></div>
<div><div class="muted small">视频端口</div><div class="mono" id="drawer-media"></div></div>
<div><div class="muted small">最后心跳</div><div id="drawer-last-seen"></div></div>
<div><div class="muted small">版本</div><div class="mono" id="drawer-version"></div></div>
</div>
<div class="actions" style="margin-top:14px">
<a class="btn ghost" id="drawer-detail" href="#">打开详情</a>
<a class="btn ghost" id="drawer-config" href="#">识别配置</a>
<a class="btn ghost" id="drawer-logs" href="#">诊断日志</a>
</div>
</div>
</aside>
<script>
let pendingBatchForm = null;
(() => {
const input = document.getElementById('filter');
const table = document.getElementById('devices-table');
if(!input || !table) return;
input.addEventListener('input', () => {
const q = (input.value || '').trim().toLowerCase();
const rows = table.tBodies[0].rows;
for(const tr of rows){
const txt = tr.innerText.toLowerCase();
tr.style.display = (!q || txt.includes(q)) ? '' : 'none';
}
});
})();
function selectedDeviceRows(){
const table = document.getElementById('devices-table');
if(!table) return [];
return Array.from(table.querySelectorAll('tbody tr')).filter(tr => {
const c = tr.querySelector('input.dev-check');
return c && c.checked;
});
}
function actionLabel(value){
const labels = {
media_start: '启动视频分析服务',
media_restart: '重启视频分析服务',
media_stop: '停止视频分析服务',
reload: '重载识别服务',
rollback: '回滚识别配置'
};
return labels[value] || value || '批量操作';
}
function confirmBatchAction(ev){
ev.preventDefault();
const form = ev.target.closest('form');
const rows = selectedDeviceRows();
if(!rows.length){
alert('请先选择设备');
return false;
}
pendingBatchForm = form;
const action = form.querySelector('select[name="action"]');
const panel = document.getElementById('batch-confirm-panel');
const summary = document.getElementById('batch-confirm-summary');
const list = document.getElementById('batch-confirm-list');
summary.textContent = `即将对 ${rows.length} 台节点执行:${actionLabel(action ? action.value : '')}`;
list.innerHTML = rows.map(tr => {
const id = tr.getAttribute('data-device-id') || '';
const name = tr.getAttribute('data-device-name') || '-';
const ip = tr.getAttribute('data-device-ip') || '-';
return `<span class="pill">${name} / ${ip} / ${id}</span>`;
}).join('');
panel.hidden = false;
panel.scrollIntoView({block:'nearest'});
return false;
}
function cancelBatchAction(){
pendingBatchForm = null;
const panel = document.getElementById('batch-confirm-panel');
if(panel) panel.hidden = true;
}
function submitBatchAction(){
if(pendingBatchForm) pendingBatchForm.submit();
}
function openDeviceDrawer(button){
const tr = button.closest('tr');
if(!tr) return;
const id = tr.getAttribute('data-device-id') || '';
const name = tr.getAttribute('data-device-name') || id;
const ip = tr.getAttribute('data-device-ip') || '';
const agent = tr.getAttribute('data-device-agent-port') || '';
const media = tr.getAttribute('data-device-media-port') || '';
const online = tr.getAttribute('data-device-online') === 'true';
const version = tr.getAttribute('data-device-version') || '-';
const lastSeen = tr.getAttribute('data-device-last-seen') || '-';
document.getElementById('drawer-title').textContent = name || '节点摘要';
document.getElementById('drawer-subtitle').textContent = id;
document.getElementById('drawer-online').innerHTML = online ? '<span class="pill ok">在线</span>' : '<span class="pill bad">离线</span>';
document.getElementById('drawer-address').textContent = `${ip}:${agent}`;
document.getElementById('drawer-media').textContent = media;
document.getElementById('drawer-last-seen').textContent = lastSeen;
document.getElementById('drawer-version').textContent = version;
document.getElementById('drawer-detail').href = `/ui/devices/${encodeURIComponent(id)}`;
document.getElementById('drawer-config').href = `/ui/devices/${encodeURIComponent(id)}/config-friendly`;
document.getElementById('drawer-logs').href = `/ui/devices/${encodeURIComponent(id)}/logs?limit=200`;
const drawer = document.getElementById('device-drawer');
drawer.classList.add('open');
drawer.setAttribute('aria-hidden', 'false');
}
function closeDeviceDrawer(){
const drawer = document.getElementById('device-drawer');
drawer.classList.remove('open');
drawer.setAttribute('aria-hidden', 'true');
}
(() => {
const table = document.getElementById('devices-table');
const selectAll = document.getElementById('selectAll');
const out = document.getElementById('selectedCount');
if(!table || !selectAll || !out) return;
const visibleRows = () => Array.from(table.tBodies[0].rows).filter(tr => tr.style.display !== 'none');
const getChecks = (rows) => rows.map(tr => tr.querySelector('input.dev-check')).filter(Boolean);
const updateCount = () => {
const checks = getChecks(Array.from(table.tBodies[0].rows));
const n = checks.filter(c => c.checked).length;
out.textContent = String(n);
};
selectAll.addEventListener('change', () => {
const rows = visibleRows();
const checks = getChecks(rows);
for(const c of checks) c.checked = selectAll.checked;
updateCount();
});
table.addEventListener('change', (e) => {
const el = e.target;
if(el && el.classList && el.classList.contains('dev-check')) updateCount();
});
updateCount();
})();
(() => {
const els = Array.from(document.querySelectorAll('[data-media-status]'));
if(!els.length) return;
const labelFromHttp = (status, txt) => {
if(status === 501) return {cls:'warn', text:'未启用', title:txt};
if(status === 401) return {cls:'warn', text:'未授权', title:txt};
if(status === 404) return {cls:'warn', text:'未实现', title:txt};
return {cls:'warn', text:`HTTP ${status}`, title:txt};
};
async function fetchOne(el){
const id = el.getAttribute('data-media-status');
el.textContent = '查询中...';
el.className = 'pill warn';
el.title = '';
let res;
let txt = '';
try{
res = await fetch(`/api/devices/${encodeURIComponent(id)}/media-server/status`, { method: 'GET' });
txt = await res.text();
}catch(e){
el.className = 'pill warn';
el.textContent = '请求失败';
el.title = String(e);
return;
}
if(!res.ok){
const v = labelFromHttp(res.status, txt);
el.className = 'pill ' + v.cls;
el.textContent = v.text;
el.title = v.title || '';
return;
}
let obj;
try{ obj = JSON.parse(txt); }catch(e){}
if(!obj || typeof obj.running !== 'boolean'){
el.className = 'pill warn';
el.textContent = '未知';
el.title = txt;
return;
}
const pid = (typeof obj.pid === 'number' && obj.pid > 0) ? obj.pid : 0;
const cfg = (typeof obj.config_path === 'string') ? obj.config_path : '';
if(obj.running){
el.className = 'pill ok';
el.textContent = pid ? `运行中 PID ${pid}` : '运行中';
}else{
el.className = 'pill bad';
el.textContent = pid ? `未运行 PID ${pid}` : '未运行';
}
el.title = cfg;
}
const limit = 6;
let idx = 0;
const workers = Array.from({length: Math.min(limit, els.length)}, async () => {
while(true){
const i = idx++;
if(i >= els.length) return;
await fetchOne(els[i]);
}
});
Promise.all(workers);
})();
</script>
{{end}}