controller: Phase 6+ — Web UI mini dashboard

Static HTML/JS dashboard в cuda_grid_controller/static/index.html, mounted
на /ui и / FastAPI endpoints. Vanilla JS + HLS.js (CDN) для video player.

Controls:
  Audio source — buttons из /audio/{instance} list, switch через POST
  Intercom — Start (music↓) / End (restore)
  Snapshot — opens PNG в new tab
  Manual overlay — form для rect/text/dim/icon types
  Chat — placeholder (info-toast про mosquitto_pub)
  State — refresh каждые 2 sec, shows layout/overlays_count + raw list

Player consumes HLS на http://server:8888/live/index.m3u8 (mediamtx).
TV не нужен — браузер на любом устройстве в LAN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gx
2026-05-21 05:27:27 +01:00
parent d8674e599d
commit d90c139dce
3 changed files with 289 additions and 3 deletions
@@ -0,0 +1,274 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>cuda-grid control</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<style>
:root { --bg:#1a1a1f; --fg:#e6e6e6; --accent:#4af; --muted:#888; --good:#4f8; --warn:#fa4; --err:#f44; }
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,sans-serif; background:var(--bg); color:var(--fg); }
header { padding:8px 16px; background:#0f0f12; border-bottom:1px solid #2a2a2f; display:flex; justify-content:space-between; align-items:center; }
header h1 { margin:0; font-size:16px; font-weight:600; }
header .status { font-size:12px; color:var(--muted); }
.layout { display:grid; grid-template-columns:1fr 360px; gap:12px; padding:12px; height:calc(100vh - 44px); }
@media (max-width:900px) { .layout { grid-template-columns:1fr; height:auto; } }
.video-pane { background:#000; border-radius:6px; overflow:hidden; position:relative; min-height:300px; }
.video-pane video { width:100%; height:100%; object-fit:contain; display:block; }
.controls { overflow-y:auto; }
.card { background:#22232a; border-radius:6px; padding:12px; margin-bottom:10px; }
.card h2 { margin:0 0 8px; font-size:13px; font-weight:600; text-transform:uppercase; color:var(--accent); letter-spacing:.5px; }
.row { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:8px; }
button { background:#333540; color:var(--fg); border:1px solid #444; border-radius:4px; padding:6px 12px; cursor:pointer; font-size:13px; }
button:hover { background:#3d404a; }
button.primary { background:var(--accent); color:#000; border-color:var(--accent); }
button.danger { background:var(--err); border-color:var(--err); }
button:disabled { opacity:.5; cursor:not-allowed; }
input,select,textarea { background:#1a1b22; color:var(--fg); border:1px solid #444; border-radius:4px; padding:6px 8px; font-size:13px; width:100%; }
label { font-size:11px; color:var(--muted); display:block; margin-bottom:2px; }
.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:6px; margin-bottom:6px; }
.toast { position:fixed; bottom:16px; right:16px; padding:8px 16px; border-radius:4px; font-size:13px; transition:opacity .3s; opacity:0; pointer-events:none; }
.toast.show { opacity:1; }
.toast.ok { background:var(--good); color:#000; }
.toast.err { background:var(--err); color:#fff; }
pre { background:#0f0f12; padding:8px; border-radius:4px; font-size:11px; overflow-x:auto; max-height:180px; margin:0; }
.kv { display:grid; grid-template-columns:auto 1fr; gap:4px 10px; font-size:12px; }
.kv .k { color:var(--muted); }
</style>
</head>
<body>
<header>
<h1>cuda-grid control</h1>
<div class="status" id="status">connecting…</div>
</header>
<div class="layout">
<div class="video-pane">
<video id="video" controls autoplay muted playsinline></video>
</div>
<div class="controls">
<!-- Audio -->
<div class="card">
<h2>Audio source</h2>
<div class="row" id="audio-buttons"></div>
</div>
<!-- Intercom -->
<div class="card">
<h2>Intercom (ducking)</h2>
<div class="row">
<button onclick="api('POST','/intercom/tv_grid/start',null,'intercom ON')">Start (music↓)</button>
<button onclick="api('POST','/intercom/tv_grid/end',null,'intercom OFF')">End</button>
</div>
</div>
<!-- Snapshot -->
<div class="card">
<h2>Snapshot</h2>
<div class="row">
<button class="primary" onclick="snap()">Take snapshot</button>
</div>
</div>
<!-- Manual overlay -->
<div class="card">
<h2>Manual overlay</h2>
<div class="form-grid">
<div><label>type</label>
<select id="ov-type" onchange="ovTypeChanged()">
<option value="rect">rect</option>
<option value="text">text</option>
<option value="dim">dim</option>
<option value="icon">icon</option>
</select></div>
<div><label>cell (0..3 / empty=absolute)</label>
<input id="ov-cell" type="number" placeholder=""></div>
<div><label>x</label><input id="ov-x" type="number" step="0.01" value="0.1"></div>
<div><label>y</label><input id="ov-y" type="number" step="0.01" value="0.1"></div>
<div><label>w</label><input id="ov-w" type="number" step="0.01" value="0.3"></div>
<div><label>h</label><input id="ov-h" type="number" step="0.01" value="0.2"></div>
<div><label>color (hex)</label><input id="ov-color" type="color" value="#ff8800"></div>
<div><label>opacity (0..1)</label><input id="ov-opacity" type="number" step="0.05" value="1.0" min="0" max="1"></div>
</div>
<div id="ov-extra" class="form-grid"></div>
<div class="row">
<button class="primary" onclick="addOverlay()">Add</button>
<button class="danger" onclick="api('DELETE','/overlay/tv_grid',null,'cleared all')">Clear all</button>
</div>
</div>
<!-- Chat publish -->
<div class="card">
<h2>Chat message</h2>
<div class="row">
<input id="chat-msg" type="text" placeholder="message" style="flex:1">
<button onclick="pubChat()">Send</button>
</div>
</div>
<!-- State -->
<div class="card">
<h2>State</h2>
<div class="kv" id="state-kv"></div>
<details><summary style="cursor:pointer; font-size:12px; color:var(--muted); margin-top:6px">overlays raw</summary>
<pre id="overlays-raw"></pre></details>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
const INSTANCE = 'tv_grid';
const HLS_URL = `http://${location.hostname}:8888/live/index.m3u8`;
// ── Video ─────────────────────────────────────────────
function initVideo() {
const v = document.getElementById('video');
if (Hls.isSupported()) {
const hls = new Hls({ liveDurationInfinity:true, lowLatencyMode:true, maxBufferLength:6 });
hls.loadSource(HLS_URL);
hls.attachMedia(v);
hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(()=>{}));
hls.on(Hls.Events.ERROR, (_,d) => { if (d.fatal) setStatus('HLS error: '+d.type, true); });
} else if (v.canPlayType('application/vnd.apple.mpegurl')) {
v.src = HLS_URL; v.play().catch(()=>{});
} else {
setStatus('HLS не supported in this browser', true);
}
}
// ── API helpers ───────────────────────────────────────
function setStatus(msg, err=false) {
const s = document.getElementById('status');
s.textContent = msg; s.style.color = err ? 'var(--err)' : 'var(--muted)';
}
function toast(msg, ok=true) {
const t = document.getElementById('toast');
t.textContent = msg; t.className = 'toast show ' + (ok?'ok':'err');
setTimeout(()=>t.className='toast', 1800);
}
async function api(method, path, body=null, okMsg='ok') {
try {
const opts = { method, headers:{} };
if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
const r = await fetch(path, opts);
const j = r.headers.get('content-type')?.includes('json') ? await r.json() : await r.text();
if (r.ok) { toast(okMsg, true); return j; }
toast(`${r.status}: ${JSON.stringify(j).slice(0,80)}`, false);
} catch (e) { toast('fail: '+e.message, false); }
}
// ── Audio buttons (build dynamically) ─────────────────
async function loadAudio() {
const r = await fetch(`/audio/${INSTANCE}`);
if (!r.ok) return;
const d = await r.json();
const box = document.getElementById('audio-buttons');
box.innerHTML = '';
d.sources.forEach(s => {
const b = document.createElement('button');
b.textContent = s.label; b.dataset.name = s.name;
b.onclick = () => api('POST', `/audio/${INSTANCE}/set`, {source:s.name}, '→ '+s.label);
box.appendChild(b);
});
}
// ── Snapshot ──────────────────────────────────────────
async function snap() {
const r = await fetch(`/snapshot/${INSTANCE}`, {method:'POST'});
if (!r.ok) { toast('snapshot fail', false); return; }
const blob = await r.blob();
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
}
// ── Manual overlay ────────────────────────────────────
function ovTypeChanged() {
const t = document.getElementById('ov-type').value;
const extra = document.getElementById('ov-extra');
extra.innerHTML = '';
const wh = document.querySelectorAll('#ov-w,#ov-h');
wh.forEach(e => e.parentElement.style.display = (t==='rect'||t==='dim') ? '' : 'none');
if (t === 'text') {
extra.innerHTML = `<div style="grid-column:1/-1"><label>text</label><input id="ov-text" type="text" value="Hello"></div>
<div><label>font_size</label><input id="ov-font" type="number" value="32" min="8" max="256"></div>`;
} else if (t === 'icon') {
extra.innerHTML = `<div style="grid-column:1/-1"><label>icon name</label><input id="ov-icon" type="text" value="domofon"></div>`;
} else if (t === 'rect') {
extra.innerHTML = `<div><label>border only</label><input id="ov-border" type="checkbox"></div>
<div><label>border width</label><input id="ov-bw" type="number" value="2" min="1" max="32"></div>`;
}
}
function rndId() { return 'ui_'+Math.random().toString(36).slice(2,8); }
async function addOverlay() {
const t = document.getElementById('ov-type').value;
const body = {
id: rndId(),
type: t,
x: +document.getElementById('ov-x').value,
y: +document.getElementById('ov-y').value,
opacity: +document.getElementById('ov-opacity').value,
};
const cell = document.getElementById('ov-cell').value;
if (cell !== '') body.cell = +cell;
if (t==='rect' || t==='dim' || t==='image') {
body.w = +document.getElementById('ov-w').value;
body.h = +document.getElementById('ov-h').value;
}
if (t==='rect' || t==='text' || t==='dim') body.color = document.getElementById('ov-color').value;
if (t==='text') {
body.text = document.getElementById('ov-text').value;
body.font_size = +document.getElementById('ov-font').value;
}
if (t==='icon') body.name = document.getElementById('ov-icon').value;
if (t==='rect') {
body.border_only = document.getElementById('ov-border').checked;
body.border_width = +document.getElementById('ov-bw').value;
}
await api('POST', `/overlay/${INSTANCE}/add`, body, '+overlay '+body.id);
}
// ── Chat ──────────────────────────────────────────────
async function pubChat() {
const msg = document.getElementById('chat-msg').value.trim();
if (!msg) return;
// No direct MQTT publish endpoint in controller — use manual text overlay в качестве workaround
// (controller's chat subscribes к MQTT — нужен external mosquitto_pub).
// Здесь делаем quick — add text overlay временно.
toast('Chat = MQTT topic cuda_grid/chat/alerts (use mosquitto_pub externally)', false);
}
// ── State refresh ─────────────────────────────────────
async function refreshState() {
try {
const r = await fetch('/state');
if (!r.ok) { setStatus('controller offline', true); return; }
const d = await r.json();
const inst = d.instances?.[INSTANCE];
if (!inst) return;
document.getElementById('state-kv').innerHTML =
`<span class="k">layout:</span><span>${inst.active_layout||'—'}</span>` +
`<span class="k">overlays:</span><span>${inst.overlays_count}</span>` +
`<span class="k">zmq:</span><span style="font-size:11px">${inst.zmq_endpoint}</span>`;
const r2 = await fetch(`/overlay/${INSTANCE}`);
if (r2.ok) {
const d2 = await r2.json();
document.getElementById('overlays-raw').textContent =
d2.overlays.map(o => `${o.id} ${o.type} cell=${o.cell ?? '-'}`).join('\n');
}
setStatus(`controller OK · ${inst.overlays_count} overlays`);
} catch (e) { setStatus('refresh fail: '+e.message, true); }
}
// ── Init ──────────────────────────────────────────────
ovTypeChanged();
initVideo();
loadAudio();
refreshState();
setInterval(refreshState, 2000);
</script>
</body>
</html>