chore: 1.2 ship-prep sweep — close 38 issues

Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
  validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
  production React UMD instead of dev builds + in-browser Babel (#139,
  #122)
- Search wrapper gets role=search; global search input gets aria-label,
  role=combobox, aria-controls/aria-expanded/aria-activedescendant
  wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
  mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
  flex container — switched to flex:1 + min-height:0 (#131, #132,
  editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
  the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
  data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
  window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
  uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)

Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
  Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
  of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
  tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
  transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
  detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
  tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
  create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
  private + loopback hosts for non-admins, denies common service
  ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
  rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
  survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
  server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
  (ampp_sync_status / attempts / next_attempt_at + scheduler retry
  loop with exponential backoff) (#77)

Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)

Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
  Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
  migration tool to v1.3
This commit is contained in:
opencode 2026-05-27 02:06:14 +00:00
parent 64d739b40d
commit 04ce096e67
41 changed files with 1089 additions and 277 deletions

View file

@ -16,7 +16,9 @@
# Optional env vars (needed only if starting the worker or capture profiles): # Optional env vars (needed only if starting the worker or capture profiles):
# REDIS_URL, DATABASE_URL, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY # REDIS_URL, DATABASE_URL, S3_ENDPOINT, S3_BUCKET, S3_ACCESS_KEY, S3_SECRET_KEY
# BMD_DEVICE_0 DeckLink device path (default: /dev/blackmagic/dv0) # BMD_DEVICE_0 DeckLink device path (default: /dev/blackmagic/dv0)
# (DeckLink IO / Quad cards expose /dev/blackmagic/io* instead — set BMD_DEVICE_PREFIX=io)
# BMD_DEVICE_1 DeckLink device path (default: /dev/blackmagic/dv1) # BMD_DEVICE_1 DeckLink device path (default: /dev/blackmagic/dv1)
# BMD_DEVICE_PREFIX Naming prefix for synthesized BMD_COUNT-based devices (default: dv). Use 'io' for IO/Quad.
# LIVE_DIR Host path for HLS live segments (default: /mnt/NVME/MAM/wild-dragon-live) # LIVE_DIR Host path for HLS live segments (default: /mnt/NVME/MAM/wild-dragon-live)
# #
# Profiles: # Profiles:
@ -51,6 +53,7 @@ services:
GPU_COUNT: ${GPU_COUNT:--1} GPU_COUNT: ${GPU_COUNT:--1}
BMD_COUNT: ${BMD_COUNT:--1} BMD_COUNT: ${BMD_COUNT:--1}
BMD_MODEL: ${BMD_MODEL:-} BMD_MODEL: ${BMD_MODEL:-}
BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv}
LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live} LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock

View file

@ -0,0 +1,15 @@
-- Issue #106 — bind cluster tokens to a specific hostname so a compromised
-- worker token can't be used to hijack another node's `api_url` via
-- POST /cluster/heartbeat.
--
-- `bound_hostname` is NULL for ordinary user tokens (no binding) and set
-- to the node's hostname for node-agent tokens. The heartbeat handler
-- checks that body.hostname === token.bound_hostname when bound_hostname
-- is non-null.
ALTER TABLE api_tokens
ADD COLUMN IF NOT EXISTS bound_hostname TEXT;
CREATE INDEX IF NOT EXISTS api_tokens_bound_hostname_idx
ON api_tokens (bound_hostname)
WHERE bound_hostname IS NOT NULL;

View file

@ -0,0 +1,19 @@
-- Issue #77 — AMPP sync used to be fire-and-forget: failures were swallowed
-- with a console.error and never retried. Track the state of every asset's
-- AMPP sync so the scheduler tick can retry pending/failed rows on a
-- backoff schedule.
--
-- ampp_sync_status: 'pending' | 'synced' | 'failed' | 'disabled'
-- ampp_sync_attempts: count, used for exponential backoff
-- ampp_sync_next_attempt_at: when the scheduler should next try this asset
-- ampp_sync_last_error: short error message for the operator (truncated)
ALTER TABLE assets
ADD COLUMN IF NOT EXISTS ampp_sync_status TEXT NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS ampp_sync_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS ampp_sync_next_attempt_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS ampp_sync_last_error TEXT;
CREATE INDEX IF NOT EXISTS assets_ampp_sync_idx
ON assets (ampp_sync_status, ampp_sync_next_attempt_at)
WHERE ampp_sync_status IN ('pending', 'failed');

View file

