gui: redesign WebRTC admin page with Wild Dragon brand
Some checks failed
ci / vet + build (push) Failing after 4m50s
ci / race tests (push) Has been skipped
ci / WebRTC smoke (5-viewer fanout) (push) Has been skipped
ci / WebRTC latency p95 gate (push) Has been skipped

- Rajdhani + JetBrains Mono typefaces via Google Fonts
- Deep dark palette: #09090d bg, #ff5c28 accent
- 22px dot-grid background texture
- Sticky frosted-glass header with WD monogram SVG
- Wild Dragon wordmark SVG on login panel
- Process cards with border-left state indicator (green/amber/red)
- Animated pulse dots on state badges
- Cleaner WHEP URL row with Copy + Open buttons
- Log panels hidden until first entry (no empty box on load)
- All JS functionality preserved (JWT auth, toggle+restart, WHEP copy)
This commit is contained in:
Zac Gaetano 2026-05-09 12:27:08 -04:00
parent dd639b697f
commit 70d0ddb2e3

View file

@ -2,217 +2,377 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Dragon Fork — WebRTC Admin</title> <title>Wild Dragon — WebRTC Admin</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Rajdhani:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
color-scheme: light dark; --bg: #09090d;
--fg: #e7e7ea; --panel: #111118;
--bg: #0d0e12; --panel2: #16161f;
--accent: #ff6633; --border: #1f1f2e;
--muted: #8b8e98; --border2: #2a2a3d;
--good: #5dd29c; --fg: #e8e8f0;
--warn: #ffb45e; --fg2: #9898b0;
--bad: #ff6470; --accent: #ff5c28;
--panel: #1a1c22; --accent2: #ff7a4a;
--border: #232530; --good: #4ecb8d;
--bad: #ff4d60;
--warn: #f5a623;
--mono: 'JetBrains Mono', ui-monospace, Menlo, monospace;
} }
* { box-sizing: border-box; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
margin: 0; font-family: 'Rajdhani', system-ui, sans-serif;
font: 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 16px;
font-weight: 500;
line-height: 1.5;
background: var(--bg); background: var(--bg);
color: var(--fg); color: var(--fg);
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* dot grid */
background-image: radial-gradient(circle, #1e1e30 1px, transparent 1px);
background-size: 22px 22px;
background-attachment: fixed;
} }
header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
gap: 0.75rem;
flex-wrap: wrap;
}
header h1 {
margin: 0;
font-size: 1.05rem;
letter-spacing: 0.02em;
}
header h1 .accent { color: var(--accent); }
header .subtitle { color: var(--muted); font-size: 0.85rem; }
header .spacer { flex: 1; }
header .nav a {
color: var(--muted);
text-decoration: none;
margin-left: 1rem;
font-size: 0.85rem;
}
header .nav a:hover { color: var(--accent); }
/* ── HEADER ─────────────────────────────────────────────────── */
header {
position: sticky;
top: 0;
z-index: 100;
height: 52px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
background: rgba(9,9,13,0.88);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.header-logo {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
flex-shrink: 0;
}
/* WD monogram — 28×28 */
.header-monogram {
width: 28px;
height: 28px;
flex-shrink: 0;
}
.header-divider {
width: 1px;
height: 22px;
background: var(--border2);
flex-shrink: 0;
}
.header-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg);
}
.header-title span { color: var(--accent); }
.header-spacer { flex: 1; }
nav a {
color: var(--fg2);
text-decoration: none;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-left: 20px;
transition: color 0.15s;
}
nav a:hover { color: var(--accent); }
/* ── MAIN ───────────────────────────────────────────────────── */
main { main {
padding: 1.5rem; flex: 1;
max-width: 1200px; padding: 32px 24px;
max-width: 960px;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
flex: 1; }
/* ── LOGIN PANEL ────────────────────────────────────────────── */
#login-panel {
max-width: 400px;
margin: 48px auto 0;
}
.login-wordmark {
display: block;
margin: 0 auto 28px;
} }
.panel { .panel {
background: var(--panel); background: var(--panel);
border-radius: 10px; border: 1px solid var(--border);
padding: 1.25rem; border-radius: 12px;
margin-bottom: 1rem; padding: 24px;
} margin-bottom: 16px;
.panel h2 {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
} }
.row { .panel-header {
display: flex; display: flex;
gap: 0.75rem;
align-items: center; align-items: center;
flex-wrap: wrap; justify-content: space-between;
margin-bottom: 20px;
} }
label { .panel-title {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--fg2);
}
/* ── FORM ELEMENTS ──────────────────────────────────────────── */
.field {
margin-bottom: 14px;
}
.field label {
display: block; display: block;
margin-top: 0.5rem; font-size: 11px;
color: var(--muted); font-weight: 700;
font-size: 0.78rem; letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--fg2);
margin-bottom: 6px;
} }
input[type=text], input[type=password] { .field input {
width: 100%; width: 100%;
padding: 0.55rem 0.7rem; padding: 10px 12px;
margin-top: 0.25rem;
background: var(--bg); background: var(--bg);
border: 1px solid #2a2c36; border: 1px solid var(--border2);
border-radius: 6px;
color: var(--fg);
font: inherit;
}
input[type=text]:focus, input[type=password]:focus {
border-color: var(--accent);
outline: none;
}
button {
padding: 0.55rem 1rem;
border: none;
border-radius: 6px;
background: var(--accent);
color: #000;
font-weight: 600;
cursor: pointer;
font: inherit;
font-weight: 600;
}
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.secondary { background: #2a2c36; color: var(--fg); }
button.danger { background: rgba(255,100,112,0.20); color: var(--bad); }
button.small { padding: 0.35rem 0.7rem; font-size: 0.85rem; }
.pill {
display: inline-block;
padding: 0.1rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
background: #2a2c36;
}
.pill.good { background: rgba(93,210,156,0.18); color: var(--good); }
.pill.warn { background: rgba(255,180,94,0.18); color: var(--warn); }
.pill.bad { background: rgba(255,100,112,0.20); color: var(--bad); }
.process-list { display: flex; flex-direction: column; gap: 0.6rem; }
.process {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem 1rem;
padding: 0.9rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
color: var(--fg);
font-family: 'Rajdhani', sans-serif;
font-size: 15px;
font-weight: 500;
transition: border-color 0.15s;
} }
.process .id { .field input:focus {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; outline: none;
font-weight: 600; border-color: var(--accent);
word-break: break-all; box-shadow: 0 0 0 3px rgba(255,92,40,0.12);
} }
.process .meta {
color: var(--muted); /* ── BUTTONS ────────────────────────────────────────────────── */
font-size: 0.8rem; .btn {
} display: inline-flex;
.process .actions {
display: flex;
gap: 0.4rem;
align-items: flex-start;
flex-wrap: wrap;
justify-content: flex-end;
}
.process .whep-url {
grid-column: 1 / -1;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.78rem;
color: var(--accent);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4rem 0.6rem;
word-break: break-all;
display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 6px;
padding: 9px 18px;
border: none;
border-radius: 8px;
font-family: 'Rajdhani', sans-serif;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
} }
.process .whep-url a { .btn:active { transform: scale(0.97); }
color: var(--muted); .btn:disabled { opacity: 0.35; cursor: not-allowed; transform: none; }
text-decoration: none; .btn-primary { background: var(--accent); color: #fff; }
margin-left: auto; .btn-primary:hover:not(:disabled) { background: var(--accent2); }
.btn-secondary { background: var(--panel2); color: var(--fg); border: 1px solid var(--border2); }
.btn-secondary:hover:not(:disabled) { border-color: var(--fg2); }
.btn-danger { background: rgba(255,77,96,0.12); color: var(--bad); border: 1px solid rgba(255,77,96,0.25); }
.btn-danger:hover:not(:disabled) { background: rgba(255,77,96,0.22); }
.btn-sm { padding: 6px 12px; font-size: 12px; }
/* ── BADGES ─────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.badge-good { background: rgba(78,203,141,0.12); color: var(--good); }
.badge-bad { background: rgba(255,77,96,0.12); color: var(--bad); }
.badge-warn { background: rgba(245,166,35,0.12); color: var(--warn); }
.badge-neutral { background: var(--panel2); color: var(--fg2); }
.badge-accent { background: rgba(255,92,40,0.12); color: var(--accent); }
/* animated pulse dot */
.pulse-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0; flex-shrink: 0;
} }
.process .whep-url a:hover { color: var(--accent); } .pulse-dot.good { background: var(--good); box-shadow: 0 0 0 0 rgba(78,203,141,0.4); animation: pulse-green 2s infinite; }
.pulse-dot.bad { background: var(--bad); box-shadow: 0 0 0 0 rgba(255,77,96,0.4); animation: pulse-red 2s infinite; }
.pulse-dot.warn { background: var(--warn); box-shadow: 0 0 0 0 rgba(245,166,35,0.4); animation: pulse-warn 2s infinite; }
.empty { @keyframes pulse-green {
color: var(--muted); 0% { box-shadow: 0 0 0 0 rgba(78,203,141,0.5); }
text-align: center; 70% { box-shadow: 0 0 0 6px rgba(78,203,141,0); }
padding: 2rem 0; 100% { box-shadow: 0 0 0 0 rgba(78,203,141,0); }
}
@keyframes pulse-red {
0% { box-shadow: 0 0 0 0 rgba(255,77,96,0.5); }
70% { box-shadow: 0 0 0 6px rgba(255,77,96,0); }
100% { box-shadow: 0 0 0 0 rgba(255,77,96,0); }
}
@keyframes pulse-warn {
0% { box-shadow: 0 0 0 0 rgba(245,166,35,0.5); }
70% { box-shadow: 0 0 0 6px rgba(245,166,35,0); }
100% { box-shadow: 0 0 0 0 rgba(245,166,35,0); }
} }
/* ── PROCESS CARDS ──────────────────────────────────────────── */
.process-list { display: flex; flex-direction: column; gap: 10px; }
.process-card {
background: var(--panel2);
border: 1px solid var(--border);
border-left: 3px solid var(--border2);
border-radius: 10px;
padding: 16px 18px;
display: grid;
grid-template-columns: 1fr auto;
gap: 12px 16px;
transition: border-color 0.2s;
}
.process-card.state-running { border-left-color: var(--good); }
.process-card.state-failed { border-left-color: var(--bad); }
.process-card.state-connecting { border-left-color: var(--warn); }
.process-card.state-finished { border-left-color: var(--fg2); }
.process-id {
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
color: var(--fg);
word-break: break-all;
margin-bottom: 8px;
}
.process-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.process-ref {
font-size: 12px;
color: var(--fg2);
font-family: var(--mono);
}
.process-actions {
display: flex;
gap: 8px;
align-items: flex-start;
justify-content: flex-end;
}
.whep-row {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 10px;
background: rgba(255,92,40,0.06);
border: 1px solid rgba(255,92,40,0.2);
border-radius: 8px;
padding: 8px 12px;
font-family: var(--mono);
font-size: 12px;
color: var(--accent);
word-break: break-all;
}
.whep-row .whep-label {
font-family: 'Rajdhani', sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg2);
flex-shrink: 0;
}
.whep-row .whep-url-text { flex: 1; }
.whep-row .whep-actions { display: flex; gap: 6px; flex-shrink: 0; }
/* ── LOG ────────────────────────────────────────────────────── */
.log { .log {
margin-top: 1rem; margin-top: 16px;
max-height: 180px; max-height: 160px;
overflow-y: auto; overflow-y: auto;
background: var(--bg); background: var(--bg);
border: 1px solid var(--border); border: 1px solid var(--border);
padding: 0.6rem 0.8rem; border-radius: 8px;
border-radius: 6px; padding: 10px 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: var(--mono);
font-size: 0.78rem; font-size: 12px;
line-height: 1.4; line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
.log .ts { color: var(--muted); } .log .ts { color: var(--fg2); }
.log .lvl-bad { color: var(--bad); } .log .l-bad { color: var(--bad); }
.log .lvl-good { color: var(--good); } .log .l-good { color: var(--good); }
.log .lvl-warn { color: var(--warn); } .log .l-warn { color: var(--warn); }
/* Login screen takes the full panel by itself */ /* ── EMPTY STATE ─────────────────────────────────────────────── */
#login-panel { max-width: 420px; margin: 4rem auto 0; } .empty {
text-align: center;
padding: 40px 20px;
color: var(--fg2);
font-size: 15px;
}
/* ── ROW UTIL ────────────────────────────────────────────────── */
.row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
</style> </style>
</head> </head>
<body> <body>
<!-- ── HEADER ──────────────────────────────────────────────────── -->
<header> <header>
<h1>Dragon Fork <span class="accent">WebRTC Admin</span></h1> <a class="header-logo" href="/">
<span class="subtitle">enable / disable WebRTC egress per process</span> <!-- WD monogram 28×28 -->
<span class="spacer"></span> <svg class="header-monogram" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<nav class="nav"> <rect width="28" height="28" rx="6" fill="#1a1a24"/>
<!-- flame chevron -->
<path d="M14 20 L8 10 L11.5 13 L14 8 L16.5 13 L20 10 Z" fill="#ff5c28" opacity="0.9"/>
<!-- WD text mark -->
<text x="3.5" y="26" font-family="'Rajdhani',sans-serif" font-size="8.5" font-weight="700" fill="#e8e8f0" letter-spacing="0.5">WD</text>
</svg>
</a>
<div class="header-divider"></div>
<span class="header-title">WebRTC <span>Admin</span></span>
<div class="header-spacer"></div>
<nav>
<a href="/">Restreamer</a> <a href="/">Restreamer</a>
<a href="/whep-player.html">WHEP Player</a> <a href="/whep-player.html">WHEP Player</a>
<a href="/api/swagger/index.html">API</a> <a href="/api/swagger/index.html">API</a>
@ -220,58 +380,95 @@
</nav> </nav>
</header> </header>
<!-- ── MAIN ────────────────────────────────────────────────────── -->
<main> <main>
<!-- LOGIN PANEL --> <!-- LOGIN PANEL -->
<section id="login-panel" class="panel"> <section id="login-panel">
<h2>Sign in</h2>
<p style="color: var(--muted); margin: 0 0 0.75rem 0;"> <!-- Wild Dragon wordmark SVG -->
Use the same credentials as Core's API <svg class="login-wordmark" width="220" height="48" viewBox="0 0 220 48" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="Wild Dragon">
(<code>API_AUTH_USERNAME</code> / <code>API_AUTH_PASSWORD</code>). <!-- rounded dark rect icon -->
<rect x="0" y="4" width="40" height="40" rx="8" fill="#1a1a24"/>
<!-- flame gradient defs -->
<defs>
<linearGradient id="flameG" x1="20" y1="38" x2="20" y2="12" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#ff5c28"/>
<stop offset="100%" stop-color="#ffaa44"/>
</linearGradient>
</defs>
<!-- flame/chevron -->
<path d="M20 36 L11 20 L16 24 L20 14 L24 24 L29 20 Z" fill="url(#flameG)"/>
<!-- W mark -->
<text x="5" y="44" font-family="'Rajdhani',sans-serif" font-size="13" font-weight="700" fill="#c8c8e0" letter-spacing="1">WD</text>
<!-- WILD text -->
<text x="50" y="26" font-family="'Rajdhani',sans-serif" font-size="20" font-weight="300" letter-spacing="4" fill="#e8e8f0">WILD</text>
<!-- DRAGON text -->
<text x="50" y="44" font-family="'Rajdhani',sans-serif" font-size="20" font-weight="700" letter-spacing="4" fill="#ff5c28">DRAGON</text>
</svg>
<div class="panel">
<div class="panel-header">
<span class="panel-title">Sign in</span>
</div>
<p style="font-size:14px; color:var(--fg2); margin-bottom:18px;">
Use your Core API credentials (<code style="font-family:var(--mono);font-size:12px;color:var(--fg)">API_AUTH_USERNAME</code> / <code style="font-family:var(--mono);font-size:12px;color:var(--fg)">API_AUTH_PASSWORD</code>).
</p> </p>
<div class="field">
<label for="login-user">Username</label> <label for="login-user">Username</label>
<input id="login-user" type="text" autocomplete="username"> <input id="login-user" type="text" autocomplete="username" placeholder="admin">
</div>
<div class="field">
<label for="login-pass">Password</label> <label for="login-pass">Password</label>
<input id="login-pass" type="password" autocomplete="current-password"> <input id="login-pass" type="password" autocomplete="current-password">
<div class="row" style="margin-top: 1rem;">
<button id="btn-login">Sign in</button>
</div> </div>
<div id="login-log" class="log" style="margin-top: 1rem;" aria-live="polite"></div> <div class="row" style="margin-top:18px;">
<button class="btn btn-primary" id="btn-login">Sign in</button>
</div>
<div id="login-log" class="log" aria-live="polite" style="display:none"></div>
</div>
</section> </section>
<!-- ADMIN PANEL --> <!-- ADMIN PANEL -->
<section id="admin-panel" class="panel" style="display:none"> <section id="admin-panel" style="display:none">
<div class="row" style="justify-content: space-between; margin-bottom: 0.75rem;"> <div class="panel">
<h2 style="margin: 0;">Processes</h2> <div class="panel-header">
<div class="row" style="gap: 0.4rem;"> <span class="panel-title">Processes</span>
<button id="btn-refresh" class="secondary small">Refresh</button> <div class="row" style="gap:6px;">
<button class="btn btn-secondary btn-sm" id="btn-refresh">↻ Refresh</button>
</div> </div>
</div> </div>
<div id="process-list" class="process-list"> <div id="process-list" class="process-list">
<div class="empty">Loading...</div> <div class="empty">Loading…</div>
</div>
<div id="admin-log" class="log" aria-live="polite" style="display:none"></div>
</div> </div>
<div id="admin-log" class="log" style="margin-top: 1rem;" aria-live="polite"></div>
</section> </section>
</main> </main>
<script> <script>
// --- tiny state ------------------------------------------------- // ── tiny state ────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const TOKEN_KEY = 'dragonfork-admin-token'; const TOKEN_KEY = 'dragonfork-admin-token';
let authToken = null; let authToken = null;
function log(panel, line, level = 'info') { function log(panel, line, level = 'info') {
panel.style.display = '';
const ts = new Date().toLocaleTimeString(); const ts = new Date().toLocaleTimeString();
const cls = level === 'bad' ? 'l-bad' : level === 'good' ? 'l-good' : level === 'warn' ? 'l-warn' : '';
const div = document.createElement('div'); const div = document.createElement('div');
div.innerHTML = `<span class="ts">${ts}</span> <span class="lvl-${level}">${escapeHTML(line)}</span>`; div.innerHTML = `<span class="ts">${ts}</span> <span class="${cls}">${escapeHTML(line)}</span>`;
panel.prepend(div); panel.prepend(div);
} }
function escapeHTML(s) { function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ return String(s).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[c]); })[c]);
} }
// --- auth ------------------------------------------------------- // ── auth ──────────────────────────────────────────────────────────
function setAuth(token) { function setAuth(token) {
authToken = token; authToken = token;
if (token) { if (token) {
@ -325,15 +522,13 @@
$('login-user').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); }); $('login-user').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
$('logout-link').addEventListener('click', (e) => { e.preventDefault(); setAuth(null); }); $('logout-link').addEventListener('click', (e) => { e.preventDefault(); setAuth(null); });
// Restore session if we have a stashed token. The first /process call // Restore cached session (will bounce back to login on 401)
// will fail with 401 if it's expired; we catch that and bounce back to
// the login panel rather than asking the user to clear localStorage.
try { try {
const cached = localStorage.getItem(TOKEN_KEY); const cached = localStorage.getItem(TOKEN_KEY);
if (cached) setAuth(cached); if (cached) setAuth(cached);
} catch (e) {} } catch (e) {}
// --- API helpers ------------------------------------------------ // ── API helpers ───────────────────────────────────────────────────
async function api(method, path, body) { async function api(method, path, body) {
const headers = { 'Authorization': 'Bearer ' + authToken }; const headers = { 'Authorization': 'Bearer ' + authToken };
const init = { method, headers }; const init = { method, headers };
@ -343,7 +538,6 @@
} }
const r = await fetch(path, init); const r = await fetch(path, init);
if (r.status === 401) { if (r.status === 401) {
// Stale token. Bounce to login.
log($('admin-log'), 'session expired, please sign in again', 'warn'); log($('admin-log'), 'session expired, please sign in again', 'warn');
setAuth(null); setAuth(null);
throw new Error('unauthorized'); throw new Error('unauthorized');
@ -356,16 +550,13 @@
return r.json(); return r.json();
} }
// --- process list ----------------------------------------------- // ── process list ──────────────────────────────────────────────────
$('btn-refresh').addEventListener('click', refreshProcesses); $('btn-refresh').addEventListener('click', refreshProcesses);
async function refreshProcesses() { async function refreshProcesses() {
if (!authToken) return; if (!authToken) return;
$('process-list').innerHTML = '<div class="empty">Loading...</div>'; $('process-list').innerHTML = '<div class="empty">Loading</div>';
try { try {
// The list endpoint paginates via ?id=&filter=&order=. Without
// params it returns up to a server-side cap which is plenty for
// a 1-5 stream deploy. ?filter=config keeps the response small.
const procs = await api('GET', '/api/v3/process?filter=config,state'); const procs = await api('GET', '/api/v3/process?filter=config,state');
renderProcesses(procs); renderProcesses(procs);
} catch (err) { } catch (err) {
@ -375,6 +566,26 @@
} }
} }
function stateClass(state) {
if (state === 'running') return 'state-running';
if (state === 'failed' || state === 'killed') return 'state-failed';
if (state === 'finishing') return 'state-finished';
return 'state-connecting'; // starting, reconnecting, idle, etc.
}
function stateBadgeClass(state) {
if (state === 'running') return 'badge-good';
if (state === 'failed' || state === 'killed') return 'badge-bad';
if (state === 'finishing') return 'badge-neutral';
return 'badge-warn';
}
function stateDotClass(state) {
if (state === 'running') return 'good';
if (state === 'failed' || state === 'killed') return 'bad';
return 'warn';
}
function renderProcesses(procs) { function renderProcesses(procs) {
const list = $('process-list'); const list = $('process-list');
list.innerHTML = ''; list.innerHTML = '';
@ -389,86 +600,104 @@
} }
function renderProcess(proc) { function renderProcess(proc) {
const el = document.createElement('div');
el.className = 'process';
const cfg = proc.config || {}; const cfg = proc.config || {};
const webrtcEnabled = !!(cfg.webrtc && cfg.webrtc.enabled); const webrtcEnabled = !!(cfg.webrtc && cfg.webrtc.enabled);
const state = (proc.state && proc.state.exec) || (proc.state && proc.state.state) || 'unknown'; const state = (proc.state && proc.state.exec) || (proc.state && proc.state.state) || 'unknown';
const stateKlass = state === 'running' ? 'good' : (state === 'failed' ? 'bad' : 'warn');
// Left side: id + meta const card = document.createElement('div');
card.className = 'process-card ' + stateClass(state);
// ── left ──
const left = document.createElement('div'); const left = document.createElement('div');
left.innerHTML = `
<div class="id">${escapeHTML(proc.id || '(no id)')}</div>
<div class="meta">
<span class="pill ${stateKlass}">${escapeHTML(state)}</span>
WebRTC: <span class="pill ${webrtcEnabled ? 'good' : ''}">${webrtcEnabled ? 'enabled' : 'disabled'}</span>
${cfg.reference ? '· ref: ' + escapeHTML(cfg.reference) : ''}
</div>
`;
el.appendChild(left);
// Right side: action buttons // id
const idEl = document.createElement('div');
idEl.className = 'process-id';
idEl.textContent = proc.id || '(no id)';
left.appendChild(idEl);
// meta row: state badge + webrtc badge + ref
const meta = document.createElement('div');
meta.className = 'process-meta';
const dotCls = stateDotClass(state);
const badgeCls = stateBadgeClass(state);
const stateBadge = document.createElement('span');
stateBadge.className = 'badge ' + badgeCls;
stateBadge.innerHTML = `<span class="pulse-dot ${dotCls}"></span>${escapeHTML(state)}`;
meta.appendChild(stateBadge);
const webrtcBadge = document.createElement('span');
webrtcBadge.className = 'badge ' + (webrtcEnabled ? 'badge-accent' : 'badge-neutral');
webrtcBadge.textContent = 'WebRTC ' + (webrtcEnabled ? 'on' : 'off');
meta.appendChild(webrtcBadge);
if (cfg.reference) {
const ref = document.createElement('span');
ref.className = 'process-ref';
ref.textContent = cfg.reference;
meta.appendChild(ref);
}
left.appendChild(meta);
card.appendChild(left);
// ── right: actions ──
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'actions'; actions.className = 'process-actions';
const toggle = document.createElement('button');
toggle.className = webrtcEnabled ? 'danger small' : 'small';
toggle.textContent = webrtcEnabled ? 'Disable WebRTC' : 'Enable WebRTC';
toggle.addEventListener('click', () => toggleWebRTC(proc.id, !webrtcEnabled, toggle));
actions.appendChild(toggle);
el.appendChild(actions);
// Bottom row: WHEP URL with copy and "open in player" const toggleBtn = document.createElement('button');
toggleBtn.className = 'btn btn-sm ' + (webrtcEnabled ? 'btn-danger' : 'btn-secondary');
toggleBtn.textContent = webrtcEnabled ? 'Disable WebRTC' : 'Enable WebRTC';
toggleBtn.addEventListener('click', () => toggleWebRTC(proc.id, !webrtcEnabled, toggleBtn));
actions.appendChild(toggleBtn);
card.appendChild(actions);
// ── WHEP URL row ──
if (webrtcEnabled) { if (webrtcEnabled) {
const whepURL = location.origin + '/api/v3/whep/' + encodeURIComponent(proc.id); const whepURL = location.origin + '/api/v3/whep/' + encodeURIComponent(proc.id);
const playerURL = '/whep-player.html?url=' + encodeURIComponent(whepURL); const playerURL = '/whep-player.html?url=' + encodeURIComponent(whepURL);
const w = document.createElement('div');
w.className = 'whep-url'; const row = document.createElement('div');
w.innerHTML = ` row.className = 'whep-row';
<span>${escapeHTML(whepURL)}</span> row.innerHTML = `
<a href="#" data-copy="${escapeHTML(whepURL)}">copy</a> <span class="whep-label">WHEP</span>
<a href="${escapeHTML(playerURL)}" target="_blank" rel="noopener">open ↗</a> <span class="whep-url-text">${escapeHTML(whepURL)}</span>
<span class="whep-actions">
<button class="btn btn-secondary btn-sm" data-copy="${escapeHTML(whepURL)}">Copy</button>
<a class="btn btn-secondary btn-sm" href="${escapeHTML(playerURL)}" target="_blank" rel="noopener" style="text-decoration:none">Open ↗</a>
</span>
`; `;
const copyLink = w.querySelector('[data-copy]'); row.querySelector('[data-copy]').addEventListener('click', (e) => {
copyLink.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
navigator.clipboard?.writeText(whepURL).then( navigator.clipboard?.writeText(whepURL).then(
() => log($('admin-log'), 'WHEP URL copied', 'good'), () => log($('admin-log'), 'WHEP URL copied', 'good'),
() => log($('admin-log'), 'clipboard write failed (no permission?)', 'warn'), () => log($('admin-log'), 'clipboard write failed', 'warn'),
); );
}); });
el.appendChild(w); card.appendChild(row);
} }
return el; return card;
} }
// --- toggle webrtc.enabled -------------------------------------- // ── toggle webrtc.enabled ─────────────────────────────────────────
async function toggleWebRTC(id, enabled, btn) { async function toggleWebRTC(id, enabled, btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = enabled ? 'Enabling...' : 'Disabling...'; btn.textContent = enabled ? 'Enabling…' : 'Disabling…';
try { try {
// Read the current full process config and patch only the webrtc
// block. The PUT /process/:id endpoint replaces the entire
// config, so partial payloads would silently drop other settings.
const cfg = await api('GET', '/api/v3/process/' + encodeURIComponent(id) + '/config'); const cfg = await api('GET', '/api/v3/process/' + encodeURIComponent(id) + '/config');
cfg.webrtc = cfg.webrtc || {}; cfg.webrtc = cfg.webrtc || {};
cfg.webrtc.enabled = enabled; cfg.webrtc.enabled = enabled;
await api('PUT', '/api/v3/process/' + encodeURIComponent(id), cfg); await api('PUT', '/api/v3/process/' + encodeURIComponent(id), cfg);
// Restart the process so the new RTP output legs take effect.
// Without this the toggle takes effect on the next manual restart
// only — surprising behaviour for a "Enable" button.
try { try {
await api('PUT', '/api/v3/process/' + encodeURIComponent(id) + '/command', { command: 'restart' }); await api('PUT', '/api/v3/process/' + encodeURIComponent(id) + '/command', { command: 'restart' });
log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id} (restarted)`, 'good'); log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id} (restarted)`, 'good');
} catch (cmdErr) { } catch (cmdErr) {
// Process may not have been running; the config update still applied.
log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id}, restart skipped: ${cmdErr.message}`, 'warn'); log($('admin-log'), `webrtc ${enabled ? 'enabled' : 'disabled'} on ${id}, restart skipped: ${cmdErr.message}`, 'warn');
} }
// Refresh the list to pick up the new state + WHEP URL.
await refreshProcesses(); await refreshProcesses();
} catch (err) { } catch (err) {
if (err.message === 'unauthorized') return; if (err.message === 'unauthorized') return;