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
@@ -45,7 +45,8 @@ class ChartCfg(BaseModel):
default=None,
description="MQTT topic с numeric payload. Если None — fake sine wave для demo.",
)
refresh_sec: float = Field(default=2.0, ge=0.5, le=60.0)
refresh_sec: float = Field(default=0.5, ge=0.05, le=60.0,
description="0.05 = 20 Hz max; 0.5 default = 2 Hz")
max_points: int = Field(default=60, ge=10, le=600)
line_color: tuple[int, int, int] = (0, 255, 128)
bg_color: tuple[int, int, int, int] = (0, 0, 0, 180)
@@ -248,7 +249,7 @@ class DynamicRenderer:
await self._register_overlay(cfg.id, cfg.target_instance, cfg.cell,
cfg.x, cfg.y, cfg.opacity, cfg.z_order)
registered = True
await asyncio.sleep(0.5) # tight check для chat reactivity
await asyncio.sleep(0.1) # tight check для chat reactivity (10 Hz)
except asyncio.CancelledError:
raise
except Exception as e:
+12 -1
View File
@@ -3,11 +3,13 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
import structlog
from fastapi import Body, FastAPI, HTTPException
from fastapi.responses import Response
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, TypeAdapter
from .config import Config
@@ -36,6 +38,15 @@ def create_app(
description="Control plane для vf_cuda_grid FFmpeg filter",
)
# Static UI — http://controller:8080/ui/
static_dir = Path(__file__).parent / "static"
if static_dir.exists():
app.mount("/ui", StaticFiles(directory=str(static_dir), html=True), name="ui")
@app.get("/")
async def root_redirect():
return FileResponse(static_dir / "index.html")
def _check_instance(name: str):
inst = next((i for i in cfg.instances if i.name == name), None)
if inst is None:
@@ -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>