safesight-control/internal/web/ui/templates/config_friendly.html

501 lines
18 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 "config_friendly"}}
<div class="card">
<h2>友好配置(实例化通道 / instances</h2>
<div class="muted small">设备:<a class="mono" href="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>{{.Device.DeviceName}}),通过 agent <code class="mono">/v1/config/ui/*</code> 生成并应用配置。</div>
</div>
<div class="card" id="cfg-status">
<h2 id="cfg-status-title">加载中</h2>
<div class="muted small" id="cfg-status-msg">正在读取 schema/state…</div>
<div class="actions" id="cfg-help" style="display:none;margin-top:10px">
<a href="/ui/devices">去设备列表重新搜索</a>
<a href="/ui/devices/{{.Device.DeviceID}}/config-ui">打开 JSON 调试页</a>
</div>
</div>
<div class="card" id="cfg-editor" style="display:none">
<h2>通道列表</h2>
<div class="row" style="margin-top:10px; gap:16px">
<div style="flex:1">
<div class="muted small">新增 / 编辑通道</div>
<div class="row" style="margin-top:8px">
<div>
<div class="muted small">name</div>
<input id="inst-name" placeholder="cam1" />
</div>
<div>
<div class="muted small">template</div>
<select id="inst-template"></select>
</div>
</div>
<div style="margin-top:10px" id="inst-fields"></div>
<div class="actions" style="margin-top:10px">
<button id="btn-save">保存到列表</button>
<button id="btn-reset" type="button">清空表单</button>
</div>
<div class="muted small" style="margin-top:8px">提示:这里只生成 <code class="mono">instances[]</code>通道。global/queue 会沿用设备当前配置。</div>
</div>
<div style="flex:1">
<div class="muted small">当前通道(点击编辑)</div>
<div class="table-wrap" style="margin-top:8px">
<table id="inst-table" style="min-width:640px">
<thead>
<tr><th>name</th><th>template</th><th>操作</th></tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="actions" style="margin-top:10px">
<button id="btn-plan" type="button">Plan预览 diff</button>
<button id="btn-apply" type="button">Apply写盘 + reload</button>
</div>
</div>
</div>
</div>
<div class="card" id="cfg-result" style="display:none">
<h2>结果</h2>
<pre id="result-pre"></pre>
</div>
<div class="card" id="cfg-json" style="display:none">
<h2>将要提交的 JSONconfig/ui/apply 请求体)</h2>
<pre id="payload-pre"></pre>
</div>
<script>
(() => {
const deviceId = {{printf "%q" .Device.DeviceID}};
const statusEl = document.getElementById('cfg-status');
const statusTitleEl = document.getElementById('cfg-status-title');
const statusMsgEl = document.getElementById('cfg-status-msg');
const helpEl = document.getElementById('cfg-help');
const editorEl = document.getElementById('cfg-editor');
const resultEl = document.getElementById('cfg-result');
const resultPre = document.getElementById('result-pre');
const payloadEl = document.getElementById('cfg-json');
const payloadPre = document.getElementById('payload-pre');
const nameEl = document.getElementById('inst-name');
const tplEl = document.getElementById('inst-template');
const fieldsEl = document.getElementById('inst-fields');
const tableBody = document.querySelector('#inst-table tbody');
const btnSave = document.getElementById('btn-save');
const btnReset = document.getElementById('btn-reset');
const btnPlan = document.getElementById('btn-plan');
const btnApply = document.getElementById('btn-apply');
const idFromPath = (() => {
try{
const parts = String(location.pathname || '').split('/');
// /ui/devices/{id}/config-friendly
if(parts.length > 3 && parts[1] === 'ui' && parts[2] === 'devices') return decodeURIComponent(parts[3] || '');
}catch(e){}
return '';
})();
const effectiveId = idFromPath || deviceId;
const apiBase = `/api/devices/${encodeURIComponent(effectiveId)}/v1`;
const PRD_FALLBACK = {
transcode_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
},
yolo_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'model_path', t:'string', req:true},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
},
yolo_alarm_minio: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'model_path', t:'string', req:true},
{k:'minio_endpoint', t:'string', req:true},
{k:'minio_bucket', t:'string', req:true},
{k:'minio_ak', t:'string', req:true},
{k:'minio_sk', t:'string', req:true},
{k:'cooldown_ms', t:'integer', def:3000}
]
},
face_det_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'det_model_path', t:'string', req:true},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
},
face_det_recog_rtsp_hls: {
fields: [
{k:'url', t:'string', req:true, label:'RTSP URL'},
{k:'det_model_path', t:'string', req:true},
{k:'recog_model_path', t:'string', req:true},
{k:'gallery_path', t:'string', def:'./models/face_gallery.db'},
{k:'thr_accept', t:'number', def:0.45},
{k:'thr_margin', t:'number', def:0.05},
{k:'fps', t:'integer', def:30},
{k:'src_w', t:'integer', def:1280},
{k:'src_h', t:'integer', def:720},
{k:'gop', t:'integer', def:60},
{k:'bitrate_kbps', t:'integer', def:2000},
{k:'rtsp_port', t:'integer', def:8555},
{k:'hls_path', t:'string'}
]
}
};
let baseState = { global: undefined, queue: undefined, instances: [] };
let templateSchemas = {}; // name -> {properties, required}
const esc = (s) => String(s ?? '');
const pretty = (obj) => {
try { return JSON.stringify(obj, null, 2); } catch(e) { return String(obj); }
};
function setStatus(title, msg, showHelp){
statusEl.style.display = '';
statusTitleEl.textContent = esc(title);
statusMsgEl.textContent = esc(msg);
helpEl.style.display = showHelp ? '' : 'none';
}
function normalizeSchema(schema){
// Expect something like: {templates:[{name, schema:{type:'object', properties:{...}, required:[...]}}]}
// Fallback: {templates:["..."]}
const out = { templates: [], schemas: {} };
if(!schema || typeof schema !== 'object') return out;
const tpls = schema.templates;
if(Array.isArray(tpls)){
for(const t of tpls){
if(typeof t === 'string'){
out.templates.push(t);
continue;
}
if(t && typeof t === 'object'){
const name = t.name || t.template || t.id;
if(typeof name === 'string' && name){
out.templates.push(name);
const sch = t.schema || t.params_schema || t.paramsSchema;
if(sch && typeof sch === 'object') out.schemas[name] = sch;
}
}
}
}
// Sometimes schema might be a map: {template_schemas:{...}}
const m = schema.template_schemas || schema.templateSchemas || schema.schemas;
if(m && typeof m === 'object'){
for(const [k,v] of Object.entries(m)){
if(!out.templates.includes(k)) out.templates.push(k);
if(v && typeof v === 'object') out.schemas[k] = v;
}
}
if(!out.templates.length){
out.templates = Object.keys(PRD_FALLBACK);
}
return out;
}
function buildFieldList(tplName){
const sch = templateSchemas[tplName];
if(sch && sch.type === 'object' && sch.properties && typeof sch.properties === 'object'){
const req = Array.isArray(sch.required) ? new Set(sch.required) : new Set();
const fields = [];
for(const [k,prop] of Object.entries(sch.properties)){
const t = (prop && typeof prop.type === 'string') ? prop.type : 'string';
fields.push({k, t, req: req.has(k), def: prop.default, enum: prop.enum, desc: prop.description || prop.title});
}
return fields;
}
const fb = PRD_FALLBACK[tplName];
return fb ? fb.fields : [];
}
function inputForField(f, value){
const id = `f_${f.k}`;
const label = f.label || f.k;
const req = f.req ? '<span class="muted small">(必填)</span>' : '';
const desc = f.desc ? `<div class="muted small" style="margin-top:6px">${esc(f.desc)}</div>` : '';
if(Array.isArray(f.enum) && f.enum.length){
const opts = f.enum.map(v => `<option value=${JSON.stringify(String(v))}>${esc(v)}</option>`).join('');
return `<div class="card" style="margin:10px 0;padding:10px 12px">
<div class="muted small">${esc(label)} ${req}</div>
<select id="${id}">${opts}</select>
${desc}
</div>`;
}
const type = (f.t === 'integer' || f.t === 'number') ? 'number' : 'text';
const step = (f.t === 'number') ? 'any' : '1';
const v = (value !== undefined && value !== null) ? value : (f.def !== undefined ? f.def : '');
return `<div class="card" style="margin:10px 0;padding:10px 12px">
<div class="muted small">${esc(label)} ${req}</div>
<input id="${id}" type="${type}" step="${step}" value=${JSON.stringify(String(v))} />
${desc}
</div>`;
}
function collectParams(tplName){
const fields = buildFieldList(tplName);
const params = {};
for(const f of fields){
const el = document.getElementById(`f_${f.k}`);
if(!el) continue;
let v = el.value;
if(v === '' || v === null || v === undefined){
if(f.req) throw new Error(`缺少必填字段: ${f.k}`);
continue;
}
if(f.t === 'integer'){
const n = Number(v);
if(!Number.isFinite(n)) throw new Error(`字段 ${f.k} 不是有效整数`);
params[f.k] = Math.trunc(n);
}else if(f.t === 'number'){
const n = Number(v);
if(!Number.isFinite(n)) throw new Error(`字段 ${f.k} 不是有效数字`);
params[f.k] = n;
}else{
params[f.k] = String(v);
}
}
return params;
}
function renderFields(tplName, params){
const fields = buildFieldList(tplName);
if(!fields.length){
fieldsEl.innerHTML = '<div class="muted small">(该模板没有可渲染字段;如需支持,请补充 schema 或在 PRD_FALLBACK 中补字段)</div>';
return;
}
fieldsEl.innerHTML = fields.map(f => inputForField(f, params ? params[f.k] : undefined)).join('');
// Auto fill hls_path if present
const hls = document.getElementById('f_hls_path');
if(hls && !hls.value){
const nm = (nameEl.value || '').trim();
if(nm) hls.value = `./web/hls/${nm}/index.m3u8`;
}
}
function renderTable(){
tableBody.innerHTML = '';
for(const inst of baseState.instances){
const tr = document.createElement('tr');
tr.innerHTML = `<td class="mono">${esc(inst.name)}</td><td class="mono">${esc(inst.template)}</td><td></td>`;
const td = tr.querySelector('td:last-child');
const btnEdit = document.createElement('button');
btnEdit.textContent = '编辑';
btnEdit.type = 'button';
btnEdit.addEventListener('click', () => {
nameEl.value = inst.name || '';
tplEl.value = inst.template || '';
renderFields(tplEl.value, inst.params || {});
});
const btnDel = document.createElement('button');
btnDel.textContent = '删除';
btnDel.type = 'button';
btnDel.addEventListener('click', () => {
if(!confirm(`确认删除通道 ${inst.name} ?`)) return;
baseState.instances = baseState.instances.filter(x => x.name !== inst.name);
renderTable();
renderPayload();
});
td.appendChild(btnEdit);
td.appendChild(document.createTextNode(' '));
td.appendChild(btnDel);
tableBody.appendChild(tr);
}
}
function buildPayload(){
const p = { instances: baseState.instances };
if(baseState.global !== undefined) p.global = baseState.global;
if(baseState.queue !== undefined) p.queue = baseState.queue;
return p;
}
function renderPayload(){
payloadEl.style.display = '';
payloadPre.textContent = pretty(buildPayload());
}
async function postJson(path, obj){
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(obj)
});
const txt = await res.text();
let j;
try { j = JSON.parse(txt); } catch(e) {}
if(!res.ok){
const msg = j && j.error ? j.error : txt;
throw new Error(`HTTP ${res.status}: ${msg}`);
}
return j || txt;
}
async function load(){
setStatus('加载中', '正在读取 schema/state…');
let schema, state;
try{
const schemaUrl = `${apiBase}/config/ui/schema`;
const stateUrl = `${apiBase}/config/ui/state`;
const [s1, s2] = await Promise.all([fetch(schemaUrl), fetch(stateUrl)]);
const t1 = await s1.text();
const t2 = await s2.text();
schema = (() => { try { return JSON.parse(t1); } catch(e) { return null; } })();
state = (() => { try { return JSON.parse(t2); } catch(e) { return null; } })();
if(!s1.ok){
const extra = `\n\n请求GET ${schemaUrl}\n设备ID(页面): ${deviceId}\n设备ID(URL): ${idFromPath || '-'}`;
const hint = (String(t1).includes('device not found') || String(t1).includes('DEVICE_NOT_FOUND'))
? `\n\n提示managerd 的设备列表在内存里;如果刚重启 managerd需要先到“设备”页点一次“搜索UDP 广播)”。`
: '';
throw new Error(`schema HTTP ${s1.status}: ${t1}${extra}${hint}`);
}
if(!s2.ok){
const extra = `\n\n请求GET ${stateUrl}\n设备ID(页面): ${deviceId}\n设备ID(URL): ${idFromPath || '-'}`;
throw new Error(`state HTTP ${s2.status}: ${t2}${extra}`);
}
}catch(e){
setStatus('加载失败', String(e), true);
return;
}
if(schema && state){
const norm = normalizeSchema(schema);
templateSchemas = norm.schemas || {};
tplEl.innerHTML = '';
for(const n of norm.templates){
const opt = document.createElement('option');
opt.value = n;
opt.textContent = n;
tplEl.appendChild(opt);
}
baseState = {
global: state && typeof state.global === 'object' ? state.global : undefined,
queue: state && typeof state.queue === 'object' ? state.queue : undefined,
instances: Array.isArray(state && state.instances) ? state.instances.map(x => ({
name: x.name,
template: x.template,
params: (x.params && typeof x.params === 'object') ? x.params : {}
})) : []
};
statusEl.style.display = 'none';
editorEl.style.display = '';
renderTable();
tplEl.value = norm.templates[0] || '';
renderFields(tplEl.value, {});
renderPayload();
return;
}
}
tplEl.addEventListener('change', () => renderFields(tplEl.value, {}));
nameEl.addEventListener('input', () => {
const hls = document.getElementById('f_hls_path');
if(hls && (!hls.value || hls.value.includes('/hls/'))) {
const nm = (nameEl.value || '').trim();
if(nm) hls.value = `./web/hls/${nm}/index.m3u8`;
}
});
btnReset.addEventListener('click', () => {
nameEl.value = '';
renderFields(tplEl.value, {});
});
btnSave.addEventListener('click', (ev) => {
ev.preventDefault();
resultEl.style.display = 'none';
try{
const name = (nameEl.value || '').trim();
if(!name) throw new Error('name 不能为空');
const tplName = tplEl.value;
if(!tplName) throw new Error('template 不能为空');
const params = collectParams(tplName);
const next = { name, template: tplName, params };
const idx = baseState.instances.findIndex(x => x.name === name);
if(idx >= 0) baseState.instances[idx] = next;
else baseState.instances.push(next);
baseState.instances.sort((a,b) => String(a.name).localeCompare(String(b.name)));
renderTable();
renderPayload();
setStatus('已更新', `已保存到列表:${name}`);
statusEl.style.display = '';
setTimeout(() => { statusEl.style.display = 'none'; }, 1200);
}catch(e){
setStatus('校验失败', String(e));
statusEl.style.display = '';
}
});
btnPlan.addEventListener('click', async () => {
resultEl.style.display = 'none';
try{
const payload = buildPayload();
renderPayload();
const out = await postJson(`${apiBase}/config/ui/plan`, payload);
resultEl.style.display = '';
resultPre.textContent = pretty(out);
}catch(e){
resultEl.style.display = '';
resultPre.textContent = String(e);
}
});
btnApply.addEventListener('click', async () => {
resultEl.style.display = 'none';
if(!confirm('确认 Apply将写盘并触发 media-server reload失败会自动 rollback')) return;
try{
const payload = buildPayload();
renderPayload();
const out = await postJson(`${apiBase}/config/ui/apply`, payload);
resultEl.style.display = '';
resultPre.textContent = pretty(out);
}catch(e){
resultEl.style.display = '';
resultPre.textContent = String(e);
}
});
load();
})();
</script>
{{end}}