@ -32,7 +32,7 @@ import metricsRouter from './routes/metrics.js';
import commentsRouter from './routes/comments.js'; import commentsRouter from './routes/comments.js';
import importsRouter from './routes/imports.js'; import importsRouter from './routes/imports.js';
import storageRouter from './routes/storage.js'; import storageRouter from './routes/storage.js';
import { startSchedulerLoop } from './scheduler.js'; import { startSchedulerLoop, stopSchedulerLoop } from './scheduler.js';
import { startCleanupLoop } from './tasks/cleanupTempSegments.js'; import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
const app = express(); const app = express();
@ -99,17 +99,54 @@ import { dirname, join } from 'node:path';
const __dirnameMig = dirname(fileURLToPath(import.meta.url)); const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() { async function runMigrations() {
// Issue #107 — previously the loop swallowed errors and let the server boot
// on a half-migrated schema. Now: track applied migrations in a table, run
// every pending one inside a transaction, and exit non-zero on failure so
// the orchestrator restarts (and so an operator notices) instead of serving
// 500s for the next month.
const dir = join(__dirnameMig, 'db', 'migrations'); const dir = join(__dirnameMig, 'db', 'migrations');
let files = []; let files = [];
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; } try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
checksum_sha TEXT
)
`);
// Allow forcing a re-run via env when iterating locally.
const force = process.env.MIGRATIONS_FORCE === '1';
const allowFailures = process.env.MIGRATIONS_ALLOW_FAILURES === '1';
const appliedRes = await pool.query('SELECT filename FROM schema_migrations');
const applied = new Set(appliedRes.rows.map(r => r.filename));
for (const f of files) { for (const f of files) {
if (!force && applied.has(f)) continue;
const sql = readFileSync(join(dir, f), 'utf8'); const sql = readFileSync(join(dir, f), 'utf8');
const client = await pool.connect();
try { try {
await pool.query(sql); await client.query('BEGIN');
await client.query(sql);
await client.query(
`INSERT INTO schema_migrations (filename) VALUES ($1)
ON CONFLICT (filename) DO UPDATE SET applied_at = NOW()`,
[f]
);
await client.query('COMMIT');
console.log('[migration] applied ' + f); console.log('[migration] applied ' + f);
} catch (err) { } catch (err) {
console.error('[migration] failed ' + f, err.message); await client.query('ROLLBACK').catch(() => {});
console.error('[migration] FAILED ' + f + ': ' + err.message);
client.release();
if (allowFailures) continue;
// Hard fail — better to crash now than serve traffic on a broken schema.
console.error('[migration] aborting startup. Set MIGRATIONS_ALLOW_FAILURES=1 to override.');
process.exit(1);
} }
client.release();
} }
} }
await runMigrations(); await runMigrations();
@ -192,7 +229,7 @@ async function selfHeartbeat() {
setInterval(selfHeartbeat, 30_000); setInterval(selfHeartbeat, 30_000);
selfHeartbeat(); selfHeartbeat();
app.listen(PORT, () => { const server = app.listen(PORT, () => {
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)'; const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
console.log(`MAM API listening on port ${PORT}`); console.log(`MAM API listening on port ${PORT}`);
console.log(`Authentication: ${authMode}`); console.log(`Authentication: ${authMode}`);
@ -203,3 +240,44 @@ app.listen(PORT, () => {
// Boot the temp-segment cleanup loop (runs hourly). // Boot the temp-segment cleanup loop (runs hourly).
startCleanupLoop(); startCleanupLoop();
}); });
// Issue #100 — graceful shutdown. Without this, `docker stop` (SIGTERM) killed
// the process mid-scheduler-tick, leaving Redis connections and Docker
// sockets dangling and producing partial DB writes. Now: stop the scheduler,
// finish in-flight HTTP requests, close PG/Redis pools, and exit cleanly
// (or hard-exit after 25 s if something is stuck).
let _shuttingDown = false;
async function gracefulShutdown(signal) {
if (_shuttingDown) return;
_shuttingDown = true;
console.log(`[shutdown] received ${signal} — closing gracefully…`);
// Stop accepting new requests + wind down the scheduler tick.
try { stopSchedulerLoop(); } catch (_) {}
// Force-exit watchdog so a hung connection can't keep us alive forever.
const killSwitch = setTimeout(() => {
console.error('[shutdown] forced exit after 25s timeout');
process.exit(1);
}, 25_000);
killSwitch.unref();
// Stop the HTTP server (waits for in-flight requests to finish).
await new Promise(resolve => server.close(resolve));
// Close DB pool + S3 client + any other resources. Best-effort.
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
console.log('[shutdown] clean exit');
process.exit(0);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('uncaughtException', (err) => {
console.error('[fatal] uncaughtException:', err);
gracefulShutdown('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
console.error('[fatal] unhandledRejection:', reason);
});

View file

@ -32,7 +32,7 @@ export const requireAuth = async (req, res, next) => {
const hash = crypto.createHash('sha256').update(raw).digest('hex'); const hash = crypto.createHash('sha256').update(raw).digest('hex');
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT t.user_id AS id, u.username, u.role `SELECT t.user_id AS id, u.username, u.role, t.bound_hostname
FROM api_tokens t FROM api_tokens t
JOIN users u ON u.id = t.user_id JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1 WHERE t.token_hash = $1
@ -41,6 +41,7 @@ export const requireAuth = async (req, res, next) => {
); );
if (rows.length > 0) { if (rows.length > 0) {
req.user = rows[0]; req.user = rows[0];
req.tokenBoundHostname = rows[0].bound_hostname || null;
// Fire-and-forget last_used_at update // Fire-and-forget last_used_at update
pool.query( pool.query(
'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1', 'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1',

View file

@ -1,11 +1,73 @@
// Error & validation middleware.
//
// Issue #101 — the previous handler echoed every error's `.message` straight
// to the client, leaking raw Postgres column names, schema details, and
// invalid UUID syntax errors to anyone hitting a malformed route.
//
// Issue #102 — every /:id route was hitting Postgres with the raw param,
// returning a 500 (with a PG error in the body) instead of a clean 400.
//
// Both are addressed here: `validateUuid` checks param shape before the
// route runs; `errorHandler` keeps detailed messages server-side and only
// surfaces a generic message + the response status to the client.
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function validateUuid(paramName = 'id') {
return (req, res, next) => {
const v = req.params[paramName];
if (!v || !UUID_RE.test(v)) {
return res.status(400).json({ error: `Invalid ${paramName} — must be a UUID` });
}
next();
};
}
// Patterns Postgres uses for its error codes that are operator-only noise.
const PG_LEAKY_CODES = new Set([
'22P02', // invalid_text_representation (bad UUID, etc.)
'23502', // not_null_violation
'23503', // foreign_key_violation
'23505', // unique_violation
'42703', // undefined_column
'42P01', // undefined_table
'42601', // syntax_error
]);
const GENERIC_MESSAGES = {
'22P02': 'Invalid input format',
'23502': 'Required field missing',
'23503': 'Referenced record not found',
'23505': 'Record already exists',
'42703': 'Internal database error',
'42P01': 'Internal database error',
'42601': 'Internal database error',
};
export const errorHandler = (err, req, res, next) => { export const errorHandler = (err, req, res, next) => {
console.error('Error:', err); // Log the full error server-side; operators get the detail.
console.error('[error]', req.method, req.originalUrl, err);
// Postgres errors carry a `.code` (string from SQLSTATE).
if (err && err.code && PG_LEAKY_CODES.has(err.code)) {
const generic = GENERIC_MESSAGES[err.code] || 'Database error';
const status = err.code === '22P02' || err.code === '23502' ? 400 : 409;
return res.status(status).json({ error: generic, code: err.code });
}
const status = err.status || 500; const status = err.status || 500;
const message = err.message || 'Internal Server Error';
res.status(status).json({ // 5xx — never let a raw Error.message escape; clients get a stable shape.
error: message, if (status >= 500) {
return res.status(status).json({
error: 'Internal Server Error',
status,
});
}
// 4xx — operator-authored messages are safe to surface.
return res.status(status).json({
error: err.message || 'Bad request',
status, status,
}); });
}; };

View file

@ -5,12 +5,14 @@ import { promises as fs } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js'; import { getSignedUrlForObject, deleteObject, s3Client, getS3Bucket } from '../s3/client.js';
import { GetObjectCommand } from '@aws-sdk/client-s3'; import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// BullMQ queue connection (mirrors worker/src/index.js) // BullMQ queue connection (mirrors worker/src/index.js)
const parseRedisUrl = (url) => { const parseRedisUrl = (url) => {
@ -43,11 +45,17 @@ router.get('/', async (req, res, next) => {
status, status,
search, search,
media_type, media_type,
limit = 50, limit: rawLimit = 50,
offset = 0, offset: rawOffset = 0,
include_archived, include_archived,
} = req.query; } = req.query;
// Issue #119 — clamp pagination so an attacker (or a buggy client) can't
// request ?limit=999999999 and OOM the API while it serialises rows.
const MAX_LIMIT = 500;
const limit = Math.max(1, Math.min(MAX_LIMIT, parseInt(rawLimit, 10) || 50));
const offset = Math.max(0, parseInt(rawOffset, 10) || 0);
let query = ` let query = `
SELECT a.*, SELECT a.*,
COUNT(*) OVER() AS full_count COUNT(*) OVER() AS full_count
@ -577,37 +585,75 @@ router.get('/:id/video', async (req, res, next) => {
const origIsVideo = a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext)); const origIsVideo = a.original_s3_key && VIDEO_EXTS.some(ext => a.original_s3_key.toLowerCase().endsWith(ext));
const key = a.proxy_s3_key || (origIsVideo ? a.original_s3_key : null); const key = a.proxy_s3_key || (origIsVideo ? a.original_s3_key : null);
if (!key) return res.status(404).json({ error: 'No browser-playable source' }); if (!key) return res.status(404).json({ error: 'No browser-playable source' });
const params = { Bucket: getS3Bucket(), Key: key };
// Issue #143 — seeking to the very end of a clip stalled the player.
// Two contributing causes:
// 1) The previous 416 path re-fetched the full object with GetObject to
// learn the size (HEAD would do, and not transfer the bytes).
// 2) 416 responses inherited the same `private, max-age=3600` cache
// directive as 206, so once the browser hit "out of range" near EOF
// it stayed cached for the rest of the session and the player never
// retried.
// Now: HEAD the object first to learn the true size, clamp the client's
// Range to a valid window, and return uncached 416 only for truly
// unsatisfiable requests.
let totalSize = 0;
try {
const head = await s3Client.send(new HeadObjectCommand({ Bucket: getS3Bucket(), Key: key }));
totalSize = head.ContentLength || 0;
} catch (_) {
// If HEAD fails we still try the GET — keeps behaviour for backends
// that disallow HEAD.
}
const rangeHeader = req.headers.range; const rangeHeader = req.headers.range;
if (rangeHeader) params.Range = rangeHeader; let clampedRange = rangeHeader;
if (rangeHeader && totalSize > 0) {
// bytes=START-END or bytes=START- (we ignore multi-range; not used by HTML5 video).
const m = /^bytes=(\d+)-(\d*)$/.exec(rangeHeader.trim());
if (m) {
let start = parseInt(m[1], 10);
let end = m[2] === '' ? totalSize - 1 : parseInt(m[2], 10);
if (!Number.isFinite(start) || start < 0) start = 0;
if (!Number.isFinite(end) || end >= totalSize) end = totalSize - 1;
if (start >= totalSize) {
// Genuinely past EOF — return a clean, uncached 416.
res.writeHead(416, {
'Content-Type': 'text/plain',
'Content-Length': '0',
'Content-Range': `bytes */${totalSize}`,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store',
});
return res.end();
}
if (start > end) start = end;
clampedRange = `bytes=${start}-${end}`;
}
}
const params = { Bucket: getS3Bucket(), Key: key };
if (clampedRange) params.Range = clampedRange;
let s3Res; let s3Res;
try { try {
s3Res = await s3Client.send(new GetObjectCommand(params)); s3Res = await s3Client.send(new GetObjectCommand(params));
} catch (err) { } catch (err) {
// S3 returns InvalidRange (416) when the requested range exceeds the file. // Defensive: even with clamping, some backends still throw InvalidRange.
// Forward as a proper 416 with the actual file size so the browser can
// adjust instead of erroring out (which would freeze the player).
if (err.Code === 'InvalidRange' || err.$metadata?.httpStatusCode === 416) { if (err.Code === 'InvalidRange' || err.$metadata?.httpStatusCode === 416) {
// Need to know the actual file size — do a HEAD request
try {
const headRes = await s3Client.send(new GetObjectCommand({ Bucket: getS3Bucket(), Key: key }));
const totalSize = headRes.ContentLength || 0;
headRes.Body?.destroy?.(); // close the body stream we don't need
res.writeHead(416, { res.writeHead(416, {
'Content-Type': 'text/plain', 'Content-Type': 'text/plain',
'Content-Length': '0',
'Content-Range': `bytes */${totalSize}`, 'Content-Range': `bytes */${totalSize}`,
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store',
}); });
return res.end('Requested range not satisfiable'); return res.end();
} catch (_) {
return res.status(416).end('Requested range not satisfiable');
}
} }
throw err; throw err;
} }
const status = rangeHeader ? 206 : 200; const status = clampedRange ? 206 : 200;
const headers = { const headers = {
'Content-Type': 'video/mp4', 'Content-Type': 'video/mp4',
'Accept-Ranges': 'bytes', 'Accept-Ranges': 'bytes',

View file

@ -1,11 +1,13 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// GET / - List bins. Filter by project_id when supplied; otherwise return // GET / - List bins. Filter by project_id when supplied; otherwise return
// every bin across every project so the Library / asset-context-menu can // every bin across every project so the Library / asset-context-menu can

View file

@ -110,6 +110,25 @@ router.post('/heartbeat', async (req, res, next) => {
if (!hostname) return res.status(400).json({ error: 'hostname is required' }); if (!hostname) return res.status(400).json({ error: 'hostname is required' });
// Issue #106 — any authenticated user used to be able to POST a heartbeat
// for an arbitrary hostname and overwrite the primary node's `api_url`,
// effectively hijacking job dispatch. Now: if the caller's token is bound
// to a hostname (node-agent tokens are bound at issue time), the body
// hostname must match. Admin users with no binding are allowed for ops.
if (process.env.AUTH_ENABLED === 'true') {
const bound = req.tokenBoundHostname;
if (bound && bound !== hostname) {
return res.status(403).json({
error: `Token is bound to "${bound}" but heartbeat reported "${hostname}"`,
});
}
if (!bound && req.user?.role !== 'admin') {
return res.status(403).json({
error: 'Heartbeat requires a node-bound token or admin session',
});
}
}
const effectiveIp = pickIp(ip_address, req.ip || req.socket?.remoteAddress); const effectiveIp = pickIp(ip_address, req.ip || req.socket?.remoteAddress);
const r = await pool.query( const r = await pool.query(

View file

@ -1,10 +1,12 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// ── Redis connection ────────────────────────────────────────────────────────── // ── Redis connection ──────────────────────────────────────────────────────────
const parseRedisUrl = (url) => { const parseRedisUrl = (url) => {

View file

@ -1,11 +1,13 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// Helper function to slugify // Helper function to slugify
const slugify = (str) => { const slugify = (str) => {

View file

@ -5,11 +5,13 @@ import dgram from 'dgram';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { getS3Bucket } from '../s3/client.js'; import { getS3Bucket } from '../s3/client.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// Base port for on-demand SDI sidecar containers on remote worker nodes. // Base port for on-demand SDI sidecar containers on remote worker nodes.
// Device index 0 → 7438, index 1 → 7439, etc. // Device index 0 → 7438, index 1 → 7439, etc.
@ -141,29 +143,42 @@ function pickRecorderFields(body) {
} }
// GET / - List all recorders // GET / - List all recorders
//
// Issue #121 — previous version fired N PG queries + N Docker inspects per
// list call. Now we resolve `live_asset_id` for every recording row in a
// single LATERAL JOIN, and the Docker `started_at` lookups are bounded by
// the number of currently-recording rows (typically <10) and run in
// parallel with a per-call timeout from `dockerApi`.
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const result = await pool.query( const result = await pool.query(`
'SELECT * FROM recorders ORDER BY created_at DESC' SELECT r.*, la.live_asset_id
); FROM recorders r
const rows = await Promise.all(result.rows.map(async (r) => { LEFT JOIN LATERAL (
if (r.status === 'recording' && r.container_id) { SELECT a.id AS live_asset_id
FROM assets a
WHERE r.status = 'recording'
AND a.project_id = r.project_id
AND a.display_name = r.current_session_id
AND a.status = 'live'
ORDER BY a.created_at DESC
LIMIT 1
) la ON TRUE
ORDER BY r.created_at DESC
`);
const rows = result.rows;
// Only inspect containers for recorders that actually claim to be recording.
const inspectable = rows.filter(r => r.status === 'recording' && r.container_id);
await Promise.all(inspectable.map(async (r) => {
try { try {
const insp = await dockerApi('GET', `/containers/${r.container_id}/json`); const insp = await dockerApi('GET', `/containers/${r.container_id}/json`);
if (insp.status === 200 && insp.data && insp.data.State) { if (insp.status === 200 && insp.data && insp.data.State) {
r.started_at = insp.data.State.StartedAt; r.started_at = insp.data.State.StartedAt;
} }
} catch (_) { /* leave started_at undefined */ } } catch (_) { /* leave started_at undefined */ }
try {
const live = await pool.query(
`SELECT id FROM assets WHERE project_id = $1 AND display_name = $2 AND status = 'live' ORDER BY created_at DESC LIMIT 1`,
[r.project_id, r.current_session_id]
);
if (live.rows.length > 0) r.live_asset_id = live.rows[0].id;
} catch (_) { /* skip */ }
}
return r;
})); }));
res.json(rows); res.json(rows);
} catch (err) { } catch (err) {
next(err); next(err);
@ -413,8 +428,14 @@ router.post('/:id/start', async (req, res, next) => {
signal: AbortSignal.timeout(15000), signal: AbortSignal.timeout(15000),
}); });
if (!sidecarRes.ok) { if (!sidecarRes.ok) {
// #105 — never proxy the remote node's raw response back to the
// browser; it could contain echoed env vars on bad-request paths.
const details = await sidecarRes.json().catch(() => ({})); const details = await sidecarRes.json().catch(() => ({}));
return res.status(502).json({ error: 'Remote node failed to start sidecar', details }); console.error('[recorders] remote sidecar start failed:', JSON.stringify(details));
return res.status(502).json({
error: 'Remote node failed to start sidecar',
details: (details && details.message) || 'see server logs',
});
} }
const sidecarData = await sidecarRes.json(); const sidecarData = await sidecarRes.json();
containerId = sidecarData.containerId; containerId = sidecarData.containerId;
@ -447,18 +468,23 @@ router.post('/:id/start', async (req, res, next) => {
const createRes = await dockerApi('POST', '/containers/create', containerConfig); const createRes = await dockerApi('POST', '/containers/create', containerConfig);
if (createRes.status !== 201) { if (createRes.status !== 201) {
// Issue #105 — log the full Docker error server-side, but never echo
// the create payload (which contains S3_ACCESS_KEY / STREAM_KEY in
// Env) back to the client. Send a short, generic message.
console.error('[recorders] container create failed:', JSON.stringify(createRes.data));
return res.status(500).json({ return res.status(500).json({
error: 'Failed to create container', error: 'Failed to create container',
details: createRes.data, details: (createRes.data && createRes.data.message) || 'see server logs',
}); });
} }
containerId = createRes.data.Id; containerId = createRes.data.Id;
const startRes = await dockerApi('POST', `/containers/${containerId}/start`); const startRes = await dockerApi('POST', `/containers/${containerId}/start`);
if (startRes.status !== 204) { if (startRes.status !== 204) {
console.error('[recorders] container start failed:', JSON.stringify(startRes.data));
return res.status(500).json({ return res.status(500).json({
error: 'Failed to start container', error: 'Failed to start container',
details: startRes.data, details: (startRes.data && startRes.data.message) || 'see server logs',
}); });
} }
} }
@ -692,12 +718,53 @@ router.delete('/:id', async (req, res, next) => {
} }
}); });
// Issue #104 — limit probe targets so an authed user can't scan the cluster's
// internal services (Docker socket, DB, metadata endpoints).
const ALLOWED_PROBE_SCHEMES = new Set(['srt', 'rtmp', 'rtmps', 'rtsp', 'udp', 'rtp']);
const BLOCKED_PROBE_PORTS = new Set([22, 25, 53, 80, 443, 5432, 6379, 9000, 9100, 9229]);
function isPrivateOrLoopback(host) {
if (!host) return true;
const h = host.toLowerCase();
if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal')) return true;
// Hostname lookups happen later by the socket; here we just bail on the
// obvious cases. IPv4 private ranges + IPv6 link-local + AWS metadata IP.
if (/^127\./.test(h)) return true;
if (/^10\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)) return true;
if (/^169\.254\./.test(h)) return true; // link-local / AWS metadata
if (/^100\.6[4-9]\./.test(h) || /^100\.[7-9]\d\./.test(h) || /^100\.1[0-1]\d\./.test(h) || /^100\.12[0-7]\./.test(h)) return true;
if (/^0\./.test(h) || /^::1$/.test(h) || /^fe80:/.test(h) || /^fc/.test(h) || /^fd/.test(h)) return true;
return false;
}
function isAdmin(req) {
if (process.env.AUTH_ENABLED !== 'true') return true;
return req.user?.role === 'admin';
}
// POST /probe - Probe a source URL for reachability. // POST /probe - Probe a source URL for reachability.
// Tries the capture service first; falls back to basic TCP/UDP connectivity // Tries the capture service first; falls back to basic TCP/UDP connectivity
// check when capture is not running. // check when capture is not running.
router.post('/probe', async (req, res) => { router.post('/probe', async (req, res) => {
const { source_type, url } = req.body || {}; const { source_type, url } = req.body || {};
// Validate URL up-front so we don't even let the capture service see junk.
let parsed = null;
if (url) {
try { parsed = new URL(url); }
catch { return res.status(400).json({ error: 'Invalid URL' }); }
const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
if (!ALLOWED_PROBE_SCHEMES.has(proto)) {
return res.status(400).json({ error: `Scheme "${proto}" is not permitted for probe (#104)` });
}
// Non-admin users can only probe public hostnames. Admins may probe LAN.
if (!isAdmin(req) && isPrivateOrLoopback(parsed.hostname)) {
return res.status(403).json({ error: 'Probe target must be a public host (#104)' });
}
}
// Try the capture service first (5s timeout) // Try the capture service first (5s timeout)
try { try {
const r = await fetch('http://capture:3001/capture/probe', { const r = await fetch('http://capture:3001/capture/probe', {
@ -712,7 +779,7 @@ router.post('/probe', async (req, res) => {
// capture service not running — fall through to basic connectivity probe // capture service not running — fall through to basic connectivity probe
} }
if (!url) { if (!parsed) {
return res.json({ return res.json({
reachable: false, reachable: false,
mode: 'basic', mode: 'basic',
@ -720,16 +787,15 @@ router.post('/probe', async (req, res) => {
}); });
} }
let parsed;
try { parsed = new URL(url); } catch {
return res.status(400).json({ error: 'Invalid URL' });
}
const host = parsed.hostname; const host = parsed.hostname;
const proto = (parsed.protocol || '').replace(':', '').toLowerCase(); const proto = (parsed.protocol || '').replace(':', '').toLowerCase();
const isUdp = proto === 'srt' || source_type === 'srt'; const isUdp = proto === 'srt' || source_type === 'srt';
const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935); const port = parseInt(parsed.port, 10) || (isUdp ? 9000 : 1935);
if (BLOCKED_PROBE_PORTS.has(port) && !isAdmin(req)) {
return res.status(403).json({ error: `Port ${port} is not permitted for probe (#104)` });
}
const reachable = await (isUdp ? probeUdp(host, port) : probeTcp(host, port)); const reachable = await (isUdp ? probeUdp(host, port) : probeTcp(host, port));
return res.json({ return res.json({

View file

@ -6,9 +6,11 @@
import express from 'express'; import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']); const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']);
const TERMINAL = new Set(['completed', 'failed', 'cancelled']); const TERMINAL = new Set(['completed', 'failed', 'cancelled']);

View file

@ -88,6 +88,16 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// Safe archive entry — only basic relative paths, no parent traversal, no symlinks.
function isUnsafeEntry(rel) {
if (!rel) return true;
if (path.isAbsolute(rel)) return true;
// Normalize without leaving the staging directory.
const normalized = path.posix.normalize(rel.replace(/\\/g, '/'));
if (normalized.startsWith('..') || normalized.includes('/../') || normalized === '..') return true;
return false;
}
router.post('/:vendor', upload.single('archive'), async (req, res, next) => { router.post('/:vendor', upload.single('archive'), async (req, res, next) => {
try { try {
const vendor = req.params.vendor; const vendor = req.params.vendor;
@ -95,27 +105,55 @@ router.post('/:vendor', upload.single('archive'), async (req, res, next) => {
if (!req.file) return res.status(400).json({ error: 'No archive uploaded (field "archive")' }); if (!req.file) return res.status(400).json({ error: 'No archive uploaded (field "archive")' });
const dir = path.join(SDK_ROOT, vendor); const dir = path.join(SDK_ROOT, vendor);
const dirReal = path.resolve(dir);
// Wipe any previous staging so partial uploads don't leave stale headers. // Wipe any previous staging so partial uploads don't leave stale headers.
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
await fs.mkdir(dir, { recursive: true }); await fs.mkdir(dir, { recursive: true });
const originalName = req.file.originalname || 'sdk.bin'; // Issue #118 — never trust the client-supplied filename. Sanitise to a
const archivePath = path.join(dir, originalName); // basename with no path separators, drop nul bytes, and force into `dir`.
const safeName = path.basename((req.file.originalname || 'sdk.bin').replace(/\u0000/g, '')) || 'sdk.bin';
const archivePath = path.join(dir, safeName);
await fs.writeFile(archivePath, req.file.buffer); await fs.writeFile(archivePath, req.file.buffer);
// Pick an extractor based on extension. tar handles .tar / .tar.gz / .tgz; // Pick an extractor based on extension. tar handles .tar / .tar.gz / .tgz;
// unzip handles .zip. The capture container will be built separately on // unzip handles .zip. The capture container will be built separately on
// the host with a DeckLink/AJA/Deltacast card; this route just stages. // the host with a DeckLink/AJA/Deltacast card; this route just stages.
const lower = originalName.toLowerCase(); const lower = safeName.toLowerCase();
let cmd, args; let cmd, args, listCmd, listArgs;
if (lower.endsWith('.zip')) { if (lower.endsWith('.zip')) {
cmd = 'unzip'; args = ['-q', '-o', archivePath, '-d', dir]; cmd = 'unzip'; args = ['-q', '-o', archivePath, '-d', dir];
listCmd = 'unzip'; listArgs = ['-Z1', archivePath];
} else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) { } else if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
// --absolute-names=no would be ideal, but isn't portable. Block via
// post-extract scan + reject any entry with a parent-traversal path.
cmd = 'tar'; args = ['-xf', archivePath, '-C', dir]; cmd = 'tar'; args = ['-xf', archivePath, '-C', dir];
listCmd = 'tar'; listArgs = ['-tf', archivePath];
} else { } else {
return res.status(400).json({ error: 'Unsupported archive format — use .zip, .tar.gz, .tgz, or .tar' }); return res.status(400).json({ error: 'Unsupported archive format — use .zip, .tar.gz, .tgz, or .tar' });
} }
// Pre-flight: list entries and reject the upload if any escape the dir
// (zip-slip / tar-slip). Cheaper than extracting then deleting.
const entries = await new Promise((resolve, reject) => {
const child = spawn(listCmd, listArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.on('error', reject);
child.on('exit', code => {
if (code === 0) resolve(stdout.split('\n').map(s => s.trim()).filter(Boolean));
else reject(new Error(`${listCmd} listing exited ${code}: ${stderr.slice(0, 500)}`));
});
});
const bad = entries.find(isUnsafeEntry);
if (bad) {
await fs.unlink(archivePath).catch(() => {});
return res.status(400).json({ error: `Refusing archive with unsafe entry: ${bad}` });
}
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stderr = ''; let stderr = '';
@ -127,6 +165,26 @@ router.post('/:vendor', upload.single('archive'), async (req, res, next) => {
}); });
}); });
// Defense-in-depth: walk the staged tree and remove anything that's not a
// regular file or directory (symlinks/device nodes can still escape).
async function walkAndSanitize(p) {
const entries = await fs.readdir(p, { withFileTypes: true });
for (const e of entries) {
const full = path.join(p, e.name);
const real = await fs.realpath(full).catch(() => null);
if (!real || !real.startsWith(dirReal + path.sep) && real !== dirReal) {
await fs.rm(full, { recursive: true, force: true });
continue;
}
if (e.isSymbolicLink() || (!e.isFile() && !e.isDirectory())) {
await fs.rm(full, { recursive: true, force: true });
continue;
}
if (e.isDirectory()) await walkAndSanitize(full);
}
}
await walkAndSanitize(dir);
// Best-effort: remove the archive after a successful extract so we only // Best-effort: remove the archive after a successful extract so we only
// keep the unpacked headers/.so files on disk. // keep the unpacked headers/.so files on disk.
await fs.unlink(archivePath).catch(() => {}); await fs.unlink(archivePath).catch(() => {});

View file

@ -3,6 +3,7 @@ import express from 'express';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
import { getSignedUrlForObject } from '../s3/client.js'; import { getSignedUrlForObject } from '../s3/client.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { validateUuid } from '../middleware/errors.js';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
const parseRedisUrl = (url) => { const parseRedisUrl = (url) => {
@ -20,6 +21,7 @@ const conformQueue = new Queue('conform', {
const router = express.Router(); const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
router.param('id', (req, res, next) => validateUuid('id')(req, res, next));
// ── Row mapper ──────────────────────────────────────────────────────────────── // ── Row mapper ────────────────────────────────────────────────────────────────
// node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a // node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a

View file

@ -20,7 +20,7 @@ const userId = req => req.user?.id || req.session?.userId;
router.get('/', async (req, res, next) => { router.get('/', async (req, res, next) => {
try { try {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT id, name, token_prefix, last_used_at, expires_at, created_at `SELECT id, name, token_prefix, last_used_at, expires_at, created_at, bound_hostname
FROM api_tokens FROM api_tokens
WHERE user_id = $1 WHERE user_id = $1
ORDER BY created_at DESC`, ORDER BY created_at DESC`,
@ -33,7 +33,7 @@ router.get('/', async (req, res, next) => {
// ── Create ──────────────────────────────────────────────────── // ── Create ────────────────────────────────────────────────────
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
try { try {
const { name, expires_in_days } = req.body; const { name, expires_in_days, bound_hostname } = req.body;
if (!name) return res.status(400).json({ error: 'name required' }); if (!name) return res.status(400).json({ error: 'name required' });
// Generate: wd_ + 40 random hex chars = 43 chars total // Generate: wd_ + 40 random hex chars = 43 chars total
@ -45,11 +45,18 @@ router.post('/', async (req, res, next) => {
? new Date(Date.now() + parseInt(expires_in_days, 10) * 86400000) ? new Date(Date.now() + parseInt(expires_in_days, 10) * 86400000)
: null; : null;
// Issue #106 — `bound_hostname` ties a token to a single worker hostname.
// Heartbeats are rejected if the body hostname doesn't match the binding,
// preventing a stolen worker token from hijacking another node's api_url.
const bound = bound_hostname && typeof bound_hostname === 'string'
? bound_hostname.trim() || null
: null;
const { rows } = await pool.query( const { rows } = await pool.query(
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at) `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at, bound_hostname)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, token_prefix, last_used_at, expires_at, created_at`, RETURNING id, name, token_prefix, last_used_at, expires_at, created_at, bound_hostname`,
[userId(req), name.trim(), hash, prefix, expiresAt] [userId(req), name.trim(), hash, prefix, expiresAt, bound]
); );
// Return raw token ONCE — it is never stored in plaintext // Return raw token ONCE — it is never stored in plaintext

View file

@ -1,5 +1,8 @@
import express from 'express'; import express from 'express';
import multer from 'multer'; import multer from 'multer';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js'; import pool from '../db/pool.js';
@ -14,9 +17,23 @@ import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';
const router = express.Router(); const router = express.Router();
const memoryStorage = multer.memoryStorage(); // Issue #120 — was multer.memoryStorage(): a 500 MB part stayed pinned in
// 500 MB file size cap on multipart parts to prevent OOM (#74) // RAM per concurrent upload, OOM'ing the API under load. Disk storage in
const upload = multer({ storage: memoryStorage, limits: { fileSize: 500 * 1024 * 1024 } }); // /tmp (or UPLOAD_TMP_DIR override) keeps the API memory footprint flat
// and is just as fast to stream back out to S3.
const UPLOAD_TMP_DIR = process.env.UPLOAD_TMP_DIR || path.join(os.tmpdir(), 'df-uploads');
try { fs.mkdirSync(UPLOAD_TMP_DIR, { recursive: true }); } catch {}
const diskStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_TMP_DIR),
filename: (_req, _file, cb) => cb(null, `part-${uuidv4()}`),
});
const upload = multer({ storage: diskStorage, limits: { fileSize: 500 * 1024 * 1024 } });
// Best-effort cleanup of an uploaded tmp part. Never throws.
function unlinkPart(p) {
if (!p) return;
fs.unlink(p, () => {});
}
const parseRedisUrl = (url) => { const parseRedisUrl = (url) => {
const parsed = new URL(url); const parsed = new URL(url);
@ -49,13 +66,24 @@ async function resolveBinPath(binId) {
} }
/** /**
* Fire-and-forget: mirror asset's project/bin path into AMPP folder hierarchy. * Issue #77 mirror asset's project/bin path into AMPP folder hierarchy
* Never throws failures are logged but never surface to the caller. * and track the sync state on the asset row so the scheduler can retry
* failed rows on a backoff schedule. Still safe to call fire-and-forget
* from upload endpoints; the caller never sees an exception, but the
* failure is now persisted instead of swallowed.
*/ */
async function syncToAmpp(assetId, projectId, binId) { export async function syncToAmpp(assetId, projectId, binId) {
try { try {
const config = await getAmppConfig(); const config = await getAmppConfig();
if (!config) return; if (!config) {
await pool.query(
`UPDATE assets SET ampp_sync_status = 'disabled', ampp_sync_last_error = NULL,
ampp_sync_next_attempt_at = NULL
WHERE id = $1`,
[assetId]
);
return;
}
const projResult = await pool.query( const projResult = await pool.query(
'SELECT name FROM projects WHERE id = $1', 'SELECT name FROM projects WHERE id = $1',
@ -71,16 +99,34 @@ async function syncToAmpp(assetId, projectId, binId) {
} }
const folderId = await ensureFolderPath(config, segments); const folderId = await ensureFolderPath(config, segments);
if (!folderId) return; if (!folderId) throw new Error('ensureFolderPath returned no id');
await pool.query( await pool.query(
'UPDATE assets SET ampp_folder_id = $1, ampp_synced_at = NOW() WHERE id = $2', `UPDATE assets
SET ampp_folder_id = $1,
ampp_synced_at = NOW(),
ampp_sync_status = 'synced',
ampp_sync_attempts = 0,
ampp_sync_next_attempt_at = NULL,
ampp_sync_last_error = NULL
WHERE id = $2`,
[folderId, assetId] [folderId, assetId]
); );
console.log(`[AMPP] asset ${assetId} → folder ${folderId} (${segments.join(' / ')})`); console.log(`[AMPP] asset ${assetId} → folder ${folderId} (${segments.join(' / ')})`);
} catch (err) { } catch (err) {
console.error(`[AMPP] sync failed for asset ${assetId}:`, err.message); // Persist the failure with exponential backoff so the scheduler retries.
const msg = (err.message || String(err)).slice(0, 500);
await pool.query(
`UPDATE assets
SET ampp_sync_status = 'failed',
ampp_sync_attempts = ampp_sync_attempts + 1,
ampp_sync_last_error = $2,
ampp_sync_next_attempt_at = NOW() + (LEAST(LEAST(ampp_sync_attempts + 1, 8), 8) * INTERVAL '2 minutes')
WHERE id = $1`,
[assetId, msg]
).catch(() => {});
console.error(`[AMPP] sync failed for asset ${assetId}: ${msg}`);
} }
} }
@ -155,22 +201,26 @@ router.post('/init', async (req, res, next) => {
// POST /api/v1/upload/part - Upload a single part // POST /api/v1/upload/part - Upload a single part
router.post('/part', upload.single('file'), async (req, res, next) => { router.post('/part', upload.single('file'), async (req, res, next) => {
const tmpPath = req.file && req.file.path;
try { try {
const { uploadId, key, partNumber } = req.body; const { uploadId, key, partNumber } = req.body;
if (!uploadId || !key || !partNumber || !req.file) { if (!uploadId || !key || !partNumber || !req.file) {
unlinkPart(tmpPath);
return res.status(400).json({ return res.status(400).json({
error: 'Missing required fields: uploadId, key, partNumber, and file', error: 'Missing required fields: uploadId, key, partNumber, and file',
}); });
} }
// Stream the on-disk part to S3 instead of buffering in RAM (#120).
const partUpload = await s3Client.send( const partUpload = await s3Client.send(
new UploadPartCommand({ new UploadPartCommand({
Bucket: getS3Bucket(), Bucket: getS3Bucket(),
Key: key, Key: key,
PartNumber: parseInt(partNumber, 10), PartNumber: parseInt(partNumber, 10),
UploadId: uploadId, UploadId: uploadId,
Body: req.file.buffer, Body: fs.createReadStream(tmpPath),
ContentLength: req.file.size,
}) })
); );
@ -180,6 +230,8 @@ router.post('/part', upload.single('file'), async (req, res, next) => {
}); });
} catch (err) { } catch (err) {
next(err); next(err);
} finally {
unlinkPart(tmpPath);
} }
}); });
@ -263,10 +315,12 @@ router.post('/abort', async (req, res, next) => {
// POST /api/v1/upload/simple - Single-file upload for smaller files (<50 MB) // POST /api/v1/upload/simple - Single-file upload for smaller files (<50 MB)
router.post('/simple', upload.single('file'), async (req, res, next) => { router.post('/simple', upload.single('file'), async (req, res, next) => {
const tmpPath = req.file && req.file.path;
try { try {
const { filename, projectId, binId, tags, contentType } = req.body; const { filename, projectId, binId, tags, contentType } = req.body;
if (!filename || !projectId || !req.file) { if (!filename || !projectId || !req.file) {
unlinkPart(tmpPath);
return res.status(400).json({ return res.status(400).json({
error: 'Missing required fields: filename, projectId, and file', error: 'Missing required fields: filename, projectId, and file',
}); });
@ -291,7 +345,8 @@ router.post('/simple', upload.single('file'), async (req, res, next) => {
] ]
); );
await uploadStream(s3Key, req.file.buffer, mimeType); // Stream the on-disk upload directly to S3 (#120).
await uploadStream(s3Key, fs.createReadStream(tmpPath), mimeType);
const result = await pool.query( const result = await pool.query(
`UPDATE assets SET status = 'processing', updated_at = NOW() `UPDATE assets SET status = 'processing', updated_at = NOW()
@ -315,6 +370,8 @@ router.post('/simple', upload.single('file'), async (req, res, next) => {
res.json(asset); res.json(asset);
} catch (err) { } catch (err) {
next(err); next(err);
} finally {
unlinkPart(tmpPath);
} }
}); });

View file

@ -8,6 +8,7 @@
// forward by 1 day / 7 days into a new 'pending' row. // forward by 1 day / 7 days into a new 'pending' row.
import pool from './db/pool.js'; import pool from './db/pool.js';
import { syncToAmpp } from './routes/upload.js';
const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10); const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`; const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
@ -28,21 +29,52 @@ async function callSelf(path, method = 'POST') {
return res.json().catch(() => ({})); return res.json().catch(() => ({}));
} }
// Issue #103 — every mam-api replica runs the same tick on the same interval,
// so a multi-node deploy would double-fire recorder starts/stops. We guard
// the whole tick with a PG advisory lock (1 = scheduler) so exactly one
// replica processes a given interval. Pure-Postgres, no extra infra.
const SCHEDULER_LOCK_KEY = 8210301; // arbitrary, must be stable across replicas
async function tryAcquireSchedulerLock(client) {
const r = await client.query('SELECT pg_try_advisory_lock($1) AS got', [SCHEDULER_LOCK_KEY]);
return !!r.rows[0]?.got;
}
async function releaseSchedulerLock(client) {
await client.query('SELECT pg_advisory_unlock($1)', [SCHEDULER_LOCK_KEY]).catch(() => {});
}
async function tick() { async function tick() {
if (_tickRunning) return; if (_tickRunning) return;
_tickRunning = true; _tickRunning = true;
const client = await pool.connect();
let haveLock = false;
try { try {
// 1) Start any pending schedules whose window has opened haveLock = await tryAcquireSchedulerLock(client);
const dueStart = await pool.query( if (!haveLock) {
`SELECT * FROM recorder_schedules // Another replica is processing this interval — bail silently.
return;
}
// 1) Atomically claim pending schedules whose window has opened. The
// UPDATE...RETURNING flips status to 'running' in the same statement
// so even if another replica got past the lock (it can't, but
// belt-and-braces) each row can only be claimed once.
const dueStart = await client.query(
`UPDATE recorder_schedules
SET status = 'starting', updated_at = NOW()
WHERE id IN (
SELECT id FROM recorder_schedules
WHERE status = 'pending' AND start_at <= NOW() AND end_at > NOW() WHERE status = 'pending' AND start_at <= NOW() AND end_at > NOW()
ORDER BY start_at ASC` ORDER BY start_at ASC
FOR UPDATE SKIP LOCKED
)
RETURNING *`
); );
for (const s of dueStart.rows) { for (const s of dueStart.rows) {
try { try {
const result = await callSelf(`/api/v1/recorders/${s.recorder_id}/start`); const result = await callSelf(`/api/v1/recorders/${s.recorder_id}/start`);
await pool.query( await client.query(
`UPDATE recorder_schedules `UPDATE recorder_schedules
SET status = 'running', last_asset_id = NULL, updated_at = NOW() SET status = 'running', last_asset_id = NULL, updated_at = NOW()
WHERE id = $1`, WHERE id = $1`,
@ -50,7 +82,7 @@ async function tick() {
); );
console.log(`[scheduler] started schedule "${s.name}" on recorder ${s.recorder_id} (session=${result.current_session_id || '?'})`); console.log(`[scheduler] started schedule "${s.name}" on recorder ${s.recorder_id} (session=${result.current_session_id || '?'})`);
} catch (err) { } catch (err) {
await pool.query( await client.query(
`UPDATE recorder_schedules `UPDATE recorder_schedules
SET status = 'failed', error_message = $2, updated_at = NOW() SET status = 'failed', error_message = $2, updated_at = NOW()
WHERE id = $1`, WHERE id = $1`,
@ -60,11 +92,17 @@ async function tick() {
} }
} }
// 2) Stop any running schedules whose window has closed // 2) Atomically claim running schedules whose window has closed.
const dueStop = await pool.query( const dueStop = await client.query(
`SELECT * FROM recorder_schedules `UPDATE recorder_schedules
SET status = 'stopping', updated_at = NOW()
WHERE id IN (
SELECT id FROM recorder_schedules
WHERE status = 'running' AND end_at <= NOW() WHERE status = 'running' AND end_at <= NOW()
ORDER BY end_at ASC` ORDER BY end_at ASC
FOR UPDATE SKIP LOCKED
)
RETURNING *`
); );
for (const s of dueStop.rows) { for (const s of dueStop.rows) {
try { try {
@ -75,10 +113,10 @@ async function tick() {
[s.id] [s.id]
); );
console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`); console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
await enqueueNextOccurrence(s); await enqueueNextOccurrence(s, client);
} catch (err) { } catch (err) {
// Stop failed — flag as failed but don't keep trying forever. // Stop failed — flag as failed but don't keep trying forever.
await pool.query( await client.query(
`UPDATE recorder_schedules `UPDATE recorder_schedules
SET status = 'failed', error_message = $2, updated_at = NOW() SET status = 'failed', error_message = $2, updated_at = NOW()
WHERE id = $1`, WHERE id = $1`,
@ -89,7 +127,7 @@ async function tick() {
} }
// 3) If a schedule was cancelled while running, stop the recorder. // 3) If a schedule was cancelled while running, stop the recorder.
const cancelledRunning = await pool.query( const cancelledRunning = await client.query(
`SELECT s.* FROM recorder_schedules s `SELECT s.* FROM recorder_schedules s
JOIN recorders r ON r.id = s.recorder_id JOIN recorders r ON r.id = s.recorder_id
WHERE s.status = 'cancelled' AND r.status = 'recording' WHERE s.status = 'cancelled' AND r.status = 'recording'
@ -108,7 +146,7 @@ async function tick() {
// If a capture container crashes without calling mark-empty/mark-complete, // If a capture container crashes without calling mark-empty/mark-complete,
// the asset row stays status='live' indefinitely. Timeout after 2 hours. // the asset row stays status='live' indefinitely. Timeout after 2 hours.
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10); const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
const staleResult = await pool.query( const staleResult = await client.query(
`UPDATE assets `UPDATE assets
SET status = 'error', SET status = 'error',
updated_at = NOW() updated_at = NOW()
@ -122,21 +160,39 @@ async function tick() {
console.warn(`[scheduler] marked stale live asset as error: ${row.id} (${row.display_name})`); console.warn(`[scheduler] marked stale live asset as error: ${row.id} (${row.display_name})`);
} }
} }
// 5) AMPP sync retry (#77). Pick up any pending/failed rows whose
// next-attempt time has arrived and retry them. Cap per tick so we
// don't burn budget on a single rough interval.
const ampps = await client.query(
`SELECT id, project_id, bin_id FROM assets
WHERE ampp_sync_status IN ('pending', 'failed')
AND (ampp_sync_next_attempt_at IS NULL OR ampp_sync_next_attempt_at <= NOW())
AND ampp_sync_attempts < 8
ORDER BY ampp_sync_next_attempt_at NULLS FIRST
LIMIT 25`
);
for (const row of ampps.rows) {
await syncToAmpp(row.id, row.project_id, row.bin_id);
}
} catch (err) { } catch (err) {
console.error('[scheduler] tick error:', err); console.error('[scheduler] tick error:', err);
} finally { } finally {
if (haveLock) await releaseSchedulerLock(client);
client.release();
_tickRunning = false; _tickRunning = false;
} }
} }
async function enqueueNextOccurrence(schedule) { async function enqueueNextOccurrence(schedule, client) {
if (schedule.recurrence === 'none') return; if (schedule.recurrence === 'none') return;
const days = schedule.recurrence === 'weekly' ? 7 : 1; const days = schedule.recurrence === 'weekly' ? 7 : 1;
const start = new Date(schedule.start_at); const start = new Date(schedule.start_at);
const end = new Date(schedule.end_at); const end = new Date(schedule.end_at);
start.setUTCDate(start.getUTCDate() + days); start.setUTCDate(start.getUTCDate() + days);
end.setUTCDate(end.getUTCDate() + days); end.setUTCDate(end.getUTCDate() + days);
await pool.query( const q = client || pool;
await q.query(
`INSERT INTO recorder_schedules `INSERT INTO recorder_schedules
(name, recorder_id, start_at, end_at, recurrence, status) (name, recorder_id, start_at, end_at, recurrence, status)
VALUES ($1, $2, $3, $4, $5, 'pending')`, VALUES ($1, $2, $3, $4, $5, 'pending')`,

View file

@ -266,14 +266,17 @@ async function probeGpusViaSmi() {
function detectHardware() { function detectHardware() {
const capabilities = { gpus: [], blackmagic: [] }; const capabilities = { gpus: [], blackmagic: [] };
// Issue #108 — previously GPU_COUNT short-circuited the entire detection
// path, throwing away the nvidia-smi enrichment (model, memory, driver
// version). Now: override sets the *count*, but if nvidia-smi successfully
// probed at startup we keep its rich entries for the overridden indexes.
const gpuOverride = parseInt(process.env.GPU_COUNT || '-1', 10); const gpuOverride = parseInt(process.env.GPU_COUNT || '-1', 10);
if (gpuOverride >= 0) { if (gpuOverride >= 0) {
for (let i = 0; i < gpuOverride; i++) { for (let i = 0; i < gpuOverride; i++) {
capabilities.gpus.push({ device: `/dev/nvidia${i}`, type: 'nvidia', index: i }); const enriched = (_gpuCache || []).find(g => g.index === i);
capabilities.gpus.push(enriched || { device: `/dev/nvidia${i}`, type: 'nvidia', index: i });
} }
} else { } else if (_gpuCache !== null && _gpuCache.length > 0) {
// Use nvidia-smi cache if populated, otherwise fall back to /dev file scan
if (_gpuCache !== null && _gpuCache.length > 0) {
capabilities.gpus = _gpuCache; capabilities.gpus = _gpuCache;
} else { } else {
for (let i = 0; i < 16; i++) { for (let i = 0; i < 16; i++) {
@ -283,18 +286,25 @@ function detectHardware() {
} catch (_) { break; } } catch (_) { break; }
} }
} }
}
// Issue #109 — DeckLink device naming differs by model: capture-only cards
// expose /dev/blackmagic/dv${i}, while DeckLink IO / Quad cards expose
// /dev/blackmagic/io${i}. The previous code hardcoded `dv` which broke
// capability reporting on every IO / Quad node. Detect what's actually
// present and only synthesize names when /dev/blackmagic isn't mounted.
const bmdOverride = parseInt(process.env.BMD_COUNT || '-1', 10); const bmdOverride = parseInt(process.env.BMD_COUNT || '-1', 10);
if (bmdOverride >= 0) {
for (let i = 0; i < bmdOverride; i++) {
capabilities.blackmagic.push({ device: `/dev/blackmagic/dv${i}`, index: i });
}
} else {
try { try {
const bmdEntries = fs.readdirSync('/dev/blackmagic'); const bmdEntries = fs.readdirSync('/dev/blackmagic');
// Filesystem wins — devices speak for themselves.
capabilities.blackmagic = bmdEntries.map((d, i) => ({ device: `/dev/blackmagic/${d}`, index: i })); capabilities.blackmagic = bmdEntries.map((d, i) => ({ device: `/dev/blackmagic/${d}`, index: i }));
} catch (_) {} } catch (_) {
// No /dev/blackmagic mount — fall back to the BMD_COUNT override.
if (bmdOverride >= 0) {
const namePrefix = (process.env.BMD_DEVICE_PREFIX || 'dv').replace(/[^a-z]/gi, '');
for (let i = 0; i < bmdOverride; i++) {
capabilities.blackmagic.push({ device: `/dev/blackmagic/${namePrefix}${i}`, index: i });
}
}
} }
// Best-effort model name from BMD_MODEL env (set manually) — used by the UI // Best-effort model name from BMD_MODEL env (set manually) — used by the UI

View file

@ -1,21 +1,25 @@
# Stage 1: build CSS bundle # Stage 1: build CSS bundle + precompile JSX (issue #139)
FROM node:20-alpine AS css-build FROM node:20-alpine AS build
WORKDIR /build WORKDIR /build
# Copy only the files needed to install deps (better cache layering) # Copy only the files needed to install deps (better cache layering)
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund RUN npm install --no-audit --no-fund
# Copy source CSS + tailwind config + every HTML file (tailwind scans HTML to determine which utilities to emit) # Copy source CSS + tailwind config + every HTML/JSX file
COPY tailwind.config.js postcss.config.js ./ COPY tailwind.config.js postcss.config.js ./
COPY src/ ./src/ COPY src/ ./src/
COPY public/ ./public/ COPY public/ ./public/
COPY scripts/ ./scripts/
# Build into public/dist/app.css # Build CSS into public/dist/app.css
RUN npx postcss ./src/css/app.css -o ./public/dist/app.css --env production RUN npx postcss ./src/css/app.css -o ./public/dist/app.css --env production
# Precompile every .jsx → public/dist/*.js (no in-browser Babel at runtime)
RUN node scripts/build-jsx.js
# Stage 2: runtime # Stage 2: runtime
FROM nginx:alpine FROM nginx:alpine
COPY --from=css-build /build/public/ /usr/share/nginx/html/ COPY --from=build /build/public/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80

View file

@ -4,7 +4,8 @@
"private": true, "private": true,
"description": "Build-time-only deps for the Wild Dragon web-ui Tailwind/flyon-ui pipeline. Not shipped at runtime.", "description": "Build-time-only deps for the Wild Dragon web-ui Tailwind/flyon-ui pipeline. Not shipped at runtime.",
"scripts": { "scripts": {
"build:css": "postcss ./src/css/app.css -o ./public/dist/app.css --env production" "build:css": "postcss ./src/css/app.css -o ./public/dist/app.css --env production",
"build:jsx": "node scripts/build-jsx.js"
}, },
"devDependencies": { "devDependencies": {
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
@ -13,6 +14,7 @@
"flyonui": "^1.0.0", "flyonui": "^1.0.0",
"postcss-import": "^16.0.0", "postcss-import": "^16.0.0",
"postcss-cli": "^11.0.0", "postcss-cli": "^11.0.0",
"cssnano": "^7.0.0" "cssnano": "^7.0.0",
"esbuild": "^0.24.0"
} }
} }

View file

@ -9,6 +9,21 @@ function App() {
const [showNewRecorder, setShowNewRecorder] = React.useState(false); const [showNewRecorder, setShowNewRecorder] = React.useState(false);
const [dataReady, setDataReady] = React.useState(false); const [dataReady, setDataReady] = React.useState(false);
const [loadError, setLoadError] = React.useState(null); const [loadError, setLoadError] = React.useState(null);
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(() => {
try {
const stored = localStorage.getItem('df.sidebar.collapsed');
if (stored != null) return stored === '1';
// Default: collapsed on mobile, expanded on desktop.
return typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
} catch { return false; }
});
const toggleSidebar = React.useCallback(() => {
setSidebarCollapsed(prev => {
const next = !prev;
try { localStorage.setItem('df.sidebar.collapsed', next ? '1' : '0'); } catch {}
return next;
});
}, []);
React.useEffect(() => { React.useEffect(() => {
document.documentElement.style.setProperty('--accent', ACCENT); document.documentElement.style.setProperty('--accent', ACCENT);
@ -104,8 +119,8 @@ function App() {
const hideTopbar = !openAsset && route === 'home'; const hideTopbar = !openAsset && route === 'home';
return ( return (
<div className="app" data-density="comfortable" data-grid-size="md" data-sidebar="expanded"> <div className="app" data-density="comfortable" data-grid-size="md" data-sidebar={sidebarCollapsed ? 'collapsed' : 'expanded'}>
<Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} me={window.ZAMPP_DATA?.ME} /> <Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} me={window.ZAMPP_DATA?.ME} collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
<div className="main"> <div className="main">
{!openAsset && !hideTopbar && ( {!openAsset && !hideTopbar && (
<Topbar <Topbar
@ -113,6 +128,7 @@ function App() {
onNavigate={navigate} onNavigate={navigate}
onOpenAsset={setOpenAsset} onOpenAsset={setOpenAsset}
onOpenProject={openProjectFromAnywhere} onOpenProject={openProjectFromAnywhere}
onToggleSidebar={toggleSidebar}
/> />
)} )}
{content} {content}

View file

@ -1,6 +1,46 @@
// 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)
// Gated logger (#123). Production deploys ship muted; appending ?debug=1
// to the URL (or localStorage.df_debug = '1') re-enables full console output.
(function setupLogger() {
let enabled = false;
try {
enabled =
/(?:^|[?&])debug=1(?:&|$)/.test(location.search) ||
localStorage.getItem('df_debug') === '1' ||
location.hostname === 'localhost' || location.hostname === '127.0.0.1';
} catch {}
const noop = () => {};
window.DF_LOG = {
debug: enabled ? console.debug.bind(console) : noop,
warn: enabled ? console.warn.bind(console) : noop,
error: console.error.bind(console), // errors always surface
};
})();
// Premiere panel releases embedded in this deployment. Bumping the version
// here is the single source of truth both the Editor download buttons and
// the Settings Capture SDKs page read from this list (#125).
window.PREMIERE_RELEASES = [
{
version: '1.0.1',
zxp: '/downloads/dragonflight-premiere-panel-1.0.1.zxp',
installer: '/downloads/dragonflight-premiere-panel-1.0.1-windows-setup.exe',
notes: 'Latest — auto-relinking, growing-file support, batch trim',
latest: true,
},
{
version: '1.0.0',
zxp: '/downloads/dragonflight-premiere-panel-1.0.0.zxp',
installer: '/downloads/dragonflight-premiere-panel-1.0.0-windows-setup.exe',
notes: 'Initial release',
latest: false,
},
];
window.PREMIERE_LATEST = window.PREMIERE_RELEASES.find(r => r.latest) || window.PREMIERE_RELEASES[0];
window.ZAMPP_DATA = { window.ZAMPP_DATA = {
PROJECTS: [], PROJECTS: [],

View file

@ -17,27 +17,26 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script> <script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script> <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.15/dist/hls.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.15/dist/hls.min.js" crossorigin="anonymous"></script>
<script type="text/babel" src="data.jsx"></script> <script src="dist/data.js"></script>
<script type="text/babel" src="icons.jsx"></script> <script src="dist/icons.js"></script>
<script type="text/babel" src="visuals.jsx"></script> <script src="dist/visuals.js"></script>
<script type="text/babel" src="shell.jsx"></script> <script src="dist/shell.js"></script>
<script type="text/babel" src="screens-home.jsx"></script> <script src="dist/screens-home.js"></script>
<script type="text/babel" src="screens-library.jsx"></script> <script src="dist/screens-library.js"></script>
<script type="text/babel" src="screens-asset.jsx"></script> <script src="dist/screens-asset.js"></script>
<script type="text/babel" src="screens-projects.jsx"></script> <script src="dist/screens-projects.js"></script>
<script type="text/babel" src="screens-ingest.jsx"></script> <script src="dist/screens-ingest.js"></script>
<script type="text/babel" src="screens-jobs.jsx"></script> <script src="dist/screens-jobs.js"></script>
<script src="js/timecode.js"></script> <script src="js/timecode.js"></script>
<script src="js/timeline.js"></script> <script src="js/timeline.js"></script>
<script src="js/bmd-card.js"></script> <script src="js/bmd-card.js"></script>
<script type="text/babel" src="screens-editor.jsx"></script> <script src="dist/screens-editor.js"></script>
<script type="text/babel" src="screens-admin.jsx"></script> <script src="dist/screens-admin.js"></script>
<script type="text/babel" src="modal-new-recorder.jsx"></script> <script src="dist/modal-new-recorder.js"></script>
<script type="text/babel" src="app.jsx"></script> <script src="dist/app.js"></script>
</body> </body>
</html> </html>

View file

@ -238,7 +238,7 @@
</div> </div>
<div class="wd-form-group"> <div class="wd-form-group">
<label class="wd-label" for="su-password">Password (min 8 chars)</label> <label class="wd-label" for="su-password">Password (min 8 chars)</label>
<input class="wd-input" id="su-password" name="password" type="password" required minlength="8" placeholder="••••••••"> <input class="wd-input" id="su-password" name="password" type="password" autocomplete="new-password" required minlength="8" placeholder="••••••••">
</div> </div>
<div class="wd-form-group"> <div class="wd-form-group">
<label class="wd-label" for="su-display">Display name (optional)</label> <label class="wd-label" for="su-display">Display name (optional)</label>

View file

@ -119,7 +119,7 @@ function NewRecorderModal({ open, onClose }) {
<div style={{ fontSize: 15, fontWeight: 600 }}>New recorder</div> <div style={{ fontSize: 15, fontWeight: 600 }}>New recorder</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>Configure source, codec, and destination</div> <div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>Configure source, codec, and destination</div>
</div> </div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">

View file

@ -65,7 +65,7 @@ function InviteUserModal({ onCreated, onClose }) {
<div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head"> <div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Invite user</div> <div style={{ fontSize: 15, fontWeight: 600 }}>Invite user</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="field"> <div className="field">
@ -83,6 +83,7 @@ function InviteUserModal({ onCreated, onClose }) {
<div className="field"> <div className="field">
<label className="field-label">Password</label> <label className="field-label">Password</label>
<input className="field-input" type="password" value={form.password} <input className="field-input" type="password" value={form.password}
autoComplete="new-password"
onChange={e => setForm(p => ({...p, password: e.target.value}))} onChange={e => setForm(p => ({...p, password: e.target.value}))}
onKeyDown={onKey} placeholder="Temporary password" /> onKeyDown={onKey} placeholder="Temporary password" />
</div> </div>
@ -232,7 +233,7 @@ function Users() {
{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" 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); }}>
<Icon name="more" /> <Icon name="more" />
</button> </button>
{menuFor === u.id && ( {menuFor === u.id && (
@ -299,7 +300,7 @@ function EditUserModal({ user, onClose, onSaved }) {
<div className="modal" style={{ width: 400 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ width: 400 }} onClick={e => e.stopPropagation()}>
<div className="modal-head"> <div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Rename user</div> <div style={{ fontSize: 15, fontWeight: 600 }}>Rename user</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="field"> <div className="field">
@ -328,13 +329,25 @@ 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.
const mountedRef = React.useRef(true);
const savedTimerRef = React.useRef(null);
React.useEffect(() => () => {
mountedRef.current = false;
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
}, []);
const valid = pw.length >= 8 && pw === pw2; const valid = pw.length >= 8 && pw === pw2;
const submit = () => { const submit = () => {
if (!valid) return; if (!valid) return;
setSaving(true); setErr(null); setSaving(true); setErr(null);
window.ZAMPP_API.fetch('/users/' + user.id, { method: 'PATCH', body: JSON.stringify({ password: pw }) }) window.ZAMPP_API.fetch('/users/' + user.id, { method: 'PATCH', body: JSON.stringify({ password: pw }) })
.then(() => { setSaving(false); setDone(true); setTimeout(onSaved, 1200); }) .then(() => {
.catch(e => { setSaving(false); setErr(e.message); }); if (!mountedRef.current) return;
setSaving(false); setDone(true);
savedTimerRef.current = setTimeout(() => { if (mountedRef.current) onSaved(); }, 1200);
})
.catch(e => { if (mountedRef.current) { setSaving(false); setErr(e.message); } });
}; };
return ( return (
@ -342,7 +355,7 @@ function PasswordResetModal({ user, onClose, onSaved }) {
<div className="modal" style={{ width: 400 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ width: 400 }} onClick={e => e.stopPropagation()}>
<div className="modal-head"> <div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Reset password · @{user.username}</div> <div style={{ fontSize: 15, fontWeight: 600 }}>Reset password · @{user.username}</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
{done ? ( {done ? (
@ -357,7 +370,7 @@ function PasswordResetModal({ user, onClose, onSaved }) {
value={pw} onChange={e => setPw(e.target.value)} value={pw} onChange={e => setPw(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }} onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }}
style={{ paddingRight: 36 }} /> style={{ paddingRight: 36 }} />
<button className="icon-btn" style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }} <button className="icon-btn" aria-label={show ? 'Hide password' : 'Show password'} style={{ position: 'absolute', right: 4, top: '50%', transform: 'translateY(-50%)' }}
onClick={() => setShow(s => !s)} type="button" tabIndex={-1}> onClick={() => setShow(s => !s)} type="button" tabIndex={-1}>
<Icon name={show ? 'eye-off' : 'eye'} size={13} /> <Icon name={show ? 'eye-off' : 'eye'} size={13} />
</button> </button>
@ -496,7 +509,7 @@ function GroupsPanel({ groups, users, onChange }) {
{groupMembers.map(m => ( {groupMembers.map(m => (
<span key={m.id} className="badge outline" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}> <span key={m.id} className="badge outline" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
@{m.username} @{m.username}
<button className="icon-btn" style={{ width: 16, height: 16, padding: 0 }} onClick={() => removeMember(g, m.id)} title="Remove"> <button className="icon-btn" aria-label="Remove member" style={{ width: 16, height: 16, padding: 0 }} onClick={() => removeMember(g, m.id)} title="Remove">
<Icon name="x" size={9} /> <Icon name="x" size={9} />
</button> </button>
</span> </span>
@ -742,7 +755,7 @@ function CostCalculator({ onClose }) {
<div style={{ fontSize: 15, fontWeight: 600 }}>Token Cost Calculator</div> <div style={{ fontSize: 15, fontWeight: 600 }}>Token Cost Calculator</div>
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2 }}>What it would cost on AMPP-style pricing</div> <div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 2 }}>What it would cost on AMPP-style pricing</div>
</div> </div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<CalcSlider label="Users" value={users} onChange={setUsers} min={1} max={100} unit=" people" /> <CalcSlider label="Users" value={users} onChange={setUsers} min={1} max={100} unit=" people" />
@ -783,6 +796,20 @@ function CalcSlider({ label, value, onChange, min, max, step = 1, unit }) {
function Containers() { function Containers() {
const [containers, setContainers] = React.useState(null); const [containers, setContainers] = React.useState(null);
const [restartFlashState, setRestartFlashState] = React.useState(null);
const [logsModalState, setLogsModalState] = React.useState(null);
// #111 guard restart-flash timers against unmount.
const mountedRef = React.useRef(true);
const flashTimerRef = React.useRef(null);
React.useEffect(() => () => {
mountedRef.current = false;
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
}, []);
const setRestartFlashSafe = (v) => { if (mountedRef.current) setRestartFlashState(v); };
const scheduleFlashClear = (ms) => {
if (flashTimerRef.current) clearTimeout(flashTimerRef.current);
flashTimerRef.current = setTimeout(() => setRestartFlashSafe(null), ms);
};
function load() { function load() {
setContainers(null); setContainers(null);
@ -795,17 +822,26 @@ function Containers() {
const running = (containers || []).filter(c => c.state === 'running').length; const running = (containers || []).filter(c => c.state === 'running').length;
const [restartFlash, setRestartFlash] = React.useState(null); const restartFlash = restartFlashState;
const [logsModal, setLogsModal] = React.useState(null); const logsModal = logsModalState;
const setLogsModal = setLogsModalState;
const showLogs = (c) => setLogsModal(c); const showLogs = (c) => setLogsModal(c);
const restartContainer = (c) => { const restartContainer = (c) => {
if (!window.confirm('Restart container "' + c.name + '"?\nIn-flight requests will be dropped.')) return; if (!window.confirm('Restart container "' + c.name + '"?\nIn-flight requests will be dropped.')) return;
setRestartFlash({ name: c.name, status: 'pending' }); setRestartFlashSafe({ name: c.name, status: 'pending' });
window.ZAMPP_API.fetch('/cluster/containers/' + encodeURIComponent(c.id || c.name) + '/restart', { method: 'POST' }) window.ZAMPP_API.fetch('/cluster/containers/' + encodeURIComponent(c.id || c.name) + '/restart', { method: 'POST' })
.then(() => { setRestartFlash({ name: c.name, status: 'ok' }); load(); setTimeout(() => setRestartFlash(null), 3000); }) .then(() => {
.catch(e => { setRestartFlash({ name: c.name, status: 'fail', error: e.message }); setTimeout(() => setRestartFlash(null), 5000); }); if (!mountedRef.current) return;
setRestartFlashSafe({ name: c.name, status: 'ok' });
load();
scheduleFlashClear(3000);
})
.catch(e => {
setRestartFlashSafe({ name: c.name, status: 'fail', error: e.message });
scheduleFlashClear(5000);
});
}; };
return ( return (
@ -844,7 +880,7 @@ function Containers() {
<div className="modal" style={{ width: 540 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ width: 540 }} onClick={e => e.stopPropagation()}>
<div className="modal-head"> <div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Logs · {logsModal.name}</div> <div style={{ fontSize: 15, fontWeight: 600 }}>Logs · {logsModal.name}</div>
<button className="icon-btn" onClick={() => setLogsModal(null)}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={() => setLogsModal(null)}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 8 }}> <div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 8 }}>
@ -1336,7 +1372,7 @@ function Cluster() {
<div className="modal" style={{ width: 560 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ width: 560 }} onClick={e => e.stopPropagation()}>
<div className="modal-head"> <div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>{adviceModal.title}</div> <div style={{ fontSize: 15, fontWeight: 600 }}>{adviceModal.title}</div>
<button className="icon-btn" onClick={() => setAdviceModal(null)}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={() => setAdviceModal(null)}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
{(adviceModal.lines || []).map((l, i) => ( {(adviceModal.lines || []).map((l, i) => (
@ -1588,22 +1624,24 @@ function S3SettingsCard() {
return ( return (
<SettingsCard icon="hdd" title="S3 / Object Storage" sub="S3-compatible bucket for media asset storage" <SettingsCard icon="hdd" title="S3 / Object Storage" sub="S3-compatible bucket for media asset storage"
tag={secretExists ? <span className="badge success">connected</span> : <span className="badge warning">not configured</span>}> tag={secretExists ? <span className="badge success">connected</span> : <span className="badge warning">not configured</span>}>
{loading ? <div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div> : (<> {loading ? <div style={{ color: 'var(--text-3)', fontSize: 12.5 }}>Loading</div> : (
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<SField label="Endpoint URL"> <SField label="Endpoint URL">
<input className="field-input mono" value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" /> <input className="field-input mono" type="url" required value={s3.s3_endpoint} onChange={e => setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" />
</SField> </SField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<SField label="Region"><input className="field-input mono" value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /></SField> <SField label="Region"><input className="field-input mono" required value={s3.s3_region} onChange={e => setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /></SField>
<SField label="Bucket"><input className="field-input mono" 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" value={s3.s3_access_key} onChange={e => setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" /></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'} /></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 className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button> <button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save & apply'}</button>
<button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button> <button type="button" className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
</div> </div>
</>)} </form>
)}
</SettingsCard> </SettingsCard>
); );
} }
@ -1631,6 +1669,7 @@ function GpuSettingsCard() {
return ( return (
<SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file" <SettingsCard icon="gpu" title="Proxy encoding" sub="Global proxy encoder applied to every ingested file"
tag={gpuEnabled ? <span className="badge success">GPU mode</span> : <span className="badge neutral">CPU mode</span>}> tag={gpuEnabled ? <span className="badge success">GPU mode</span> : <span className="badge neutral">CPU mode</span>}>
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 4 }}> <div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: 4 }}>
These settings drive the proxy worker for <strong style={{ color: 'var(--text-2)' }}>every</strong> ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264. These settings drive the proxy worker for <strong style={{ color: 'var(--text-2)' }}>every</strong> ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264.
</div> </div>
@ -1693,8 +1732,9 @@ function GpuSettingsCard() {
<SettingsMsg msg={msg} /> <SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}> <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button> <button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div> </div>
</form>
</SettingsCard> </SettingsCard>
); );
} }
@ -1724,6 +1764,7 @@ function GrowingSettingsCard() {
return ( return (
<SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop" <SettingsCard icon="hdd" title="Growing files (SMB)" sub="High-speed local landing zone; promote to S3 on stop"
tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}> tag={enabled ? <span className="badge success">enabled</span> : <span className="badge neutral">disabled</span>}>
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<SField label="Enable growing-file capture"> <SField label="Enable growing-file capture">
<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={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} /> <input type="checkbox" checked={enabled} onChange={e => set('growing_enabled', String(e.target.checked))} />
@ -1741,8 +1782,9 @@ function GrowingSettingsCard() {
</SField> </SField>
<SettingsMsg msg={msg} /> <SettingsMsg msg={msg} />
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}> <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button> <button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
</div> </div>
</form>
</SettingsCard> </SettingsCard>
); );
} }
@ -1798,23 +1840,9 @@ const SDK_VENDORS = [
}, },
]; ];
// Premiere panel releases embedded in the deployment // Premiere panel releases single source of truth lives on `window.PREMIERE_RELEASES`
const PREMIERE_RELEASES = [ // (see data.jsx). Local alias for readability.
{ const PREMIERE_RELEASES = window.PREMIERE_RELEASES;
version: '1.0.1',
zxp: '/downloads/dragonflight-premiere-panel-1.0.1.zxp',
installer: '/downloads/dragonflight-premiere-panel-1.0.1-windows-setup.exe',
notes: 'Latest — auto-relinking, growing-file support, batch trim',
latest: true,
},
{
version: '1.0.0',
zxp: '/downloads/dragonflight-premiere-panel-1.0.0.zxp',
installer: '/downloads/dragonflight-premiere-panel-1.0.0-windows-setup.exe',
notes: 'Initial release',
latest: false,
},
];
function SdkSettingsCard() { function SdkSettingsCard() {
const [statuses, setStatuses] = React.useState(null); const [statuses, setStatuses] = React.useState(null);
@ -1901,7 +1929,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
// 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', '/api/v1/sdk/' + vendor.id); xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id);
xhr.withCredentials = true; xhr.withCredentials = true;
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)); if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));
@ -1992,17 +2020,19 @@ function AmppSettingsCard() {
return ( return (
<SettingsCard icon="link" title="AMPP integration" sub="Migrate assets and metadata from Grass Valley AMPP" <SettingsCard icon="link" title="AMPP integration" sub="Migrate assets and metadata from Grass Valley AMPP"
tag={tokenExists ? <span className="badge success">connected</span> : <span className="badge neutral">not configured</span>}> tag={tokenExists ? <span className="badge success">connected</span> : <span className="badge neutral">not configured</span>}>
<form onSubmit={e => { e.preventDefault(); save(); }} autoComplete="off">
<SField label="AMPP base URL"> <SField label="AMPP base URL">
<input className="field-input mono" 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'} /> <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 }}>
<button className="btn primary sm" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save'}</button> <button type="submit" className="btn primary sm" disabled={saving}>{saving ? 'Saving…' : 'Save'}</button>
<button className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button> <button type="button" className="btn ghost sm" onClick={test} disabled={testing}>{testing ? 'Testing…' : 'Test connection'}</button>
</div> </div>
</form>
</SettingsCard> </SettingsCard>
); );
} }

View file

@ -159,8 +159,33 @@ 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,
// treat it as a clean end. Avoids the silent-freeze users hit when seeking
// to the last instant of a clip.
React.useEffect(() => {
if (!stallStart) return;
if (!videoRef.current || !totalMs) return;
const id = setTimeout(() => {
const v = videoRef.current;
if (!v) return;
const posMs = (v.currentTime || 0) * 1000;
if (totalMs - posMs <= 250 && (playerState === 'waiting' || playerState === 'stalled')) {
try { v.pause(); } catch (_) {}
setPlaying(false);
setPlayerState('paused');
setStallStart(null);
}
}, 1200);
return () => clearTimeout(id);
}, [stallStart, totalMs, playerState]);
const seek = function(ms) { const seek = function(ms) {
const clamped = Math.max(0, Math.min(totalMs || 0, ms)); // #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
// 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.
const upperBoundMs = Math.max(0, (totalMs || 0) - 50);
const clamped = Math.max(0, Math.min(upperBoundMs, ms));
setCurrentMs(clamped); setCurrentMs(clamped);
if (videoRef.current) videoRef.current.currentTime = clamped / 1000; if (videoRef.current) videoRef.current.currentTime = clamped / 1000;
}; };
@ -326,7 +351,7 @@ function AssetDetail({ asset, onClose }) {
return ( return (
<div className="asset-detail fade-in"> <div className="asset-detail fade-in">
<div className="asset-detail-header"> <div className="asset-detail-header">
<button className="icon-btn" onClick={onClose}><Icon name="arrowLeft" /></button> <button className="icon-btn" aria-label="Back" onClick={onClose}><Icon name="arrowLeft" /></button>
<div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}> <div style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontWeight: 600, fontSize: 14 }}>{asset.name}</span> <span style={{ fontWeight: 600, fontSize: 14 }}>{asset.name}</span>
@ -341,7 +366,7 @@ function AssetDetail({ asset, onClose }) {
<Icon name="download" />{downloading ? 'Preparing…' : 'Download'} <Icon name="download" />{downloading ? 'Preparing…' : 'Download'}
</button> </button>
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<button ref={moreBtnRef} className="icon-btn" onClick={function(e) { e.stopPropagation(); setMenuOpen(function(v) { return !v; }); }}> <button ref={moreBtnRef} className="icon-btn" aria-label="More actions" onClick={function(e) { e.stopPropagation(); setMenuOpen(function(v) { return !v; }); }}>
<Icon name="more" /> <Icon name="more" />
</button> </button>
{menuOpen && ( {menuOpen && (
@ -479,7 +504,7 @@ function AssetDetail({ asset, onClose }) {
{totalMs > 0 && ( {totalMs > 0 && (
<div className="player-controls"> <div className="player-controls">
<button className="icon-btn" onClick={togglePlay}> <button className="icon-btn" aria-label={playing ? 'Pause' : 'Play'} onClick={togglePlay}>
<Icon name={playing ? "pause" : "play"} size={14} /> <Icon name={playing ? "pause" : "play"} size={14} />
</button> </button>
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-2)", minWidth: 70 }}>{msToTimecode(currentMs)}</span> <span className="mono" style={{ fontSize: 11.5, color: "var(--text-2)", minWidth: 70 }}>{msToTimecode(currentMs)}</span>
@ -491,12 +516,14 @@ function AssetDetail({ asset, onClose }) {
<button <button
className="icon-btn" className="icon-btn"
title="Toggle mute" title="Toggle mute"
aria-label="Toggle mute"
onClick={function() { if (videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } }}> onClick={function() { if (videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } }}>
<Icon name="audio" size={14} /> <Icon name="audio" size={14} />
</button> </button>
<button <button
className="icon-btn" className="icon-btn"
title="Toggle fullscreen" title="Toggle fullscreen"
aria-label="Toggle fullscreen"
onClick={function() { if (videoRef.current && videoRef.current.requestFullscreen) videoRef.current.requestFullscreen().catch(function() {}); }}> onClick={function() { if (videoRef.current && videoRef.current.requestFullscreen) videoRef.current.requestFullscreen().catch(function() {}); }}>
<Icon name="layout" size={14} /> <Icon name="layout" size={14} />
</button> </button>

View file

@ -32,7 +32,14 @@ function Editor() {
const pgmVideoRef = React.useRef(null); const pgmVideoRef = React.useRef(null);
const tlRef = React.useRef(null); const tlRef = React.useRef(null);
const saveTimerRef = React.useRef(null); const saveTimerRef = React.useRef(null);
const statusTimerRef = React.useRef(null);
const streamCacheRef = React.useRef({}); const streamCacheRef = React.useRef({});
const mountedRef = React.useRef(true);
React.useEffect(() => () => {
mountedRef.current = false;
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
}, []);
const tlInitRef = React.useRef(false); const tlInitRef = React.useRef(false);
// Refs so Timeline callbacks always read current values without stale closure issues // Refs so Timeline callbacks always read current values without stale closure issues
@ -105,7 +112,7 @@ function Editor() {
const list = Array.isArray(r) ? r : []; const list = Array.isArray(r) ? r : [];
setSequences(list); setSequences(list);
if (list.length) openSequence(list[0].id); if (list.length) openSequence(list[0].id);
} catch (e) { console.error('Failed to load sequences', e); } } catch (e) { window.DF_LOG.warn('[editor] load sequences failed', e); }
} }
async function openSequence(id) { async function openSequence(id) {
@ -125,7 +132,7 @@ function Editor() {
isDirtyRef.current = false; isDirtyRef.current = false;
setSaveStatus(''); setSaveStatus('');
renderTimelineClips(clips); renderTimelineClips(clips);
} catch (e) { console.error('Failed to open sequence', e); } } catch (e) { window.DF_LOG.warn('[editor] open sequence failed', e); }
} }
async function createNewSequence() { async function createNewSequence() {
@ -137,7 +144,7 @@ function Editor() {
setNewSeqName(''); setNewSeqName('');
setShowNewSeq(false); setShowNewSeq(false);
openSequence(r.id); openSequence(r.id);
} catch (e) { console.error('Failed to create sequence', e); } } catch (e) { window.DF_LOG.warn('[editor] create sequence failed', e); }
} }
async function renameSequence() { async function renameSequence() {
@ -146,7 +153,7 @@ function Editor() {
await window.ZAMPP_API.updateSequence(currentSeq.id, { name: renameVal.trim() }); await window.ZAMPP_API.updateSequence(currentSeq.id, { name: renameVal.trim() });
setSequences(prev => prev.map(s => s.id === currentSeq.id ? { ...s, name: renameVal.trim() } : s)); setSequences(prev => prev.map(s => s.id === currentSeq.id ? { ...s, name: renameVal.trim() } : s));
setCurrentSeq(prev => prev ? { ...prev, name: renameVal.trim() } : prev); setCurrentSeq(prev => prev ? { ...prev, name: renameVal.trim() } : prev);
} catch (e) { console.error('Rename failed', e); } } catch (e) { window.DF_LOG.warn('[editor] rename failed', e); }
setRenamingSeq(false); setRenamingSeq(false);
} }
@ -162,7 +169,7 @@ function Editor() {
setHistory([[]]); setHistory([[]]);
setHistoryIdx(0); setHistoryIdx(0);
if (remaining.length) openSequence(remaining[0].id); if (remaining.length) openSequence(remaining[0].id);
} catch (e) { console.error('Delete failed', e); } } catch (e) { window.DF_LOG.warn('[editor] delete failed', e); }
} }
function renderTimelineClips(clips) { function renderTimelineClips(clips) {
@ -211,7 +218,7 @@ function Editor() {
try { try {
const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream'); const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream');
if (r && r.url) { streamUrl = r.url; streamCacheRef.current[asset.id] = r.url; } if (r && r.url) { streamUrl = r.url; streamCacheRef.current[asset.id] = r.url; }
} catch (e) { console.error('Failed to get stream URL', e); } } catch (e) { window.DF_LOG.warn('[editor] stream URL failed', e); }
} }
window.Timeline.addClip({ ...asset, streamUrl }, 0, (asset.duration_ms || 10000) / 1000, payload.track, payload.timelineFrames); window.Timeline.addClip({ ...asset, streamUrl }, 0, (asset.duration_ms || 10000) / 1000, payload.track, payload.timelineFrames);
} }
@ -239,13 +246,17 @@ function Editor() {
source_out_frames: c.source_out_frames, source_out_frames: c.source_out_frames,
})); }));
await window.ZAMPP_API.syncSequenceClips(seq.id, clips); await window.ZAMPP_API.syncSequenceClips(seq.id, clips);
if (!mountedRef.current) return;
setIsDirty(false); setIsDirty(false);
isDirtyRef.current = false; isDirtyRef.current = false;
setSaveStatus('Saved'); setSaveStatus('Saved');
setTimeout(() => setSaveStatus(''), 2000); if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
statusTimerRef.current = setTimeout(() => {
if (mountedRef.current) setSaveStatus('');
}, 2000);
} catch (e) { } catch (e) {
setSaveStatus('Save failed'); if (mountedRef.current) setSaveStatus('Save failed');
console.error('Auto-save failed', e); window.DF_LOG.warn('[editor] auto-save failed', e);
} }
} }
@ -292,7 +303,7 @@ function Editor() {
try { try {
const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream'); const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream');
if (r && r.url) { url = r.url; cache[asset.id] = url; } if (r && r.url) { url = r.url; cache[asset.id] = url; }
} catch (e) { console.error('Failed to get stream URL', e); } } catch (e) { window.DF_LOG.warn('[editor] stream URL failed', e); }
} }
if (url) { vid.src = url; vid.load(); } if (url) { vid.src = url; vid.load(); }
} }
@ -322,7 +333,7 @@ function Editor() {
function exportEDL() { function exportEDL() {
if (!currentSeq) return; if (!currentSeq) return;
const name = (currentSeq.name || 'sequence').replace(/[^a-z0-9]/gi, '_') + '.edl'; const name = (currentSeq.name || 'sequence').replace(/[^a-z0-9]/gi, '_') + '.edl';
window.ZAMPP_API.exportSequenceEDL(currentSeq.id, name).catch(e => console.error('EDL export failed', e)); window.ZAMPP_API.exportSequenceEDL(currentSeq.id, name).catch(e => window.DF_LOG.warn('[editor] EDL export failed', e));
} }
function openClipInSource(clip) { function openClipInSource(clip) {
@ -397,15 +408,15 @@ function Editor() {
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: 10, marginTop: 4 }}> <div style={{ display: 'flex', gap: 10, marginTop: 4 }}>
<a href="/downloads/dragonflight-premiere-panel-1.0.1.zxp" download style={{ textDecoration: 'none' }}> <a href={(window.PREMIERE_LATEST || {}).zxp || '#'} download style={{ textDecoration: 'none' }}>
<button className="btn primary">Download ZXP</button> <button className="btn primary">Download ZXP</button>
</a> </a>
<a href="/downloads/dragonflight-premiere-panel-1.0.1-windows-setup.exe" download style={{ textDecoration: 'none' }}> <a href={(window.PREMIERE_LATEST || {}).installer || '#'} download style={{ textDecoration: 'none' }}>
<button className="btn ghost">Windows Installer</button> <button className="btn ghost">Windows Installer</button>
</a> </a>
</div> </div>
<div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: -4 }}> <div style={{ fontSize: 11.5, color: 'var(--text-3)', marginTop: -4 }}>
Dragonflight Premiere Panel v1.0.1 Dragonflight Premiere Panel v{(window.PREMIERE_LATEST || {}).version || '—'}
</div> </div>
</div> </div>
@ -618,7 +629,7 @@ function ClipContextMenu({ clip, x, y, onClose, onOpenSource, onDuplicate, onSpl
function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrames, streamCacheRef }) { function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrames, streamCacheRef }) {
const [pgmPlaying, setPgmPlaying] = React.useState(false); const [pgmPlaying, setPgmPlaying] = React.useState(false);
const [pgmClipIdx, setPgmClipIdx] = React.useState(-1); const [pgmClipIdx, setPgmClipIdx] = React.useState(-1);
const [pgmClips, setPgMclips] = React.useState([]); const [pgmClips, setPgmClips] = React.useState([]);
function getV1Clips() { function getV1Clips() {
if (!currentSeq || !currentSeq.clips) return []; if (!currentSeq || !currentSeq.clips) return [];
@ -632,7 +643,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
} }
const v1 = getV1Clips(); const v1 = getV1Clips();
if (!v1.length) return; if (!v1.length) return;
setPgMclips(v1); setPgmClips(v1);
const idx = v1.findIndex(c => playheadFrames >= c.timeline_in_frames && playheadFrames < c.timeline_out_frames); const idx = v1.findIndex(c => playheadFrames >= c.timeline_in_frames && playheadFrames < c.timeline_out_frames);
setPgmClipIdx(idx >= 0 ? idx : 0); setPgmClipIdx(idx >= 0 ? idx : 0);
setPgmPlaying(true); setPgmPlaying(true);
@ -658,7 +669,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame
try { try {
const r = await window.ZAMPP_API.fetch('/assets/' + clip.asset_id + '/stream'); const r = await window.ZAMPP_API.fetch('/assets/' + clip.asset_id + '/stream');
if (r && r.url) { url = r.url; cache[clip.asset_id] = url; } if (r && r.url) { url = r.url; cache[clip.asset_id] = url; }
} catch (e) { console.error('Stream fetch failed', e); return; } } catch (e) { window.DF_LOG.warn('[editor] stream fetch failed', e); return; }
} }
if (vid.src !== url) { vid.src = url; vid.load(); } if (vid.src !== url) { vid.src = url; vid.load(); }
const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94); const srcInSecs = clip.source_in_frames / (window.TC ? window.TC.FPS : 59.94);
@ -708,7 +719,9 @@ 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) {
const tag = document.activeElement.tagName; // #116 `document.activeElement` is null in some edge cases (iframe focus,
// popovers, devtools-driven focus), and the previous code threw NPE here.
const tag = (document.activeElement && document.activeElement.tagName) || '';
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return; if (['INPUT', 'TEXTAREA', 'SELECT'].includes(tag)) return;
if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onRedo(); return; } if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onRedo(); return; }
if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onUndo(); return; } if ((e.ctrlKey || e.metaKey) && (e.key === 'z' || e.key === 'Z')) { e.preventDefault(); onUndo(); return; }

View file

@ -33,7 +33,7 @@ async function _uploadFile(file, projectId, onProgress) {
fd.append('filename', file.name); fd.append('filename', file.name);
fd.append('projectId', projectId); fd.append('projectId', projectId);
fd.append('contentType', mime); fd.append('contentType', mime);
return _xhrPost('/api/v1/upload/simple', fd, return _xhrPost((window.ZAMPP_API_PREFIX || '/api/v1') + '/upload/simple', fd,
(loaded, total) => onProgress(Math.round((loaded / total) * 100))); (loaded, total) => onProgress(Math.round((loaded / total) * 100)));
} }
@ -53,7 +53,7 @@ async function _uploadFile(file, projectId, onProgress) {
fd.append('uploadId', uploadId); fd.append('uploadId', uploadId);
fd.append('key', key); fd.append('key', key);
fd.append('partNumber', String(i + 1)); fd.append('partNumber', String(i + 1));
const partRes = await _xhrPost('/api/v1/upload/part', fd, const partRes = await _xhrPost((window.ZAMPP_API_PREFIX || '/api/v1') + '/upload/part', fd,
(loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100))); (loaded, total) => onProgress(Math.round(((i + loaded / total) / totalParts) * 100)));
parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag }); parts.push({ PartNumber: i + 1, ETag: partRes.etag || partRes.ETag });
} }
@ -506,7 +506,7 @@ function Recorders({ navigate, onNew }) {
// 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;
console.warn('[recorders] poll error:', err?.message); window.DF_LOG.warn('[recorders] poll error:', err?.message);
}); });
}, []); }, []);
@ -702,7 +702,7 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) {
: <button className="btn subtle sm" onClick={toggle} disabled={pending}> : <button className="btn subtle sm" onClick={toggle} disabled={pending}>
{pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>} {pending ? '…' : <><span className="rec-dot" style={{ background: 'var(--live)' }} />Record</>}
</button>} </button>}
<button className="icon-btn" onClick={handleDelete} title="Delete recorder" style={{ color: 'var(--text-3)' }}> <button className="icon-btn" onClick={handleDelete} title="Delete recorder" aria-label="Delete recorder" style={{ color: 'var(--text-3)' }}>
<Icon name="x" /> <Icon name="x" />
</button> </button>
</div> </div>
@ -1023,7 +1023,7 @@ function _StatusStrip({ schedules, recorders, now, projects }) {
<span key={s.id} className="epg-status-pill"> <span key={s.id} className="epg-status-pill">
{color && <span className="epg-status-pill-bar" style={{ background: color }} />} {color && <span className="epg-status-pill-bar" style={{ background: color }} />}
<span className="epg-status-pill-name">{s.name}</span> <span className="epg-status-pill-name">{s.name}</span>
<span className="epg-status-pill-rec mono">{rec?.name || s.recorder_id.slice(0, 8)}</span> <span className="epg-status-pill-rec mono">{rec?.name || (s.recorder_id ? s.recorder_id.slice(0, 8) : 'unassigned')}</span>
<span className="epg-status-pill-time mono">{elapsed} · ends {endsAt}</span> <span className="epg-status-pill-time mono">{elapsed} · ends {endsAt}</span>
</span> </span>
); );
@ -1037,7 +1037,7 @@ function _StatusStrip({ schedules, recorders, now, projects }) {
<> <>
<span className="epg-status-label muted">Next up</span> <span className="epg-status-label muted">Next up</span>
<span className="epg-status-next">{next.name}</span> <span className="epg-status-next">{next.name}</span>
<span className="epg-status-next-rec mono">{recMap[next.recorder_id]?.name || next.recorder_id.slice(0, 8)}</span> <span className="epg-status-next-rec mono">{recMap[next.recorder_id]?.name || (next.recorder_id ? next.recorder_id.slice(0, 8) : 'unassigned')}</span>
<span className="epg-status-next-time mono">{_fmtCountdown(new Date(next.start_at) - now)} · {_fmtTime(new Date(next.start_at))}</span> <span className="epg-status-next-time mono">{_fmtCountdown(new Date(next.start_at) - now)} · {_fmtTime(new Date(next.start_at))}</span>
</> </>
) : ( ) : (
@ -1445,9 +1445,9 @@ function Schedule({ navigate }) {
<div className="epg-toolbar"> <div className="epg-toolbar">
<div className="epg-date"> <div className="epg-date">
<button className="icon-btn" onClick={() => setDay(_addDays(day, -1))} title="Previous day"><Icon name="chevron" style={{ transform: 'rotate(90deg)' }} /></button> <button className="icon-btn" onClick={() => setDay(_addDays(day, -1))} title="Previous day" aria-label="Previous day"><Icon name="chevron" style={{ transform: 'rotate(90deg)' }} /></button>
<div className="epg-date-label">{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}</div> <div className="epg-date-label">{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}</div>
<button className="icon-btn" onClick={() => setDay(_addDays(day, 1))} title="Next day"><Icon name="chevron" style={{ transform: 'rotate(-90deg)' }} /></button> <button className="icon-btn" onClick={() => setDay(_addDays(day, 1))} title="Next day" aria-label="Next day"><Icon name="chevron" style={{ transform: 'rotate(-90deg)' }} /></button>
<button className="btn ghost sm" onClick={() => setDay(_dayStart(new Date()))} disabled={_sameDay(day, new Date())}>Today</button> <button className="btn ghost sm" onClick={() => setDay(_dayStart(new Date()))} disabled={_sameDay(day, new Date())}>Today</button>
</div> </div>
<div className="spacer" /> <div className="spacer" />
@ -1566,7 +1566,7 @@ function Schedule({ navigate }) {
{s.name} {s.name}
{s.error_message && <div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 3 }}>{s.error_message}</div>} {s.error_message && <div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 3 }}>{s.error_message}</div>}
</div> </div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{s.recorder_name || s.recorder_id.slice(0, 8)}</div> <div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{s.recorder_name || (s.recorder_id ? s.recorder_id.slice(0, 8) : 'unassigned')}</div>
<div className="mono" style={{ fontSize: 11.5 }}>{_fmtWhen(s.start_at)}</div> <div className="mono" style={{ fontSize: 11.5 }}>{_fmtWhen(s.start_at)}</div>
<div className="mono" style={{ fontSize: 11.5 }}>{_durationMin(s.start_at, s.end_at)} min</div> <div className="mono" style={{ fontSize: 11.5 }}>{_durationMin(s.start_at, s.end_at)} min</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{s.recurrence === 'none' ? 'one-shot' : s.recurrence}</div> <div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{s.recurrence === 'none' ? 'one-shot' : s.recurrence}</div>
@ -1651,7 +1651,7 @@ function EditScheduleModal({ schedule, onClose, onSaved }) {
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head"> <div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Edit scheduled recording</div> <div style={{ fontSize: 15, fontWeight: 600 }}>Edit scheduled recording</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="field"> <div className="field">
@ -1762,7 +1762,7 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
<div className="modal-head"> <div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>New scheduled recording</div> <div style={{ fontSize: 15, fontWeight: 600 }}>New scheduled recording</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="field"> <div className="field">

View file

@ -243,7 +243,7 @@ function JobRow({ job, onRetry, onDelete }) {
</button> </button>
)} )}
{(job.status === 'queued' || job.status === 'done' || job.status === 'failed') && ( {(job.status === 'queued' || job.status === 'done' || job.status === 'failed') && (
<button className="icon-btn" title="Remove job from the queue" <button className="icon-btn" aria-label="Remove job from the queue" title="Remove job from the queue"
onClick={() => onDelete(job, 'delete')}><Icon name="x" /></button> onClick={() => onDelete(job, 'delete')}><Icon name="x" /></button>
)} )}
</div> </div>

View file

@ -229,7 +229,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
<div> <div>
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ display: 'flex', alignItems: 'center' }}>
<h4 style={{ flex: 1, margin: 0 }}>Bins</h4> <h4 style={{ flex: 1, margin: 0 }}>Bins</h4>
<button className="icon-btn" onClick={createBin} <button className="icon-btn" aria-label="Create bin" onClick={createBin}
title={openProject ? 'Create bin in this project' : 'Open a project to create a bin'} title={openProject ? 'Create bin in this project' : 'Open a project to create a bin'}
style={{ opacity: openProject ? 1 : 0.5 }}> style={{ opacity: openProject ? 1 : 0.5 }}>
<Icon name="plus" size={11} /> <Icon name="plus" size={11} />
@ -346,7 +346,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
<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" 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>
</div> </div>
); );
})} })}
@ -614,7 +614,7 @@ function RenameAssetModal({ asset, onClose, onSaved }) {
<div className="modal" style={{ width: 420 }} onClick={function(e) { e.stopPropagation(); }}> <div className="modal" style={{ width: 420 }} onClick={function(e) { e.stopPropagation(); }}>
<div className="modal-head"> <div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Rename asset</div> <div style={{ fontSize: 15, fontWeight: 600 }}>Rename asset</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="field"> <div className="field">

View file

@ -18,7 +18,7 @@ function NewProjectModal({ onClose, onCreated }) {
<div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head"> <div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>New project</div> <div style={{ fontSize: 15, fontWeight: 600 }}>New project</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="field"> <div className="field">
@ -143,7 +143,7 @@ function Projects({ onOpenProject, navigate }) {
<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" 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 && (
<div className="row-menu" onClick={e => e.stopPropagation()}> <div className="row-menu" onClick={e => e.stopPropagation()}>
<button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button> <button onClick={() => { setMenuFor(null); onOpenProject(p); }}><Icon name="library" size={11} />Open</button>
@ -186,7 +186,7 @@ function RenameProjectModal({ project, onClose, onSaved }) {
<div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}> <div className="modal" style={{ width: 420 }} onClick={e => e.stopPropagation()}>
<div className="modal-head"> <div className="modal-head">
<div style={{ fontSize: 15, fontWeight: 600 }}>Rename project</div> <div style={{ fontSize: 15, fontWeight: 600 }}>Rename project</div>
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button> <button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div> </div>
<div className="modal-body"> <div className="modal-body">
<div className="field"> <div className="field">

View file

@ -2,7 +2,7 @@
const NAV_TREE = [ const NAV_TREE = [
{ id: "home", label: "Home", icon: "home" }, { id: "home", label: "Home", icon: "home" },
{ id: "dashboard", label: "Dashboard", icon: "library" }, { 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" },
{ {
@ -16,7 +16,7 @@ const NAV_TREE = [
{ id: "monitors", label: "Monitors", icon: "monitor" }, { id: "monitors", label: "Monitors", icon: "monitor" },
], ],
}, },
{ id: "jobs", label: "Jobs", icon: "jobs", badge: { kind: "neutral", text: "3" } }, { id: "jobs", label: "Jobs", icon: "jobs" },
{ id: "editor", label: "Editor", icon: "editor" }, { id: "editor", label: "Editor", icon: "editor" },
]; ];
@ -47,6 +47,7 @@ function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup })
<> <>
<div <div
className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`} className={`nav-item ${isActive && !isGroup ? "active" : ""} ${isGroup ? "has-children" : ""} ${isOpen ? "open" : ""}`}
data-tip={item.label}
onClick={() => { onClick={() => {
if (isGroup) toggleGroup(item.id); if (isGroup) toggleGroup(item.id);
else onSelect(item.id); else onSelect(item.id);
@ -78,8 +79,34 @@ function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup })
); );
} }
function Sidebar({ active, onNavigate, me }) { function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"])); const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
const [jobsBadge, setJobsBadge] = React.useState(null);
// Live jobs count (#130) poll /jobs/count for active jobs and render the
// result as the sidebar badge. Falls back to hidden on error.
React.useEffect(() => {
let cancelled = false;
const tick = () => {
window.ZAMPP_API.fetch('/jobs?status=active&limit=200')
.then(d => {
if (cancelled) return;
const list = Array.isArray(d) ? d : (d?.jobs || d?.items || []);
const n = Array.isArray(list) ? list.length : 0;
setJobsBadge(n > 0 ? { kind: n > 5 ? 'warning' : 'neutral', text: n > 99 ? '99+' : String(n) } : null);
})
.catch(() => setJobsBadge(null));
};
tick();
const id = setInterval(tick, 10000);
return () => { cancelled = true; clearInterval(id); };
}, []);
// Apply the live jobs badge to the Jobs nav item.
const navTree = React.useMemo(
() => NAV_TREE.map(n => n.id === 'jobs' && jobsBadge ? { ...n, badge: jobsBadge } : n),
[jobsBadge]
);
const toggleGroup = (id) => { const toggleGroup = (id) => {
setOpenGroups(prev => { setOpenGroups(prev => {
const next = new Set(prev); const next = new Set(prev);
@ -98,15 +125,29 @@ function Sidebar({ active, onNavigate, me }) {
return ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-header" onClick={() => onNavigate('home')} style={{ cursor: 'pointer' }} title="Home"> <div className="sidebar-header">
<div
className="brand-link"
onClick={() => onNavigate('home')}
style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 0 }}
title="Home"
>
<img className="brand-logo" src="img/dragon-logo.png" alt="Dragonflight" draggable="false" /> <img className="brand-logo" src="img/dragon-logo.png" alt="Dragonflight" draggable="false" />
<div>
<div className="brand-name">Dragonflight</div> <div className="brand-name">Dragonflight</div>
</div>
<div className="brand-sub">v1.2.0</div> <div className="brand-sub">v1.2.0</div>
</div> </div>
<button
className="icon-btn sidebar-toggle"
onClick={onToggle}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-expanded={!collapsed}
>
<Icon name="chevron" size={14} style={{ transform: collapsed ? 'rotate(0deg)' : 'rotate(180deg)', transition: 'transform 120ms' }} />
</button>
</div>
<div className="sidebar-scroll"> <div className="sidebar-scroll">
{NAV_TREE.map(item => ( {navTree.map(item => (
<NavItem <NavItem
key={item.id} key={item.id}
item={item} item={item}
@ -136,7 +177,7 @@ function Sidebar({ active, onNavigate, me }) {
{me?.role || '—'}{me?.synthetic ? ' · auth off' : ''} {me?.role || '—'}{me?.synthetic ? ' · auth off' : ''}
</div> </div>
</div> </div>
<button className="icon-btn" data-tip="Sign out" title="Sign out" <button className="icon-btn" aria-label="Sign out" data-tip="Sign out" title="Sign out"
onClick={() => { onClick={() => {
// Best-effort logout API call may 401 with auth disabled, that's fine. // Best-effort logout API call may 401 with auth disabled, that's fine.
window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' }) window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' })
@ -261,8 +302,9 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
const showDropdown = open && q.trim().length > 0; const showDropdown = open && q.trim().length > 0;
const listboxId = 'global-search-listbox';
return ( return (
<div className="search-wrap"> <div className="search-wrap" role="search">
<div className={`search ${showDropdown ? 'is-open' : ''}`}> <div className={`search ${showDropdown ? 'is-open' : ''}`}>
<Icon name="search" className="search-icon" /> <Icon name="search" className="search-icon" />
<input <input
@ -273,12 +315,21 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
onBlur={() => setTimeout(() => setOpen(false), 120)} onBlur={() => setTimeout(() => setOpen(false), 120)}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder="Search assets, projects, recorders…" placeholder="Search assets, projects, recorders…"
type="search"
role="combobox"
aria-label="Search assets, projects, recorders, jobs, and users"
aria-expanded={showDropdown}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={showDropdown && results[sel] ? `${listboxId}-opt-${sel}` : undefined}
/> />
<span className="kbd">{isMac ? '⌘K' : 'Ctrl K'}</span> <span className="kbd" aria-hidden="true">{isMac ? '⌘K' : 'Ctrl K'}</span>
</div> </div>
{showDropdown && ( {showDropdown && (
<div <div
ref={listRef} ref={listRef}
id={listboxId}
role="listbox"
className="search-results" className="search-results"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
> >
@ -287,6 +338,9 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
) : results.map((r, i) => ( ) : results.map((r, i) => (
<div <div
key={r.kind + '-' + i + '-' + (r.item ? (r.item.id || r.item.name) : (r.target || r.label))} key={r.kind + '-' + i + '-' + (r.item ? (r.item.id || r.item.name) : (r.target || r.label))}
id={`${listboxId}-opt-${i}`}
role="option"
aria-selected={i === sel}
className={`search-result ${i === sel ? 'active' : ''}`} className={`search-result ${i === sel ? 'active' : ''}`}
onClick={() => handleSelect(r)} onClick={() => handleSelect(r)}
onMouseEnter={() => setSel(i)} onMouseEnter={() => setSel(i)}
@ -310,7 +364,7 @@ function GlobalSearch({ onNavigate, onOpenAsset, onOpenProject }) {
); );
} }
function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) { 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);
@ -332,6 +386,16 @@ function Topbar({ crumbs, onNavigate, right, onOpenAsset, onOpenProject }) {
return ( return (
<header className="topbar"> <header className="topbar">
{onToggleSidebar && (
<button
className="icon-btn topbar-menu"
onClick={onToggleSidebar}
aria-label="Toggle sidebar"
title="Toggle sidebar"
>
<Icon name="list" size={16} />
</button>
)}
<div className="crumb"> <div className="crumb">
{crumbs.map((c, i) => ( {crumbs.map((c, i) => (
<React.Fragment key={i}> <React.Fragment key={i}>

View file

@ -1,7 +1,8 @@
/* ========== Asset detail ========== */ /* ========== Asset detail ========== */
.asset-detail { .asset-detail {
display: flex; flex-direction: column; display: flex; flex-direction: column;
height: 100%; flex: 1; /* parent is `.main` (flex col) — fill remaining vertical space */
min-height: 0;
background: var(--bg-0); background: var(--bg-0);
overflow: hidden; overflow: hidden;
} }

View file

@ -65,7 +65,12 @@
.activity-text .target { word-break: break-word; } .activity-text .target { word-break: break-word; }
.asset-card .meta .sub { overflow: hidden; } .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
narrow cards it overlapped the project text. Force the duration column to
never overflow and let the project label ellipsize. */
.asset-card .meta .sub > .duration { flex-shrink: 0; margin-left: auto; }
.asset-card .meta .sub > :not(.duration) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.stat-card .label, .stat-card .value, .stat-card .delta { position: relative; z-index: 1; } .stat-card .label, .stat-card .value, .stat-card .delta { position: relative; z-index: 1; }
.stat-card .spark { z-index: 0; } .stat-card .spark { z-index: 0; }
@ -609,3 +614,70 @@
0%, 100% { opacity: 0.95; r: 4; } 0%, 100% { opacity: 0.95; r: 4; }
50% { opacity: 0.6; r: 5; } 50% { opacity: 0.6; r: 5; }
} }
/* ========== Mobile sidebar (issue #134) ========== */
.topbar-menu { display: none; }
@media (max-width: 768px) {
.topbar-menu { display: grid; place-items: center; }
.app { grid-template-columns: 0 1fr; }
.app[data-sidebar="expanded"] { grid-template-columns: 220px 1fr; }
.app[data-sidebar="expanded"] .sidebar {
position: fixed;
top: 0; left: 0;
height: 100vh;
width: 220px;
z-index: 200;
box-shadow: 0 0 40px rgba(0,0,0,0.6);
}
.app[data-sidebar="collapsed"] .sidebar { display: none; }
.app[data-sidebar="expanded"]::before {
content: "";
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 199;
}
}
/* ========== Sidebar collapse/expand (issue #142) ========== */
.sidebar-header { gap: 8px; }
.sidebar-toggle {
flex-shrink: 0;
margin-left: auto;
width: 28px; height: 28px;
display: grid; place-items: center;
}
.app[data-sidebar="collapsed"] .brand-name,
.app[data-sidebar="collapsed"] .brand-sub,
.app[data-sidebar="collapsed"] .nav-item > span,
.app[data-sidebar="collapsed"] .nav-section-label,
.app[data-sidebar="collapsed"] .nav-badge,
.app[data-sidebar="collapsed"] .nav-item.has-children > .nav-caret,
.app[data-sidebar="collapsed"] .nav-children,
.app[data-sidebar="collapsed"] .user-meta {
display: none !important;
}
.app[data-sidebar="collapsed"] .brand-link { gap: 0; justify-content: center; flex: 0; }
.app[data-sidebar="collapsed"] .sidebar-header { padding: 0 6px; justify-content: center; }
.app[data-sidebar="collapsed"] .sidebar-toggle { margin: 0; }
.app[data-sidebar="collapsed"] .nav-item { justify-content: center; padding: 0; }
.app[data-sidebar="collapsed"] .sidebar-footer { padding: 10px 6px; justify-content: center; }
.app[data-sidebar="collapsed"] .sidebar-footer .icon-btn { display: none; }
.app[data-sidebar="collapsed"] .nav-item { position: relative; }
.app[data-sidebar="collapsed"] .nav-item:hover::after {
content: attr(data-tip);
position: absolute;
left: 100%;
margin-left: 8px;
top: 50%;
transform: translateY(-50%);
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text-1);
font-size: 12px;
padding: 4px 8px;
border-radius: var(--r-sm);
white-space: nowrap;
z-index: 100;
pointer-events: none;
}

View file

@ -429,7 +429,8 @@
/* ========== Editor ========== */ /* ========== Editor ========== */
.editor-shell { .editor-shell {
display: flex; flex-direction: column; display: flex; flex-direction: column;
height: 100%; flex: 1; /* parent is `.main` (flex col) — fill remaining vertical space */
min-height: 0;
background: var(--bg-0); background: var(--bg-0);
} }
.editor-topbar { .editor-topbar {
@ -626,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;
height: 100%; 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;

View file

@ -273,7 +273,8 @@
.library-layout { .library-layout {
display: grid; display: grid;
grid-template-columns: 240px 1fr; grid-template-columns: 240px 1fr;
height: 100%; flex: 1; /* parent is `.main` (flex col) — fill remaining vertical space (#131) */
min-height: 0;
overflow: hidden; overflow: hidden;
} }
.library-rail { .library-rail {

View file

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

View file

@ -9,7 +9,7 @@ function AssetThumb({ asset, size = 'md' }) {
React.useEffect(() => { React.useEffect(() => {
if (!asset.id || thumbUrl || !asset.thumbnail_s3_key) return; if (!asset.id || thumbUrl || !asset.thumbnail_s3_key) return;
let cancelled = false; let cancelled = false;
fetch('/api/v1/assets/' + asset.id + '/thumbnail', { credentials: 'include' }) fetch((window.ZAMPP_API_PREFIX || '/api/v1') + '/assets/' + asset.id + '/thumbnail', { credentials: 'include' })
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(d => { if (!cancelled && d && d.url) { _thumbCache.set(asset.id, d.url); setThumbUrl(d.url); } }) .then(d => { if (!cancelled && d && d.url) { _thumbCache.set(asset.id, d.url); setThumbUrl(d.url); } })
.catch(() => {}); .catch(() => {});
@ -25,10 +25,11 @@ function AssetThumb({ asset, size = 'md' }) {
); );
} }
const altText = asset.name ? `Thumbnail for ${asset.name}` : 'Asset thumbnail';
return ( return (
<div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}> <div className="asset-thumb" style={{ background: 'var(--bg-2)', aspectRatio: aspect, overflow: 'hidden', position: 'relative' }}>
{thumbUrl {thumbUrl
? <img src={thumbUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} /> ? <img src={thumbUrl} alt={altText} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
: <FauxFrame />} : <FauxFrame />}
{asset.status === 'live' && ( {asset.status === 'live' && (
<div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none' }} /> <div style={{ position: 'absolute', inset: 0, border: '2px solid var(--live)', pointerEvents: 'none' }} />

View file

@ -0,0 +1,34 @@
// Pre-compile every public/*.jsx to public/dist/*.js using esbuild.
// Issue #139 — production was shipping React dev builds + in-browser @babel/standalone.
// Now JSX is transformed at Docker build time and the runtime loads minified UMD React.
const { build } = require('esbuild');
const path = require('path');
const fs = require('fs');
const root = path.resolve(__dirname, '..');
const publicDir = path.join(root, 'public');
const outDir = path.join(publicDir, 'dist');
fs.mkdirSync(outDir, { recursive: true });
const entries = fs.readdirSync(publicDir).filter(f => f.endsWith('.jsx'));
(async () => {
for (const f of entries) {
const src = path.join(publicDir, f);
const out = path.join(outDir, f.replace(/\.jsx$/, '.js'));
await build({
entryPoints: [src],
outfile: out,
bundle: false,
loader: { '.jsx': 'jsx' },
jsx: 'transform',
jsxFactory: 'React.createElement',
jsxFragment: 'React.Fragment',
minify: true,
sourcemap: true,
target: ['es2019'],
logLevel: 'info',
});
}
console.log(`[build-jsx] compiled ${entries.length} jsx files to ${outDir}`);
})().catch(err => { console.error(err); process.exit(1); });