openvibers/openrestream Commits

Give OpenRestream the shared app shell and designed session history (#244)

OP OpenVibers committed 4 days ago · 04d7eff8f1 · 5 files, +171 −49

* Give OpenRestream shared app navigation and session cards with detail sheets

* Merge remote-tracking branch 'origin/main' into ui/openre-shell-1109

# Conflicts:
#	package-lock.json
#	package.json

* Sessions: a lone card keeps its width, Mine/Everyone shows which is on, no Viewers: Unavailable

auto-fill keeps one session at a card's width instead of the page's. The selected scope is an accent-tinted pill. An ended card shows Length and Health only; Watching here appears while it runs and someone watches on OpenRestream's own players; the sheet keeps the note that destinations report no totals.

Changes

ddiff --git a/server/ui/cards.js b/server/ui/cards.js
nnew file mode 100644
iindex 0000000..09d3d29
---- /dev/null
++++ b/server/ui/cards.js
@@@ -0,0 +1,32 @@
++'use strict';
++const kit = require('openvibe-shared/kit');
++const { PLATFORMS, markSvg } = require('./platforms');
++const esc = kit.esc;
++const emptySessions = () => kit.empty({ icon: 'clock', title: 'Your next broadcast starts here', text: 'Your restream sessions show here: each time you go live, where it went and how it did', action: { href: '/go-live', label: 'Go live', icon: 'live' } }) + '<p class="ore-how"><a href="/#how-it-works">How restreaming works</a></p>';
++const emptyStreams = () => kit.empty({ icon: 'live', title: 'One stream, every destination', text: 'Create a stream, connect your destinations and go live from your encoder.', action: { href: '/streams#new-stream', label: 'Create a stream', icon: 'plus' } });
++function platform(id) {
++ const p = PLATFORMS.find(p => p.id === id);
++ return `<span class="ore-platform">${p ? markSvg(p) : kit.icon('link')}<span>${esc(p ? p.name : id || 'Destination')}</span></span>`;
++}
++function scope(all) {
++ return `<nav class="ore-scope" aria-label="Session owner"><a href="/sessions"${!all ? ' aria-current="page"' : ''}>Mine</a><a href="/sessions?all=1"${all ? ' aria-current="page"' : ''}>Everyone</a></nav>`;
++}
++function session(s, definition, outputs = [], destinations = []) {
++ const started = new Date(s.live_at || s.created_at);
++ const valid = Number.isFinite(started.getTime());
++ const running = ['starting', 'live', 'ending'].includes(s.state);
++ const minutes = valid && s.ended_at ? Math.max(0, Math.round((Number(s.ended_at) - started.getTime()) / 60000)) : null;
++ const length = minutes == null ? (running ? 'In progress' : 'Unavailable') : minutes < 1 ? 'Under a minute' : `${Math.floor(minutes / 60) ? Math.floor(minutes / 60) + 'h ' : ''}${minutes % 60}m`;
++ const health = outputs.some(o => o.last_error || ['failed', 'error', 'lost'].includes(o.state)) ? 'Needs attention' : outputs.length ? (running ? 'Delivering' : 'Finished') : 'No output data';
++ const title = definition && definition.title || 'Restream session';
++ const sid = 'session-' + s.id;
++ // Viewers only while it runs and someone is watching here (OpenRestream's own players); the destinations report no
++ // totals, so an ended card never shows a number it does not have (the sheet says why).
++ const watching = running && Number(s.viewers) > 0 ? `<div><dt>Watching here</dt><dd>${esc(String(Number(s.viewers)))}</dd></div>` : '';
++ const metrics = `<dl class="ore-session-metrics"><div><dt>Length</dt><dd>${esc(length)}</dd></div><div><dt>Health</dt><dd>${esc(health)}</dd></div>${watching}</dl>`;
++ const marks = `<div class="ore-session-destinations" data-ovk-scroll aria-label="Destinations">${destinations.filter(Boolean).map(d => platform(d.platform)).join('') || '<span class="muted">No destinations recorded</span>'}</div>`;
++ const when = valid ? `<time datetime="${started.toISOString()}">${esc(started.toLocaleString('en-US', { timeZone: 'UTC', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }))} UTC</time>` : 'Start unavailable';
++ const details = `<p>${when}</p>${metrics}${marks}<p class="muted">Viewer totals are not reported by these destinations.</p>` + kit.button({ href: '/sessions/' + s.id, icon: 'clock', label: 'Full session details', kind: 'quiet' });
++ return `<article class="ore-session-card"><div class="ore-session-top"><span class="ovk-heading-icon">${kit.icon('live')}</span>${kit.badge(s.state, running ? 'live' : '')}</div><h2><a href="/sessions/${esc(s.id)}">${esc(title)}</a></h2><p class="muted">${when}</p>${metrics}${marks}<div class="ore-session-actions">${kit.sheetButton({ sheet: sid, icon: 'list', label: 'Session details', iconOnly: false })}</div>${kit.sheet({ id: sid, title, body: details })}</article>`;
++}
++module.exports = { emptySessions, emptyStreams, platform, scope, session };
ddiff --git a/server/ui/layout.js b/server/ui/layout.js
iindex 845a730..2791435 100644
---- a/server/ui/layout.js
++++ b/server/ui/layout.js
@@@ -34,10 +34,10 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
.ore-stats{margin:0;display:grid;gap:10px}.ore-stats div{border:1px solid var(--ore-line);border-radius:12px;padding:12px 16px;background:var(--bg-card,#131c2e)}
.ore-stats dt{font-size:1.25rem;font-weight:700}.ore-stats dd{margin:2px 0 0;color:var(--ore-muted);font-size:.92rem}
/* The hero (11-07): the signal flow, one stream in, every platform out, a switch each. */
--.ore-hero{display:grid;gap:28px;align-items:center;padding:28px 0 8px}
++.ore-hero{display:grid;grid-template-columns:minmax(0,1fr);gap:28px;align-items:center;padding:28px 0 8px}
@media (min-width:900px){.ore-hero{grid-template-columns:minmax(0,1fr) minmax(0,1.05fr);gap:40px;padding:56px 0 16px}}
.ore-eyebrow{margin:0 0 10px;font-size:.78rem;font-weight:800;letter-spacing:.09em;text-transform:uppercase;color:color-mix(in srgb,var(--accent,#3b82f6) 70%,var(--ore-text))}
--.ore-hero h1{margin:0;font-size:clamp(2.1rem,6vw,3.6rem);line-height:1.02;letter-spacing:-.045em;text-wrap:balance}
++.ore-hero-copy{min-width:0}.ore-hero h1{margin:0;font-size:clamp(2.1rem,6vw,3.6rem);line-height:1.02;letter-spacing:-.045em;text-wrap:balance}
.ore-accent{background:linear-gradient(100deg,#9146FF,#FF0033 45%,#53FC18 90%);-webkit-background-clip:text;background-clip:text;color:transparent}
.ore-lede{margin:16px 0 0;max-width:52ch;font-size:1.06rem;line-height:1.6;color:var(--ore-muted)}
.ore-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}
@@@ -110,7 +110,28 @@ main.ore .sc-hero,main.ore .sc-sec,main.ore .sc-cta{padding-left:0;padding-right
.ore .nf-links a{display:inline-block;padding:9px 16px;border-radius:999px;border:1px solid var(--ore-line);background:var(--ore-panel);color:var(--ore-text);text-decoration:none;font-weight:600}
.ore .nf-links a:hover,.ore .nf-links a:focus-visible{border-color:var(--ore-accent);outline:0}
.ore form:has(input[name=record_to][value=videos]:checked) .ore-live-only{display:none}
--@media (max-width:640px){.ore th:nth-child(n+4),.ore td:nth-child(n+4){display:none}}
++.ore .ovk-page-head h1{margin:0}.ore .ovk-page-head a,.ore .ovk-btn,.ore .ovk-icon-btn{color:inherit;text-decoration:none}
++.ore .ovk-btn--primary{color:var(--on-accent-strong,#fff)}
++.ore-session-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,320px),1fr));gap:16px}
++.ore-session-card{padding:20px;background:var(--ore-panel);border:1px solid var(--ore-line);border-radius:20px;min-width:0}
++.ore-session-card h2{margin:14px 0 4px;font-size:1.2rem}.ore-session-card h2 a{color:var(--ore-text);text-decoration:none}
++.ore-session-top{display:flex;justify-content:space-between;align-items:center}
++.ore-session-metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(88px,1fr));gap:12px;margin:18px 0}
++.ore-session-metrics dt{font-size:.8rem;color:var(--ore-muted)}.ore-session-metrics dd{margin:4px 0 0;font-weight:650;overflow-wrap:anywhere}
++.ore-session-destinations{display:flex;gap:8px;overflow:auto;position:relative;padding-bottom:8px}
++.ore-platform{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;border-radius:999px;background:var(--bg-secondary);padding:6px 10px;font-size:.85rem}
++.ore-platform svg{width:20px;height:20px;flex:none}
++.ore-session-actions{display:flex;justify-content:flex-end;margin-top:12px}
++.ore-scope{display:flex;width:fit-content;gap:4px;padding:4px;border-radius:999px;background:var(--bg-secondary);border:1px solid var(--ore-line);margin-bottom:20px}
++.ore-scope a{display:flex;align-items:center;justify-content:center;min-height:44px;min-width:90px;border-radius:999px;color:var(--ore-muted);text-decoration:none}
++.ore-scope a[aria-current]{background:color-mix(in srgb,var(--ore-accent) 22%,var(--ore-panel));color:var(--ore-text);font-weight:700;box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--ore-accent) 45%,transparent)}
++.ore-scope a:not([aria-current]):hover{color:var(--ore-text)}
++.ore-how{text-align:center}.ore-how a{display:inline-flex;align-items:center;min-height:44px}
++.ore :where(button,input,select){min-height:44px}.ore input[type=checkbox],.ore input[type=radio]{min-height:auto}
++.ore table{display:block;overflow-x:auto;position:relative}.ore .ovk-sheet{color:var(--ore-text)}
++@media(min-width:1000px){main.ore{padding-top:36px}}
++@media(max-width:760px){main.ore{padding-top:16px}.ore-session-card{padding:16px}.ore .ovk-heading-icon{flex-shrink:0}}
++
/* Touch targets on phones (fleet audit, 2026-10-29): touch pointers only. */
@media (pointer: coarse) {
.ore-faq summary { min-height: 44px; }
@@@ -145,7 +166,11 @@ function renderPage({ title, body, user, canonicalPath = '/', robots = 'noindex,
links: [
{ label: 'Streams', href: '/streams', icon: 'fa-tower-broadcast' },
{ label: 'Sessions', href: '/sessions', icon: 'fa-clock-rotate-left' },
++ { label: 'Destinations', href: '/destinations', icon: 'fa-satellite-dish' },
++ { label: 'Go live', href: '/go-live', icon: 'fa-circle-play' },
++ { label: 'You', href: '/settings', icon: 'fa-user' },
],
++ appBar: true,
sessionUrl: '/auth/me',
silentLogin: '/auth/login?silent=1&next={url}',
loginUrl: `/auth/login?next=${encodeURIComponent(canonicalPath)}`,
@@@ -173,8 +198,9 @@ ${icon}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<style>${CSS}</style>
<!-- The shared showcase stylesheet on every page, not only the home: inner pages use its buttons (sc-btn), and without it a sign-in button drew as a bare link (phone sweep, 2026-11-01). It is scoped to its own sc- classes. -->
--${[...new Set(['showcase.css', ...styles])].map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
++${[...new Set(['showcase.css', 'kit.css', 'kit-data.css', ...styles])].map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
<script src="${ovServe.url('navbar.js')}" defer></script>
++<script src="${ovServe.url('kit-web.js')}" defer></script>
<script src="${ovServe.url('footer.js')}" defer></script>
</head>
<body>
ddiff --git a/server/ui/routes.js b/server/ui/routes.js
iindex f5b14f1..042f025 100644
---- a/server/ui/routes.js
++++ b/server/ui/routes.js
@@@ -20,6 +20,8 @@ const ovServe = require('openvibe-shared/serve');
const seo = require('openvibe-shared/seo');
const frame = require('openvibe-shared/frame');
const showcase = require('openvibe-shared/showcase');
++const kit = require('openvibe-shared/kit');
++const cards = require('./cards');
const express = require('express');
const { renderPage, esc } = require('./layout');
const { TWIN_PATH_RE } = require('../agent-pages');
@@@ -84,7 +86,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
if (req.caller.kind === 'user' && req.caller.subject) return true;
page(req, res, {
title: 'Sign in', canonicalPath: req.path,
-- body: `<h1>Sign in</h1><p>OpenRestream uses your OpenVibe account.</p><p><a class="sc-btn sc-primary" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,
++ body: `${kit.pageHead({ title: 'Sign in', icon: 'user', back: { href: '/', label: 'Home' } })}<p>OpenRestream uses your OpenVibe account.</p><p><a class="sc-btn sc-primary" href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in with OpenVibe</a></p>`,
}, 401);
return false;
}
@@@ -93,14 +95,14 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
const given = String((req.body && req.body._csrf) || '');
const expected = csrfFor(req.caller.token || '');
if (given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) return true;
-- page(req, res, { title: 'Expired form', body: '<h1>That form expired</h1><p>Go back, reload the page and try again.</p>' }, 403);
++ page(req, res, { title: 'Expired form', body: kit.pageHead({ title: 'That form expired', icon: 'help', lede: 'Go back, reload the page and try again.', back: { href: '/', label: 'Home' } }) }, 403);
return false;
}
async function ownDefinition(req, res, id) {
const d = await store.definitions.get(id);
if (!d || d.state === 'archived' || !auth.canAccess(req.caller, d.owner_subject)) {
-- page(req, res, { title: 'Not found', body: '<h1>Stream not found</h1><p><a href="/streams">Your streams</a></p>' }, 404);
++ page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Stream not found', icon: 'help', back: { href: '/streams', label: 'Streams' } }) }, 404);
return null;
}
return d;
@@@ -109,7 +111,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
async function ownDestination(req, res, id) {
const dest = await store.outputs.destinationRow(id);
const d = dest ? await ownDefinition(req, res, dest.definition_id) : null;
-- if (!dest && !res.headersSent) page(req, res, { title: 'Not found', body: '<h1>Destination not found</h1>' }, 404);
++ if (!dest && !res.headersSent) page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Destination not found', icon: 'help', back: { href: '/', label: 'Home' } }) }, 404);
return d ? { dest, definition: d } : null;
}
@@@ -157,7 +159,7 @@ function createUiRouter({ rt, auth, limits = null, liveLink = null }) {
name: 'OpenRestream', url: `${config.baseUrl}/`, description: FRONT_PAGE_DESCRIPTION, category: 'MultimediaApplication',
}), seo.jsonLd.faq(FRONT_FAQ)],
body: `${frontPage(config, f)}
--${signedIn ? `<h2>Your streams</h2>${mine.length ? await streamTable(mine) : '<p class="muted">No streams yet.</p>'}<p><a href="/streams">Manage streams</a></p>` : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}
++${signedIn ? kit.section({ title: 'Your streams', icon: 'live', actions: [kit.button({ href: '/streams', label: 'Manage streams', icon: 'gear', kind: 'quiet' })], body: mine.length ? await streamTable(mine) : cards.emptyStreams() }) : '<p><a href="/auth/login?next=/streams">Sign in with OpenVibe</a> to manage your streams.</p>'}
${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' })}`,
});
});
@@@ -190,7 +192,7 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
['Recording', 'Per stream: a VOD, clips only, or nothing, saved to your OpenVibe.Video'],
['Minimum', 'None: no follower, subscriber or eligibility threshold'],
];
-- return `<section class="sc-sec ore-tier" aria-labelledby="ore-tier-h"><h2 id="ore-tier-h">The free tier, in numbers</h2>
++ return `<section class="sc-sec ore-tier" aria-labelledby="ore-tier-h"><h2 id="ore-tier-h" class="ovk-titled">${kit.icon('list', { cls: 'ovk-heading-icon' })}The free tier, in numbers</h2>
<dl class="ore-tier-grid">${rows.map(([k, v]) => `<div><dt>${esc(k)}</dt><dd>${esc(v)}</dd></div>`).join('')}</dl></section>`;
}
@@@ -216,19 +218,19 @@ ${frame.shipped({ service: 'openre', title: 'Recently shipped on OpenRestream' }
<ul class="ore-dests">${dests}<li class="ore-dest ore-dest-own" style="--c:var(--accent,#3b82f6);--i:${PLATFORMS.length}"><label><input type="checkbox" class="ore-dest-in" checked><span class="ore-wire" aria-hidden="true"></span><span class="ore-dest-mark ore-dest-ov" aria-hidden="true">OV</span><span class="ore-dest-name">OpenVibe</span><span class="ore-switch" aria-hidden="true"></span></label></li></ul>
<p class="ore-total" aria-live="polite">Live on <span class="ore-n"></span> places at once</p>
</div>`;
-- const hero = `<section class="ore-hero" aria-labelledby="ore-hero-h">
++ const hero = `<section class="ore-hero" aria-label="OpenRestream">
<div class="ore-hero-copy">
<p class="ore-eyebrow">Open restreaming · free · no follower minimum</p>
--<h1 id="ore-hero-h">Go live once.<br><span class="ore-accent">Stream everywhere.</span></h1>
--<ul class="ore-logos" aria-label="Streams to">${PLATFORMS.map((pl) => `<li style="--c:${pl.color}" title="${esc(pl.name)}">${markSvg(pl)}<span class="ore-sr">${esc(pl.name)}</span></li>`).join('')}</ul>
--<p class="ore-lede">Send one stream from OBS or your browser to Twitch, YouTube, Kick and any RTMP or SRT server at once. Your OpenVibe Live channel comes with it.</p>
++${kit.pageHead({ title: 'Go live once. Stream everywhere.', icon: 'live', lede: 'Send one stream from OBS or your browser to Twitch, YouTube, Kick and any RTMP or SRT server at once. Your OpenVibe Live channel comes with it.' })}
++<ul data-ovk-scroll="drift" class="ore-logos" aria-label="Streams to">${PLATFORMS.map((pl) => `<li style="--c:${pl.color}" title="${esc(pl.name)}">${markSvg(pl)}<span class="ore-sr">${esc(pl.name)}</span></li>`).join('')}</ul>
++
<div class="ore-actions"><a class="ore-btn ore-btn-primary" href="/streams">Start restreaming</a><a class="ore-btn" href="${esc(live)}">Go live from your browser</a></div>
<p class="ore-note">Alpha: RTMP in; RTMP, RTMPS and SRT out. Going live from a browser happens on OpenVibe.Live for now (<a href="${esc(live)}/docs/go-live-in-your-browser">how it works</a>).</p>
</div>
${flow}
</section>
--${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : ''}`;
-- return hero + showcase.features({
++${stats.length ? `<dl data-ovk-scroll class="ore-stats ore-stats-strip">${stats.map(([v, d]) => `<div><dt>${n(v)}</dt><dd>${esc(d)}</dd></div>`).join('')}</dl>` : ''}`;
++ const content = hero + showcase.features({
title: 'What OpenRestream does',
items: [
{ icon: 'ov:live', title: 'One stream, every platform', text: 'Twitch, YouTube, Kick and any RTMP, RTMPS or SRT server at once. Each destination has its own health, logs and retries.' },
@@@ -244,7 +246,7 @@ ${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) =>
lede: 'Add each destination once, test it, and it goes live with you every time.',
items: DESTINATIONS.map((d) => ({ title: d.name, text: d.text })),
}) + showcase.steps({
-- title: 'Restream with OBS',
++ id: 'how-it-works', title: 'Restream with OBS',
items: [
{ title: 'Sign in', text: 'With your OpenVibe account.', href: '/auth/login?next=/streams' },
{ title: 'Create a stream', text: 'Copy the RTMP server and key; the key is shown only once. OpenVibe Live is already ticked.' },
@@@ -255,17 +257,17 @@ ${stats.length ? `<dl class="ore-stats ore-stats-strip">${stats.map(([v, d]) =>
title: 'OBS settings',
lede: 'In OBS, open Settings → Stream, choose Custom, and paste the server and the key from your stream\'s page.',
samples: [{ label: 'Settings → Stream', code: `Service: Custom...\nServer: ${ingestServer(cfg)}\nStream Key: (from your stream's page, shown once)` }],
-- }) + `<section class="sc-sec ore-faq" aria-labelledby="ore-faq-h"><h2 id="ore-faq-h">Questions</h2>
++ }) + `<section class="sc-sec ore-faq" aria-labelledby="ore-faq-h"><h2 id="ore-faq-h" class="ovk-titled">${kit.icon('help', { cls: 'ovk-heading-icon' })}Questions</h2>
${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('\n')}
</section>`;
++ return content.replace(/<h2([^>]*)>(What OpenRestream does|Where it can stream|Restream with OBS|OBS settings)<\/h2>/g, (_, attrs, title) => `<h2${attrs} class="ovk-titled">${kit.icon('live', { cls: 'ovk-heading-icon' })}${title}</h2>`);
}
async function streamTable(list) {
-- return `<table><tr><th>Stream</th><th>State</th><th>Now</th><th>Linked to</th></tr>${(await Promise.all(list.map(async (d) => {
++ return kit.list(await Promise.all(list.map(async d => {
const open = (await store.sessions.openFor(d.id))[0];
-- const refs = d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', ');
-- return `<tr><td><a href="/streams/${esc(d.id)}">${esc(d.title)}</a></td><td>${pill(d.state)}</td><td>${open ? pill(open.state) : '<span class="muted">offline</span>'}</td><td class="mono">${esc(refs) || '—'}</td></tr>`;
-- }))).join('')}</table>`;
++ return kit.row({ href: '/streams/' + d.id, mark: kit.icon('live'), title: d.title, sub: open ? 'Live now' : 'Ready for your next broadcast', meta: d.state, badge: open ? kit.badge(open.state, 'live') : '' });
++ })));
}
router.get('/streams', async (req, res) => {
@@@ -273,8 +275,8 @@ ${FRONT_FAQ.map((f) => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p
const list = await store.definitions.list({ owner_subject: req.caller.subject });
page(req, res, {
title: 'Streams', canonicalPath: '/streams',
-- body: `<h1>Streams</h1>${list.length ? await streamTable(list) : '<p class="muted">No streams yet.</p>'}
--<div class="card"><h2>New stream</h2>${form(req, '/streams', `
++ body: `${kit.pageHead({ title: 'Streams', icon: 'live', back: { href: '/', label: 'Home' }, lede: 'Set up once. Send your next stream everywhere.', actions: [{ href: '#new-stream', icon: 'plus', label: 'New stream' }] })}${list.length ? await streamTable(list) : cards.emptyStreams()}
++<div class="card" id="new-stream"><h2 class="ovk-titled">${kit.icon('plus', { cls: 'ovk-heading-icon' })}New stream</h2>${form(req, '/streams', `
<label>Title</label><input type="text" name="title" maxlength="140" required>
${whereItGoes(null)}
<div class="ore-live-only"><div class="row"><div><label>Recording</label><select name="recording_mode"><option value="vod">Record a VOD</option><option value="clips">Clips only</option><option value="none">Do not record</option></select></div>
@@@ -315,7 +317,7 @@ ${option('videos', 'Record only, to my videos', `Nothing goes live. Each session
res.set('Cache-Control', 'no-store');
page(req, res, {
title: heading, canonicalPath: `/streams/${definition.id}`,
-- body: `<h1>${esc(heading)}</h1>
++ body: `${kit.pageHead({ title: heading, icon: 'key', back: { href: '/streams', label: 'Streams' } })}
<div class="card"><p><strong>Copy the key now.</strong> OpenRestream stores only a hash of it; this page is the only time it is shown.</p>
<label>Server (OBS: Settings → Stream → Custom)</label><code class="secret">${esc(ep.rtmp ? ep.rtmp.url : '')}</code>
<label>Stream key</label><code class="secret">${esc(key.key)}</code></div>
@@@ -336,6 +338,24 @@ ${liveNote(live)}${recordNote(definition)}
await keyPage(req, res, definition, key, 'Stream created', live);
});
++ router.get('/destinations', async (req, res) => {
++ if (!needUser(req, res)) return;
++ const definitions = await store.definitions.list({ owner_subject: req.caller.subject });
++ const rows = (await Promise.all(definitions.map(async d => (await store.outputs.destinations(d.id)).map(x =>
++ kit.row({ href: '/destinations/' + x.id, mark: cards.platform(x.platform), title: x.name || x.platform, sub: d.title, meta: x.enabled ? 'Enabled' : 'Disabled' })
++ )))).flat();
++ page(req, res, { title: 'Destinations', canonicalPath: '/destinations', body: kit.pageHead({ title: 'Destinations', icon: 'link', back: { href: '/', label: 'Home' }, lede: 'Every place your streams go.', actions: [{ href: '/streams', icon: 'plus', label: 'Add a destination' }] }) + (rows.length ? kit.list(rows) : kit.empty({ icon: 'link', title: 'Choose your destinations', text: 'Open a stream to connect Twitch, YouTube, Kick or your own server.', action: { href: '/streams', icon: 'plus', label: 'Choose a stream' } })) });
++ });
++ router.get('/go-live', async (req, res) => {
++ if (!needUser(req, res)) return;
++ const definitions = await store.definitions.list({ owner_subject: req.caller.subject });
++ page(req, res, { title: 'Go live', canonicalPath: '/go-live', body: kit.pageHead({ title: 'Go live', icon: 'live', back: { href: '/', label: 'Home' }, lede: 'Choose your encoder, check your destinations and start broadcasting.' }) + kit.section({ title: 'From your browser', icon: 'video', body: kit.empty({ icon: 'video', text: 'Use your camera or share your screen on OpenVibe Live.', action: { href: config.liveUrl + '/broadcast', icon: 'live', label: 'Open the studio' } }) }) + kit.section({ title: 'From OBS or an encoder', icon: 'live', body: definitions.length ? await streamTable(definitions) : cards.emptyStreams() }) });
++ });
++ router.get('/settings', async (req, res) => {
++ if (!needUser(req, res)) return;
++ page(req, res, { title: 'You', canonicalPath: '/settings', body: kit.pageHead({ title: 'You', icon: 'user', back: { href: '/', label: 'Home' }, lede: 'Your account and stream preferences.' }) + kit.section({ title: 'Stream settings', icon: 'gear', body: kit.empty({ icon: 'gear', text: 'Recording, playback and destination settings belong to each stream.', action: { href: '/streams', label: 'Manage streams', icon: 'gear' } }) }) + kit.section({ title: 'Your account', icon: 'user', body: kit.button({ href: 'https://openvibe.network/settings', label: 'Account settings', icon: 'gear', kind: 'quiet' }) + kit.button({ href: '/auth/logout', label: 'Sign out', icon: 'back', kind: 'quiet' }) }) });
++ });
++
router.get('/streams/:id', async (req, res) => {
if (!needUser(req, res)) return;
const d = await ownDefinition(req, res, req.params.id);
@@@ -349,28 +369,28 @@ ${liveNote(live)}${recordNote(definition)}
const outFor = async (destId) => await outputs.find(o => o.destination_id === destId);
page(req, res, {
title: d.title, canonicalPath: `/streams/${d.id}`,
-- body: `<h1>${esc(d.title)} ${pill(d.state)}</h1>
++ body: `${kit.pageHead({ title: d.title, icon: 'live', back: { href: '/streams', label: 'Streams' }, actions: [{ href: '#stream-settings', label: 'Settings', icon: 'gear' }] })}${pill(d.state)}
<p class="muted mono">${esc(d.id)}${d.external_refs.length ? ` · linked to ${esc(d.external_refs.map(r => `${r.service}:${r.type}:${r.id}`).join(', '))}` : ''}</p>
${d.record_to ? recordNote(d) : liveStatus(d, req.query.live)}
--<div class="card"><h2>Ingest</h2>
++<div class="card"><h2 class="ovk-titled">${kit.icon('live', { cls: 'ovk-heading-icon' })}Ingest</h2>
${ep.rtmp ? `<label>RTMP server</label><code class="secret">${esc(ep.rtmp.url)}</code>` : '<p class="muted">RTMP is not enabled for this stream.</p>'}
--<table><tr><th>Key</th><th>Status</th><th>Created</th><th>Last used</th></tr>${keys.map(k => `<tr><td class="mono">ork_…${esc(k.hint)}</td><td>${pill(k.status)}${k.grace_until ? ` until ${esc(iso(k.grace_until))}` : ''}</td><td>${esc(iso(k.created_at))}</td><td>${esc(iso(k.last_used_at))}</td></tr>`).join('') || '<tr><td colspan="4">No usable key: rotate to get one.</td></tr>'}</table>
++<table data-ovk-scroll><tr><th>Key</th><th>Status</th><th>Created</th><th>Last used</th></tr>${keys.map(k => `<tr><td class="mono">ork_…${esc(k.hint)}</td><td>${pill(k.status)}${k.grace_until ? ` until ${esc(iso(k.grace_until))}` : ''}</td><td>${esc(iso(k.created_at))}</td><td>${esc(iso(k.last_used_at))}</td></tr>`).join('') || '<tr><td colspan="4">No usable key: rotate to get one.</td></tr>'}</table>
${form(req, `/streams/${d.id}/rotate`, `<div class="row"><div><label>Old key stays valid for (seconds, 0 = revoke now)</label><input type="number" name="grace_seconds" min="0" max="604800" value="0"></div>
<div><label><input type="checkbox" name="end_sessions" value="1"> also disconnect a running session</label></div></div><button type="submit" class="danger">Rotate key</button>`, { confirm: 'Issue a new key and retire the current one?' })}
</div>
--<div class="card"><h2>Now</h2>${open ? `<p>${pill(open.state)} since ${esc(iso(open.live_at || open.created_at))} on ${esc(open.worker_kind)} generation ${esc(open.worker_generation)} · <a href="/sessions/${esc(open.id)}">session</a></p>
++<div class="card"><h2 class="ovk-titled">${kit.icon('live', { cls: 'ovk-heading-icon' })}Now</h2>${open ? `<p>${pill(open.state)} since ${esc(iso(open.live_at || open.created_at))} on ${esc(open.worker_kind)} generation ${esc(open.worker_generation)} · <a href="/sessions/${esc(open.id)}">session</a></p>
${form(req, `/sessions/${open.id}/end`, '<button type="submit" class="danger">End session</button>', { cls: 'inline', confirm: 'Disconnect the encoder?' })}` : '<p class="muted">Offline.</p>'}</div>
--<div class="card"><h2>Restream destinations</h2>${d.record_to ? '<p class="muted">Off while this stream records only to your videos.</p>' : ''}
--${dests.length ? `<table><tr><th>Destination</th><th>Output</th><th>Health</th><th>Actions</th></tr>${(await Promise.all(dests.map(async (x) => {
++<div class="card"><h2 class="ovk-titled">${kit.icon('link', { cls: 'ovk-heading-icon' })}Restream destinations</h2>${d.record_to ? '<p class="muted">Off while this stream records only to your videos.</p>' : ''}
++${dests.length ? `<table data-ovk-scroll><tr><th>Destination</th><th>Output</th><th>Health</th><th>Actions</th></tr>${(await Promise.all(dests.map(async (x) => {
const o = await outFor(x.id);
return `<tr><td><strong>${esc(x.name || x.platform)}</strong> <span class="muted">${esc(x.platform)}</span><br><span class="mono">${esc(x.server_url)}</span><br><span class="muted">key ${esc(x.stream_key_hint || 'not set')}${x.enabled ? '' : ' · disabled'}${x.auto_start ? ' · auto-start' : ''}${x.hold_reason ? ` · held: ${esc(x.hold_reason)}` : ''}${x.cooldown_ms ? ` · cooling down until ${esc(x.cooldown_until)}` : ''}</span></td>
<td>${o ? pill(o.state) : '<span class="muted">—</span>'}</td>
<td>${o && o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s · speed ${esc(o.progress.speed ?? '?')}` : ''}${o && o.last_error ? `<br><span class="muted">${esc(o.last_error)}</span>` : ''}${!o && x.last_error ? `<span class="muted">${esc(x.last_error)}</span>` : ''}</td>
<td>${form(req, `/destinations/${x.id}/test`, '<button type="submit" class="secondary">Test</button>', { cls: 'inline' })}${open && !d.record_to ? (o && ['pending', 'starting', 'live', 'error'].includes(o.state) ? form(req, `/destinations/${x.id}/stop`, '<button type="submit" class="secondary">Stop</button>', { cls: 'inline' }) : form(req, `/destinations/${x.id}/start`, '<button type="submit">Start</button>', { cls: 'inline' })) : ''}
<a href="/destinations/${esc(x.id)}">Edit</a>${form(req, `/destinations/${x.id}/delete`, '<button type="submit" class="danger">Delete</button>', { cls: 'inline', confirm: 'Delete this destination?' })}</td></tr>`;
-- }))).join('')}</table>` : '<p class="muted">No destinations.</p>'}
++ }))).join('')}</table>` : kit.empty({ icon: 'link', text: 'Add the places this stream should go below.' })}
<h3>Add a destination</h3>${form(req, `/streams/${d.id}/destinations`, destinationFields({}))}</div>
--<div class="card"><h2>Settings</h2>${form(req, `/streams/${d.id}`, `
++<div class="card"><h2 class="ovk-titled" id="stream-settings">${kit.icon('gear', { cls: 'ovk-heading-icon' })}Settings</h2>${form(req, `/streams/${d.id}`, `
<label>Title</label><input type="text" name="title" maxlength="140" value="${esc(d.title)}">
${whereItGoes(d.record_to)}
<div class="row"><div class="ore-live-only"><label>Recording</label><select name="recording_mode">${['vod', 'clips', 'none'].map(m => `<option value="${m}"${d.recording_mode === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div>
@@@ -379,7 +399,7 @@ ${whereItGoes(d.record_to)}
<div><label>State</label><select name="state">${['active', 'disabled'].map(m => `<option${d.state === m ? ' selected' : ''}>${m}</option>`).join('')}</select></div></div>
<label class="ore-live-only"><input type="checkbox" name="mirror_to_live" value="1"${d.mirror_to_live ? ' checked' : ''}> Show it live on my OpenVibe Live channel too</label>
<button type="submit">Save</button>`)}</div>
--<h2>Sessions</h2>${sessionTable(sessions)}`,
++<h2 class="ovk-titled">${kit.icon('clock', { cls: 'ovk-heading-icon' })}Sessions</h2>${await sessionTable(sessions)}`,
});
});
@@@ -417,9 +437,14 @@ ${whereItGoes(d.record_to)}
return out;
}
-- function sessionTable(list) {
-- if (!list.length) return '<p class="muted">No sessions yet.</p>';
-- return `<table><tr><th>Session</th><th>State</th><th>Started</th><th>Ended</th><th>Worker</th></tr>${list.map(s => `<tr><td><a class="mono" href="/sessions/${esc(s.id)}">${esc(s.id)}</a></td><td>${pill(s.state)}${s.failure_reason ? ` <span class="muted">${esc(s.failure_reason)}</span>` : ''}</td><td>${esc(iso(s.live_at || s.created_at))}</td><td>${esc(iso(s.ended_at))}</td><td>${esc(s.worker_kind)} #${esc(s.worker_generation)}</td></tr>`).join('')}</table>`;
++ async function sessionTable(list) {
++ if (!list.length) return cards.emptySessions();
++ return `<div class="ore-session-grid">${(await Promise.all(list.map(async s => {
++ const definition = await store.definitions.get(s.definition_id);
++ const outputs = await store.outputs.outputsOfSession(s.id);
++ const destinations = await Promise.all(outputs.map(o => store.outputs.getDestination(o.destination_id)));
++ return cards.session(s, definition, outputs, destinations);
++ }))).join('')}</div>`;
}
post('/streams/:id', async (req, res) => {
@@@ -461,9 +486,9 @@ ${whereItGoes(d.record_to)}
const logs = await store.outputs.logsOfDestination(x.id, 50);
page(req, res, {
title: `Destination ${x.name || x.platform}`, canonicalPath: `/destinations/${x.id}`,
-- body: `<h1>${esc(x.name || x.platform)}</h1><p><a href="/streams/${esc(own.definition.id)}">← ${esc(own.definition.title)}</a></p>
++ body: `${kit.pageHead({ title: x.name || x.platform, icon: 'link', back: { href: '/streams/' + own.definition.id, label: own.definition.title }, lede: 'Connection settings and delivery history.' })}
<div class="card">${form(req, `/destinations/${x.id}`, destinationFields(x))}</div>
--<h2>Log</h2>${logs.length ? `<table><tr><th>When</th><th>Level</th><th>Message</th></tr>${logs.map(l => `<tr><td>${esc(iso(l.at))}</td><td>${pill(l.level === 'error' ? 'failed' : l.level === 'warn' ? 'error' : 'ok')}</td><td class="mono">${esc(l.message)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing logged yet.</p>'}`,
++<h2 class="ovk-titled">${kit.icon('list', { cls: 'ovk-heading-icon' })}Log</h2>${logs.length ? `<table data-ovk-scroll><tr><th>When</th><th>Level</th><th>Message</th></tr>${logs.map(l => `<tr><td>${esc(iso(l.at))}</td><td>${pill(l.level === 'error' ? 'failed' : l.level === 'warn' ? 'error' : 'ok')}</td><td class="mono">${esc(l.message)}</td></tr>`).join('')}</table>` : '<p class="muted">Nothing logged yet.</p>'}`,
});
});
@@@ -488,7 +513,7 @@ ${whereItGoes(d.record_to)}
await store.outputs.log(null, own.dest.id, result.ok ? 'info' : 'warn', `test ${result.ok ? 'passed' : 'failed'}: ${result.checks.map(c => `${c.check} ${c.ok ? 'ok' : 'FAIL'} (${c.detail})`).join('; ')}`);
page(req, res, {
title: 'Destination test',
-- body: `<h1>Test ${result.ok ? pill('ok') : pill('failed')}</h1><table>${result.checks.map(c => `<tr><td>${esc(c.check)}</td><td>${c.ok ? pill('ok') : pill('failed')}</td><td>${esc(c.detail)}</td></tr>`).join('')}</table>
++ body: `${kit.pageHead({ title: 'Destination test', icon: 'check', back: { href: '/destinations', label: 'Destinations' } })}${result.ok ? pill('ok') : pill('failed')}<table data-ovk-scroll>${result.checks.map(c => `<tr><td>${esc(c.check)}</td><td>${c.ok ? pill('ok') : pill('failed')}</td><td>${esc(c.detail)}</td></tr>`).join('')}</table>
<p class="muted">A passing test means the ingest is reachable. Whether the platform accepts the key shows once an output runs.</p><p><a href="/streams/${esc(own.definition.id)}">Back</a></p>`,
});
});
@@@ -510,22 +535,22 @@ ${whereItGoes(d.record_to)}
router.get('/sessions', async (req, res) => {
if (!needUser(req, res)) return;
const all = req.caller.staff && req.query.all === '1';
-- page(req, res, { title: 'Sessions', canonicalPath: '/sessions', body: `<h1>Sessions</h1>${req.caller.staff ? `<p class="muted"><a href="/sessions${all ? '' : '?all=1'}">${all ? 'Only mine' : 'Everyone (staff)'}</a></p>` : ''}${sessionTable(await store.sessions.list({ owner_subject: all ? undefined : req.caller.subject, limit: 100 }))}` });
++ page(req, res, { title: 'Sessions', canonicalPath: '/sessions', body: `${kit.pageHead({ title: 'Sessions', icon: 'clock', back: { href: '/', label: 'Home' }, lede: 'Your live history, from first frame to final viewer.', actions: [{ href: '/go-live', label: 'Go live', icon: 'live' }] })}${req.caller.staff ? cards.scope(all) : ''}${await sessionTable(await store.sessions.list({ owner_subject: all ? undefined : req.caller.subject, limit: 100 }))}` });
});
router.get('/sessions/:id', async (req, res) => {
if (!needUser(req, res)) return;
const s = await store.sessions.get(req.params.id);
const d = s ? await store.definitions.row(s.definition_id) : null;
-- if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: '<h1>Session not found</h1>' }, 404);
++ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Session not found', icon: 'help', back: { href: '/', label: 'Home' } }) }, 404);
const outputs = await store.outputs.outputsOfSession(s.id);
const rec = await store.recordings.bySession(s.id);
const pb = await store.sessions.playback(s);
const open = ['starting', 'live', 'ending'].includes(s.state);
return page(req, res, {
title: `Session ${s.id}`, canonicalPath: `/sessions/${s.id}`,
-- body: `<h1>Session ${pill(s.state)}</h1><p class="mono">${esc(s.id)}</p><p><a href="/streams/${esc(d.id)}">← ${esc(d.title)}</a></p>
--<div class="card"><table>
++ body: `${kit.pageHead({ title: d.title || 'Session', icon: 'clock', back: { href: '/sessions', label: 'Sessions' }, lede: 'Session details, output health and lifecycle.' })}${pill(s.state)}
++<div class="card"><table data-ovk-scroll>
<tr><th>Protocol</th><td>${esc(s.protocol)}</td></tr>
<tr><th>Worker</th><td>${esc(s.worker_kind)} generation ${esc(s.worker_generation)} <span class="mono muted">${esc(s.worker_id)}</span></td></tr>
<tr><th>Live since</th><td>${esc(iso(s.live_at))}</td></tr><tr><th>Ended</th><td>${esc(iso(s.ended_at))} ${esc(s.end_reason || s.failure_reason || '')}</td></tr>
@@@ -533,15 +558,15 @@ ${whereItGoes(d.record_to)}
<tr><th>Recording</th><td>${rec ? `${pill(rec.state)} ${rec.media_vod_id ? `Media ${esc(rec.media_app)} VOD ${esc(rec.media_vod_id)}` : ''} ${esc(rec.last_error || '')}${rec.media_app === config.media.videosAppId ? `<br>Private, in <a href="${esc(config.videoUrl)}/me/videos">your videos on OpenVibe.Video</a> once it is ready.` : ''}` : '<span class="muted">none</span>'}</td></tr>
<tr><th>Playback</th><td>${pb && pb.flv && s.state === 'live' ? `<a class="mono" href="${esc(pb.flv.public_url)}">${esc(pb.flv.public_url)}</a> (HTTP-FLV)` : '—'}</td></tr>
</table>${open ? form(req, `/sessions/${s.id}/end`, '<button type="submit" class="danger">End session</button>', { confirm: 'Disconnect the encoder?' }) : ''}</div>
--<h2>Outputs</h2>${outputs.length ? `<table><tr><th>Output</th><th>State</th><th>Health</th><th>Restarts</th></tr>${(await outputs.map(o => `<tr><td class="mono">${esc(o.id)}<br><span class="muted">${esc(o.destination_id)}</span></td><td>${pill(o.state)}</td><td>${o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s` : ''} ${esc(o.last_error || '')}</td><td>${esc(o.restart_attempts)}/${esc(o.max_restart_attempts)}</td></tr>`)).join('')}</table>` : '<p class="muted">No outputs.</p>'}
--<h2>Lifecycle</h2><table>${(await store.sessions.transitions(s.id)).map(t => `<tr><td>${esc(iso(t.at))}</td><td>${esc(t.from_state || '·')} → ${pill(t.to_state)}</td><td>${esc(t.reason || '')}</td><td class="mono muted">${esc(t.actor || '')}</td></tr>`).join('')}</table>`,
++<h2 class="ovk-titled">${kit.icon('link', { cls: 'ovk-heading-icon' })}Outputs</h2>${outputs.length ? `<table data-ovk-scroll><tr><th>Output</th><th>State</th><th>Health</th><th>Restarts</th></tr>${(await outputs.map(o => `<tr><td class="mono">${esc(o.id)}<br><span class="muted">${esc(o.destination_id)}</span></td><td>${pill(o.state)}</td><td>${o.progress ? `${esc(o.progress.fps ?? '?')} fps · ${esc(o.progress.bitrate_kbps ?? '?')} kbit/s` : ''} ${esc(o.last_error || '')}</td><td>${esc(o.restart_attempts)}/${esc(o.max_restart_attempts)}</td></tr>`)).join('')}</table>` : kit.empty({ icon: 'link', text: 'Delivery health appears here when this session sends to a destination.' })}
++<h2 class="ovk-titled">${kit.icon('clock', { cls: 'ovk-heading-icon' })}Lifecycle</h2><table data-ovk-scroll>${(await store.sessions.transitions(s.id)).map(t => `<tr><td>${esc(iso(t.at))}</td><td>${esc(t.from_state || '·')} → ${pill(t.to_state)}</td><td>${esc(t.reason || '')}</td><td class="mono muted">${esc(t.actor || '')}</td></tr>`).join('')}</table>`,
});
});
post('/sessions/:id/end', async (req, res) => {
const s = await store.sessions.get(req.params.id);
const d = s ? await store.definitions.row(s.definition_id) : null;
-- if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: '<h1>Session not found</h1>' }, 404);
++ if (!s || !d || !auth.canAccess(req.caller, d.owner_subject)) return page(req, res, { title: 'Not found', body: kit.pageHead({ title: 'Session not found', icon: 'help', back: { href: '/', label: 'Home' } }) }, 404);
await store.sessions.requestEnd(s.id, req.caller.subject);
return res.redirect(303, `/sessions/${s.id}`);
});
ddiff --git a/test/app-shell.test.js b/test/app-shell.test.js
nnew file mode 100644
iindex 0000000..7c3a965
---- /dev/null
++++ b/test/app-shell.test.js
@@@ -0,0 +1,38 @@
++'use strict';
++const assert = require('assert');
++const { bootApi, request, userToken, suite, OWNER, OTHER } = require('./helpers');
++const cards = require('../server/ui/cards');
++const t = suite('app-shell');
++t('session scope and new tabs retain owner boundaries', async () => {
++ const api = await bootApi();
++ try {
++ const mine = await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'My broadcast' });
++ await api.rt.store.definitions.create({ owner_subject: OTHER, title: 'Private other broadcast' });
++ const cookie = `ov_token=${userToken()}`;
++ for (const path of ['/streams', '/destinations', '/settings', '/go-live', '/sessions?all=1']) {
++ const r = await request(api.base, 'GET', path, { cookie });
++ assert.strictEqual(r.status, 200, path);
++ assert.ok(r.text.includes('ovk-page-head'), path);
++ assert.ok(r.text.includes('"appBar":true'), path);
++ assert.ok(!r.text.includes('Private other broadcast'), path);
++ }
++ const ordinary = await request(api.base, 'GET', '/sessions?all=1', { cookie });
++ assert.ok(!ordinary.text.includes('Session owner'));
++ assert.ok(ordinary.text.includes('Your restream sessions show here: each time you go live, where it went and how it did'));
++ const staff = await request(api.base, 'GET', '/sessions?all=1', { cookie: `ov_token=${userToken({ role: 'admin' })}` });
++ assert.ok(staff.text.includes('Session owner') && staff.text.includes('Everyone'));
++ const anonymous = await request(api.base, 'GET', '/destinations');
++ assert.ok(anonymous.status === 401 || anonymous.status === 302);
++ assert.ok(mine.definition.id);
++ } finally { await api.close(); }
++});
++t('session cards escape content and distinguish missing measurements', () => {
++ const h = cards.session({ id: 's_test', state: 'ended', created_at: 1700000000000, ended_at: 1700003600000 }, { title: '<script>bad</script>' }, [{ state: 'error', last_error: 'lost' }], [{ platform: 'twitch' }]);
++ assert.ok(h.includes('&lt;script&gt;') && !h.includes('<script>bad'));
++ assert.ok(h.includes('1h 0m') && h.includes('Needs attention'));
++ // No made-up number: an ended card has no Viewers figure, and its sheet says the destinations report none.
++ assert.ok(!h.includes('<dt>Viewers</dt>') && h.includes('Viewer totals are not reported by these destinations.'));
++ assert.ok(h.includes('data-ovk-sheet') && h.includes('<dialog') && h.includes('/sessions/s_test'));
++ assert.ok(h.includes('Twitch') && h.includes('<svg'));
++});
++void t.run();
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
iindex 3083b4b..1581bb3 100644
---- a/test/discovery.test.js
++++ b/test/discovery.test.js
@@@ -108,7 +108,8 @@ t('the front page leads with open restreaming, links to Live for browser go-live
try {
const r = await request(api.base, 'GET', '/');
assert.strictEqual(r.status, 200);
-- assert.match(r.text, /<h1 id="ore-hero-h">Go live once\.<br><span class="ore-accent">Stream everywhere\.<\/span><\/h1>/);
++ assert.match(r.text, /<h1>Go live once\. Stream everywhere\.<\/h1>/);
++ assert.match(r.text, /ovk-page-head/);
// The hero's signal flow (11-07): every platform with its own switch, the count by CSS, no script needed.
const flow = (/<div class="ore-flow"[\s\S]*?<\/ul>/.exec(r.text) || [''])[0];
for (const name of ['Twitch', 'YouTube', 'Kick', 'X', 'Facebook', 'TikTok', 'LinkedIn', 'OpenVibe']) assert.match(flow, new RegExp('<span class="ore-dest-name">' + name + '</span>'), name);
@@@ -116,7 +117,7 @@ t('the front page leads with open restreaming, links to Live for browser go-live
assert.doesNotMatch(flow, /<script/);
// The free tier's real limits, as the service runs them (market outcome 1): the cap from its own config.
const tier = (/<section class="sc-sec ore-tier"[\s\S]*?<\/section>/.exec(r.text) || [''])[0];
-- assert.match(tier, /<h2 id="ore-tier-h">The free tier, in numbers<\/h2>/);
++ assert.match(tier, /<h2 id="ore-tier-h"[^>]*>[\s\S]*?The free tier, in numbers<\/h2>/);
assert.match(tier, /<dt>Destinations<\/dt><dd>Up to 10 a stream, each with its own health, logs and retries<\/dd>/);
assert.match(tier, /<dt>Quality<\/dt><dd>What you send: a stream from OBS is copied, never re-encoded<\/dd>/);
assert.match(tier, /<dt>Out<\/dt><dd>RTMP, RTMPS and SRT<\/dd>/);