369 lines
14 KiB
HTML
369 lines
14 KiB
HTML
{{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}}
|