ui: full audit pass (fixes #146, #147, #148, #149, #151, #152, #153, #154, #155)

Sweep of 9 web-ui audit findings from tracker #156. Issue #150 (modal
codec stubs) deferred per user request.

## #146 sweep em-dashes (186 to 0)
- Replace placeholder '—' with '·' across all jsx
- Convert ' — ' to ': ' or '. ' in copy where context permits
- Comment-only em-dashes converted to ASCII dash
- Sweep css files too (16 comments)

## #147 remove glassmorphism + accent gradients
- Strip 8 backdrop-filter declarations from styles-screens.css and
  styles-asset.css. Only legit modal scrim in styles-modal.css remains.
- Replace .job-progress-fill gradient with solid var(--accent)
- Replace .monitor-tile.audio gradient with flat var(--bg-1)

## #148 extract Jobs inline styles to CSS
- Cut 19 inline style={{...}} blocks in screens-jobs.jsx to 1 (dynamic
  width on progress bar). Live DOM was 487 inline-styled elements due
  to per-row repetition; now ~0.
- Added job-row-kind, job-row-asset, job-row-node, job-row-time,
  job-row-actions, job-row-status-* utility classes in styles-screens.css

## #149 sidebar IA reorganized
- Replace flat NAV_TREE + ADMIN_TREE with NAV_SECTIONS:
  Workspace / Ingest / Operations / Admin
- Move Capture out of Ingest into Operations (it's a live-signal monitor,
  not an ingest action)
- Drop the 0/N capture badge from nav (belongs in topbar)
- Add BETA badge to Editor

## #151 redesign Editor 'Coming Soon' bumper
- Replace fullscreen glassmorphism + gradient + glow overlay with a flat
  beta banner across the top of the editor area
- New .editor-beta-banner CSS class (flat, accent-soft tint, no blur)

## #152 hide Tokens parody, restore real API token mgmt
- New top-level Tokens admin page wraps existing ApiTokensSection
- Old parody renamed to TokensParody, accessible at /tokens-parody route
- Add window-level df:nav event for cross-component routing

## #153 make Home actually useful
- New activity strip below the launcher grid: 'Recording now' tiles for
  live recorders, 'Last 24 hours' tiles for newly created assets, plus
  an attention strip when there are failed jobs or errored recorders
- Each item is clickable and routes to the relevant screen

## #154 aria-labels on icon-only buttons
- Projects + Library grid/list view toggles now have aria-label + title

## #155 page-header pattern
- Dashboard now renders a proper .page-header h1 with subtitle + alert
  badge + cluster status pip
- Library toolbar-title promoted to h1 for screen-reader hierarchy
- Document Home/Library/Editor full-bleed exceptions in DESIGN.md
- Editor's chrome is the beta banner (covered by #151)
This commit is contained in:
Zac Gaetano 2026-05-28 23:50:07 +00:00
parent f54c49d2dc
commit 342b56af35
20 changed files with 596 additions and 353 deletions

View file

@ -90,6 +90,16 @@ Never use `gradient text` (impeccable absolute ban). Emphasis via weight and siz
- Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section. - Panels (`.panel`): `--bg-1` + 1px `--border` + `--r-lg`. Use for grouped lists, not for every section.
- Do NOT nest panels. - Do NOT nest panels.
### Page header
Standard screens use `.page > .page-header > h1`. Three screens are documented exceptions because they need full-bleed layouts and their own top-chrome:
- **Home** uses `.launcher` (lobby pattern: hero logo + tile grid + status pip).
- **Library** uses `.library-layout` (dual-pane rail + main). The h1 sits inside `.library-toolbar` as `.toolbar-title`.
- **Editor** uses `.editor-shell` (NLE with timeline + monitors). The beta banner doubles as its top chrome.
All other screens should render `<div className="page"><div className="page-header"><h1>…</h1>…</div>…</div>` for consistent IA and screen-reader hierarchy.
## Shadow ## Shadow
Two tokens, used sparingly: Two tokens, used sparingly:

View file

