546 lines
21 KiB
HTML
546 lines
21 KiB
HTML
{{define "config_friendly"}}
|
||
{{template "device_nav" .}}
|
||
<div class="card">
|
||
<h2>识别方案配置</h2>
|
||
<div class="muted small">节点:<a class="mono" href="/ui/devices/{{.Device.DeviceID}}">{{.Device.DeviceID}}</a>({{.Device.DeviceName}})。</div>
|
||
</div>
|
||
|
||
<div class="card config-wizard">
|
||
<div class="wizard-steps">
|
||
<button type="button" class="wizard-step active" data-step="node">1 选择节点</button>
|
||
<button type="button" class="wizard-step" data-step="channels">2 编辑视频通道</button>
|
||
<button type="button" class="wizard-step" data-step="preview">3 预览变更</button>
|
||
<button type="button" class="wizard-step" data-step="deploy">4 部署结果</button>
|
||
</div>
|
||
<div class="wizard-panel" data-panel="node">
|
||
<h2>选择节点</h2>
|
||
<div class="drawer-grid" style="margin-top:12px">
|
||
<div><div class="muted small">节点名称</div><div>{{.Device.DeviceName}}</div></div>
|
||
<div><div class="muted small">节点标识</div><div class="mono">{{.Device.DeviceID}}</div></div>
|
||
<div><div class="muted small">管理地址</div><div class="mono">{{.Device.IP}}:{{.Device.AgentPort}}</div></div>
|
||
<div><div class="muted small">状态</div><div>{{if .Device.Online}}<span class="pill ok">在线</span>{{else}}<span class="pill bad">离线</span>{{end}}</div></div>
|
||
</div>
|
||
<div class="actions" style="margin-top:12px"><button type="button" onclick="activateWizardStep('channels')">下一步:编辑视频通道</button></div>
|
||
</div>
|
||
<div class="wizard-panel" data-panel="channels">
|
||
<h2>编辑视频通道</h2>
|
||
<div class="actions" style="margin-top:12px"><button type="button" onclick="document.getElementById('cfg-editor').scrollIntoView({block:'start'}); activateWizardStep('channels')">前往通道编辑器</button></div>
|
||
</div>
|
||
<div class="wizard-panel" data-panel="preview">
|
||
<h2>预览变更</h2>
|
||
<div class="actions" style="margin-top:12px"><button type="button" onclick="document.getElementById('btn-plan').click()">前往预览变更</button></div>
|
||
</div>
|
||
<div class="wizard-panel" data-panel="deploy">
|
||
<h2>部署结果</h2>
|
||
<div class="actions" style="margin-top:12px"><button type="button" onclick="document.getElementById('cfg-result').scrollIntoView({block:'start'})">查看部署结果</button></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" id="cfg-status">
|
||
<h2 id="cfg-status-title">加载中</h2>
|
||
<div class="muted small" id="cfg-status-msg">正在读取配置结构和当前状态...</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" data-wizard-section="channels" 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">通道名称</div>
|
||
<input id="inst-name" placeholder="cam1" />
|
||
</div>
|
||
<div>
|
||
<div class="muted small">识别模板</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">提示:这里只编辑视频通道列表,系统级参数会沿用设备当前配置。</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>通道</th><th>识别模板</th><th>操作</th></tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="actions" style="margin-top:10px">
|
||
<button id="btn-plan" type="button">预览变更</button>
|
||
<button id="btn-apply" type="button">部署到设备</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" id="cfg-result" data-wizard-section="deploy" style="display:none">
|
||
<h2>执行结果</h2>
|
||
<pre id="result-pre"></pre>
|
||
</div>
|
||
|
||
<div class="card" id="cfg-json" data-wizard-section="preview" style="display:none">
|
||
<h2>高级 JSON 预览</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 wizardSteps = Array.from(document.querySelectorAll('.wizard-step'));
|
||
const wizardPanels = Array.from(document.querySelectorAll('.wizard-panel'));
|
||
function activateWizardStep(step){
|
||
for(const el of wizardSteps) el.classList.toggle('active', el.getAttribute('data-step') === step);
|
||
for(const panel of wizardPanels) panel.classList.toggle('active', panel.getAttribute('data-panel') === step);
|
||
}
|
||
for(const el of wizardSteps){
|
||
el.addEventListener('click', () => activateWizardStep(el.getAttribute('data-step')));
|
||
}
|
||
activateWizardStep('node');
|
||
|
||
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">(该模板没有可渲染字段;如需支持,请补充配置结构或使用高级 JSON。)</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('加载中', '正在读取配置结构和当前状态...');
|
||
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节点标识(页面): ${deviceId}\n节点标识(URL): ${idFromPath || '-'}`;
|
||
const hint = (String(t1).includes('device not found') || String(t1).includes('DEVICE_NOT_FOUND'))
|
||
? `\n\n提示:设备列表保存在当前服务内存里;如果刚重启服务,需要先到“设备列表”页点一次“开始扫描”。`
|
||
: '';
|
||
throw new Error(`配置结构 HTTP ${s1.status}: ${t1}${extra}${hint}`);
|
||
}
|
||
if(!s2.ok){
|
||
const extra = `\n\n请求:GET ${stateUrl}\n节点标识(页面): ${deviceId}\n节点标识(URL): ${idFromPath || '-'}`;
|
||
throw new Error(`当前状态 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';
|
||
activateWizardStep('preview');
|
||
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('确认部署到设备?系统会写入配置并重载视频分析服务,失败时会尝试回滚。')) return;
|
||
activateWizardStep('deploy');
|
||
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}}
|