diff --git a/docker-compose.worker.yml b/docker-compose.worker.yml index 26d5508..688866d 100644 --- a/docker-compose.worker.yml +++ b/docker-compose.worker.yml @@ -16,7 +16,9 @@ # 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 # 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_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) # # Profiles: @@ -48,9 +50,10 @@ services: NODE_IP: ${NODE_IP:-} AGENT_PORT: ${AGENT_PORT:-7436} HEARTBEAT_MS: ${HEARTBEAT_MS:-30000} - GPU_COUNT: ${GPU_COUNT:--1} - BMD_COUNT: ${BMD_COUNT:--1} - BMD_MODEL: ${BMD_MODEL:-} + GPU_COUNT: ${GPU_COUNT:--1} + BMD_COUNT: ${BMD_COUNT:--1} + BMD_MODEL: ${BMD_MODEL:-} + BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv} LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live} volumes: - /var/run/docker.sock:/var/run/docker.sock diff --git a/services/mam-api/src/db/migrations/019-node-token-binding.sql b/services/mam-api/src/db/migrations/019-node-token-binding.sql new file mode 100644 index 0000000..6b19fd8 --- /dev/null +++ b/services/mam-api/src/db/migrations/019-node-token-binding.sql @@ -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; diff --git a/services/mam-api/src/db/migrations/020-ampp-sync-retry.sql b/services/mam-api/src/db/migrations/020-ampp-sync-retry.sql new file mode 100644 index 0000000..10b7fe0 --- /dev/null +++ b/services/mam-api/src/db/migrations/020-ampp-sync-retry.sql @@ -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'); diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 837c3ef..cfdeaa6 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -32,7 +32,7 @@ import metricsRouter from './routes/metrics.js'; import commentsRouter from './routes/comments.js'; import importsRouter from './routes/imports.js'; import storageRouter from './routes/storage.js'; -import { startSchedulerLoop } from './scheduler.js'; +import { startSchedulerLoop, stopSchedulerLoop } from './scheduler.js'; import { startCleanupLoop } from './tasks/cleanupTempSegments.js'; const app = express(); @@ -99,17 +99,54 @@ import { dirname, join } from 'node:path'; const __dirnameMig = dirname(fileURLToPath(import.meta.url)); 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'); let files = []; 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) { + if (!force && applied.has(f)) continue; const sql = readFileSync(join(dir, f), 'utf8'); + const client = await pool.connect(); 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); } 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(); @@ -192,7 +229,7 @@ async function selfHeartbeat() { setInterval(selfHeartbeat, 30_000); selfHeartbeat(); -app.listen(PORT, () => { +const server = app.listen(PORT, () => { 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(`Authentication: ${authMode}`); @@ -203,3 +240,44 @@ app.listen(PORT, () => { // Boot the temp-segment cleanup loop (runs hourly). 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); +}); diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js index 0044bb1..21c06da 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -32,7 +32,7 @@ export const requireAuth = async (req, res, next) => { const hash = crypto.createHash('sha256').update(raw).digest('hex'); try { 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 JOIN users u ON u.id = t.user_id WHERE t.token_hash = $1 @@ -41,6 +41,7 @@ export const requireAuth = async (req, res, next) => { ); if (rows.length > 0) { req.user = rows[0]; + req.tokenBoundHostname = rows[0].bound_hostname || null; // Fire-and-forget last_used_at update pool.query( 'UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1', diff --git a/services/mam-api/src/middleware/errors.js b/services/mam-api/src/middleware/errors.js index 994e561..c57ae7f 100644 --- a/services/mam-api/src/middleware/errors.js +++ b/services/mam-api/src/middleware/errors.js @@ -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) => { - 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 message = err.message || 'Internal Server Error'; - res.status(status).json({ - error: message, + // 5xx — never let a raw Error.message escape; clients get a stable shape. + 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, }); }; diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 5ead4ad..6532a8f 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -5,12 +5,14 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import pool from '../db/pool.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 { validateUuid } from '../middleware/errors.js'; const router = express.Router(); router.use(requireAuth); +router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // BullMQ queue connection (mirrors worker/src/index.js) const parseRedisUrl = (url) => { @@ -43,11 +45,17 @@ router.get('/', async (req, res, next) => { status, search, media_type, - limit = 50, - offset = 0, + limit: rawLimit = 50, + offset: rawOffset = 0, include_archived, } = 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 = ` SELECT a.*, 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 key = a.proxy_s3_key || (origIsVideo ? a.original_s3_key : null); 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; - 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; try { s3Res = await s3Client.send(new GetObjectCommand(params)); } catch (err) { - // S3 returns InvalidRange (416) when the requested range exceeds the file. - // Forward as a proper 416 with the actual file size so the browser can - // adjust instead of erroring out (which would freeze the player). + // Defensive: even with clamping, some backends still throw InvalidRange. 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, { - 'Content-Type': 'text/plain', - 'Content-Range': `bytes */${totalSize}`, - 'Accept-Ranges': 'bytes', - }); - return res.end('Requested range not satisfiable'); - } catch (_) { - return res.status(416).end('Requested range not satisfiable'); - } + res.writeHead(416, { + 'Content-Type': 'text/plain', + 'Content-Length': '0', + 'Content-Range': `bytes */${totalSize}`, + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'no-store', + }); + return res.end(); } throw err; } - const status = rangeHeader ? 206 : 200; + const status = clampedRange ? 206 : 200; const headers = { 'Content-Type': 'video/mp4', 'Accept-Ranges': 'bytes', diff --git a/services/mam-api/src/routes/bins.js b/services/mam-api/src/routes/bins.js index 8344bce..c7dbfd4 100644 --- a/services/mam-api/src/routes/bins.js +++ b/services/mam-api/src/routes/bins.js @@ -1,11 +1,13 @@ import express from 'express'; import pool from '../db/pool.js'; import { requireAuth } from '../middleware/auth.js'; +import { validateUuid } from '../middleware/errors.js'; import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); 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 // every bin across every project so the Library / asset-context-menu can diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js index 5756002..112449a 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -110,6 +110,25 @@ router.post('/heartbeat', async (req, res, next) => { 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 r = await pool.query( diff --git a/services/mam-api/src/routes/jobs.js b/services/mam-api/src/routes/jobs.js index 8f28a0e..58d2cda 100644 --- a/services/mam-api/src/routes/jobs.js +++ b/services/mam-api/src/routes/jobs.js @@ -1,10 +1,12 @@ import express from 'express'; import pool from '../db/pool.js'; import { requireAuth } from '../middleware/auth.js'; +import { validateUuid } from '../middleware/errors.js'; import { Queue } from 'bullmq'; const router = express.Router(); router.use(requireAuth); +router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // ── Redis connection ────────────────────────────────────────────────────────── const parseRedisUrl = (url) => { diff --git a/services/mam-api/src/routes/projects.js b/services/mam-api/src/routes/projects.js index d4f4ea7..18e3089 100644 --- a/services/mam-api/src/routes/projects.js +++ b/services/mam-api/src/routes/projects.js @@ -1,11 +1,13 @@ import express from 'express'; import pool from '../db/pool.js'; import { requireAuth } from '../middleware/auth.js'; +import { validateUuid } from '../middleware/errors.js'; import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); router.use(requireAuth); +router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // Helper function to slugify const slugify = (str) => { diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 1a575fc..7f66445 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -5,11 +5,13 @@ import dgram from 'dgram'; import pool from '../db/pool.js'; import { getS3Bucket } from '../s3/client.js'; import { requireAuth } from '../middleware/auth.js'; +import { validateUuid } from '../middleware/errors.js'; import { v4 as uuidv4 } from 'uuid'; const router = express.Router(); 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. // Device index 0 → 7438, index 1 → 7439, etc. @@ -141,29 +143,42 @@ function pickRecorderFields(body) { } // 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) => { try { - const result = await pool.query( - 'SELECT * FROM recorders ORDER BY created_at DESC' - ); - const rows = await Promise.all(result.rows.map(async (r) => { - if (r.status === 'recording' && r.container_id) { - try { - const insp = await dockerApi('GET', `/containers/${r.container_id}/json`); - if (insp.status === 200 && insp.data && insp.data.State) { - r.started_at = insp.data.State.StartedAt; - } - } 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; + const result = await pool.query(` + SELECT r.*, la.live_asset_id + FROM recorders r + LEFT JOIN LATERAL ( + 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 { + const insp = await dockerApi('GET', `/containers/${r.container_id}/json`); + if (insp.status === 200 && insp.data && insp.data.State) { + r.started_at = insp.data.State.StartedAt; + } + } catch (_) { /* leave started_at undefined */ } })); + res.json(rows); } catch (err) { next(err); @@ -413,8 +428,14 @@ router.post('/:id/start', async (req, res, next) => { signal: AbortSignal.timeout(15000), }); 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(() => ({})); - 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(); containerId = sidecarData.containerId; @@ -447,18 +468,23 @@ router.post('/:id/start', async (req, res, next) => { const createRes = await dockerApi('POST', '/containers/create', containerConfig); 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({ error: 'Failed to create container', - details: createRes.data, + details: (createRes.data && createRes.data.message) || 'see server logs', }); } containerId = createRes.data.Id; const startRes = await dockerApi('POST', `/containers/${containerId}/start`); if (startRes.status !== 204) { + console.error('[recorders] container start failed:', JSON.stringify(startRes.data)); return res.status(500).json({ 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. // Tries the capture service first; falls back to basic TCP/UDP connectivity // check when capture is not running. router.post('/probe', async (req, res) => { 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 { 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 } - if (!url) { + if (!parsed) { return res.json({ reachable: false, 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 proto = (parsed.protocol || '').replace(':', '').toLowerCase(); const isUdp = proto === 'srt' || source_type === 'srt'; 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)); return res.json({ diff --git a/services/mam-api/src/routes/schedules.js b/services/mam-api/src/routes/schedules.js index 5ac4a04..94b42e8 100644 --- a/services/mam-api/src/routes/schedules.js +++ b/services/mam-api/src/routes/schedules.js @@ -6,9 +6,11 @@ import express from 'express'; import pool from '../db/pool.js'; import { requireAuth } from '../middleware/auth.js'; +import { validateUuid } from '../middleware/errors.js'; const router = express.Router(); router.use(requireAuth); +router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']); const TERMINAL = new Set(['completed', 'failed', 'cancelled']); diff --git a/services/mam-api/src/routes/sdk.js b/services/mam-api/src/routes/sdk.js index c80a14d..57769ec 100644 --- a/services/mam-api/src/routes/sdk.js +++ b/services/mam-api/src/routes/sdk.js @@ -88,6 +88,16 @@ router.get('/', async (req, res, next) => { } 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) => { try { 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")' }); const dir = path.join(SDK_ROOT, vendor); + const dirReal = path.resolve(dir); + // Wipe any previous staging so partial uploads don't leave stale headers. await fs.rm(dir, { recursive: true, force: true }); await fs.mkdir(dir, { recursive: true }); - const originalName = req.file.originalname || 'sdk.bin'; - const archivePath = path.join(dir, originalName); + // Issue #118 — never trust the client-supplied filename. Sanitise to a + // 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); // Pick an extractor based on extension. tar handles .tar / .tar.gz / .tgz; // unzip handles .zip. The capture container will be built separately on // the host with a DeckLink/AJA/Deltacast card; this route just stages. - const lower = originalName.toLowerCase(); - let cmd, args; + const lower = safeName.toLowerCase(); + let cmd, args, listCmd, listArgs; 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')) { - cmd = 'tar'; args = ['-xf', archivePath, '-C', dir]; + // --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]; + listCmd = 'tar'; listArgs = ['-tf', archivePath]; } else { 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) => { const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); 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 // keep the unpacked headers/.so files on disk. await fs.unlink(archivePath).catch(() => {}); diff --git a/services/mam-api/src/routes/sequences.js b/services/mam-api/src/routes/sequences.js index 585c7c6..01d2825 100644 --- a/services/mam-api/src/routes/sequences.js +++ b/services/mam-api/src/routes/sequences.js @@ -3,6 +3,7 @@ import express from 'express'; import pool from '../db/pool.js'; import { getSignedUrlForObject } from '../s3/client.js'; import { requireAuth } from '../middleware/auth.js'; +import { validateUuid } from '../middleware/errors.js'; import { Queue } from 'bullmq'; const parseRedisUrl = (url) => { @@ -20,6 +21,7 @@ const conformQueue = new Queue('conform', { const router = express.Router(); router.use(requireAuth); +router.param('id', (req, res, next) => validateUuid('id')(req, res, next)); // ── Row mapper ──────────────────────────────────────────────────────────────── // node-postgres returns NUMERIC columns as strings. Coerce frame_rate to a diff --git a/services/mam-api/src/routes/tokens.js b/services/mam-api/src/routes/tokens.js index 75fe371..3f031b7 100644 --- a/services/mam-api/src/routes/tokens.js +++ b/services/mam-api/src/routes/tokens.js @@ -20,7 +20,7 @@ const userId = req => req.user?.id || req.session?.userId; router.get('/', async (req, res, next) => { try { 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 WHERE user_id = $1 ORDER BY created_at DESC`, @@ -33,7 +33,7 @@ router.get('/', async (req, res, next) => { // ── Create ──────────────────────────────────────────────────── router.post('/', async (req, res, next) => { 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' }); // 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) : 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( - `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, name, token_prefix, last_used_at, expires_at, created_at`, - [userId(req), name.trim(), hash, prefix, expiresAt] + `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at, bound_hostname) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, name, token_prefix, last_used_at, expires_at, created_at, bound_hostname`, + [userId(req), name.trim(), hash, prefix, expiresAt, bound] ); // Return raw token ONCE — it is never stored in plaintext diff --git a/services/mam-api/src/routes/upload.js b/services/mam-api/src/routes/upload.js index 3b92c84..fcb89ef 100644 --- a/services/mam-api/src/routes/upload.js +++ b/services/mam-api/src/routes/upload.js @@ -1,5 +1,8 @@ import express from 'express'; import multer from 'multer'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { Queue } from 'bullmq'; import { v4 as uuidv4 } from 'uuid'; import pool from '../db/pool.js'; @@ -14,9 +17,23 @@ import { getAmppConfig, ensureFolderPath } from '../ampp/client.js'; const router = express.Router(); -const memoryStorage = multer.memoryStorage(); -// 500 MB file size cap on multipart parts to prevent OOM (#74) -const upload = multer({ storage: memoryStorage, limits: { fileSize: 500 * 1024 * 1024 } }); +// Issue #120 — was multer.memoryStorage(): a 500 MB part stayed pinned in +// RAM per concurrent upload, OOM'ing the API under load. Disk storage in +// /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 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. - * Never throws — failures are logged but never surface to the caller. + * Issue #77 — mirror asset's project/bin path into AMPP folder hierarchy + * 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 { 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( 'SELECT name FROM projects WHERE id = $1', @@ -71,16 +99,34 @@ async function syncToAmpp(assetId, projectId, binId) { } const folderId = await ensureFolderPath(config, segments); - if (!folderId) return; + if (!folderId) throw new Error('ensureFolderPath returned no id'); 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] ); console.log(`[AMPP] asset ${assetId} → folder ${folderId} (${segments.join(' / ')})`); } 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 router.post('/part', upload.single('file'), async (req, res, next) => { + const tmpPath = req.file && req.file.path; try { const { uploadId, key, partNumber } = req.body; if (!uploadId || !key || !partNumber || !req.file) { + unlinkPart(tmpPath); return res.status(400).json({ 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( new UploadPartCommand({ Bucket: getS3Bucket(), Key: key, PartNumber: parseInt(partNumber, 10), 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) { 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) router.post('/simple', upload.single('file'), async (req, res, next) => { + const tmpPath = req.file && req.file.path; try { const { filename, projectId, binId, tags, contentType } = req.body; if (!filename || !projectId || !req.file) { + unlinkPart(tmpPath); return res.status(400).json({ 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( `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); } catch (err) { next(err); + } finally { + unlinkPart(tmpPath); } }); diff --git a/services/mam-api/src/scheduler.js b/services/mam-api/src/scheduler.js index 721a28b..8b6b305 100644 --- a/services/mam-api/src/scheduler.js +++ b/services/mam-api/src/scheduler.js @@ -8,6 +8,7 @@ // forward by 1 day / 7 days into a new 'pending' row. 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 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(() => ({})); } +// 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() { if (_tickRunning) return; _tickRunning = true; + const client = await pool.connect(); + let haveLock = false; try { - // 1) Start any pending schedules whose window has opened - const dueStart = await pool.query( - `SELECT * FROM recorder_schedules - WHERE status = 'pending' AND start_at <= NOW() AND end_at > NOW() - ORDER BY start_at ASC` + haveLock = await tryAcquireSchedulerLock(client); + if (!haveLock) { + // 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() + ORDER BY start_at ASC + FOR UPDATE SKIP LOCKED + ) + RETURNING *` ); for (const s of dueStart.rows) { try { const result = await callSelf(`/api/v1/recorders/${s.recorder_id}/start`); - await pool.query( + await client.query( `UPDATE recorder_schedules SET status = 'running', last_asset_id = NULL, updated_at = NOW() 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 || '?'})`); } catch (err) { - await pool.query( + await client.query( `UPDATE recorder_schedules SET status = 'failed', error_message = $2, updated_at = NOW() WHERE id = $1`, @@ -60,11 +92,17 @@ async function tick() { } } - // 2) Stop any running schedules whose window has closed - const dueStop = await pool.query( - `SELECT * FROM recorder_schedules - WHERE status = 'running' AND end_at <= NOW() - ORDER BY end_at ASC` + // 2) Atomically claim running schedules whose window has closed. + const dueStop = await client.query( + `UPDATE recorder_schedules + SET status = 'stopping', updated_at = NOW() + WHERE id IN ( + SELECT id FROM recorder_schedules + WHERE status = 'running' AND end_at <= NOW() + ORDER BY end_at ASC + FOR UPDATE SKIP LOCKED + ) + RETURNING *` ); for (const s of dueStop.rows) { try { @@ -75,10 +113,10 @@ async function tick() { [s.id] ); console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`); - await enqueueNextOccurrence(s); + await enqueueNextOccurrence(s, client); } catch (err) { // Stop failed — flag as failed but don't keep trying forever. - await pool.query( + await client.query( `UPDATE recorder_schedules SET status = 'failed', error_message = $2, updated_at = NOW() WHERE id = $1`, @@ -89,7 +127,7 @@ async function tick() { } // 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 JOIN recorders r ON r.id = s.recorder_id 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, // 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 staleResult = await pool.query( + const staleResult = await client.query( `UPDATE assets SET status = 'error', updated_at = NOW() @@ -122,21 +160,39 @@ async function tick() { 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) { console.error('[scheduler] tick error:', err); } finally { + if (haveLock) await releaseSchedulerLock(client); + client.release(); _tickRunning = false; } } -async function enqueueNextOccurrence(schedule) { +async function enqueueNextOccurrence(schedule, client) { if (schedule.recurrence === 'none') return; const days = schedule.recurrence === 'weekly' ? 7 : 1; const start = new Date(schedule.start_at); const end = new Date(schedule.end_at); start.setUTCDate(start.getUTCDate() + days); end.setUTCDate(end.getUTCDate() + days); - await pool.query( + const q = client || pool; + await q.query( `INSERT INTO recorder_schedules (name, recorder_id, start_at, end_at, recurrence, status) VALUES ($1, $2, $3, $4, $5, 'pending')`, diff --git a/services/node-agent/index.js b/services/node-agent/index.js index 9f9ac41..e8bf31d 100644 --- a/services/node-agent/index.js +++ b/services/node-agent/index.js @@ -266,35 +266,45 @@ async function probeGpusViaSmi() { function detectHardware() { 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); if (gpuOverride >= 0) { 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 if (_gpuCache !== null && _gpuCache.length > 0) { + capabilities.gpus = _gpuCache; } else { - // Use nvidia-smi cache if populated, otherwise fall back to /dev file scan - if (_gpuCache !== null && _gpuCache.length > 0) { - capabilities.gpus = _gpuCache; - } else { - for (let i = 0; i < 16; i++) { - try { - fs.accessSync(`/dev/nvidia${i}`, fs.constants.F_OK); - capabilities.gpus.push({ device: `/dev/nvidia${i}`, type: 'nvidia', index: i }); - } catch (_) { break; } - } + for (let i = 0; i < 16; i++) { + try { + fs.accessSync(`/dev/nvidia${i}`, fs.constants.F_OK); + capabilities.gpus.push({ device: `/dev/nvidia${i}`, type: 'nvidia', index: i }); + } 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); - if (bmdOverride >= 0) { - for (let i = 0; i < bmdOverride; i++) { - capabilities.blackmagic.push({ device: `/dev/blackmagic/dv${i}`, index: i }); + try { + const bmdEntries = fs.readdirSync('/dev/blackmagic'); + // Filesystem wins — devices speak for themselves. + capabilities.blackmagic = bmdEntries.map((d, i) => ({ device: `/dev/blackmagic/${d}`, index: i })); + } 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 }); + } } - } else { - try { - const bmdEntries = fs.readdirSync('/dev/blackmagic'); - capabilities.blackmagic = bmdEntries.map((d, i) => ({ device: `/dev/blackmagic/${d}`, index: i })); - } catch (_) {} } // Best-effort model name from BMD_MODEL env (set manually) — used by the UI diff --git a/services/web-ui/Dockerfile b/services/web-ui/Dockerfile index a3f04c1..2c96b1e 100644 --- a/services/web-ui/Dockerfile +++ b/services/web-ui/Dockerfile @@ -1,21 +1,25 @@ -# Stage 1: build CSS bundle -FROM node:20-alpine AS css-build +# Stage 1: build CSS bundle + precompile JSX (issue #139) +FROM node:20-alpine AS build WORKDIR /build # Copy only the files needed to install deps (better cache layering) COPY package.json package-lock.json* ./ 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 src/ ./src/ 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 +# Precompile every .jsx → public/dist/*.js (no in-browser Babel at runtime) +RUN node scripts/build-jsx.js + # Stage 2: runtime 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 EXPOSE 80 diff --git a/services/web-ui/package.json b/services/web-ui/package.json index 4d7cd31..2f57d36 100644 --- a/services/web-ui/package.json +++ b/services/web-ui/package.json @@ -4,7 +4,8 @@ "private": true, "description": "Build-time-only deps for the Wild Dragon web-ui Tailwind/flyon-ui pipeline. Not shipped at runtime.", "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": { "tailwindcss": "^3.4.0", @@ -13,6 +14,7 @@ "flyonui": "^1.0.0", "postcss-import": "^16.0.0", "postcss-cli": "^11.0.0", - "cssnano": "^7.0.0" + "cssnano": "^7.0.0", + "esbuild": "^0.24.0" } } diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx index e8af183..06e9121 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -9,6 +9,21 @@ function App() { const [showNewRecorder, setShowNewRecorder] = React.useState(false); const [dataReady, setDataReady] = React.useState(false); 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(() => { document.documentElement.style.setProperty('--accent', ACCENT); @@ -104,8 +119,8 @@ function App() { const hideTopbar = !openAsset && route === 'home'; return ( -
- +
+
{!openAsset && !hideTopbar && ( )} {content} diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index 2568ff6..03c38e2 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -1,6 +1,46 @@ // data.jsx — API client; populates window.ZAMPP_DATA from real endpoints 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 = { PROJECTS: [], diff --git a/services/web-ui/public/index.html b/services/web-ui/public/index.html index 0b2b98b..5c8927e 100644 --- a/services/web-ui/public/index.html +++ b/services/web-ui/public/index.html @@ -17,27 +17,26 @@
- - - + + - - - - - - - - - - + + + + + + + + + + - - - - + + + + diff --git a/services/web-ui/public/login.html b/services/web-ui/public/login.html index ea93071..42aaa9a 100644 --- a/services/web-ui/public/login.html +++ b/services/web-ui/public/login.html @@ -238,7 +238,7 @@
- +
diff --git a/services/web-ui/public/modal-new-recorder.jsx b/services/web-ui/public/modal-new-recorder.jsx index f9e243b..6168d7d 100644 --- a/services/web-ui/public/modal-new-recorder.jsx +++ b/services/web-ui/public/modal-new-recorder.jsx @@ -119,7 +119,7 @@ function NewRecorderModal({ open, onClose }) {
New recorder
Configure source, codec, and destination
- +
diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index ac67747..44ff897 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -65,7 +65,7 @@ function InviteUserModal({ onCreated, onClose }) {
e.stopPropagation()}>
Invite user
- +
@@ -83,6 +83,7 @@ function InviteUserModal({ onCreated, onClose }) {
setForm(p => ({...p, password: e.target.value}))} onKeyDown={onKey} placeholder="Temporary password" />
@@ -232,7 +233,7 @@ function Users() { {u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
- {menuFor === u.id && ( @@ -299,7 +300,7 @@ function EditUserModal({ user, onClose, onSaved }) {
e.stopPropagation()}>
Rename user
- +
@@ -328,13 +329,25 @@ function PasswordResetModal({ user, onClose, onSaved }) { const [err, setErr] = React.useState(null); 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 submit = () => { if (!valid) return; setSaving(true); setErr(null); window.ZAMPP_API.fetch('/users/' + user.id, { method: 'PATCH', body: JSON.stringify({ password: pw }) }) - .then(() => { setSaving(false); setDone(true); setTimeout(onSaved, 1200); }) - .catch(e => { setSaving(false); setErr(e.message); }); + .then(() => { + 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 ( @@ -342,7 +355,7 @@ function PasswordResetModal({ user, onClose, onSaved }) {
e.stopPropagation()}>
Reset password · @{user.username}
- +
{done ? ( @@ -357,7 +370,7 @@ function PasswordResetModal({ user, onClose, onSaved }) { value={pw} onChange={e => setPw(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && valid) submit(); }} style={{ paddingRight: 36 }} /> - @@ -496,7 +509,7 @@ function GroupsPanel({ groups, users, onChange }) { {groupMembers.map(m => ( @{m.username} - @@ -742,7 +755,7 @@ function CostCalculator({ onClose }) {
Token Cost Calculator
What it would cost on AMPP-style pricing
- +
@@ -783,6 +796,20 @@ function CalcSlider({ label, value, onChange, min, max, step = 1, unit }) { function Containers() { 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() { setContainers(null); @@ -795,17 +822,26 @@ function Containers() { const running = (containers || []).filter(c => c.state === 'running').length; - const [restartFlash, setRestartFlash] = React.useState(null); - const [logsModal, setLogsModal] = React.useState(null); + const restartFlash = restartFlashState; + const logsModal = logsModalState; + const setLogsModal = setLogsModalState; const showLogs = (c) => setLogsModal(c); const restartContainer = (c) => { 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' }) - .then(() => { setRestartFlash({ name: c.name, status: 'ok' }); load(); setTimeout(() => setRestartFlash(null), 3000); }) - .catch(e => { setRestartFlash({ name: c.name, status: 'fail', error: e.message }); setTimeout(() => setRestartFlash(null), 5000); }); + .then(() => { + 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 ( @@ -844,7 +880,7 @@ function Containers() {
e.stopPropagation()}>
Logs · {logsModal.name}
- +
@@ -1336,7 +1372,7 @@ function Cluster() {
e.stopPropagation()}>
{adviceModal.title}
- +
{(adviceModal.lines || []).map((l, i) => ( @@ -1588,22 +1624,24 @@ function S3SettingsCard() { return ( connected : not configured}> - {loading ?
Loading…
: (<> - - setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" /> - -
- setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /> - setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /> -
- setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" /> - setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} /> - -
- - -
- )} + {loading ?
Loading…
: ( +
{ e.preventDefault(); save(); }} autoComplete="off"> + + setS3(p => ({...p, s3_endpoint: e.target.value}))} placeholder="https://s3.example.com" /> + +
+ setS3(p => ({...p, s3_region: e.target.value}))} placeholder="us-east-1" /> + setS3(p => ({...p, s3_bucket: e.target.value}))} placeholder="my-bucket" /> +
+ setS3(p => ({...p, s3_access_key: e.target.value}))} placeholder="Access key ID" autoComplete="off" /> + setS3(p => ({...p, s3_secret_key: e.target.value}))} placeholder={secretExists ? '(saved — type to replace)' : 'Secret key'} autoComplete="new-password" /> + +
+ + +
+ + )}
); } @@ -1631,6 +1669,7 @@ function GpuSettingsCard() { return ( GPU mode : CPU mode}> +
{ e.preventDefault(); save(); }} autoComplete="off">
These settings drive the proxy worker for every ingested asset (SDI, SRT, RTMP, upload). GPU mode uses NVENC / VAAPI when hardware is available; CPU mode uses libx264.
@@ -1693,8 +1732,9 @@ function GpuSettingsCard() {
- +
+
); } @@ -1724,6 +1764,7 @@ function GrowingSettingsCard() { return ( enabled : disabled}> +
{ e.preventDefault(); save(); }} autoComplete="off">
- +
+
); } @@ -1798,23 +1840,9 @@ const SDK_VENDORS = [ }, ]; -// Premiere panel releases embedded in the deployment -const 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, - }, -]; +// Premiere panel releases — single source of truth lives on `window.PREMIERE_RELEASES` +// (see data.jsx). Local alias for readability. +const PREMIERE_RELEASES = window.PREMIERE_RELEASES; function SdkSettingsCard() { 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. await new Promise((resolve) => { 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.upload.onprogress = (e) => { if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)); @@ -1992,17 +2020,19 @@ function AmppSettingsCard() { return ( connected : not configured}> - - setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" /> - - - setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} /> - - -
- - -
+
{ e.preventDefault(); save(); }} autoComplete="off"> + + setCfg(p => ({...p, ampp_base_url: e.target.value}))} placeholder="https://my-org.gvampp.tv" /> + + + setCfg(p => ({...p, ampp_token: e.target.value}))} placeholder={tokenExists ? '(saved — type to replace)' : 'AMPP API token'} autoComplete="new-password" /> + + +
+ + +
+
); } diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx index 2d1efd2..6e594af 100644 --- a/services/web-ui/public/screens-asset.jsx +++ b/services/web-ui/public/screens-asset.jsx @@ -159,8 +159,33 @@ function AssetDetail({ asset, onClose }) { return () => clearInterval(i); }, [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 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); if (videoRef.current) videoRef.current.currentTime = clamped / 1000; }; @@ -326,7 +351,7 @@ function AssetDetail({ asset, onClose }) { return (
- +
{asset.name} @@ -341,7 +366,7 @@ function AssetDetail({ asset, onClose }) { {downloading ? 'Preparing…' : 'Download'}
- {menuOpen && ( @@ -479,7 +504,7 @@ function AssetDetail({ asset, onClose }) { {totalMs > 0 && (
- {msToTimecode(currentMs)} @@ -491,12 +516,14 @@ function AssetDetail({ asset, onClose }) { diff --git a/services/web-ui/public/screens-editor.jsx b/services/web-ui/public/screens-editor.jsx index dcc09fd..fd62c89 100644 --- a/services/web-ui/public/screens-editor.jsx +++ b/services/web-ui/public/screens-editor.jsx @@ -32,7 +32,14 @@ function Editor() { const pgmVideoRef = React.useRef(null); const tlRef = React.useRef(null); const saveTimerRef = React.useRef(null); + const statusTimerRef = React.useRef(null); 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); // Refs so Timeline callbacks always read current values without stale closure issues @@ -105,7 +112,7 @@ function Editor() { const list = Array.isArray(r) ? r : []; setSequences(list); 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) { @@ -125,7 +132,7 @@ function Editor() { isDirtyRef.current = false; setSaveStatus(''); 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() { @@ -137,7 +144,7 @@ function Editor() { setNewSeqName(''); setShowNewSeq(false); 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() { @@ -146,7 +153,7 @@ function Editor() { await window.ZAMPP_API.updateSequence(currentSeq.id, { name: renameVal.trim() }); setSequences(prev => prev.map(s => s.id === currentSeq.id ? { ...s, name: renameVal.trim() } : s)); 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); } @@ -162,7 +169,7 @@ function Editor() { setHistory([[]]); setHistoryIdx(0); 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) { @@ -211,7 +218,7 @@ function Editor() { try { const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream'); 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); } @@ -239,13 +246,17 @@ function Editor() { source_out_frames: c.source_out_frames, })); await window.ZAMPP_API.syncSequenceClips(seq.id, clips); + if (!mountedRef.current) return; setIsDirty(false); isDirtyRef.current = false; setSaveStatus('Saved'); - setTimeout(() => setSaveStatus(''), 2000); + if (statusTimerRef.current) clearTimeout(statusTimerRef.current); + statusTimerRef.current = setTimeout(() => { + if (mountedRef.current) setSaveStatus(''); + }, 2000); } catch (e) { - setSaveStatus('Save failed'); - console.error('Auto-save failed', e); + if (mountedRef.current) setSaveStatus('Save failed'); + window.DF_LOG.warn('[editor] auto-save failed', e); } } @@ -292,7 +303,7 @@ function Editor() { try { const r = await window.ZAMPP_API.fetch('/assets/' + asset.id + '/stream'); 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(); } } @@ -322,7 +333,7 @@ function Editor() { function exportEDL() { if (!currentSeq) return; 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) { @@ -397,15 +408,15 @@ function Editor() {
- Dragonflight Premiere Panel v1.0.1 + Dragonflight Premiere Panel v{(window.PREMIERE_LATEST || {}).version || '—'}
@@ -618,7 +629,7 @@ function ClipContextMenu({ clip, x, y, onClose, onOpenSource, onDuplicate, onSpl function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrames, streamCacheRef }) { const [pgmPlaying, setPgmPlaying] = React.useState(false); const [pgmClipIdx, setPgmClipIdx] = React.useState(-1); - const [pgmClips, setPgMclips] = React.useState([]); + const [pgmClips, setPgmClips] = React.useState([]); function getV1Clips() { if (!currentSeq || !currentSeq.clips) return []; @@ -632,7 +643,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame } const v1 = getV1Clips(); if (!v1.length) return; - setPgMclips(v1); + setPgmClips(v1); const idx = v1.findIndex(c => playheadFrames >= c.timeline_in_frames && playheadFrames < c.timeline_out_frames); setPgmClipIdx(idx >= 0 ? idx : 0); setPgmPlaying(true); @@ -658,7 +669,7 @@ function ProgramMonitor({ videoRef, currentSeq, playheadFrames, setPlayheadFrame try { const r = await window.ZAMPP_API.fetch('/assets/' + clip.asset_id + '/stream'); 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(); } 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 }) { React.useEffect(() => { 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 ((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; } diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index a0e8af9..d1233e6 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -33,7 +33,7 @@ async function _uploadFile(file, projectId, onProgress) { fd.append('filename', file.name); fd.append('projectId', projectId); 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))); } @@ -53,7 +53,7 @@ async function _uploadFile(file, projectId, onProgress) { fd.append('uploadId', uploadId); fd.append('key', key); 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))); 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 // will be cleared automatically when the component unmounts on redirect (#55) 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 }) { : } -
@@ -1023,7 +1023,7 @@ function _StatusStrip({ schedules, recorders, now, projects }) { {color && } {s.name} - {rec?.name || s.recorder_id.slice(0, 8)} + {rec?.name || (s.recorder_id ? s.recorder_id.slice(0, 8) : 'unassigned')} {elapsed} · ends {endsAt} ); @@ -1037,7 +1037,7 @@ function _StatusStrip({ schedules, recorders, now, projects }) { <> Next up {next.name} - {recMap[next.recorder_id]?.name || next.recorder_id.slice(0, 8)} + {recMap[next.recorder_id]?.name || (next.recorder_id ? next.recorder_id.slice(0, 8) : 'unassigned')} {_fmtCountdown(new Date(next.start_at) - now)} · {_fmtTime(new Date(next.start_at))} ) : ( @@ -1445,9 +1445,9 @@ function Schedule({ navigate }) {
- +
{_sameDay(day, new Date()) ? 'Today · ' + _fmtDay(day) : _fmtDay(day)}
- +
@@ -1566,7 +1566,7 @@ function Schedule({ navigate }) { {s.name} {s.error_message &&
{s.error_message}
}
-
{s.recorder_name || s.recorder_id.slice(0, 8)}
+
{s.recorder_name || (s.recorder_id ? s.recorder_id.slice(0, 8) : 'unassigned')}
{_fmtWhen(s.start_at)}
{_durationMin(s.start_at, s.end_at)} min
{s.recurrence === 'none' ? 'one-shot' : s.recurrence}
@@ -1651,7 +1651,7 @@ function EditScheduleModal({ schedule, onClose, onSaved }) {
e.stopPropagation()}>
Edit scheduled recording
- +
@@ -1762,7 +1762,7 @@ function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, default
e.stopPropagation()}>
New scheduled recording
- +
diff --git a/services/web-ui/public/screens-jobs.jsx b/services/web-ui/public/screens-jobs.jsx index ba4503c..26cdec2 100644 --- a/services/web-ui/public/screens-jobs.jsx +++ b/services/web-ui/public/screens-jobs.jsx @@ -243,7 +243,7 @@ function JobRow({ job, onRetry, onDelete }) { )} {(job.status === 'queued' || job.status === 'done' || job.status === 'failed') && ( - )}
diff --git a/services/web-ui/public/screens-library.jsx b/services/web-ui/public/screens-library.jsx index e1b13d4..42a7665 100644 --- a/services/web-ui/public/screens-library.jsx +++ b/services/web-ui/public/screens-library.jsx @@ -229,7 +229,7 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {

Bins

- +
); })} @@ -614,7 +614,7 @@ function RenameAssetModal({ asset, onClose, onSaved }) {
Rename asset
- +
diff --git a/services/web-ui/public/screens-projects.jsx b/services/web-ui/public/screens-projects.jsx index 8447af8..8d0921a 100644 --- a/services/web-ui/public/screens-projects.jsx +++ b/services/web-ui/public/screens-projects.jsx @@ -18,7 +18,7 @@ function NewProjectModal({ onClose, onCreated }) {
e.stopPropagation()}>
New project
- +
@@ -143,7 +143,7 @@ function Projects({ onOpenProject, navigate }) {
{p.updated || '—'}
e.stopPropagation()}> - + {menuFor === p.id && (
e.stopPropagation()}> @@ -186,7 +186,7 @@ function RenameProjectModal({ project, onClose, onSaved }) {
e.stopPropagation()}>
Rename project
- +
diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx index 392de0e..7f9ab28 100644 --- a/services/web-ui/public/shell.jsx +++ b/services/web-ui/public/shell.jsx @@ -2,7 +2,7 @@ const NAV_TREE = [ { 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: "projects", label: "Projects", icon: "folder" }, { @@ -16,7 +16,7 @@ const NAV_TREE = [ { 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" }, ]; @@ -47,6 +47,7 @@ function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup }) <>
{ if (isGroup) toggleGroup(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 [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) => { setOpenGroups(prev => { const next = new Set(prev); @@ -98,15 +125,29 @@ function Sidebar({ active, onNavigate, me }) { return (