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:
parent
64d739b40d
commit
04ce096e67
41 changed files with 1089 additions and 277 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
19
services/mam-api/src/db/migrations/020-ampp-sync-retry.sql
Normal file
19
services/mam-api/src/db/migrations/020-ampp-sync-retry.sql
Normal 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');
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
|
|
|
||||||
|
|
@ -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(() => {});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')`,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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' }} />
|
||||||
|
|
|
||||||
34
services/web-ui/scripts/build-jsx.js
Normal file
34
services/web-ui/scripts/build-jsx.js
Normal 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); });
|
||||||
Loading…
Reference in a new issue