240 lines
7.9 KiB
HTML
240 lines
7.9 KiB
HTML
{{define "devices"}}
|
||
<div class="card">
|
||
<h2>发现设备</h2>
|
||
<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">搜索(UDP 广播)</button>
|
||
</div>
|
||
</form>
|
||
<div style="align-self:end">
|
||
<a href="/ui/devices-add"><button type="button">手动添加设备</button></a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<h2>概览</h2>
|
||
<div class="stats">
|
||
<div class="stat"><div class="k">已登记设备</div><div class="v">{{.DeviceCount}}</div></div>
|
||
<div class="stat"><div class="k">在线</div><div class="v">{{.OnlineCount}}</div></div>
|
||
<div class="stat"><div class="k">离线</div><div class="v">{{.OfflineCount}}</div></div>
|
||
<div class="stat"><div class="k">本次发现</div><div class="v">{{.FoundCount}}</div></div>
|
||
</div>
|
||
</div>
|
||
|
||
{{if .Found}}
|
||
<div class="card">
|
||
<h2>本次发现({{len .Found}})</h2>
|
||
<div class="muted small">提示:发现结果会同步写入下方“设备列表(Registry)”。</div>
|
||
<div class="table-wrap" style="margin-top:10px">
|
||
<table>
|
||
<thead>
|
||
<tr><th>设备ID</th><th>名称</th><th>地址</th><th>Media 端口</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>设备列表(Registry)</h2>
|
||
<div class="row" style="margin-top:10px">
|
||
<div>
|
||
<div class="muted small">筛选(设备ID / 名称 / IP)</div>
|
||
<input id="filter" placeholder="输入关键字..." />
|
||
</div>
|
||
<div class="muted small" style="align-self:end">由 discovery 与轮询自动更新</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">启动 Media</option>
|
||
<option value="media_restart">重启 Media</option>
|
||
<option value="media_stop">停止 Media</option>
|
||
<option value="reload">重载</option>
|
||
<option value="rollback">回滚</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<div class="muted small">config(可选,仅 start/restart 生效)</div>
|
||
<input name="config" placeholder="cam1" />
|
||
</div>
|
||
<div style="align-self:end">
|
||
<button type="submit" onclick="return confirm('确认对所选设备执行批量操作?')">执行(已选 <span id="selectedCount">0</span>)</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>设备ID</th><th>名称</th><th>地址</th><th>状态</th><th>主程序</th><th>最后在线</th><th>版本</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{{range .Devices}}
|
||
<tr>
|
||
<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">media: {{.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>
|
||
</tr>
|
||
{{end}}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<script>
|
||
(() => {
|
||
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';
|
||
}
|
||
});
|
||
})();
|
||
|
||
(() => {
|
||
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}}
|