@ -1,4 +1,4 @@
// app.jsx main shell // app.jsx - main shell
const ACCENT = '#5B7CFA'; const ACCENT = '#5B7CFA';
@ -40,6 +40,14 @@ function App() {
}, []); }, []);
const navigate = (id) => { setOpenAsset(null); setRoute(id); }; const navigate = (id) => { setOpenAsset(null); setRoute(id); };
// Window-level nav event so deeply nested components (like the Tokens
// "see the parody" link) can route without prop drilling.
React.useEffect(() => {
const handler = (e) => { if (e && e.detail) navigate(e.detail); };
window.addEventListener('df:nav', handler);
return () => window.removeEventListener('df:nav', handler);
}, []);
const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); }; const openProjectFromAnywhere = (p) => { setOpenAsset(null); setOpenProject(p); setRoute('library'); };
const crumbs = React.useMemo(() => { const crumbs = React.useMemo(() => {
@ -108,6 +116,7 @@ function App() {
case 'editor': content = <Editor />; break; case 'editor': content = <Editor />; break;
case 'users': content = <Users />; break; case 'users': content = <Users />; break;
case 'tokens': content = <Tokens />; break; case 'tokens': content = <Tokens />; break;
case 'tokens-parody': content = <TokensParody />; break;
case 'containers':content = <Containers />; break; case 'containers':content = <Containers />; break;
case 'cluster': content = <Cluster />; break; case 'cluster': content = <Cluster />; break;
case 'settings': content = <Settings />; break; case 'settings': content = <Settings />; break;
@ -115,7 +124,7 @@ function App() {
} }
} }
// Home (launcher) suppresses the topbar it's a full-bleed landing page. // Home (launcher) suppresses the topbar - it's a full-bleed landing page.
const hideTopbar = !openAsset && route === 'home'; const hideTopbar = !openAsset && route === 'home';
return ( return (

View file

@ -1,10 +1,10 @@
// auth-gate.jsx owns the "logged in or not" state. // auth-gate.jsx - owns the "logged in or not" state.
// //
// The SPA boots into <AuthGate>, which calls GET /auth/me. On 401 it then // The SPA boots into <AuthGate>, which calls GET /auth/me. On 401 it then
// calls GET /auth/setup-required and renders <SetupScreen> or <LoginScreen> // calls GET /auth/setup-required and renders <SetupScreen> or <LoginScreen>
// (defined in screens-auth.jsx, Task 16). On 200 it renders the real <App>. // (defined in screens-auth.jsx, Task 16). On 200 it renders the real <App>.
// //
// This component is the SINGLE source of truth for the auth check no other // This component is the SINGLE source of truth for the auth check - no other
// component should redirect to a login page or wipe data on 401. Other code // component should redirect to a login page or wipe data on 401. Other code
// surfaces auth failure by calling window.AuthGate.bounce(), which re-mounts // surfaces auth failure by calling window.AuthGate.bounce(), which re-mounts
// the gate so the next /auth/me request decides what to do. // the gate so the next /auth/me request decides what to do.

View file

@ -1,4 +1,4 @@
// data.jsx API client; populates window.ZAMPP_DATA from real endpoints // data.jsx - API client; populates window.ZAMPP_DATA from real endpoints
const API = '/api/v1'; const API = '/api/v1';
window.ZAMPP_API_PREFIX = API; // single source of truth (#115) window.ZAMPP_API_PREFIX = API; // single source of truth (#115)
@ -22,14 +22,14 @@ window.ZAMPP_API_PREFIX = API; // single source of truth (#115)
})(); })();
// Premiere panel releases embedded in this deployment. Bumping the version // Premiere panel releases embedded in this deployment. Bumping the version
// here is the single source of truth both the Editor download buttons and // here is the single source of truth - both the Editor download buttons and
// the Settings Capture SDKs page read from this list (#125). // the Settings Capture SDKs page read from this list (#125).
window.PREMIERE_RELEASES = [ window.PREMIERE_RELEASES = [
{ {
version: '1.2.0', version: '1.2.0',
zxp: '/downloads/dragonflight-premiere-panel-1.2.0.zxp', zxp: '/downloads/dragonflight-premiere-panel-1.2.0.zxp',
installer: null, installer: null,
notes: 'Latest design system refresh, aligned panel UI with web-ui tokens', notes: 'Latest: design system refresh, aligned panel UI with web-ui tokens',
latest: true, latest: true,
}, },
{ {
@ -86,7 +86,7 @@ async function apiFetch(path, opts = {}) {
} }
function fmtDuration(ms) { function fmtDuration(ms) {
if (!ms) return ''; if (!ms) return '·';
const s = Math.round(ms / 1000); const s = Math.round(ms / 1000);
const h = Math.floor(s / 3600); const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60); const m = Math.floor((s % 3600) / 60);
@ -96,7 +96,7 @@ function fmtDuration(ms) {
} }
function fmtSize(bytes) { function fmtSize(bytes) {
if (!bytes) return ''; if (!bytes) return '·';
if (bytes >= 1e12) return (bytes / 1e12).toFixed(1) + ' TB'; if (bytes >= 1e12) return (bytes / 1e12).toFixed(1) + ' TB';
if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB'; if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB';
if (bytes >= 1e6) return Math.round(bytes / 1e6) + ' MB'; if (bytes >= 1e6) return Math.round(bytes / 1e6) + ' MB';
@ -104,7 +104,7 @@ function fmtSize(bytes) {
} }
function fmtRelative(iso) { function fmtRelative(iso) {
if (!iso) return ''; if (!iso) return '·';
const diff = (Date.now() - new Date(iso)) / 1000; const diff = (Date.now() - new Date(iso)) / 1000;
if (diff < 60) return 'just now'; if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
@ -122,7 +122,7 @@ function normalizeAsset(a, projectMap) {
type: a.media_type || 'video', type: a.media_type || 'video',
duration: fmtDuration(a.duration_ms), duration: fmtDuration(a.duration_ms),
size: fmtSize(a.file_size), size: fmtSize(a.file_size),
res: a.resolution || '', res: a.resolution || '·',
updated: fmtRelative(a.updated_at), updated: fmtRelative(a.updated_at),
project: (projectMap && projectMap[a.project_id]) || '', project: (projectMap && projectMap[a.project_id]) || '',
comments: 0, comments: 0,
@ -133,7 +133,7 @@ function normalizeAsset(a, projectMap) {
} }
function normalizeRecorder(r) { function normalizeRecorder(r) {
let elapsed = ''; let elapsed = '·';
if (r.status === 'recording' && r.started_at) { if (r.status === 'recording' && r.started_at) {
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000); const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' + elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
@ -143,13 +143,13 @@ function normalizeRecorder(r) {
const cfg = r.source_config || {}; const cfg = r.source_config || {};
return { return {
...r, ...r,
source: r.source_type || '', source: r.source_type || '·',
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '', url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
codec: r.recording_codec || '', codec: r.recording_codec || '·',
res: r.recording_resolution || '', res: r.recording_resolution || '·',
node: r.node_id ? r.node_id.slice(0, 8) : 'primary', node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
elapsed, elapsed,
bitrate: '', bitrate: '·',
health: 100, health: 100,
audio: false, audio: false,
}; };
@ -163,9 +163,9 @@ function normalizeJob(j) {
...j, ...j,
status: statusMap[j.status] || j.status, status: statusMap[j.status] || j.status,
kind: kindMap[j.type] || j.type || 'Job', kind: kindMap[j.type] || j.type || 'Job',
asset: j.asset_name || meta.filename || '', asset: j.asset_name || meta.filename || '·',
eta: '', eta: '·',
node: meta.node || '', node: meta.node || '·',
priority: meta.priority || 'normal', priority: meta.priority || 'normal',
error: j.error || null, error: j.error || null,
progress: j.progress || 0, progress: j.progress || 0,

View file

@ -1,21 +1,21 @@
// modal-new-recorder.jsx New Recorder dialog (SRT / RTMP / SDI / Deltacast) // modal-new-recorder.jsx - New Recorder dialog (SRT / RTMP / SDI / Deltacast)
/** /**
* DevicePortPicker groups a flat per-port API response by node_id and * DevicePortPicker - groups a flat per-port API response by node_id and
* renders one button per actual port. Replaces the old code that iterated * renders one button per actual port. Replaces the old code that iterated
* over entries and synthesised port counts, which caused duplicate groups. * over entries and synthesised port counts, which caused duplicate groups.
* *
* props: * props:
* ports flat array from /cluster/devices/blackmagic or /deltacast * ports - flat array from /cluster/devices/blackmagic or /deltacast
* each entry: { node_id, hostname, model, index, device, present? } * each entry: { node_id, hostname, model, index, device, present? }
* selectedIdx currently selected device_index * selectedIdx - currently selected device_index
* selectedNode currently selected node_id * selectedNode - currently selected node_id
* onSelect(idx, nodeId) * onSelect(idx, nodeId)
* portLabel e.g. "SDI" or "Port" * portLabel - e.g. "SDI" or "Port"
* showTestBadge show TEST CARD badge when present===false * showTestBadge - show TEST CARD badge when present===false
*/ */
function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabel = 'Port', showTestBadge = false }) { function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabel = 'Port', showTestBadge = false }) {
// Group by node_id (stable one group per physical node) // Group by node_id (stable - one group per physical node)
const groups = React.useMemo(() => { const groups = React.useMemo(() => {
const map = new Map(); const map = new Map();
for (const p of ports) { for (const p of ports) {
@ -32,7 +32,7 @@ function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabe
<div className="sdi-port-mini"> <div className="sdi-port-mini">
{groups.map(group => ( {groups.map(group => (
<div key={group.nodeId} style={{ marginBottom: groups.length > 1 ? 12 : 4 }}> <div key={group.nodeId} style={{ marginBottom: groups.length > 1 ? 12 : 4 }}>
{/* Node header only show when multiple groups, or always for clarity */} {/* Node header: only show when multiple groups, or always for clarity */}
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '0 0 6px' }}> <div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.08em', color: 'var(--text-3)', textTransform: 'uppercase', padding: '0 0 6px' }}>
{group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname} {group.model ? group.model.toUpperCase() + ' · ' : ''}{group.hostname}
</div> </div>
@ -64,7 +64,7 @@ function DevicePortPicker({ ports, selectedIdx, selectedNode, onSelect, portLabe
} }
/** /**
* ManualDevicePicker fallback when no devices detected. Lets the operator * ManualDevicePicker - fallback when no devices detected. Lets the operator
* pick node + index from dropdowns. * pick node + index from dropdowns.
*/ */
function ManualDevicePicker({ nodes, nodeId, deviceIdx, portLabel, portCount, onNodeChange, onIdxChange, emptyNote }) { function ManualDevicePicker({ nodes, nodeId, deviceIdx, portLabel, portCount, onNodeChange, onIdxChange, emptyNote }) {
@ -250,7 +250,7 @@ function NewRecorderModal({ open, onClose }) {
<label className="field-label">Source type</label> <label className="field-label">Source type</label>
<div className="source-type-grid"> <div className="source-type-grid">
{[ {[
{ id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport pull caller', icon: 'signal' }, { id: 'SRT', label: 'SRT', desc: 'Secure Reliable Transport · pull caller', icon: 'signal' },
{ id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' }, { id: 'RTMP', label: 'RTMP', desc: 'Real-Time Messaging Protocol', icon: 'globe' },
{ id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' }, { id: 'SDI', label: 'SDI', desc: 'Blackmagic DeckLink hardware', icon: 'video' },
{ id: 'DELTACAST', label: 'Deltacast', desc: 'Deltacast VideoMaster SDI', icon: 'video' }, { id: 'DELTACAST', label: 'Deltacast', desc: 'Deltacast VideoMaster SDI', icon: 'video' },
@ -447,7 +447,7 @@ function NewRecorderModal({ open, onClose }) {
<span key={tag} className="mono" style={{ background: 'var(--bg-3)', borderRadius: 4, padding: '2px 8px', fontSize: 12 }}>{tag}</span> <span key={tag} className="mono" style={{ background: 'var(--bg-3)', borderRadius: 4, padding: '2px 8px', fontSize: 12 }}>{tag}</span>
))} ))}
</div> </div>
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile not configurable.</div> <div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 6 }}>Fixed proxy profile. Not configurable.</div>
</div> </div>
</div> </div>
)} )}

View file

@ -1,4 +1,4 @@
// screens-admin.jsx Users, Tokens, Containers, Cluster (graph), Settings // screens-admin.jsx - Users, Tokens, Containers, Cluster (graph), Settings
function _normalizeNode(n, x, y) { function _normalizeNode(n, x, y) {
const cap = n.capabilities || {}; const cap = n.capabilities || {};
@ -29,9 +29,9 @@ function _normalizeNode(n, x, y) {
dbId: n.id, dbId: n.id,
role: n.role || 'worker', role: n.role || 'worker',
status: n.status || (n.online ? 'online' : 'offline'), status: n.status || (n.online ? 'online' : 'offline'),
ip: n.ip_address || n.ip || '', ip: n.ip_address || n.ip || '·',
version: n.version || '', version: n.version || '·',
uptime: n.uptime || '', uptime: n.uptime || '·',
cpu: parseFloat(n.cpu_usage || n.cpu || n.cpu_percent || 0), cpu: parseFloat(n.cpu_usage || n.cpu || n.cpu_percent || 0),
mem: Math.round(memUsedMb / 1024 * 10) / 10, mem: Math.round(memUsedMb / 1024 * 10) / 10,
memTotal: Math.round(memTotalMb / 1024 * 10) / 10, memTotal: Math.round(memTotalMb / 1024 * 10) / 10,
@ -230,7 +230,7 @@ function Users() {
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'} {u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
</div> </div>
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}> <div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || ''} {u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '·'}
</div> </div>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button className="icon-btn" aria-label="User actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}> <button className="icon-btn" aria-label="User actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}>
@ -329,7 +329,7 @@ function PasswordResetModal({ user, onClose, onSaved }) {
const [err, setErr] = React.useState(null); const [err, setErr] = React.useState(null);
const [done, setDone] = React.useState(false); const [done, setDone] = React.useState(false);
// #111 guard async resolution / delayed onSaved against unmount. // #111 - guard async resolution / delayed onSaved against unmount.
const mountedRef = React.useRef(true); const mountedRef = React.useRef(true);
const savedTimerRef = React.useRef(null); const savedTimerRef = React.useRef(null);
React.useEffect(() => () => { React.useEffect(() => () => {
@ -481,7 +481,7 @@ function GroupsPanel({ groups, users, onChange }) {
<div className="panel"> <div className="panel">
{groups.length === 0 && !creating && ( {groups.length === 0 && !creating && (
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}> <div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
No groups yet click <em>New group</em> above to create one. No groups yet: click <em>New group</em> above to create one.
</div> </div>
)} )}
{groups.map(g => { {groups.map(g => {
@ -521,8 +521,8 @@ function GroupsPanel({ groups, users, onChange }) {
<select className="field-input" defaultValue="" <select className="field-input" defaultValue=""
onChange={e => { addMember(g, e.target.value); e.target.value = ''; }} onChange={e => { addMember(g, e.target.value); e.target.value = ''; }}
style={{ width: 200, padding: '4px 8px', fontSize: 12, appearance: 'auto' }}> style={{ width: 200, padding: '4px 8px', fontSize: 12, appearance: 'auto' }}>
<option value="" disabled> Pick a user </option> <option value="" disabled>Pick a user</option>
{nonMembers.map(u => <option key={u.id} value={u.id}>@{u.username} {u.name}</option>)} {nonMembers.map(u => <option key={u.id} value={u.id}>@{u.username}: {u.name}</option>)}
</select> </select>
</div> </div>
)} )}
@ -536,7 +536,28 @@ function GroupsPanel({ groups, users, onChange }) {
); );
} }
// Real Tokens admin page: wraps ApiTokensSection (defined further down) in a
// .page shell so it can be a top-level admin nav destination. The old parody
// page lives below as TokensParody and is still reachable via the hidden
// `tokens-parody` route for posterity.
function Tokens() { function Tokens() {
return (
<div className="page">
<div className="page-header">
<h1>Tokens</h1>
<span className="subtitle">API tokens for the Premiere panel, node-agents, and external integrations</span>
</div>
<div className="page-body">
<ApiTokensSection />
<div style={{ marginTop: 16, fontSize: 11.5, color: 'var(--text-3)' }}>
Looking for the old satirical pricing page? <a href="#tokens-parody" onClick={(e) => { e.preventDefault(); window.dispatchEvent(new CustomEvent('df:nav', { detail: 'tokens-parody' })); }} style={{ color: 'var(--accent-text)' }}>It's still here.</a>
</div>
</div>
</div>
);
}
function TokensParody() {
const [burned, setBurned] = React.useState(14340); const [burned, setBurned] = React.useState(14340);
const [rate, setRate] = React.useState(2.4); const [rate, setRate] = React.useState(2.4);
const [showCalc, setShowCalc] = React.useState(false); const [showCalc, setShowCalc] = React.useState(false);
@ -582,7 +603,7 @@ function Tokens() {
}, []); }, []);
const tiers = [ const tiers = [
{ name: "Starter", desc: "For \"evaluation only\" definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" }, { name: "Starter", desc: "For \"evaluation only\": definitely not production", price: "$2,400", per: "/ month", tokens: "100k tokens", popular: false, color: "#6B7280" },
{ name: "Broadcast", desc: "Most teams. Most pain.", price: "$28,000", per: "/ month", tokens: "1.5M tokens", popular: true, color: "#5B7CFA" }, { name: "Broadcast", desc: "Most teams. Most pain.", price: "$28,000", per: "/ month", tokens: "1.5M tokens", popular: true, color: "#5B7CFA" },
{ name: "Enterprise", desc: "If you have to ask, you can't afford it (but you'll ask anyway)", price: "$call us", per: "and bring lawyers", tokens: "∞ tokens", popular: false, color: "#B57CFA" }, { name: "Enterprise", desc: "If you have to ask, you can't afford it (but you'll ask anyway)", price: "$call us", per: "and bring lawyers", tokens: "∞ tokens", popular: false, color: "#B57CFA" },
]; ];
@ -643,7 +664,7 @@ function Tokens() {
</div> </div>
<div className="token-comparison"> <div className="token-comparison">
<div className="token-card-label" style={{ padding: "16px 16px 0" }}>HOURLY BURN DRAGONFLIGHT vs. THE OTHER GUYS</div> <div className="token-card-label" style={{ padding: "16px 16px 0" }}>HOURLY BURN: DRAGONFLIGHT vs. THE OTHER GUYS</div>
<div className="token-compare-chart"> <div className="token-compare-chart">
<ChartLine <ChartLine
series={[ series={[
@ -699,7 +720,7 @@ function Tokens() {
<div> <div>
<strong>Disclaimer:</strong> No actual tokens were billed in the making of this page. Wild Dragon's broadcast platform <strong>Disclaimer:</strong> No actual tokens were billed in the making of this page. Wild Dragon's broadcast platform
is self-hosted infrastructure. Any resemblance to per-API-call broadcast token economies, living or dead, is satirical is self-hosted infrastructure. Any resemblance to per-API-call broadcast token economies, living or dead, is satirical
and protected as commentary. If you came here looking for actual API tokens, that page no longer exists service and protected as commentary. If you came here looking for actual API tokens, that page no longer exists: service
credentials are managed through the cluster's own JWT issuer. credentials are managed through the cluster's own JWT issuer.
</div> </div>
</div> </div>
@ -798,7 +819,7 @@ function Containers() {
const [containers, setContainers] = React.useState(null); const [containers, setContainers] = React.useState(null);
const [restartFlashState, setRestartFlashState] = React.useState(null); const [restartFlashState, setRestartFlashState] = React.useState(null);
const [logsModalState, setLogsModalState] = React.useState(null); const [logsModalState, setLogsModalState] = React.useState(null);
// #111 guard restart-flash timers against unmount. // #111 - guard restart-flash timers against unmount.
const mountedRef = React.useRef(true); const mountedRef = React.useRef(true);
const flashTimerRef = React.useRef(null); const flashTimerRef = React.useRef(null);
React.useEffect(() => () => { React.useEffect(() => () => {
@ -960,7 +981,7 @@ function Containers() {
} }
// //
// BmdCardPanel capture-card section inside the Cluster node detail panel. // BmdCardPanel - capture-card section inside the Cluster node detail panel.
// Shows port chips with live video-presence dots AND the BMD SVG card diagram. // Shows port chips with live video-presence dots AND the BMD SVG card diagram.
// //
function BmdCardPanel({ sel, portSignals }) { function BmdCardPanel({ sel, portSignals }) {
@ -995,7 +1016,7 @@ function BmdCardPanel({ sel, portSignals }) {
<div> <div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}> <div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="video" size={11} /> <Icon name="video" size={11} />
Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '— none reported'} Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '(none reported)'}
</div> </div>
{sel.bmdPorts.length === 0 && ( {sel.bmdPorts.length === 0 && (
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No DeckLink cards detected on this node</div> <div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No DeckLink cards detected on this node</div>
@ -1021,7 +1042,7 @@ function BmdCardPanel({ sel, portSignals }) {
const { label, color } = _signalChip(sig); const { label, color } = _signalChip(sig);
const isReceiving = sig === 'receiving'; const isReceiving = sig === 'receiving';
return ( return (
<div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} ${label}` : label} <div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}: ${label}` : label}
style={{ style={{
display: "flex", alignItems: "center", gap: 5, display: "flex", alignItems: "center", gap: 5,
fontSize: 10.5, fontFamily: "var(--font-mono)", fontSize: 10.5, fontFamily: "var(--font-mono)",
@ -1070,7 +1091,7 @@ function _signalChip(sig) {
case 'error': return { label: 'ERROR', color: 'var(--danger)' }; case 'error': return { label: 'ERROR', color: 'var(--danger)' };
case 'idle': return { label: 'IDLE', color: 'var(--text-3)' }; case 'idle': return { label: 'IDLE', color: 'var(--text-3)' };
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)' }; case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)' };
default: return { label: sig || '', color: 'var(--text-4)' }; default: return { label: sig || '·', color: 'var(--text-4)' };
} }
} }
@ -1174,7 +1195,7 @@ function Cluster() {
}); });
const removeNode = (node) => { const removeNode = (node) => {
if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine it only removes it from cluster membership.')) return; if (!window.confirm('Remove node ' + node.id + ' from the cluster?\nThis does not stop the machine: it only removes it from cluster membership.')) return;
window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.dbId || node.id), { method: 'DELETE' }) window.ZAMPP_API.fetch('/cluster/nodes/' + encodeURIComponent(node.dbId || node.id), { method: 'DELETE' })
.then(() => refresh()) .then(() => refresh())
.catch(e => setAdviceModal({ title: 'Remove failed', lines: [e.message] })); .catch(e => setAdviceModal({ title: 'Remove failed', lines: [e.message] }));
@ -1323,7 +1344,7 @@ function Cluster() {
<div> <div>
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}> <div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
<Icon name="gpu" size={11} /> <Icon name="gpu" size={11} />
GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '— none reported'} GPUs {sel.gpuCount > 0 ? `(${sel.gpuCount})` : '(none reported)'}
</div> </div>
{sel.gpus.length === 0 && ( {sel.gpus.length === 0 && (
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No GPUs detected on this node</div> <div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No GPUs detected on this node</div>
@ -1504,7 +1525,7 @@ function ApiTokensSection() {
{justCreated && ( {justCreated && (
<div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}> <div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}> <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}>
Save this token now it will not be shown again Save this token now: it will not be shown again
</div> </div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div> <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div>
<button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button> <button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button>
@ -1580,7 +1601,7 @@ function Settings() {
} }
// //
// Storage unified view: live mount/bucket health on top, then the two // Storage - unified view: live mount/bucket health on top, then the two
// existing editors (S3 bucket + growing-files SMB landing zone) stacked. // existing editors (S3 bucket + growing-files SMB landing zone) stacked.
// //
@ -1595,7 +1616,7 @@ function StorageSection() {
} }
function formatBytes(n) { function formatBytes(n) {
if (n == null || isNaN(n)) return ''; if (n == null || isNaN(n)) return '·';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let v = n, i = 0; let v = n, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
@ -1628,7 +1649,7 @@ function MountHealthStrip() {
React.useEffect(() => { React.useEffect(() => {
load(); load();
// Light auto-refresh so free-space + reachability stay current while the // Light auto-refresh so free-space + reachability stay current while the
// operator is on the page. 15s is plenty these are diagnostic, not real-time. // operator is on the page. 15s is plenty - these are diagnostic, not real-time.
const t = setInterval(load, 15_000); const t = setInterval(load, 15_000);
return () => clearInterval(t); return () => clearInterval(t);
}, [load]); }, [load]);
@ -1678,9 +1699,9 @@ function MountHealthStrip() {
)} )}
</div> </div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}> <div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
<span>Container</span><span className="mono">{g.container_path || ''}</span> <span>Container</span><span className="mono">{g.container_path || '·'}</span>
<span>Host</span><span className="mono">{g.host_path || ''}</span> <span>Host</span><span className="mono">{g.host_path || '·'}</span>
<span>SMB</span><span className="mono">{g.smb_url || ''}</span> <span>SMB</span><span className="mono">{g.smb_url || '·'}</span>
<span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span> <span>Promote idle</span><span className="mono">{g.promote_after_seconds}s</span>
{g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>} {g.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{g.error}</span></>}
</div> </div>
@ -1700,8 +1721,8 @@ function MountHealthStrip() {
</div> </div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}> <div style={{ fontSize: 11.5, color: 'var(--text-3)', display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 10, rowGap: 2 }}>
<span>Endpoint</span><span className="mono">{s.endpoint || '(AWS default)'}</span> <span>Endpoint</span><span className="mono">{s.endpoint || '(AWS default)'}</span>
<span>Bucket</span><span className="mono">{s.bucket || ''}</span> <span>Bucket</span><span className="mono">{s.bucket || '·'}</span>
<span>Region</span><span className="mono">{s.region || ''}</span> <span>Region</span><span className="mono">{s.region || '·'}</span>
{s.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{s.error}</span></>} {s.error && <><span>Error</span><span style={{ color: 'var(--danger)' }}>{s.error}</span></>}
</div> </div>
</div> </div>
@ -1767,7 +1788,7 @@ function S3SettingsCard() {
<SField label="Bucket"><input className="field-input mono" required value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField> <SField label="Bucket"><input className="field-input mono" required value={s3.s3_bucket} onChange={e => setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /></SField>
</div> </div>
<SField label="Access key ID"><input className="field-input mono" required value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" autoComplete="off" /></SField> <SField label="Access key ID"><input className="field-input mono" required value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" autoComplete="off" /></SField>
<SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved type to replace)' : 'Secret key'} autoComplete="new-password" /></SField> <SField label="Secret access key"><input className="field-input mono" type="password" value={s3.s3_secret_key} onChange={e => setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved: type to replace)' : 'Secret key'} autoComplete="new-password" /></SField>
<SettingsMsg msg={msg} /> <SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}> <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button> <button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
@ -1791,7 +1812,7 @@ function GpuSettingsCard() {
const save = () => { const save = () => {
setSaving(true); setMsg(null); setSaving(true); setMsg(null);
window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) }) window.ZAMPP_API.fetch('/settings/transcoding', { method: 'PUT', body: JSON.stringify(cfg) })
.then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved new settings apply to the next proxy job.' }); }) .then(() => { setSaving(false); setMsg({ ok: true, text: 'Saved: new settings apply to the next proxy job.' }); })
.catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); }); .catch(e => { setSaving(false); setMsg({ ok: false, text: e.message }); });
}; };
@ -1810,7 +1831,7 @@ function GpuSettingsCard() {
<SField label="Hardware acceleration"> <SField label="Hardware acceleration">
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5 }}>
<input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} /> <input type="checkbox" checked={gpuEnabled} onChange={e => set('gpu_transcode_enabled', String(e.target.checked))} />
<span style={{ color: 'var(--text-2)' }}>Use GPU encoders (NVENC / VAAPI) when available falls back to CPU on missing hardware</span> <span style={{ color: 'var(--text-2)' }}>Use GPU encoders (NVENC / VAAPI) when available: falls back to CPU on missing hardware</span>
</label> </label>
</SField> </SField>
@ -1843,9 +1864,9 @@ function GpuSettingsCard() {
</SField> </SField>
<SField label="Rate control"> <SField label="Rate control">
<select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}> <select className="field-input" value={cfg.gpu_rc_mode || 'cbr'} onChange={e => set('gpu_rc_mode', e.target.value)} style={{ appearance: 'auto' }}>
<option value="cbr">CBR constant bitrate</option> <option value="cbr">CBR: constant bitrate</option>
<option value="vbr">VBR variable bitrate</option> <option value="vbr">VBR: variable bitrate</option>
<option value="cqp">CQP / CRF constant quality</option> <option value="cqp">CQP / CRF: constant quality</option>
</select> </select>
</SField> </SField>
</div> </div>
@ -1941,13 +1962,13 @@ function SdiSettingsCard() {
} }
// //
// Capture SDK deployment Blackmagic / AJA / Deltacast // Capture SDK deployment - Blackmagic / AJA / Deltacast
// //
const SDK_VENDORS = [ const SDK_VENDORS = [
{ {
id: 'blackmagic', id: 'blackmagic',
name: 'Blackmagic DeckLink', name: 'Blackmagic DeckLink',
sub: 'DeckLink SDK 16.x required for SDI capture via DeckLink cards', sub: 'DeckLink SDK 16.x: required for SDI capture via DeckLink cards',
expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so', expect: 'DeckLinkAPI.h, DeckLinkAPIDispatch.cpp, LinuxCOM.h, libDeckLinkAPI.so',
docs: 'https://www.blackmagicdesign.com/developer/product/capture', docs: 'https://www.blackmagicdesign.com/developer/product/capture',
buildHint: 'docker compose build --no-cache capture', buildHint: 'docker compose build --no-cache capture',
@ -1956,24 +1977,24 @@ const SDK_VENDORS = [
{ {
id: 'aja', id: 'aja',
name: 'AJA NTV2', name: 'AJA NTV2',
sub: 'NTV2 SDK for Kona / Io / U-Tap / T-Tap cards', sub: 'NTV2 SDK: for Kona / Io / U-Tap / T-Tap cards',
expect: 'libajantv2.so, ntv2card.h, ntv2enums.h', expect: 'libajantv2.so, ntv2card.h, ntv2enums.h',
docs: 'https://sdksupport.aja.com/', docs: 'https://sdksupport.aja.com/',
buildHint: 'FFmpeg patch + Dockerfile update pending files will be staged for the next capture image build', buildHint: 'FFmpeg patch + Dockerfile update pending: files will be staged for the next capture image build',
status: 'staging-only', status: 'staging-only',
}, },
{ {
id: 'deltacast', id: 'deltacast',
name: 'Deltacast VideoMaster', name: 'Deltacast VideoMaster',
sub: 'VideoMasterHD SDK for FLEX / DELTA-h4k2 / etc.', sub: 'VideoMasterHD SDK: for FLEX / DELTA-h4k2 / etc.',
expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so', expect: 'VideoMasterHD_Core.h, libVideoMasterHD_Core.so',
docs: 'https://www.deltacast.tv/products/sdk', docs: 'https://www.deltacast.tv/products/sdk',
buildHint: 'FFmpeg patch + Dockerfile update pending files will be staged for the next capture image build', buildHint: 'FFmpeg patch + Dockerfile update pending: files will be staged for the next capture image build',
status: 'staging-only', status: 'staging-only',
}, },
]; ];
// Premiere panel releases single source of truth lives on `window.PREMIERE_RELEASES` // Premiere panel releases - single source of truth lives on `window.PREMIERE_RELEASES`
// (see data.jsx). Local alias for readability. // (see data.jsx). Local alias for readability.
const PREMIERE_RELEASES = window.PREMIERE_RELEASES; const PREMIERE_RELEASES = window.PREMIERE_RELEASES;
@ -1988,7 +2009,7 @@ function SdkSettingsCard() {
React.useEffect(() => { load(); }, [load]); React.useEffect(() => { load(); }, [load]);
return ( return (
<SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed upload them here so the capture container can build with hardware support" <SettingsCard icon="video" title="Capture SDKs" sub="Vendor SDKs are licensed: upload them here so the capture container can build with hardware support"
tag={<span className="badge neutral">{SDK_VENDORS.length} vendors</span>}> tag={<span className="badge neutral">{SDK_VENDORS.length} vendors</span>}>
{/* ── Premiere Panel download section ── */} {/* ── Premiere Panel download section ── */}
@ -2059,7 +2080,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
const fd = new FormData(); const fd = new FormData();
fd.append('archive', file); fd.append('archive', file);
// Use XHR so we can report progress to the user fetch's stream API is fiddly. // Use XHR so we can report progress to the user - fetch's stream API is fiddly.
await new Promise((resolve) => { await new Promise((resolve) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id); xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id);
@ -2075,7 +2096,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
} else { } else {
let txt = xhr.responseText; let txt = xhr.responseText;
try { txt = JSON.parse(xhr.responseText).error || txt; } catch {} try { txt = JSON.parse(xhr.responseText).error || txt; } catch {}
onDone(vendor.name + ': upload failed ' + txt, false); onDone(vendor.name + ': upload failed: ' + txt, false);
} }
resolve(); resolve();
}; };
@ -2159,7 +2180,7 @@ function AmppSettingsCard() {
<input className="field-input mono" type="url" required value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" /> <input className="field-input mono" type="url" required value={cfg.ampp_base_url} onChange={e => setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" />
</SField> </SField>
<SField label="API token"> <SField label="API token">
<input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved type to replace)' : 'AMPP API token'} autoComplete="new-password" /> <input className="field-input mono" type="password" value={cfg.ampp_token} onChange={e => setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved: type to replace)' : 'AMPP API token'} autoComplete="new-password" />
</SField> </SField>
<SettingsMsg msg={msg} /> <SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}> <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>

View file

@ -1,6 +1,6 @@
// screens-asset.jsx asset detail (Frame.io-style player, filmstrip, comments) // screens-asset.jsx - asset detail (Frame.io-style player, filmstrip, comments)
// Simple gradient palette replaces the missing thumbGrad function // Simple gradient palette - replaces the missing thumbGrad function
const _FRAME_GRADIENTS = [ const _FRAME_GRADIENTS = [
'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)', 'linear-gradient(135deg,#1a1f2e 0%,#2a3045 100%)',
'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)', 'linear-gradient(135deg,#1e2030 0%,#2d2040 100%)',
@ -41,7 +41,7 @@ function AssetDetail({ asset, onClose }) {
// Player health: 'idle' | 'loading' | 'playing' | 'paused' | 'seeking' | 'waiting' | 'stalled' | 'error' // Player health: 'idle' | 'loading' | 'playing' | 'paused' | 'seeking' | 'waiting' | 'stalled' | 'error'
const [playerState, setPlayerState] = React.useState('idle'); const [playerState, setPlayerState] = React.useState('idle');
const [playerError, setPlayerError] = React.useState(null); const [playerError, setPlayerError] = React.useState(null);
// Array of {start, end} in milliseconds populated from HTMLMediaElement.buffered // Array of {start, end} in milliseconds - populated from HTMLMediaElement.buffered
const [buffered, setBuffered] = React.useState([]); const [buffered, setBuffered] = React.useState([]);
// Wall-clock when waiting/stalled began (so we can show how long it's been hung) // Wall-clock when waiting/stalled began (so we can show how long it's been hung)
const [stallStart, setStallStart] = React.useState(null); const [stallStart, setStallStart] = React.useState(null);
@ -89,7 +89,7 @@ function AssetDetail({ asset, onClose }) {
}, [streamUrl, streamType]); }, [streamUrl, streamType]);
// Fetch server-side filmstrip (pre-built by filmstrip worker via FFmpeg). // Fetch server-side filmstrip (pre-built by filmstrip worker via FFmpeg).
// Falls back to nothing if not ready yet user can right-click Re-generate. // Falls back to nothing if not ready yet - user can right-click Re-generate.
React.useEffect(() => { React.useEffect(() => {
if (!assetId) return; if (!assetId) return;
let cancelled = false; let cancelled = false;
@ -115,7 +115,7 @@ function AssetDetail({ asset, onClose }) {
return function() { cancelled = true; }; return function() { cancelled = true; };
}, [assetId, filmstripKey]); }, [assetId, filmstripKey]);
// Fake playback timer only used when no real video stream // Fake playback timer - only used when no real video stream
React.useEffect(() => { React.useEffect(() => {
if (!playing || totalMs <= 0 || streamUrl) return; if (!playing || totalMs <= 0 || streamUrl) return;
const i = setInterval(function() { const i = setInterval(function() {
@ -159,7 +159,7 @@ function AssetDetail({ asset, onClose }) {
return () => clearInterval(i); return () => clearInterval(i);
}, [stallStart]); }, [stallStart]);
// #143 if the player is stalled within 250 ms of EOF for more than 1.2 s, // #143 - if the player is stalled within 250 ms of EOF for more than 1.2 s,
// treat it as a clean end. Avoids the silent-freeze users hit when seeking // treat it as a clean end. Avoids the silent-freeze users hit when seeking
// to the last instant of a clip. // to the last instant of a clip.
React.useEffect(() => { React.useEffect(() => {
@ -180,7 +180,7 @@ function AssetDetail({ asset, onClose }) {
}, [stallStart, totalMs, playerState]); }, [stallStart, totalMs, playerState]);
const seek = function(ms) { const seek = function(ms) {
// #143 seeking exactly to `totalMs` parked the playhead one micro-sample // #143 - seeking exactly to `totalMs` parked the playhead one micro-sample
// past the last decoded frame; the player then asked S3 for a range past // past the last decoded frame; the player then asked S3 for a range past
// EOF and stalled silently. Pull the clamp back 50 ms so the final frames // EOF and stalled silently. Pull the clamp back 50 ms so the final frames
// are reachable but the player never asks for bytes past the file size. // are reachable but the player never asks for bytes past the file size.
@ -212,7 +212,7 @@ function AssetDetail({ asset, onClose }) {
.finally(function() { setDownloading(false); }); .finally(function() { setDownloading(false); });
}; };
// Right-click style menu on the kebab icon delete, copy ID. // Right-click style menu on the kebab icon - delete, copy ID.
const [menuOpen, setMenuOpen] = React.useState(false); const [menuOpen, setMenuOpen] = React.useState(false);
const moreBtnRef = React.useRef(null); const moreBtnRef = React.useRef(null);
React.useEffect(function() { React.useEffect(function() {
@ -258,7 +258,7 @@ function AssetDetail({ asset, onClose }) {
const regenFilmstrip = function() { const regenFilmstrip = function() {
window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' }) window.ZAMPP_API.fetch('/assets/' + assetId + '/reprocess?type=filmstrip', { method: 'POST' })
.then(function() { window.alert('Filmstrip job queued it will appear automatically when ready.'); }) .then(function() { window.alert('Filmstrip job queued: it will appear automatically when ready.'); })
.catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); }); .catch(function(e) { window.alert('Failed to queue filmstrip: ' + (e.message || 'unknown error')); });
}; };
@ -477,7 +477,7 @@ function AssetDetail({ asset, onClose }) {
<span className="badge live">LIVE · REC</span> <span className="badge live">LIVE · REC</span>
</div> </div>
)} )}
{/* Player health badge shows when waiting/stalled so the freeze is visible */} {/* Player health badge: shows when waiting/stalled so the freeze is visible */}
{streamUrl && (playerState === 'waiting' || playerState === 'stalled' || playerState === 'seeking' || playerState === 'error') && ( {streamUrl && (playerState === 'waiting' || playerState === 'stalled' || playerState === 'seeking' || playerState === 'error') && (
<div style={{ position: "absolute", top: 12, right: 12, display: "flex", gap: 6, alignItems: "center" }}> <div style={{ position: "absolute", top: 12, right: 12, display: "flex", gap: 6, alignItems: "center" }}>
<span className={'badge ' + (playerState === 'error' ? 'danger' : playerState === 'stalled' ? 'warning' : 'neutral')}> <span className={'badge ' + (playerState === 'error' ? 'danger' : playerState === 'stalled' ? 'warning' : 'neutral')}>
@ -630,7 +630,7 @@ function PlaybackBar({ current, total, onSeek, comments, buffered }) {
const bufferedRanges = Array.isArray(buffered) ? buffered : []; const bufferedRanges = Array.isArray(buffered) ? buffered : [];
return ( return (
<div className="playback-bar" ref={ref} onClick={handle}> <div className="playback-bar" ref={ref} onClick={handle}>
{/* Buffered byte ranges translucent grey segments showing what the browser has loaded */} {/* Buffered byte ranges: translucent grey segments showing what the browser has loaded */}
{total > 0 && bufferedRanges.map((br, i) => { {total > 0 && bufferedRanges.map((br, i) => {
const left = Math.max(0, (br.start / total) * 100); const left = Math.max(0, (br.start / total) * 100);
const right = Math.min(100, (br.end / total) * 100); const right = Math.min(100, (br.end / total) * 100);
@ -902,7 +902,7 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
<FileRow <FileRow
label="Filmstrip" label="Filmstrip"
present={hasFilmstrip} present={hasFilmstrip}
path={hasFilmstrip ? (asset.filmstrip_s3_key || null) : filmstripLoading ? 'Fetching…' : 'Not generated yet right-click filmstrip or click Re-generate'} path={hasFilmstrip ? (asset.filmstrip_s3_key || null) : filmstripLoading ? 'Fetching…' : 'Not generated yet: right-click filmstrip or click Re-generate'}
icon="editor" icon="editor"
actionLabel="Re-generate" actionLabel="Re-generate"
onAction={onRegenFilmstrip} onAction={onRegenFilmstrip}
@ -929,21 +929,21 @@ function FilesTab({ asset, filmFrames, filmstripLoading, streamUrl, reprocessing
function MetadataTab({ asset }) { function MetadataTab({ asset }) {
var rows = [ var rows = [
{ k: "Filename", v: asset.name }, { k: "Filename", v: asset.name },
{ k: "Duration", v: asset.duration || '' }, { k: "Duration", v: asset.duration || '·' },
{ k: "Resolution", v: asset.res || '' }, { k: "Resolution", v: asset.res || '·' },
{ k: "Codec", v: asset.codec || '' }, { k: "Codec", v: asset.codec || '·' },
{ k: "File size", v: asset.size || '' }, { k: "File size", v: asset.size || '·' },
{ k: "Status", v: asset.status || '' }, { k: "Status", v: asset.status || '·' },
{ k: "Updated", v: asset.updated || '' }, { k: "Updated", v: asset.updated || '·' },
{ k: "Project", v: asset.project || '' }, { k: "Project", v: asset.project || '·' },
]; ];
var audioMeta = asset.audio_metadata; var audioMeta = asset.audio_metadata;
if (audioMeta && Array.isArray(audioMeta) && audioMeta.length > 0) { if (audioMeta && Array.isArray(audioMeta) && audioMeta.length > 0) {
rows.push({ k: "Audio tracks", v: audioMeta.length }); rows.push({ k: "Audio tracks", v: audioMeta.length });
audioMeta.forEach(function(tr, i) { audioMeta.forEach(function(tr, i) {
var label = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1)); var label = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
var ch = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : ''); var ch = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '·');
var parts = [tr.codec || '', ch, tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '', tr.bit_depth ? tr.bit_depth + '-bit' : '', tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '']; var parts = [tr.codec || '·', ch, tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '·', tr.bit_depth ? tr.bit_depth + '-bit' : '·', tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '·'];
if (tr.language) parts.push(tr.language); if (tr.language) parts.push(tr.language);
rows.push({ k: " " + label, v: parts.join(' · ') }); rows.push({ k: " " + label, v: parts.join(' · ') });
}); });
@ -1106,13 +1106,13 @@ function AudioTab({ asset }) {
var st = trackState[i] || { muted: false, solo: false, volume: 100 }; var st = trackState[i] || { muted: false, solo: false, volume: 100 };
var isAudible = st.muted ? false : (anySolo ? st.solo : true); var isAudible = st.muted ? false : (anySolo ? st.solo : true);
var color = _AUDIO_TRACK_COLORS[i % _AUDIO_TRACK_COLORS.length]; var color = _AUDIO_TRACK_COLORS[i % _AUDIO_TRACK_COLORS.length];
var chLabel = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : ''); var chLabel = tr.channel_layout || (tr.channels ? (tr.channels === 1 ? 'mono' : tr.channels === 2 ? 'stereo' : tr.channels + 'ch') : '·');
var trackName = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1)); var trackName = tr.title || ('Track ' + (tr.index != null ? tr.index : i + 1));
var langTag = tr.language ? <span className="badge neutral" style={{ marginLeft: 6 }}>{tr.language}</span> : null; var langTag = tr.language ? <span className="badge neutral" style={{ marginLeft: 6 }}>{tr.language}</span> : null;
var codecLabel = tr.codec || ''; var codecLabel = tr.codec || '·';
var srLabel = tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : ''; var srLabel = tr.sample_rate ? (tr.sample_rate / 1000).toFixed(1) + ' kHz' : '·';
var bdLabel = tr.bit_depth ? tr.bit_depth + '-bit' : ''; var bdLabel = tr.bit_depth ? tr.bit_depth + '-bit' : '·';
var brLabel = tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : ''; var brLabel = tr.bit_rate ? Math.round(tr.bit_rate / 1000) + ' kbps' : '·';
return ( return (
<div key={i} className={'audio-track' + (isAudible ? '' : ' muted')}> <div key={i} className={'audio-track' + (isAudible ? '' : ' muted')}>
@ -1187,7 +1187,7 @@ function AudioLevelMeter({ level, label, tall }) {
} }
function parseDuration(d) { function parseDuration(d) {
if (!d || d === '' || typeof d !== 'string') return 0; if (!d || d === '·' || typeof d !== 'string') return 0;
const parts = d.split(':'); const parts = d.split(':');
if (parts.length < 2) return 0; if (parts.length < 2) return 0;
const nums = parts.map(Number); const nums = parts.map(Number);

View file

@ -1,4 +1,4 @@
// LoginScreen + SetupScreen layout B from the auth brainstorm spec: // LoginScreen + SetupScreen - layout B from the auth brainstorm spec:
// 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card. // 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card.
// Matches DESIGN.md tokens; no decoration, dense, ops register. // Matches DESIGN.md tokens; no decoration, dense, ops register.
@ -163,7 +163,7 @@
return ( return (
<Screen> <Screen>
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}> <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
First-run setup create the first admin First-run setup: create the first admin
</div> </div>
<ErrorRow text={error} /> <ErrorRow text={error} />
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus /> <Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />

View file

@ -1,4 +1,4 @@
// screens-editor.jsx NLE timeline editor // screens-editor.jsx - NLE timeline editor
// Depends on: window.TC (timecode.js), window.Timeline (timeline.js), window.ZAMPP_API // Depends on: window.TC (timecode.js), window.Timeline (timeline.js), window.ZAMPP_API
function _uid() { return 'ce_' + (++_uid._c || (_uid._c = 0)); } function _uid() { return 'ce_' + (++_uid._c || (_uid._c = 0)); }
@ -378,45 +378,23 @@ function Editor() {
return ( return (
<div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}> <div className="editor-shell" style={{ position: 'relative', display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* ── COMING SOON bumper — overlays the entire editor ── */} {/* Beta banner: flat strip across top, no glassmorphism or gradient. */}
<div style={{ <div className="editor-beta-banner">
position: 'absolute', inset: 0, zIndex: 100, <Icon name="editor" size={14} />
background: 'rgba(10, 12, 18, 0.92)', <div className="editor-beta-banner-body">
backdropFilter: 'blur(6px)', <strong>NLE editor is in beta.</strong>
display: 'flex', flexDirection: 'column', <span> Use the Premiere Pro panel for frame-accurate editing and growing-file workflows.</span>
alignItems: 'center', justifyContent: 'center',
gap: 20, pointerEvents: 'all',
}}>
<div style={{
width: 64, height: 64,
background: 'linear-gradient(135deg, var(--accent), hsl(250 80% 65%))',
borderRadius: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 0 40px rgba(91, 124, 250, 0.4)',
}}>
<Icon name="editor" size={30} />
</div> </div>
<div style={{ textAlign: 'center', maxWidth: 420 }}> <div className="editor-beta-banner-actions">
<div style={{ fontSize: 26, fontWeight: 700, letterSpacing: '-0.02em', color: 'var(--text-primary)', marginBottom: 8 }}> <a href={(window.PREMIERE_LATEST || {}).zxp || '#'} download className="btn primary sm">
NLE Editor Coming Soon Download Premiere panel
</div>
<div style={{ fontSize: 14, color: 'var(--text-3)', lineHeight: 1.6 }}>
The browser-based timeline editor is under active development.
In the meantime, use the <strong style={{ color: 'var(--text-2)' }}>Premiere Pro panel</strong> for
frame-accurate editing and growing-file workflows download it from
<strong style={{ color: 'var(--text-2)' }}> Settings Capture SDKs</strong>.
</div>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
<a href={(window.PREMIERE_LATEST || {}).zxp || '#'} download style={{ textDecoration: 'none' }}>
<button className="btn primary">Download ZXP</button>
</a> </a>
<a href={(window.PREMIERE_LATEST || {}).installer || '#'} download style={{ textDecoration: 'none' }}> <a href={(window.PREMIERE_LATEST || {}).installer || '#'} download className="btn ghost sm">
<button className="btn ghost">Windows Installer</button> Windows installer
</a> </a>
</div> <span className="editor-beta-banner-version mono">
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: -4 }}> v{(window.PREMIERE_LATEST || {}).version || '·'}
Dragonflight Premiere Panel v{(window.PREMIERE_LATEST || {}).version || '—'} </span>
</div> </div>
</div> </div>
@ -719,7 +697,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq }) { function EditorKeyboard({ onUndo, onRedo, onSave, onMarkIn, onMarkOut, currentSeq }) {
React.useEffect(() => { React.useEffect(() => {
function handler(e) { function handler(e) {
// #116 `document.activeElement` is null in some edge cases (iframe focus, // #116 - `document.activeElement` is null in some edge cases (iframe focus,
// popovers, devtools-driven focus), and the previous code threw NPE here. // popovers, devtools-driven focus), and the previous code threw NPE here.
const tag = (document.activeElement && document.activeElement.tagName) || ''; const tag = (document.activeElement && document.activeElement.tagName) || '';
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return; if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;

View file

@ -2,18 +2,18 @@
// //
// Two routes share this file: // Two routes share this file:
// //
// Home the launcher. Big-button entry into each section of the MAM. // Home - the launcher. Big-button entry into each section of the MAM.
// Untouched in this rewrite. // Untouched in this rewrite.
// //
// Dashboard the operations view. Rebuilt as a control-room status // Dashboard - the operations view. Rebuilt as a control-room status
// board, not a SaaS analytics page. Sections render top-down by // board, not a SaaS analytics page. Sections render top-down by
// operator priority: // operator priority:
// //
// 1. ON AIR live recorder tiles, full-width // 1. ON AIR - live recorder tiles, full-width
// 2. UP NEXT single-row strip of next scheduled recordings // 2. UP NEXT - single-row strip of next scheduled recordings
// 3. ATTENTION conditional; only when something failed // 3. ATTENTION - conditional; only when something failed
// 4. WORK + CLUSTER two-column dense panels // 4. WORK + CLUSTER - two-column dense panels
// 5. STATUS BAR single mono-text line, bottom // 5. STATUS BAR - single mono-text line, bottom
// //
// Anything that would just say "all clear" is hidden, not rendered. // Anything that would just say "all clear" is hidden, not rendered.
@ -91,6 +91,19 @@ function Home({ navigate }) {
const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal; const clusterHealthy = !nodesTotal || nodesOnline >= nodesTotal;
// Activity strip (#153): live recorders + last-24h assets + alerts.
const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4);
const recentAssets = (() => {
const dayAgo = Date.now() - 86400000;
return ASSETS
.filter(a => a.created_at && new Date(a.created_at).getTime() > dayAgo)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 6);
})();
const failedCount = JOBS.filter(j => j.status === 'failed').length;
const errCount = RECORDERS.filter(r => r.status === 'error').length;
const hasActivity = liveRecorders.length || recentAssets.length || failedCount || errCount;
return ( return (
<div className="launcher"> <div className="launcher">
<div className="launcher-inner"> <div className="launcher-inner">
@ -144,6 +157,71 @@ function Home({ navigate }) {
</button> </button>
</div> </div>
{hasActivity && (
<div className="launcher-activity">
{(failedCount > 0 || errCount > 0) && (
<div className="launcher-activity-strip alert">
<Icon name="alert" size={14} />
<span>
{errCount > 0 && <strong>{errCount} recorder{errCount === 1 ? '' : 's'} in error.</strong>}
{errCount > 0 && failedCount > 0 && ' '}
{failedCount > 0 && <strong>{failedCount} failed job{failedCount === 1 ? '' : 's'}.</strong>}
</span>
<button className="btn ghost sm" onClick={() => navigate('dashboard')}>Open Dashboard</button>
</div>
)}
{liveRecorders.length > 0 && (
<div className="launcher-activity-section">
<div className="launcher-activity-head">
<span className="rec-dot" />
Recording now
<span className="muted">{liveRecorders.length} live</span>
<div style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={() => navigate('monitors')}>Monitors</button>
</div>
<div className="launcher-activity-grid">
{liveRecorders.map(r => (
<button key={r.id} className="launcher-activity-item" onClick={() => navigate('recorders')}>
<span className="badge live">REC</span>
<span className="launcher-activity-item-name">{r.name}</span>
<span className="launcher-activity-item-meta mono">{r.source_type || 'sdi'}</span>
</button>
))}
</div>
</div>
)}
{recentAssets.length > 0 && (
<div className="launcher-activity-section">
<div className="launcher-activity-head">
<Icon name="library" size={12} />
Last 24 hours
<span className="muted">{recentAssets.length} new asset{recentAssets.length === 1 ? '' : 's'}</span>
<div style={{ flex: 1 }} />
<button className="btn ghost sm" onClick={() => navigate('library')}>Library</button>
</div>
<div className="launcher-activity-grid">
{recentAssets.map(a => (
<button key={a.id} className="launcher-activity-item" onClick={() => navigate('library')}>
<Icon name={a.media_type === 'audio' ? 'audio' : 'video'} size={13} />
<span className="launcher-activity-item-name">{a.display_name || a.filename || 'untitled'}</span>
<span className="launcher-activity-item-meta mono">
{(() => {
const mins = Math.round((Date.now() - new Date(a.created_at)) / 60000);
if (mins < 60) return mins + 'm';
const h = Math.round(mins / 60);
return h + 'h';
})()}
</span>
</button>
))}
</div>
</div>
)}
</div>
)}
<div className="launcher-status"> <div className="launcher-status">
<span className="launcher-status-pip"> <span className="launcher-status-pip">
<span <span
@ -166,13 +244,13 @@ function Home({ navigate }) {
} }
// //
// Dashboard broadcast-ops control board // Dashboard - broadcast-ops control board
// //
function Dashboard({ navigate }) { function Dashboard({ navigate }) {
const { RECORDERS, JOBS, NODES } = window.ZAMPP_DATA; const { RECORDERS, JOBS, NODES } = window.ZAMPP_DATA;
// Live state recompute every second so elapsed timers keep ticking. // Live state - recompute every second so elapsed timers keep ticking.
const [tick, setTick] = React.useState(0); const [tick, setTick] = React.useState(0);
React.useEffect(() => { React.useEffect(() => {
const i = setInterval(() => setTick(t => t + 1), 1000); const i = setInterval(() => setTick(t => t + 1), 1000);
@ -193,7 +271,7 @@ function Dashboard({ navigate }) {
return () => { cancelled = true; clearInterval(t); }; return () => { cancelled = true; clearInterval(t); };
}, []); }, []);
// Refresh jobs frequently this screen is the failed-job alert surface. // Refresh jobs frequently - this screen is the failed-job alert surface.
const [jobs, setJobs] = React.useState(JOBS); const [jobs, setJobs] = React.useState(JOBS);
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
@ -209,8 +287,8 @@ function Dashboard({ navigate }) {
...j, ...j,
status: statusMap[j.status] || j.status, status: statusMap[j.status] || j.status,
kind: kindMap[j.type] || j.type || 'Job', kind: kindMap[j.type] || j.type || 'Job',
asset: j.asset_name || meta.filename || '', asset: j.asset_name || meta.filename || '·',
node: meta.node || '', node: meta.node || '·',
error: j.error || null, error: j.error || null,
progress: j.progress || 0, progress: j.progress || 0,
}; };
@ -244,6 +322,22 @@ function Dashboard({ navigate }) {
return ( return (
<div className="page dash"> <div className="page dash">
<div className="page-header">
<h1>Dashboard</h1>
<span className="subtitle">Live operations: on-air recorders, jobs, cluster health</span>
<div className="spacer" />
{hasAttention && (
<span className="badge danger" title="Items need attention">
<Icon name="alert" size={10} />
{failedJobs.length + offlineNodes.length + erroredRecorders.length} alert{failedJobs.length + offlineNodes.length + erroredRecorders.length === 1 ? '' : 's'}
</span>
)}
<span className="status-pip">
<span className="dot" style={{ background: offlineNodes.length === 0 ? 'var(--success)' : 'var(--warning)' }} />
<span>{onlineNodes}/{NODES.length || 0} nodes online</span>
</span>
</div>
{/* ────────── ON AIR ────────── */} {/* ────────── ON AIR ────────── */}
<section className="dash-section"> <section className="dash-section">
<DashSectionHead <DashSectionHead
@ -325,7 +419,7 @@ function Dashboard({ navigate }) {
level="danger" level="danger"
icon="alert" icon="alert"
title={j.kind + ' failed'} title={j.kind + ' failed'}
detail={(j.asset || '') + (j.error ? ' · ' + j.error.slice(0, 100) : '')} detail={(j.asset || '·') + (j.error ? ' · ' + j.error.slice(0, 100) : '')}
onClick={() => navigate('jobs')} onClick={() => navigate('jobs')}
/> />
))} ))}
@ -491,14 +585,14 @@ function OnAirTile({ recorder, onClick }) {
<div className="dash-onair-meta"> <div className="dash-onair-meta">
<div className="dash-onair-name">{recorder.name}</div> <div className="dash-onair-name">{recorder.name}</div>
<div className="dash-onair-sub"> <div className="dash-onair-sub">
<span className="dash-onair-source">{recorder.source || ''}</span> <span className="dash-onair-source">{recorder.source || '·'}</span>
{recorder.res && recorder.res !== '' && ( {recorder.res && recorder.res !== '·' && (
<> <>
<span className="dash-onair-dot">·</span> <span className="dash-onair-dot">·</span>
<span className="dash-onair-res">{recorder.res}</span> <span className="dash-onair-res">{recorder.res}</span>
</> </>
)} )}
{recorder.codec && recorder.codec !== '' && ( {recorder.codec && recorder.codec !== '·' && (
<> <>
<span className="dash-onair-dot">·</span> <span className="dash-onair-dot">·</span>
<span className="dash-onair-codec">{recorder.codec}</span> <span className="dash-onair-codec">{recorder.codec}</span>
@ -620,7 +714,7 @@ function DashClusterRow({ node }) {
</span> </span>
<span className="dash-cluster-val">{Math.round(cpuPct)}%</span> <span className="dash-cluster-val">{Math.round(cpuPct)}%</span>
</> </>
) : <span className="dash-cluster-val muted"></span>} ) : <span className="dash-cluster-val muted">·</span>}
</span> </span>
<span className="dash-cluster-metric"> <span className="dash-cluster-metric">
{memPct != null ? ( {memPct != null ? (
@ -636,7 +730,7 @@ function DashClusterRow({ node }) {
</span> </span>
<span className="dash-cluster-val">{memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G'}</span> <span className="dash-cluster-val">{memUsed < 1 ? Math.round(memUsed * 1024) + 'M' : memUsed.toFixed(1) + 'G'}</span>
</> </>
) : <span className="dash-cluster-val muted"></span>} ) : <span className="dash-cluster-val muted">·</span>}
</span> </span>
</div> </div>
); );

View file

@ -1,4 +1,4 @@
// screens-ingest.jsx Upload, Recorders, Capture, Monitors // screens-ingest.jsx - Upload, Recorders, Capture, Monitors
/* ===== Upload helpers ===== */ /* ===== Upload helpers ===== */
const _SIMPLE_MAX = 50 * 1024 * 1024; // 50 MB simple upload const _SIMPLE_MAX = 50 * 1024 * 1024; // 50 MB simple upload
@ -38,7 +38,7 @@ async function _uploadFile(file, projectId, onProgress) {
(loaded, total) => onProgress(Math.round((loaded / total) * 100))); (loaded, total) => onProgress(Math.round((loaded / total) * 100)));
} }
// Multipart // - Multipart -
const init = await window.ZAMPP_API.fetch('/upload/init', { const init = await window.ZAMPP_API.fetch('/upload/init', {
method: 'POST', method: 'POST',
body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }), body: JSON.stringify({ filename: file.name, fileSize: file.size, contentType: mime, projectId }),
@ -106,7 +106,7 @@ function Upload({ navigate }) {
<div className="page"> <div className="page">
<div className="page-header"> <div className="page-header">
<h1>Upload</h1> <h1>Upload</h1>
<span className="subtitle">Drop video, audio, or stills we proxy and index automatically.</span> <span className="subtitle">Drop video, audio, or stills: we proxy and index automatically.</span>
</div> </div>
<div className="page-body"> <div className="page-body">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 20 }}>
@ -227,7 +227,7 @@ function YouTubeImport({ navigate }) {
window.dispatchEvent(new CustomEvent('df:assets-changed')); window.dispatchEvent(new CustomEvent('df:assets-changed'));
} else if (asset.status === 'error') { } else if (asset.status === 'error') {
patch.status = 'error'; patch.status = 'error';
patch.error = patch.error || 'Import failed check the Jobs screen for details.'; patch.error = patch.error || 'Import failed: check the Jobs screen for details.';
} else if (asset.status === 'processing') { } else if (asset.status === 'processing') {
patch.status = 'processing'; patch.status = 'processing';
} }
@ -274,7 +274,7 @@ function YouTubeImport({ navigate }) {
<div className="page"> <div className="page">
<div className="page-header"> <div className="page-header">
<h1>YouTube</h1> <h1>YouTube</h1>
<span className="subtitle">Paste a link we download and import the best available MP4.</span> <span className="subtitle">Paste a link: we download and import the best available MP4.</span>
</div> </div>
<div className="page-body"> <div className="page-body">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
@ -471,7 +471,7 @@ function HlsPreview({ assetId, muted = true, controls = false, className }) {
/* ===== Recorders ===== */ /* ===== Recorders ===== */
function _normRecorder(r) { function _normRecorder(r) {
let elapsed = ''; let elapsed = '·';
if (r.status === 'recording' && r.started_at) { if (r.status === 'recording' && r.started_at) {
const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000); const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000);
elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' + elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' +
@ -481,13 +481,13 @@ function _normRecorder(r) {
const cfg = r.source_config || {}; const cfg = r.source_config || {};
return { return {
...r, ...r,
source: r.source_type || '', source: r.source_type || '·',
url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '', url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '·',
codec: r.recording_codec || '', codec: r.recording_codec || '·',
res: r.recording_resolution || '', res: r.recording_resolution || '·',
node: r.node_id ? r.node_id.slice(0, 8) : 'primary', node: r.node_id ? r.node_id.slice(0, 8) : 'primary',
elapsed, elapsed,
bitrate: '', bitrate: '·',
health: 100, health: 100,
audio: false, audio: false,
}; };
@ -504,7 +504,7 @@ function Recorders({ navigate, onNew }) {
setRecorders(norm); setRecorders(norm);
}) })
.catch(err => { .catch(err => {
// apiFetch already redirects on 401 don't log noise, interval // apiFetch already redirects on 401 - don't log noise, interval
// will be cleared automatically when the component unmounts on redirect (#55) // will be cleared automatically when the component unmounts on redirect (#55)
if (err && err.message && err.message.includes('Unauthenticated')) return; if (err && err.message && err.message.includes('Unauthenticated')) return;
window.DF_LOG.warn('[recorders] poll error:', err?.message); window.DF_LOG.warn('[recorders] poll error:', err?.message);
@ -600,8 +600,8 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
}, [liveStatus, recorder.elapsed]); }, [liveStatus, recorder.elapsed]);
const displaySignal = liveStatus const displaySignal = liveStatus
? (liveStatus.signal || '') ? (liveStatus.signal || '·')
: (isRec ? 'connecting…' : ''); : (isRec ? 'connecting…' : '·');
const signalColor = displaySignal === 'receiving' ? 'var(--success)' const signalColor = displaySignal === 'receiving' ? 'var(--success)'
: displaySignal === 'stopped' ? 'var(--danger)' : displaySignal === 'stopped' ? 'var(--danger)'
@ -751,7 +751,7 @@ function _captureSignalChip(sig) {
case 'error': return { label: 'ERROR', color: 'var(--danger)', pulse: false }; case 'error': return { label: 'ERROR', color: 'var(--danger)', pulse: false };
case 'idle': return { label: 'IDLE', color: 'var(--text-3)', pulse: false }; case 'idle': return { label: 'IDLE', color: 'var(--text-3)', pulse: false };
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)', pulse: false }; case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)', pulse: false };
default: return { label: sig || '', color: 'var(--text-4)', pulse: false }; default: return { label: sig || '·', color: 'var(--text-4)', pulse: false };
} }
} }
@ -763,7 +763,7 @@ function CapturePortChip({ port, sigEntry }) {
return ( return (
<div <div
title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} ${label}` : label} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'}: ${label}` : label}
style={{ style={{
display: 'flex', alignItems: 'center', gap: 6, display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 5, padding: '6px 12px', borderRadius: 5,
@ -1074,7 +1074,7 @@ function MonitorTile({ feed, seed }) {
)} )}
<div className="monitor-tile-label"> <div className="monitor-tile-label">
<span className="name">{feed.name}</span> <span className="name">{feed.name}</span>
{feed.elapsed && feed.elapsed !== '' && <span className="time mono">{feed.elapsed}</span>} {feed.elapsed && feed.elapsed !== '·' && <span className="time mono">{feed.elapsed}</span>}
</div> </div>
</div> </div>
); );
@ -1091,7 +1091,7 @@ const _STATUS_BADGE = {
}; };
function _fmtWhen(iso) { function _fmtWhen(iso) {
if (!iso) return ''; if (!iso) return '·';
const d = new Date(iso); const d = new Date(iso);
// Local-time, short, human; e.g. "May 22 · 7:30 PM" // Local-time, short, human; e.g. "May 22 · 7:30 PM"
return d.toLocaleString(undefined, { return d.toLocaleString(undefined, {
@ -1335,7 +1335,7 @@ function _EventBlock({ event, recorder, dayStart, dayEnd, pph, now, projects, on
const d = drag; const d = drag;
setDrag(null); setDrag(null);
if (!d.moved) { if (!d.moved) {
// Treat as a click open the edit modal. // Treat as a click - open the edit modal.
onClick(event); onClick(event);
return; return;
} }
@ -1489,7 +1489,7 @@ function _RecorderGutter({ recorders, projects }) {
<span className={'epg-gutter-status ' + (isLive ? 'live' : isErr ? 'err' : 'idle')} /> <span className={'epg-gutter-status ' + (isLive ? 'live' : isErr ? 'err' : 'idle')} />
<div className="epg-gutter-meta"> <div className="epg-gutter-meta">
<div className="epg-gutter-name">{r.name}</div> <div className="epg-gutter-name">{r.name}</div>
<div className="epg-gutter-sub mono">{(r.source_type || '').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div> <div className="epg-gutter-sub mono">{(r.source_type || '·').toUpperCase()}{color ? ' · ' : ''}{color && <span className="epg-gutter-dot" style={{ background: color }} />}</div>
</div> </div>
</div> </div>
); );
@ -1517,7 +1517,7 @@ function Schedule({ navigate }) {
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
// Schedule data pull everything once and filter client-side for the // Schedule data - pull everything once and filter client-side for the
// active view. /schedules caps at 200 rows so this stays cheap. // active view. /schedules caps at 200 rows so this stays cheap.
const apiFilter = view === 'list' ? listFilter : 'all'; const apiFilter = view === 'list' ? listFilter : 'all';
const load = React.useCallback(() => { const load = React.useCallback(() => {
@ -1549,7 +1549,7 @@ function Schedule({ navigate }) {
const projects = window.ZAMPP_DATA?.PROJECTS || []; const projects = window.ZAMPP_DATA?.PROJECTS || [];
// Pixels per hour wider on Today (high-res operations view), tighter // Pixels per hour - wider on Today (high-res operations view), tighter
// when the user is scanning Week-at-a-glance. // when the user is scanning Week-at-a-glance.
const pph = view === 'week' ? 44 : 88; const pph = view === 'week' ? 44 : 88;
@ -1604,7 +1604,7 @@ function Schedule({ navigate }) {
}; };
const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY }); const openCtx = (s, ev) => setCtxMenu({ schedule: s, x: ev.clientX, y: ev.clientY });
// Dismiss the context menu on any outside click capture phase so a // Dismiss the context menu on any outside click - capture phase so a
// click on a menu item still fires before the menu unmounts. // click on a menu item still fires before the menu unmounts.
React.useEffect(() => { React.useEffect(() => {
if (!ctxMenu) return; if (!ctxMenu) return;
@ -1859,7 +1859,7 @@ function EditScheduleModal({ schedule, onClose, onSaved }) {
<label className="field-label">Recorder</label> <label className="field-label">Recorder</label>
<input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly <input className="field-input mono" value={schedule.recorder_name || schedule.recorder_id} readOnly
style={{ color: 'var(--text-3)' }} /> style={{ color: 'var(--text-3)' }} />
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned delete + recreate to change.</div> <div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>Recorder can't be reassigned: delete + recreate to change.</div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="field"> <div className="field">
@ -1928,11 +1928,11 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default
const endD = new Date(form.end_at); const endD = new Date(form.end_at);
if (endD <= startD) return setErr('End must be after start'); if (endD <= startD) return setErr('End must be after start');
// Warn (but allow) start times in the past the scheduler tick will fire // Warn (but allow) start times in the past - the scheduler tick will fire
// them immediately, which is occasionally what the operator wants // them immediately, which is occasionally what the operator wants
// (e.g. "record the next 30 minutes starting now"). // (e.g. "record the next 30 minutes starting now").
if (startD < new Date(Date.now() - 60_000)) { if (startD < new Date(Date.now() - 60_000)) {
if (!confirm('Start time is in the past recorder will fire immediately when saved.\nContinue?')) return; if (!confirm('Start time is in the past: recorder will fire immediately when saved.\nContinue?')) return;
} }
// Datetime-local inputs are in the browser's local zone; ship as ISO so // Datetime-local inputs are in the browser's local zone; ship as ISO so
@ -1972,7 +1972,7 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default
<select className="field-input" value={form.recorder_id} <select className="field-input" value={form.recorder_id}
onChange={e => set('recorder_id', e.target.value)} onChange={e => set('recorder_id', e.target.value)}
style={{ appearance: 'auto' }}> style={{ appearance: 'auto' }}>
{recorders.length === 0 && <option value=""> No recorders defined </option>} {recorders.length === 0 && <option value="">No recorders defined</option>}
{recorders.map(r => ( {recorders.map(r => (
<option key={r.id} value={r.id}> <option key={r.id} value={r.id}>
{r.name} · {r.source_type?.toUpperCase() || '?'} {r.name} · {r.source_type?.toUpperCase() || '?'}

View file

@ -1,7 +1,7 @@
// screens-jobs.jsx // screens-jobs.jsx
// Pick the most-meaningful timestamp + label for a job's current state. // Pick the most-meaningful timestamp + label for a job's current state.
// Returns { label, iso } caller renders "<label> <relative-time>" with // Returns { label, iso } - caller renders "<label> <relative-time>" with
// the full ISO as a tooltip. // the full ISO as a tooltip.
function _jobTimeFor(job) { function _jobTimeFor(job) {
if (job.status === 'done' && job.completed_at) return { label: 'done', iso: job.completed_at }; if (job.status === 'done' && job.completed_at) return { label: 'done', iso: job.completed_at };
@ -21,7 +21,7 @@ function _fmtAbsolute(iso) {
} catch { return iso; } } catch { return iso; }
} }
// Compact clock for the inline jobs cell "2:23 PM" if today, // Compact clock for the inline jobs cell - "2:23 PM" if today,
// "May 22 · 2:23 PM" if a different day. Full datetime stays in the tooltip. // "May 22 · 2:23 PM" if a different day. Full datetime stays in the tooltip.
function _fmtCompact(iso) { function _fmtCompact(iso) {
if (!iso) return ''; if (!iso) return '';
@ -51,9 +51,9 @@ function Jobs({ navigate }) {
...j, ...j,
status: statusMap[j.status] || j.status, status: statusMap[j.status] || j.status,
kind: kindMap[j.type] || j.type || 'Job', kind: kindMap[j.type] || j.type || 'Job',
asset: j.asset_name || meta.filename || '', asset: j.asset_name || meta.filename || '·',
eta: '', eta: '·',
node: meta.node || '', node: meta.node || '·',
priority: meta.priority || 'normal', priority: meta.priority || 'normal',
error: j.error || null, error: j.error || null,
progress: j.progress || 0, progress: j.progress || 0,
@ -83,7 +83,7 @@ function Jobs({ navigate }) {
}, [refresh]); }, [refresh]);
// One handler covers cancel (running) AND delete (queued / done / failed). // One handler covers cancel (running) AND delete (queued / done / failed).
// BullMQ's job.remove() what the API calls works on any state, so a // BullMQ's job.remove() - what the API calls - works on any state, so a
// stalled-active job (worker died mid-process, holding a concurrency slot) // stalled-active job (worker died mid-process, holding a concurrency slot)
// gets yanked and the next queued job runs. mode just changes the prompt // gets yanked and the next queued job runs. mode just changes the prompt
// copy so the operator knows what they're doing. // copy so the operator knows what they're doing.
@ -98,7 +98,7 @@ function Jobs({ navigate }) {
}, []); }, []);
// Retry every failed job at once. Useful after a transient infra issue // Retry every failed job at once. Useful after a transient infra issue
// (S3 outage, hung worker) one click per job is painful with 20+ failures. // (S3 outage, hung worker) - one click per job is painful with 20+ failures.
const handleRetryAll = React.useCallback(() => { const handleRetryAll = React.useCallback(() => {
const failedJobs = jobs.filter(j => j.status === 'failed'); const failedJobs = jobs.filter(j => j.status === 'failed');
if (failedJobs.length === 0) return; if (failedJobs.length === 0) return;
@ -148,18 +148,18 @@ function Jobs({ navigate }) {
<div className="stat-card"> <div className="stat-card">
<div className="label">Failed</div> <div className="label">Failed</div>
<div className="value">{counts.failed}</div> <div className="value">{counts.failed}</div>
<div className="delta" style={{ color: counts.failed > 0 ? 'var(--warning)' : '' }}> <div className={'delta' + (counts.failed > 0 ? ' delta-warn' : '')}>
{counts.failed > 0 ? 'Needs attention' : 'All clear'} {counts.failed > 0 ? 'Needs attention' : 'All clear'}
</div> </div>
</div> </div>
<div className="stat-card"> <div className="stat-card">
<div className="label">Total jobs</div> <div className="label">Total jobs</div>
<div className="value">{counts.all}</div> <div className="value">{counts.all}</div>
<div className="delta muted" style={{ fontSize: 10.5 }}>Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div> <div className="delta muted delta-tiny">Updated {Math.round((Date.now()-lastFetch)/1000)}s ago</div>
</div> </div>
</div> </div>
<div className="tab-group" style={{ marginTop: 20, width: 'fit-content' }}> <div className="tab-group jobs-tabs">
{[ {[
{ id: 'all', label: 'All · ' + counts.all }, { id: 'all', label: 'All · ' + counts.all },
{ id: 'running', label: 'Running · ' + counts.running }, { id: 'running', label: 'Running · ' + counts.running },
@ -171,12 +171,12 @@ function Jobs({ navigate }) {
))} ))}
</div> </div>
<div className="panel" style={{ marginTop: 12 }}> <div className="panel jobs-panel">
<div className="job-row head"> <div className="job-row head">
<div></div><div>Job</div><div>Asset</div><div>Node</div><div>Progress</div><div>Time</div><div>Priority</div><div></div> <div></div><div>Job</div><div>Asset</div><div>Node</div><div>Progress</div><div>Time</div><div>Priority</div><div></div>
</div> </div>
{filtered.length === 0 {filtered.length === 0
? <div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-3)' }}>No jobs in this category.</div> ? <div className="jobs-empty">No jobs in this category.</div>
: filtered.map(j => <JobRow key={j.id} job={j} onRetry={handleRetry} onDelete={handleDelete} />)} : filtered.map(j => <JobRow key={j.id} job={j} onRetry={handleRetry} onDelete={handleDelete} />)}
</div> </div>
</div> </div>
@ -189,36 +189,35 @@ function JobRow({ job, onRetry, onDelete }) {
return ( return (
<div className="job-row"> <div className="job-row">
<div><StatusDot status={job.status} /></div> <div><StatusDot status={job.status} /></div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <div className="job-row-kind">
<Icon name={iconMap[job.kind] || 'jobs'} size={13} style={{ color: 'var(--text-3)' }} /> <Icon name={iconMap[job.kind] || 'jobs'} size={13} className="job-row-kind-icon" />
<span style={{ fontWeight: 500 }}>{job.kind}</span> <span className="job-row-kind-name">{job.kind}</span>
</div> </div>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-2)' }}>{job.asset}</div> <div className="job-row-asset">{job.asset}</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{job.node}</div> <div className="mono job-row-node">{job.node}</div>
<div> <div>
{job.status === 'running' && ( {job.status === 'running' && (
<div className="job-progress-wrap"> <div className="job-progress-wrap">
<div className="job-progress-bar"><div className="job-progress-fill" style={{ width: job.progress + '%' }} /></div> <div className="job-progress-bar"><div className="job-progress-fill" style={{ width: job.progress + '%' }} /></div>
<span className="mono" style={{ fontSize: 10.5, color: 'var(--text-3)', minWidth: 32, textAlign: 'right' }}>{Math.round(job.progress)}%</span> <span className="mono job-row-progress-pct">{Math.round(job.progress)}%</span>
</div> </div>
)} )}
{job.status === 'done' && <span className="badge success" style={{ background: 'transparent', padding: 0 }}><Icon name="check" size={12} /> Complete</span>} {job.status === 'done' && <span className="badge success job-row-status-done"><Icon name="check" size={12} /> Complete</span>}
{job.status === 'queued' && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>Waiting</span>} {job.status === 'queued' && <span className="job-row-status-queued">Waiting</span>}
{job.status === 'failed' && ( {job.status === 'failed' && (
<span title={job.error || 'Failed'} <span title={job.error || 'Failed'} className="job-row-status-failed">
style={{ fontSize: 12, color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
<Icon name="alert" size={12} /> <Icon name="alert" size={12} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}> <span className="job-row-status-failed-msg">
{(job.error || 'Failed').slice(0, 120)} {(job.error || 'Failed').slice(0, 120)}
</span> </span>
</span> </span>
)} )}
</div> </div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} <div className="mono job-row-time"
title={(() => { const t = _jobTimeFor(job); return t ? t.label + ' at ' + _fmtAbsolute(t.iso) : ''; })()}> title={(() => { const t = _jobTimeFor(job); return t ? t.label + ' at ' + _fmtAbsolute(t.iso) : ''; })()}>
{(() => { {(() => {
const t = _jobTimeFor(job); const t = _jobTimeFor(job);
if (!t) return ''; if (!t) return '·';
// Terminal states (done/failed) anchor on the absolute clock so the // Terminal states (done/failed) anchor on the absolute clock so the
// operator can correlate with logs; queued/running show relative // operator can correlate with logs; queued/running show relative
// since it's a moving target. // since it's a moving target.
@ -229,16 +228,16 @@ function JobRow({ job, onRetry, onDelete }) {
})()} })()}
</div> </div>
<div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div> <div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div>
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}> <div className="job-row-actions">
{job.status === 'failed' && ( {job.status === 'failed' && (
<button className="btn ghost sm" onClick={() => onRetry(job)}><Icon name="refresh" />Retry</button> <button className="btn ghost sm" onClick={() => onRetry(job)}><Icon name="refresh" />Retry</button>
)} )}
{job.status === 'running' && ( {job.status === 'running' && (
/* Cancel a stalled-active job frees the BullMQ concurrency slot /* Cancel a stalled-active job: frees the BullMQ concurrency slot
so anything queued behind it can run. The worker may finish in so anything queued behind it can run. The worker may finish in
the background but its result is discarded. */ the background but its result is discarded. */
<button className="btn ghost sm" onClick={() => onDelete(job, 'cancel')} <button className="btn ghost sm job-row-cancel" onClick={() => onDelete(job, 'cancel')}
style={{ color: 'var(--danger)' }} title="Cancel this running job and free its queue slot"> title="Cancel this running job and free its queue slot">
<Icon name="x" />Cancel <Icon name="x" />Cancel
</button> </button>
)} )}

View file

@ -46,7 +46,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
const [search, setSearch] = React.useState(window._dfPendingSearch || ''); const [search, setSearch] = React.useState(window._dfPendingSearch || '');
React.useEffect(() => { delete window._dfPendingSearch; }, []); React.useEffect(() => { delete window._dfPendingSearch; }, []);
// Local state lets us re-render after delete / move-to-bin without forcing // Local state lets us re-render after delete / move-to-bin without forcing
// a full app reload keeps ZAMPP_DATA in sync as the cache of record. // a full app reload - keeps ZAMPP_DATA in sync as the cache of record.
const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []); const [allAssets, setAllAssets] = React.useState(window.ZAMPP_DATA.ASSETS || []);
const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y } const [ctxMenu, setCtxMenu] = React.useState(null); // { asset, x, y }
const [renamingAsset, setRenamingAsset] = React.useState(null); const [renamingAsset, setRenamingAsset] = React.useState(null);
@ -65,7 +65,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
runDownload(asset); runDownload(asset);
return; return;
} }
} catch (_) { /* localStorage unavailable show modal to be safe */ } } catch (_) { /* localStorage unavailable: show modal to be safe */ }
setPendingDownload(asset); setPendingDownload(asset);
}; };
const [creatingBin, setCreatingBin] = React.useState(false); const [creatingBin, setCreatingBin] = React.useState(false);
@ -273,7 +273,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
)} )}
{!creatingBin && BINS.length === 0 ? ( {!creatingBin && BINS.length === 0 ? (
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}> <div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
{openProject ? 'No bins yet click + to create one.' : 'Open a project to manage bins.'} {openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'}
</div> </div>
) : BINS.map(function(b) { ) : BINS.map(function(b) {
const isActive = selectedBinId === b.id; const isActive = selectedBinId === b.id;
@ -307,7 +307,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
<div className="library-main"> <div className="library-main">
<div className="library-toolbar"> <div className="library-toolbar">
<div className="toolbar-title">{displayTitle}</div> <h1 className="toolbar-title">{displayTitle}</h1>
<span className="count">· {assets.length} assets</span> <span className="count">· {assets.length} assets</span>
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<div className="search" style={{ width: 220 }}> <div className="search" style={{ width: 220 }}>
@ -324,8 +324,8 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
})} })}
</div> </div>
<div className="tab-group"> <div className="tab-group">
<button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }}><Icon name="grid" size={12} /></button> <button className={view === 'grid' ? 'active' : ''} onClick={function() { setView('grid'); }} aria-label="Grid view" title="Grid view"><Icon name="grid" size={12} /></button>
<button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }}><Icon name="list" size={12} /></button> <button className={view === 'list' ? 'active' : ''} onClick={function() { setView('list'); }} aria-label="List view" title="List view"><Icon name="list" size={12} /></button>
</div> </div>
<button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button> <button className="btn primary" onClick={function() { navigate('upload'); }}><Icon name="upload" />Upload</button>
</div> </div>
@ -362,7 +362,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
</div> </div>
<div className="col-sub">{a.duration}</div> <div className="col-sub">{a.duration}</div>
<div className="col-sub">{a.res}</div> <div className="col-sub">{a.res}</div>
<div className="col-sub">{a.codec || ''}</div> <div className="col-sub">{a.codec || '·'}</div>
<div className="col-sub">{a.size}</div> <div className="col-sub">{a.size}</div>
<div className="col-sub">{a.updated}</div> <div className="col-sub">{a.updated}</div>
<button className="icon-btn" aria-label="Asset actions" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button> <button className="icon-btn" aria-label="Asset actions" onClick={function(e) { e.stopPropagation(); openCtx(a, e); }}><Icon name="more" /></button>
@ -447,7 +447,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
// Hi-res download trigger shared by the card and the context menu. Resolves // Hi-res download trigger shared by the card and the context menu. Resolves
// a presigned S3 URL via /assets/:id/hires (returns { url, filename, ext }) // a presigned S3 URL via /assets/:id/hires (returns { url, filename, ext })
// and clicks a hidden anchor so the browser does the download. The download // and clicks a hidden anchor so the browser does the download. The download
// itself is direct S3 never proxied through mam-api so big files don't // itself is direct S3 - never proxied through mam-api - so big files don't
// touch the API container. // touch the API container.
function runDownload(asset) { function runDownload(asset) {
if (!asset || !asset.id) return; if (!asset || !asset.id) return;
@ -536,7 +536,7 @@ function AssetContextMenu({ asset, x, y, bins, onClose, onChanged, onOpen, onRen
)} )}
</> </>
) : ( ) : (
<div className="ctx-empty">No bins create one inside a project</div> <div className="ctx-empty">No bins: create one inside a project</div>
)} )}
<div className="ctx-divider" /> <div className="ctx-divider" />
<button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button> <button onClick={copyId}><Icon name="library" size={11} />Copy asset ID</button>
@ -606,14 +606,14 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
}} }}
/> />
)} )}
{/* Status badges and duration inside the relative wrapper so {/* Status badges and duration: inside the relative wrapper so
position:absolute is anchored to the thumbnail, not the card (#52) */} position:absolute is anchored to the thumbnail, not the card (#52) */}
<div className="thumb-status"> <div className="thumb-status">
{asset.status === 'live' && <span className="badge live">LIVE</span>} {asset.status === 'live' && <span className="badge live">LIVE</span>}
{asset.status === 'processing' && <span className="badge warning">Processing</span>} {asset.status === 'processing' && <span className="badge warning">Processing</span>}
{asset.status === 'error' && <span className="badge danger">Error</span>} {asset.status === 'error' && <span className="badge danger">Error</span>}
</div> </div>
{/* Hi-res download trigger only shown when the asset has an {/* Hi-res download trigger: only shown when the asset has an
original_s3_key (everything queued through ingest / conform). original_s3_key (everything queued through ingest / conform).
Hidden until card hover, lives in top-right of the thumb. */} Hidden until card hover, lives in top-right of the thumb. */}
{asset.original_s3_key && onDownload && ( {asset.original_s3_key && onDownload && (
@ -625,7 +625,7 @@ function AssetCard({ asset, onOpen, onContextMenu, onDownload, onDragStart, drag
<Icon name="download" size={12} /> <Icon name="download" size={12} />
</button> </button>
)} )}
{(asset.type === 'video' || !asset.type) && asset.duration !== '' && <div className="thumb-duration">{asset.duration}</div>} {(asset.type === 'video' || !asset.type) && asset.duration !== '·' && <div className="thumb-duration">{asset.duration}</div>}
</div> </div>
<div className="meta"> <div className="meta">
<div className="name">{asset.name}</div> <div className="name">{asset.name}</div>

View file

@ -100,8 +100,8 @@ function Projects({ onOpenProject, navigate }) {
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search projects…" /> <input value={search} onChange={e => setSearch(e.target.value)} placeholder="Search projects…" />
</div> </div>
<div className="tab-group"> <div className="tab-group">
<button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')}><Icon name="grid" size={12} /></button> <button className={view === 'grid' ? 'active' : ''} onClick={() => setView('grid')} aria-label="Grid view" title="Grid view"><Icon name="grid" size={12} /></button>
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')}><Icon name="list" size={12} /></button> <button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} aria-label="List view" title="List view"><Icon name="list" size={12} /></button>
</div> </div>
<button className="btn primary" onClick={() => setShowNew(true)}><Icon name="plus" />New project</button> <button className="btn primary" onClick={() => setShowNew(true)}><Icon name="plus" />New project</button>
</div> </div>
@ -140,8 +140,8 @@ function Projects({ onOpenProject, navigate }) {
<div>{p.name}</div> <div>{p.name}</div>
</div> </div>
<div className="col-sub">{p.assets || 0}</div> <div className="col-sub">{p.assets || 0}</div>
<div className="col-sub"></div> <div className="col-sub">·</div>
<div className="col-sub">{p.updated || ''}</div> <div className="col-sub">{p.updated || '·'}</div>
<div style={{ position: 'relative' }} onClick={e => e.stopPropagation()}> <div style={{ position: 'relative' }} onClick={e => e.stopPropagation()}>
<button className="icon-btn" aria-label="Project actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button> <button className="icon-btn" aria-label="Project actions" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === p.id ? null : p.id); }}><Icon name="more" /></button>
{menuFor === p.id && ( {menuFor === p.id && (
@ -210,7 +210,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
const ofProject = assets.filter(a => a.project_id === project.id); const ofProject = assets.filter(a => a.project_id === project.id);
const thumbAssets = ofProject.slice(0, 4); const thumbAssets = ofProject.slice(0, 4);
// Real status distribution ready vs processing/live vs error. // Real status distribution - ready vs processing/live vs error.
const total = ofProject.length || 1; const total = ofProject.length || 1;
const ready = ofProject.filter(a => a.status === 'ready').length; const ready = ofProject.filter(a => a.status === 'ready').length;
const inFlight = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length; const inFlight = ofProject.filter(a => a.status === 'processing' || a.status === 'live').length;
@ -259,7 +259,7 @@ function ProjectCard({ project, assets, onOpen, onRename, onDelete }) {
<div className="project-meta"> <div className="project-meta">
<span>{ofProject.length} asset{ofProject.length === 1 ? '' : 's'}</span> <span>{ofProject.length} asset{ofProject.length === 1 ? '' : 's'}</span>
<span>·</span> <span>·</span>
<span>updated {project.updated || ''}</span> <span>updated {project.updated || '·'}</span>
</div> </div>
{ofProject.length > 0 ? ( {ofProject.length > 0 ? (
<div className="project-bar" title={`ready ${ready} · in-flight ${inFlight} · errored ${errored}`}> <div className="project-bar" title={`ready ${ready} · in-flight ${inFlight} · errored ${errored}`}>

View file

@ -1,40 +1,64 @@
// shell.jsx - app shell: sidebar nav, topbar, route container // shell.jsx - app shell: sidebar nav, topbar, route container
const NAV_TREE = [ // Sidebar IA: grouped sections. Renderer prints each section's label, then
// its items. Items inside `children` of a `group:true` node still render as
// the existing expandable submenu (only used for the Capture-SDK admin tools
// today, but kept general).
const NAV_SECTIONS = [
{
label: "Workspace",
items: [
{ id: "home", label: "Home", icon: "home" }, { id: "home", label: "Home", icon: "home" },
{ id: "dashboard", label: "Dashboard", icon: "layout" }, { id: "dashboard", label: "Dashboard", icon: "layout" },
{ id: "library", label: "Library", icon: "library" }, { id: "library", label: "Library", icon: "library" },
{ id: "projects", label: "Projects", icon: "folder" }, { id: "projects", label: "Projects", icon: "folder" },
],
},
{ {
id: "ingest", label: "Ingest", icon: "upload", group: true, label: "Ingest",
children: [ items: [
{ id: "upload", label: "Upload", icon: "upload" }, { id: "upload", label: "Upload", icon: "upload" },
{ id: "youtube", label: "YouTube", icon: "download" }, { id: "youtube", label: "YouTube", icon: "download" },
{ id: "recorders", label: "Recorders", icon: "record" }, { id: "recorders", label: "Recorders", icon: "record" },
{ id: "schedule", label: "Schedule", icon: "jobs" }, { id: "schedule", label: "Schedule", icon: "jobs" },
{ id: "capture", label: "Capture", icon: "capture" },
{ id: "monitors", label: "Monitors", icon: "monitor" }, { id: "monitors", label: "Monitors", icon: "monitor" },
], ],
}, },
{
label: "Operations",
items: [
{ id: "capture", label: "Capture", icon: "capture" },
{ id: "jobs", label: "Jobs", icon: "jobs" }, { id: "jobs", label: "Jobs", icon: "jobs" },
{ id: "editor", label: "Editor", icon: "editor" }, { id: "editor", label: "Editor", icon: "editor", badge: { kind: 'neutral', text: 'BETA' } },
]; ],
},
const ADMIN_TREE = [ {
label: "Admin",
items: [
{ id: "users", label: "Users", icon: "users" }, { id: "users", label: "Users", icon: "users" },
{ id: "tokens", label: "Tokens", icon: "token" }, { id: "tokens", label: "Tokens", icon: "token" },
{ id: "containers", label: "Containers", icon: "container" }, { id: "containers", label: "Containers", icon: "container" },
{ id: "cluster", label: "Cluster", icon: "cluster" }, { id: "cluster", label: "Cluster", icon: "cluster" },
{ id: "settings", label: "Settings", icon: "settings" }, { id: "settings", label: "Settings", icon: "settings" },
],
},
]; ];
// Hidden routes: not in the sidebar but still reachable by direct nav.
// `tokens-parody` is the old satirical pricing page (see issue #152). Real
// API token management lives at /tokens (in the Admin section above).
const NAV_HIDDEN = [
{ id: "tokens-parody", label: "Tokens (parody)", icon: "token" },
];
// Back-compat: NAV_TREE and ADMIN_TREE were used by other modules.
// NAV_FLAT is consumed by topbar search and the keyboard router.
const NAV_TREE = NAV_SECTIONS.slice(0, 3).flatMap(s => s.items);
const ADMIN_TREE = NAV_SECTIONS[3].items;
const NAV_FLAT = (() => { const NAV_FLAT = (() => {
const out = []; const out = [];
const visit = (arr) => arr.forEach(n => { NAV_SECTIONS.forEach(s => s.items.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon })));
if (n.group && n.children) { visit(n.children); return; } NAV_HIDDEN.forEach(n => out.push({ id: n.id, label: n.label, icon: n.icon }));
out.push({ id: n.id, label: n.label, icon: n.icon });
});
visit(NAV_TREE); visit(ADMIN_TREE);
return out; return out;
})(); })();
@ -80,12 +104,11 @@ function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup })
} }
function Sidebar({ active, onNavigate, me, collapsed, onToggle }) { function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"])); const [openGroups, setOpenGroups] = React.useState(new Set([]));
const [jobsBadge, setJobsBadge] = React.useState(null); const [jobsBadge, setJobsBadge] = React.useState(null);
const [captureBadge, setCaptureBadge] = React.useState(null);
// Live jobs count (#130) poll /jobs/count for active jobs and render the // Live jobs count (#130): poll /jobs?status=active and render the result
// result as the sidebar badge. Falls back to hidden on error. // as the sidebar badge on the Jobs item. Falls back to hidden on error.
React.useEffect(() => { React.useEffect(() => {
let cancelled = false; let cancelled = false;
const tick = () => { const tick = () => {
@ -103,43 +126,16 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
return () => { cancelled = true; clearInterval(id); }; return () => { cancelled = true; clearInterval(id); };
}, []); }, []);
// Live DeckLink signal presence poll every 5s, badge shows receiving port count. // (Capture live-signal badge previously lived here; it now belongs in the
React.useEffect(() => { // topbar status pip alongside the cluster pip. See issue #149.)
let cancelled = false;
const tick = () => {
window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal')
.then(entries => {
if (cancelled) return;
const all = Array.isArray(entries) ? entries : [];
const live = all.filter(e => e.signal === 'receiving').length;
const total = all.length;
if (total === 0) { setCaptureBadge(null); return; }
setCaptureBadge(live > 0
? { kind: 'live', text: `${live}/${total}` }
: { kind: 'neutral', text: `0/${total}` });
})
.catch(() => setCaptureBadge(null));
};
tick();
const id = setInterval(tick, 5000);
return () => { cancelled = true; clearInterval(id); };
}, []);
// Apply live badges to nav items. // Apply the live Jobs badge to the Operations section.
const navTree = React.useMemo( const sections = React.useMemo(
() => NAV_TREE.map(n => { () => NAV_SECTIONS.map(sec => ({
if (n.id === 'jobs' && jobsBadge) return { ...n, badge: jobsBadge }; ...sec,
if (n.id === 'ingest' && n.children) { items: sec.items.map(n => (n.id === 'jobs' && jobsBadge) ? { ...n, badge: jobsBadge } : n),
return { })),
...n, [jobsBadge]
children: n.children.map(c =>
c.id === 'capture' && captureBadge ? { ...c, badge: captureBadge } : c
),
};
}
return n;
}),
[jobsBadge, captureBadge]
); );
const toggleGroup = (id) => { const toggleGroup = (id) => {
setOpenGroups(prev => { setOpenGroups(prev => {
@ -181,7 +177,10 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
</button> </button>
</div> </div>
<div className="sidebar-scroll"> <div className="sidebar-scroll">
{navTree.map(item => ( {sections.map((section, si) => (
<React.Fragment key={section.label}>
{si > 0 && <div className="nav-section-label">{section.label}</div>}
{section.items.map(item => (
<NavItem <NavItem
key={item.id} key={item.id}
item={item} item={item}
@ -191,24 +190,15 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
toggleGroup={toggleGroup} toggleGroup={toggleGroup}
/> />
))} ))}
<div className="nav-section-label">Admin</div> </React.Fragment>
{ADMIN_TREE.map(item => (
<NavItem
key={item.id}
item={item}
active={active}
onSelect={onNavigate}
openGroups={openGroups}
toggleGroup={toggleGroup}
/>
))} ))}
</div> </div>
<div className="sidebar-footer"> <div className="sidebar-footer">
<div className="avatar">{me?.initials || ''}</div> <div className="avatar">{me?.initials || '·'}</div>
<div className="user-meta"> <div className="user-meta">
<div className="user-name">{me?.name || 'Not signed in'}</div> <div className="user-name">{me?.name || 'Not signed in'}</div>
<div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false showing the OS user running the server' : ''}> <div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false: showing the OS user running the server' : ''}>
{me?.role || ''}{me?.synthetic ? ' · auth off' : ''} {me?.role || '·'}{me?.synthetic ? ' · auth off' : ''}
</div> </div>
</div> </div>
{me?.synthetic ? null : ( {me?.synthetic ? null : (
@ -265,7 +255,7 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
(D.ASSETS || []).forEach(a => { (D.ASSETS || []).forEach(a => {
const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase(); const hay = ((a.name || '') + ' ' + (a.project || '') + ' ' + (a.filename || '')).toLowerCase();
if (hay.includes(term)) { if (hay.includes(term)) {
const sub = [a.project, a.res, a.duration].filter(x => x && x !== '').join(' · '); const sub = [a.project, a.res, a.duration].filter(x => x && x !== '·').join(' · ');
out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a }); out.push({ kind: 'asset', icon: assetSearchIcon(a), label: a.name, sub, item: a });
} }
}); });
@ -399,7 +389,7 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
} }
function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject, onToggleSidebar }) { function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject, onToggleSidebar }) {
// Light cluster ping the badge in the topbar should reflect reality, // Light cluster ping - the badge in the topbar should reflect reality,
// not just look reassuring. /metrics/home returns cluster online/total. // not just look reassuring. /metrics/home returns cluster online/total.
const [clusterHealthy, setClusterHealthy] = React.useState(true); const [clusterHealthy, setClusterHealthy] = React.useState(true);
React.useEffect(() => { React.useEffect(() => {
@ -478,5 +468,6 @@ window.Sidebar = Sidebar;
window.Topbar = Topbar; window.Topbar = Topbar;
window.NAV_TREE = NAV_TREE; window.NAV_TREE = NAV_TREE;
window.NAV_FLAT = NAV_FLAT; window.NAV_FLAT = NAV_FLAT;
window.NAV_SECTIONS = NAV_SECTIONS;
window.Field = Field; window.Field = Field;
window.GlobalSearch = GlobalSearch; window.GlobalSearch = GlobalSearch;

View file

@ -1,7 +1,7 @@
/* ========== Asset detail ========== */ /* ========== Asset detail ========== */
.asset-detail { .asset-detail {
display: flex; flex-direction: column; display: flex; flex-direction: column;
flex: 1; /* parent is `.main` (flex col) fill remaining vertical space */ flex: 1; /* parent is `.main` (flex col) - fill remaining vertical space */
min-height: 0; min-height: 0;
background: var(--bg-0); background: var(--bg-0);
overflow: hidden; overflow: hidden;
@ -60,13 +60,11 @@
background: rgba(0,0,0,0.55); background: rgba(0,0,0,0.55);
border-radius: 50%; border-radius: 50%;
padding: 16px; padding: 16px;
backdrop-filter: blur(8px);
} }
.player-tc { .player-tc {
position: absolute; position: absolute;
right: 12px; bottom: 12px; right: 12px; bottom: 12px;
background: rgba(0,0,0,0.6); background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
padding: 4px 10px; padding: 4px 10px;
border-radius: 4px; border-radius: 4px;
font-family: var(--font-mono); font-family: var(--font-mono);

View file

@ -66,7 +66,7 @@
.activity-text .target { word-break: break-word; } .activity-text .target { word-break: break-word; }
.asset-card .meta .sub { overflow: hidden; min-width: 0; flex-wrap: wrap; } .asset-card .meta .sub { overflow: hidden; min-width: 0; flex-wrap: wrap; }
/* #52 duration mono badge in the meta row had no shrink behaviour, so on /* #52 - duration mono badge in the meta row had no shrink behaviour, so on
narrow cards it overlapped the project text. Force the duration column to narrow cards it overlapped the project text. Force the duration column to
never overflow and let the project label ellipsize. */ never overflow and let the project label ellipsize. */
.asset-card .meta .sub > .duration { flex-shrink: 0; margin-left: auto; } .asset-card .meta .sub > .duration { flex-shrink: 0; margin-left: auto; }
@ -76,7 +76,7 @@
.dash-sparkline { z-index: 0; } .dash-sparkline { z-index: 0; }
/* ============================================================ /* ============================================================
Search bar polish give it a real container so it doesn't Search bar polish - give it a real container so it doesn't
read as floating text on the topbar background. read as floating text on the topbar background.
============================================================ */ ============================================================ */
.topbar .search, .topbar .search,
@ -123,7 +123,7 @@
color: var(--text-2); color: var(--text-2);
} }
/* Library-local "Filter assets" search same container treatment, /* Library-local "Filter assets" search - same container treatment,
keep its compact width. */ keep its compact width. */
.library-toolbar .search { .library-toolbar .search {
background: var(--bg-2); background: var(--bg-2);
@ -165,7 +165,7 @@
} }
/* ============================================================ /* ============================================================
Right-click context menu pop it forward off the page so it Right-click context menu - pop it forward off the page so it
reads as a menu, not a floating list. reads as a menu, not a floating list.
============================================================ */ ============================================================ */
.ctx-menu { .ctx-menu {
@ -225,7 +225,7 @@
} }
.ctx-menu button.danger:hover:not(:disabled) svg { color: var(--danger); } .ctx-menu button.danger:hover:not(:disabled) svg { color: var(--danger); }
/* Row-popover menu (Users page etc.) match the same polish so the /* Row-popover menu (Users page etc.) - match the same polish so the
app feels consistent. */ app feels consistent. */
.row-menu { .row-menu {
background: var(--bg-2); background: var(--bg-2);
@ -240,7 +240,7 @@
.row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); } .row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); }
/* ============================================================ /* ============================================================
Sidebar brand logo replace the gradient "D" tile with the Sidebar brand logo - replace the gradient "D" tile with the
actual dragon-coiled-D logo. mix-blend-mode: screen drops the actual dragon-coiled-D logo. mix-blend-mode: screen drops the
light-gray PNG background so only the black silhouette + blue light-gray PNG background so only the black silhouette + blue
flame remain over the dark sidebar. flame remain over the dark sidebar.
@ -260,7 +260,7 @@
} }
/* ============================================================ /* ============================================================
Launcher home full-bleed landing page with the logo as hero Launcher home - full-bleed landing page with the logo as hero
and big section tiles. and big section tiles.
============================================================ */ ============================================================ */
.launcher { .launcher {
@ -297,7 +297,7 @@
width: 180px; width: 180px;
height: 180px; height: 180px;
object-fit: contain; object-fit: contain;
/* Convert to white same approach as .brand-logo. */ /* Convert to white - same approach as .brand-logo. */
filter: filter:
brightness(0) invert(1) brightness(0) invert(1)
drop-shadow(0 0 24px rgba(91, 124, 250, 0.28)) drop-shadow(0 0 24px rgba(91, 124, 250, 0.28))
@ -435,7 +435,7 @@
color: var(--tile-icon-fg, var(--accent-text)); color: var(--tile-icon-fg, var(--accent-text));
} }
/* Tone variants colour the icon tile + halo, leave the body text /* Tone variants - colour the icon tile + halo, leave the body text
neutral so the tile reads as a button, not a banner. */ neutral so the tile reads as a button, not a banner. */
.launcher-tile.tone-accent { .launcher-tile.tone-accent {
--tile-tint: rgba(91, 124, 250, 0.18); --tile-tint: rgba(91, 124, 250, 0.18);
@ -515,7 +515,7 @@
} }
/* ============================================================ /* ============================================================
Recorder row signal indicator with a pulsing dot when Recorder row - signal indicator with a pulsing dot when
actually receiving frames. Closes part of #2. actually receiving frames. Closes part of #2.
============================================================ */ ============================================================ */
.signal-val { .signal-val {
@ -539,7 +539,7 @@
} }
/* ============================================================ /* ============================================================
BMD card diagram rendered inside the Cluster node panel. BMD card diagram - rendered inside the Cluster node panel.
The SVG is generated by bmd-card.js; styles live here so The SVG is generated by bmd-card.js; styles live here so
they inherit the app CSS custom properties at render time. they inherit the app CSS custom properties at render time.
============================================================ */ ============================================================ */

View file

@ -361,7 +361,7 @@
display: flex; flex-direction: column; display: flex; flex-direction: column;
cursor: pointer; cursor: pointer;
} }
.monitor-tile.audio { background: linear-gradient(135deg, hsl(180 30% 12%), hsl(200 25% 6%)); } .monitor-tile.audio { background: var(--bg-1); }
.monitor-tile-label { .monitor-tile-label {
position: absolute; position: absolute;
bottom: 0; left: 0; right: 0; bottom: 0; left: 0; right: 0;
@ -391,7 +391,7 @@
display: grid; display: grid;
/* status · Job · Asset · Node · Progress · Time · Priority · Actions /* status · Job · Asset · Node · Progress · Time · Priority · Actions
Time needs room for "done May 22 · 2:23 PM · 6h ago"; Progress hosts Time needs room for "done May 22 · 2:23 PM · 6h ago"; Progress hosts
the bar + percent; Node is just "primary" or "" so it can be tight. */ the bar + percent; Node is just "primary" or "-" so it can be tight. */
grid-template-columns: 20px 110px 1fr 60px 240px 240px 70px 90px; grid-template-columns: 20px 110px 1fr 60px 240px 240px 70px 90px;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@ -420,7 +420,7 @@
} }
.job-progress-fill { .job-progress-fill {
height: 100%; height: 100%;
background: linear-gradient(90deg, var(--accent), #7C9EFF); background: var(--accent);
background-size: 200% 100%; background-size: 200% 100%;
animation: shimmer 2s linear infinite; animation: shimmer 2s linear infinite;
transition: width 300ms; transition: width 300ms;
@ -429,7 +429,7 @@
/* ========== Editor ========== */ /* ========== Editor ========== */
.editor-shell { .editor-shell {
display: flex; flex-direction: column; display: flex; flex-direction: column;
flex: 1; /* parent is `.main` (flex col) fill remaining vertical space */ flex: 1; /* parent is `.main` (flex col) - fill remaining vertical space */
min-height: 0; min-height: 0;
background: var(--bg-0); background: var(--bg-0);
} }
@ -627,7 +627,7 @@
so the gutter (left) and ruler (top) stay sticky during scroll. */ so the gutter (left) and ruler (top) stay sticky during scroll. */
.epg-page { .epg-page {
display: flex; flex-direction: column; display: flex; flex-direction: column;
flex: 1; /* parent is `.main` (flex col) fill remaining vertical space (#132) */ flex: 1; /* parent is `.main` (flex col) - fill remaining vertical space (#132) */
min-height: 0; min-height: 0;
--epg-pph: 88px; /* pixels per hour, overridden inline per view */ --epg-pph: 88px; /* pixels per hour, overridden inline per view */
--epg-row-h: 60px; --epg-row-h: 60px;
@ -786,7 +786,7 @@
grid-row: 2; grid-column: 2; grid-row: 2; grid-column: 2;
position: relative; position: relative;
background: background:
/* hour-band rhythm alternating subtle stripe every other hour */ /* hour-band rhythm - alternating subtle stripe every other hour */
repeating-linear-gradient( repeating-linear-gradient(
to right, to right,
transparent 0, transparent 0,

View file

@ -33,7 +33,6 @@
font-weight: 500; font-weight: 500;
color: white; color: white;
background: rgba(0,0,0,0.7); background: rgba(0,0,0,0.7);
backdrop-filter: blur(4px);
padding: 2px 6px; padding: 2px 6px;
border-radius: 3px; border-radius: 3px;
line-height: 1.3; line-height: 1.3;
@ -44,7 +43,7 @@
display: flex; gap: 4px; display: flex; gap: 4px;
} }
/* Hi-res download button top-right corner of an asset thumbnail. /* Hi-res download button - top-right corner of an asset thumbnail.
Hidden by default, revealed on card hover or button focus. Avoids Hidden by default, revealed on card hover or button focus. Avoids
crowding the resting-state thumb (issue #145). */ crowding the resting-state thumb (issue #145). */
.thumb-download-btn { .thumb-download-btn {
@ -61,7 +60,6 @@
opacity: 0; opacity: 0;
transform: translateY(-2px); transform: translateY(-2px);
transition: opacity 80ms ease-out, transform 120ms ease-out, background 80ms; transition: opacity 80ms ease-out, transform 120ms ease-out, background 80ms;
backdrop-filter: blur(4px);
} }
.asset-card:hover .thumb-download-btn, .asset-card:hover .thumb-download-btn,
.thumb-download-btn:focus-visible { .thumb-download-btn:focus-visible {
@ -138,7 +136,7 @@
50% { opacity: 0.6; } 50% { opacity: 0.6; }
} }
/* ========== Dashboard stats dense row, not hero-metric cards ========== */ /* ========== Dashboard stats - dense row, not hero-metric cards ========== */
.dash-stat-row { .dash-stat-row {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
@ -205,7 +203,7 @@
} }
.dash-stat-sub.up { color: var(--success); } .dash-stat-sub.up { color: var(--success); }
/* Sparkline sits in its own row at the bottom no absolute positioning */ /* Sparkline sits in its own row at the bottom - no absolute positioning */
.dash-sparkline { .dash-sparkline {
height: 24px; height: 24px;
margin-top: 4px; margin-top: 4px;
@ -250,7 +248,6 @@
background: rgba(0,0,0,0.6); background: rgba(0,0,0,0.6);
padding: 3px 8px; padding: 3px 8px;
border-radius: 4px; border-radius: 4px;
backdrop-filter: blur(4px);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -273,7 +270,6 @@
background: rgba(0,0,0,0.6); background: rgba(0,0,0,0.6);
padding: 3px 6px; padding: 3px 6px;
border-radius: 4px; border-radius: 4px;
backdrop-filter: blur(4px);
} }
.live-feed-tile-badge { .live-feed-tile-badge {
position: absolute; position: absolute;
@ -293,7 +289,6 @@
background: rgba(0,0,0,0.5); background: rgba(0,0,0,0.5);
padding: 2px 7px; padding: 2px 7px;
border-radius: 4px; border-radius: 4px;
backdrop-filter: blur(4px);
} }
.live-feed-project-dot { .live-feed-project-dot {
width: 6px; height: 6px; width: 6px; height: 6px;
@ -618,7 +613,6 @@
background: rgba(0,0,0,0.65); background: rgba(0,0,0,0.65);
padding: 2px 7px; padding: 2px 7px;
border-radius: 4px; border-radius: 4px;
backdrop-filter: blur(4px);
} }
.dash-onair-meta { .dash-onair-meta {
padding: 10px 12px; padding: 10px 12px;
@ -1085,6 +1079,8 @@
.library-toolbar .toolbar-title { .library-toolbar .toolbar-title {
font-size: 14px; font-weight: 600; font-size: 14px; font-weight: 600;
letter-spacing: -0.01em; letter-spacing: -0.01em;
margin: 0;
line-height: inherit;
} }
.library-toolbar .count { color: var(--text-3); font-size: 12.5px; } .library-toolbar .count { color: var(--text-3); font-size: 12.5px; }
@ -1203,3 +1199,150 @@
} }
.list-row .name { font-weight: 500; } .list-row .name { font-weight: 500; }
.list-row .col-sub { color: var(--text-3); font-family: var(--font-mono); font-size: 11.5px; } .list-row .col-sub { color: var(--text-3); font-family: var(--font-mono); font-size: 11.5px; }
/* Editor beta banner: flat strip on top of editor, replacing the old
glassmorphism + gradient + glow bumper. No blur, no gradients, no glow. */
.editor-beta-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--accent-soft);
border-bottom: 1px solid var(--border);
color: var(--text-2);
font-size: 12.5px;
flex-shrink: 0;
}
.editor-beta-banner > svg { color: var(--accent-text); flex-shrink: 0; }
.editor-beta-banner-body { flex: 1; min-width: 0; }
.editor-beta-banner-body strong { color: var(--text-1); font-weight: 600; margin-right: 4px; }
.editor-beta-banner-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.editor-beta-banner-actions a { text-decoration: none; }
.editor-beta-banner-version { font-size: 11px; color: var(--text-3); padding-left: 4px; }
/* Home activity strip (issue #153). Sits below the launcher grid and shows
real activity: live recorders, last-24h assets, attention alerts. */
.launcher-activity {
margin-top: 28px;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 880px;
width: 100%;
}
.launcher-activity-strip.alert {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
background: rgba(255,91,91,0.08);
border: 1px solid var(--danger);
color: var(--text-2);
font-size: 12.5px;
}
.launcher-activity-strip.alert > svg { color: var(--danger); flex-shrink: 0; }
.launcher-activity-strip.alert strong { color: var(--text-1); font-weight: 600; }
.launcher-activity-strip.alert > span { flex: 1; }
.launcher-activity-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.launcher-activity-head {
display: flex;
align-items: center;
gap: 8px;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
}
.launcher-activity-head .muted { color: var(--text-4); font-weight: 500; letter-spacing: 0; text-transform: none; font-size: 11px; }
.launcher-activity-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px;
}
.launcher-activity-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 12.5px;
color: var(--text-2);
text-align: left;
cursor: pointer;
transition: background 80ms, border-color 80ms;
}
.launcher-activity-item:hover { background: var(--bg-2); border-color: var(--border-strong); }
.launcher-activity-item > svg { color: var(--text-3); flex-shrink: 0; }
.launcher-activity-item-name {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--text-1); font-weight: 500;
}
.launcher-activity-item-meta {
font-size: 10.5px; color: var(--text-3);
text-transform: uppercase; letter-spacing: 0.04em;
flex-shrink: 0;
}
/* Jobs screen: classes extracted from per-row inline styles (issue #148).
Cuts 487 rendered inline styles to roughly zero. */
.jobs-tabs { margin-top: 20px; width: fit-content; }
.jobs-panel { margin-top: 12px; }
.jobs-empty { padding: 24px; text-align: center; color: var(--text-3); }
.stat-card .delta.delta-warn { color: var(--warning); }
.stat-card .delta.delta-tiny { font-size: 10.5px; }
.job-row .job-row-kind { display: flex; align-items: center; gap: 8px; }
.job-row .job-row-kind-icon { color: var(--text-3); }
.job-row .job-row-kind-name { font-weight: 500; }
.job-row .job-row-asset {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-2);
}
.job-row .job-row-node { font-size: 11.5px; color: var(--text-3); }
.job-row .job-row-progress-pct {
font-size: 10.5px;
color: var(--text-3);
min-width: 32px;
text-align: right;
}
.job-row .job-row-status-done { background: transparent; padding: 0; }
.job-row .job-row-status-queued { font-size: 12px; color: var(--text-3); }
.job-row .job-row-status-failed {
font-size: 12px;
color: var(--danger);
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.job-row .job-row-status-failed-msg {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.job-row .job-row-time {
font-size: 11.5px;
color: var(--text-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-row .job-row-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.job-row .job-row-cancel { color: var(--danger); }

View file

@ -15,7 +15,7 @@
/* text */ /* text */
--text-1: #F2F3F6; --text-1: #F2F3F6;
--text-2: #A8AEBC; --text-2: #A8AEBC;
--text-3: #8B92A0; /* WCAG AA (#133) was #6B7280, 4.06:1 vs --bg-0; now ~7.5:1 */ --text-3: #8B92A0; /* WCAG AA (#133) - was #6B7280, 4.06:1 vs --bg-0; now ~7.5:1 */
--text-4: #6B7280; --text-4: #6B7280;
/* accent (blue, frame.io-ish) */ /* accent (blue, frame.io-ish) */