Compare commits

..

No commits in common. "main" and "feat/playout-mcr" have entirely different histories.

15 changed files with 464 additions and 516 deletions

View file

@ -1,10 +0,0 @@
-- Migration 031 — Add last_seen_at to cluster_nodes
--
-- Playout failover (routes/playout.js restartChannel) queries cluster_nodes.last_seen_at
-- to find healthy nodes for channel re-placement. Column was missing from original
-- cluster schema; heartbeat endpoint updates it via /cluster/heartbeat.
ALTER TABLE cluster_nodes ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
-- Backfill existing nodes to NOW() so they're immediately eligible for failover
UPDATE cluster_nodes SET last_seen_at = NOW() WHERE last_seen_at IS NULL;

View file

@ -41,12 +41,18 @@ import { startCleanupLoop } from './tasks/cleanupTempSegments.js';
const app = express();
const PORT = process.env.PORT || 3000;
// ── Middleware ────────────────────────────────────────────────────────────────
// Tightened CORS — once cookies carry authority, `origin: true` would let
// any site forge requests with the cookie. Drive the allowlist from env.
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',').map(s => s.trim()).filter(Boolean);
app.use(cors({
origin: (origin, cb) => {
// No Origin header (same-origin or curl) — allow.
if (!origin) return cb(null, true);
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
// Reject cleanly: omit the Allow-Origin header so the browser surfaces
// a real CORS error instead of a 500 from a thrown Error in the callback.
console.warn('[cors] rejected origin:', origin);
return cb(null, false);
},
@ -54,8 +60,14 @@ app.use(cors({
}));
app.use(express.json({ limit: '50mb' }));
// Trust the reverse proxy only when explicitly told to (production HTTPS).
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
// HSTS — once a browser has seen this header over HTTPS for dragonflight.live,
// it auto-upgrades every future http:// request to https:// before hitting the
// wire. Cookies are Secure-only (below) and the CORS allowlist rejects HTTP,
// so without HSTS a user who lands on http:// silently can't log in.
// Only emit on actual HTTPS responses; req.secure honors trust proxy + X-Forwarded-Proto.
if (process.env.AUTH_ENABLED === 'true') {
app.use((req, res, next) => {
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
@ -63,13 +75,17 @@ if (process.env.AUTH_ENABLED === 'true') {
});
}
// Hard-fail when production-mode auth has no stable session secret. Without
// this, express-session falls back to an in-memory random secret which
// invalidates every session on restart and breaks multi-node deployments.
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
process.exit(1);
}
// Session — actually wired this time. See specs/2026-05-27-auth-system-design.md.
app.use(session({
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 /* seconds = 15 min */ }),
secret: process.env.SESSION_SECRET,
name: 'dragonflight.sid',
cookie: {
@ -79,26 +95,36 @@ app.use(session({
path: '/',
maxAge: 8 * 3600 * 1000,
},
rolling: false,
rolling: false, // sliding renewal handled in requireAuth so idle + absolute can be enforced separately
resave: false,
saveUninitialized: false,
}));
// ── Health ────────────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
// ── Auth gate ─────────────────────────────────────────────────────────────────
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
const UNAUTH_PATHS = new Set([
'/auth/login', '/auth/login/totp', '/auth/setup', '/auth/setup-required',
'/auth/google', '/auth/google/callback', '/auth/google/enabled',
]);
// node-agent now authenticates /cluster/heartbeat with a bound api_token
// (migration 019 + bound_hostname on the token). requireAuth handles the
// bearer lookup and sets req.tokenBoundHostname; the heartbeat handler in
// routes/cluster.js verifies body.hostname matches that binding.
app.use('/api/v1', requireUiHeader);
app.use('/api/v1', (req, res, next) => {
if (UNAUTH_PATHS.has(req.path)) return next();
return requireAuth(req, res, next);
});
// ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRouter);
// User and group administration is admin-only (RBAC v2). The auth gate above
// already established req.user; requireAdmin rejects non-admins with 403.
app.use('/api/v1/auth/users', requireAdmin, usersRouter);
app.use('/api/v1/users', requireAdmin, usersRouter);
app.use('/api/v1/users', requireAdmin, usersRouter); // alias for the SPA Users page
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
app.use('/api/v1/assets', assetsRouter);
app.use('/api/v1/projects', projectsRouter);
@ -121,14 +147,21 @@ app.use('/api/v1/assets/:assetId/comments', commentsRouter);
app.use('/api/v1/imports', importsRouter);
app.use('/api/v1/storage', storageRouter);
// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);
// ── Start ────────────────────────────────────────────────────────────────────
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
async function runMigrations() {
// Issue #107 — previously the loop swallowed errors and let the server boot
// on a half-migrated schema. Now: track applied migrations in a table, run
// every pending one inside a transaction, and exit non-zero on failure so
// the orchestrator restarts (and so an operator notices) instead of serving
// 500s for the next month.
const dir = join(__dirnameMig, 'db', 'migrations');
let files = [];
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
@ -141,6 +174,7 @@ async function runMigrations() {
)
`);
// 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';
@ -166,6 +200,7 @@ async function runMigrations() {
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);
}
@ -174,9 +209,13 @@ async function runMigrations() {
}
await runMigrations();
// Load S3 config from DB so any settings saved via the Settings page override env vars
await loadS3ConfigFromDb();
// ── Cluster self-heartbeat ────────────────────────────────────────────────────
function getLocalIp() {
// Prefer an explicit override — useful when running inside Docker where
// os.networkInterfaces() returns container bridge IPs, not the host LAN IP.
if (process.env.NODE_IP) return process.env.NODE_IP;
const ifaces = os.networkInterfaces();
@ -188,6 +227,9 @@ function getLocalIp() {
return '127.0.0.1';
}
// Detect NVIDIA GPUs available to this container via nvidia-smi.
// Returns an array like [{ index: 0, name: 'Tesla P4', memory_mb: 7680 }, ...]
// or an empty array if nvidia-smi is unavailable or no GPUs found.
function detectGpus() {
return new Promise(resolve => {
exec(
@ -209,10 +251,6 @@ function detectGpus() {
});
}
// Primary mam-api node self-registers in cluster_nodes every 30s. Must write
// BOTH last_seen (legacy column) and last_seen_at (added by mig 031, used by
// playout failover) — otherwise the primary appears stale to the failover
// query and channels get re-placed off it incorrectly.
async function selfHeartbeat() {
const load = os.loadavg()[0];
const total = os.totalmem();
@ -224,15 +262,14 @@ async function selfHeartbeat() {
pool.query(
`INSERT INTO cluster_nodes
(hostname, ip_address, role, version, api_url,
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen, last_seen_at)
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW(),NOW())
cpu_usage, mem_used_mb, mem_total_mb, capabilities, last_seen)
VALUES ($1,$2,'primary',$3,$4,$5,$6,$7,$8,NOW())
ON CONFLICT (hostname) DO UPDATE SET
ip_address = EXCLUDED.ip_address,
cpu_usage = EXCLUDED.cpu_usage,
mem_used_mb = EXCLUDED.mem_used_mb,
mem_total_mb = EXCLUDED.mem_total_mb,
capabilities = EXCLUDED.capabilities,
last_seen_at = NOW(),
last_seen = NOW()`,
[
process.env.NODE_HOSTNAME || os.hostname(),
@ -257,26 +294,39 @@ const server = app.listen(PORT, () => {
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
}
// Boot the recorder scheduler tick loop after the HTTP server is live so
// the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
startSchedulerLoop();
// Boot the temp-segment cleanup loop (runs hourly).
startCleanupLoop();
});
// Issue #100 — graceful shutdown. Without this, `docker stop` (SIGTERM) killed
// the process mid-scheduler-tick, leaving Redis connections and Docker
// sockets dangling and producing partial DB writes. Now: stop the scheduler,
// finish in-flight HTTP requests, close PG/Redis pools, and exit cleanly
// (or hard-exit after 25 s if something is stuck).
let _shuttingDown = false;
async function gracefulShutdown(signal) {
if (_shuttingDown) return;
_shuttingDown = true;
console.log(`[shutdown] received ${signal} — closing gracefully…`);
// Stop accepting new requests + wind down the scheduler tick.
try { stopSchedulerLoop(); } catch (_) {}
// Force-exit watchdog so a hung connection can't keep us alive forever.
const killSwitch = setTimeout(() => {
console.error('[shutdown] forced exit after 25s timeout');
process.exit(1);
}, 25_000);
killSwitch.unref();
// Stop the HTTP server (waits for in-flight requests to finish).
await new Promise(resolve => server.close(resolve));
// Close DB pool + S3 client + any other resources. Best-effort.
try { await pool.end(); } catch (e) { console.warn('[shutdown] pool.end:', e.message); }
console.log('[shutdown] clean exit');

View file

@ -4,6 +4,10 @@ import pool from '../db/pool.js';
const router = express.Router();
// If the agent reported Docker's default bridge IP (172.17.x) but the request
// itself came from a real LAN address, prefer the request source IP instead.
// We only check 172.17.x — the default docker0 bridge — not the full RFC1918
// 172.16/12 block, since real LANs (e.g. 172.18.91.x) fall in that range.
function pickIp(reportedIp, reqIp) {
const clean = (s) => (s || '').replace(/^::ffff:/, '');
const isDockerBridge = (ip) => /^172\.17\./.test(ip || '');
@ -37,6 +41,7 @@ function dockerRequest(path, method = 'GET', body = null) {
});
}
// GET / list all registered cluster nodes with online status
router.get('/', async (req, res, next) => {
try {
const r = await pool.query(
@ -52,6 +57,7 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /containers list all containers on the local Docker host
router.get('/containers', async (req, res, next) => {
try {
const containers = await dockerRequest('/containers/json?all=true');
@ -82,6 +88,7 @@ router.get('/containers', async (req, res, next) => {
}
});
// POST /containers/:nameOrId/restart
router.post('/containers/:nameOrId/restart', async (req, res, next) => {
try {
await dockerRequest(`/containers/${encodeURIComponent(req.params.nameOrId)}/restart`, 'POST');
@ -89,6 +96,7 @@ router.post('/containers/:nameOrId/restart', async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /heartbeat upsert this node's registration (includes hardware capabilities)
router.post('/heartbeat', async (req, res, next) => {
try {
const {
@ -100,6 +108,11 @@ router.post('/heartbeat', async (req, res, next) => {
if (!hostname) return res.status(400).json({ error: 'hostname is required' });
// Issue #106 — any authenticated user used to be able to POST a heartbeat
// for an arbitrary hostname and overwrite the primary node's `api_url`,
// effectively hijacking job dispatch. Now: if the caller's token is bound
// to a hostname (node-agent tokens are bound at issue time), the body
// hostname must match. Admin users with no binding are allowed for ops.
if (process.env.AUTH_ENABLED === 'true') {
const bound = req.tokenBoundHostname;
if (bound && bound !== hostname) {
@ -119,8 +132,8 @@ router.post('/heartbeat', async (req, res, next) => {
const r = await pool.query(
`INSERT INTO cluster_nodes
(hostname, ip_address, role, version, api_url,
cpu_usage, mem_used_mb, mem_total_mb, last_seen, last_seen_at, capabilities, metadata, metrics)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),NOW(),$9,$10,$11)
cpu_usage, mem_used_mb, mem_total_mb, last_seen, capabilities, metadata, metrics)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9,$10,$11)
ON CONFLICT (hostname) DO UPDATE SET
ip_address = EXCLUDED.ip_address,
role = EXCLUDED.role,
@ -130,7 +143,6 @@ router.post('/heartbeat', async (req, res, next) => {
mem_used_mb = EXCLUDED.mem_used_mb,
mem_total_mb = EXCLUDED.mem_total_mb,
last_seen = NOW(),
last_seen_at = NOW(),
capabilities = EXCLUDED.capabilities,
metadata = EXCLUDED.metadata,
metrics = COALESCE(EXCLUDED.metrics, cluster_nodes.metrics)
@ -153,25 +165,42 @@ router.post('/heartbeat', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /devices/blackmagic/signal live video-presence state for every
// DeckLink port across the cluster. For each port we check whether there is
// an active SDI recorder assigned to it and, if so, query the capture
// container for its real signal state (receiving / lost / connecting /
// error). Ports without a recorder get signal = 'no-recorder'.
//
// Response shape (array):
// { node_id, hostname, index, device, model,
// signal, framesReceived, currentFps, recorder_id, recorder_status }
router.get('/devices/blackmagic/signal', async (req, res, next) => {
try {
// 1. Fetch all cluster nodes with DeckLink capabilities.
const nodesResult = await pool.query(
`SELECT id, hostname, ip_address, api_url, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes
WHERE capabilities IS NOT NULL`
);
// 2. Fetch all SDI recorders that are pinned to a node+device_index.
const recResult = await pool.query(
`SELECT id, name, status, container_id, node_id, device_index,
source_config
FROM recorders
WHERE source_type = 'sdi' AND node_id IS NOT NULL`
);
// Build a fast lookup: "${node_id}:${device_index}" → recorder row.
const recByPort = new Map();
for (const r of recResult.rows) {
const devIdx = r.device_index ?? r.source_config?.device ?? 0;
recByPort.set(`${r.node_id}:${devIdx}`, r);
}
// 3. For each port, determine signal state. We fire all capture-container
// fetches concurrently so the endpoint stays fast even with many ports.
const tasks = [];
for (const node of nodesResult.rows) {
const nodeOnline = Number(node.stale_seconds) < 120;
@ -179,51 +208,79 @@ router.get('/devices/blackmagic/signal', async (req, res, next) => {
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
const localHostname = process.env.NODE_HOSTNAME || '';
const isRemote = node.api_url && node.hostname !== localHostname;
bm.forEach((d, idx) => {
const portIndex = d.index !== undefined ? d.index : idx;
const rec = recByPort.get(`${node.id}:${portIndex}`);
tasks.push((async () => {
const base = {
node_id: node.id, hostname: node.hostname, index: portIndex,
device: d.device || null, model, node_online: nodeOnline,
recorder_id: rec ? rec.id : null, recorder_name: rec ? rec.name : null,
node_id: node.id,
hostname: node.hostname,
index: portIndex,
device: d.device || null,
model,
node_online: nodeOnline,
recorder_id: rec ? rec.id : null,
recorder_name: rec ? rec.name : null,
recorder_status: rec ? rec.status : null,
signal: 'no-recorder', framesReceived: null, currentFps: null,
signal: 'no-recorder',
framesReceived: null,
currentFps: null,
};
if (!rec || rec.status !== 'recording' || !rec.container_id) {
// No active capture — if there's a recorder but it's not recording,
// report that; otherwise the port is unassigned.
if (rec && rec.status !== 'recording') base.signal = 'idle';
return base;
}
// Active recording — query the capture container for real signal.
try {
let live = null;
if (isRemote) {
const r = await fetch(`${node.api_url}/sidecar/${rec.container_id}/status`, { signal: AbortSignal.timeout(2500) });
const r = await fetch(
`${node.api_url}/sidecar/${rec.container_id}/status`,
{ signal: AbortSignal.timeout(2500) }
);
if (r.ok) live = (await r.json()).live;
} else {
const r = await fetch(`http://recorder-${rec.id}:3001/capture/status`, { signal: AbortSignal.timeout(2000) });
const r = await fetch(
`http://recorder-${rec.id}:3001/capture/status`,
{ signal: AbortSignal.timeout(2000) }
);
if (r.ok) live = await r.json();
}
if (live && live.signal) {
base.signal = live.signal;
base.signal = live.signal;
base.framesReceived = live.framesReceived ?? null;
base.currentFps = live.currentFps ?? null;
} else { base.signal = 'connecting'; }
} catch (_) { base.signal = 'connecting'; }
base.currentFps = live.currentFps ?? null;
} else {
base.signal = 'connecting';
}
} catch (_) {
base.signal = 'connecting';
}
return base;
})());
});
}
const results = await Promise.all(tasks);
res.json(results);
} catch (err) { next(err); }
});
// GET /devices/blackmagic flatten every node's DeckLink cards for the
// recorder picker. Returns one entry per device with the host node info.
router.get('/devices/blackmagic', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT id, hostname, ip_address, role, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes WHERE capabilities IS NOT NULL`
FROM cluster_nodes
WHERE capabilities IS NOT NULL`
);
const out = [];
for (const row of r.rows) {
@ -231,98 +288,157 @@ router.get('/devices/blackmagic', async (req, res, next) => {
const bm = (row.capabilities && row.capabilities.blackmagic) || [];
const model = (row.capabilities && row.capabilities.blackmagic_model) || null;
bm.forEach((d, idx) => {
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
role: row.role, online, model, index: d.index !== undefined ? d.index : idx, device: d.device });
out.push({
node_id: row.id,
hostname: row.hostname,
ip_address: row.ip_address,
role: row.role,
online,
model,
index: d.index !== undefined ? d.index : idx,
device: d.device,
});
});
}
res.json(out);
} catch (err) { next(err); }
});
// GET /devices/deltacast flatten every node's Deltacast cards for the
// recorder picker. Mirrors /devices/blackmagic shape so the UI can treat
// both card types uniformly.
router.get('/devices/deltacast', async (req, res, next) => {
try {
const r = await pool.query(
`SELECT id, hostname, ip_address, role, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes WHERE capabilities IS NOT NULL`
FROM cluster_nodes
WHERE capabilities IS NOT NULL`
);
const out = [];
for (const row of r.rows) {
const online = Number(row.stale_seconds) < 120;
const dc = (row.capabilities && row.capabilities.deltacast) || [];
const dc = (row.capabilities && row.capabilities.deltacast) || [];
const model = (row.capabilities && row.capabilities.deltacast_model) || null;
// Also synthesise entries from DELTACAST_PORT_COUNT if no entries reported yet —
// useful for nodes that haven't sent a heartbeat since the agent was updated.
dc.forEach((d, idx) => {
out.push({ node_id: row.id, hostname: row.hostname, ip_address: row.ip_address,
role: row.role, online, model: model || 'Deltacast',
index: d.index !== undefined ? d.index : idx, device: d.device,
present: d.present !== false, port_count: dc.length });
out.push({
node_id: row.id,
hostname: row.hostname,
ip_address: row.ip_address,
role: row.role,
online,
model: model || 'Deltacast',
index: d.index !== undefined ? d.index : idx,
device: d.device,
present: d.present !== false,
port_count: dc.length,
});
});
}
res.json(out);
} catch (err) { next(err); }
});
// GET /devices/deltacast/signal live signal state for Deltacast ports.
// Same pattern as /devices/blackmagic/signal.
router.get('/devices/deltacast/signal', async (req, res, next) => {
try {
const [nodesRes, recordersRes] = await Promise.all([
pool.query(`SELECT id, hostname, ip_address, api_url, capabilities,
pool.query(
`SELECT id, hostname, ip_address, api_url, capabilities,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes WHERE capabilities IS NOT NULL`),
pool.query(`SELECT id, node_id, device_index, status, source_type, container_id
FROM recorders WHERE source_type = 'deltacast'`),
FROM cluster_nodes
WHERE capabilities IS NOT NULL`
),
pool.query(
`SELECT id, node_id, device_index, status, source_type, container_id
FROM recorders WHERE source_type = 'deltacast'`
),
]);
const recByNodePort = {};
for (const rec of recordersRes.rows) {
recByNodePort[`${rec.node_id}:${rec.device_index}`] = rec;
}
const results = [];
const fetchPromises = [];
for (const node of nodesRes.rows) {
const online = Number(node.stale_seconds) < 120;
const dc = (node.capabilities && node.capabilities.deltacast) || [];
const model = (node.capabilities && node.capabilities.deltacast_model) || 'Deltacast';
for (const port of dc) {
const idx = port.index !== undefined ? port.index : dc.indexOf(port);
const rec = recByNodePort[`${node.id}:${idx}`];
const base = { node_id: node.id, hostname: node.hostname, ip_address: node.ip_address,
online, model, index: idx, device: port.device, present: port.present !== false,
recorder_id: rec ? rec.id : null, recorder_status: rec ? rec.status : null,
signal: 'no-recorder', framesReceived: null, currentFps: null };
const base = {
node_id: node.id,
hostname: node.hostname,
ip_address: node.ip_address,
online,
model,
index: idx,
device: port.device,
present: port.present !== false,
recorder_id: rec ? rec.id : null,
recorder_status: rec ? rec.status : null,
signal: 'no-recorder',
framesReceived: null,
currentFps: null,
};
if (!rec) { results.push(base); continue; }
if (rec.status !== 'recording') { base.signal = 'idle'; results.push(base); continue; }
// Active recording — query capture container for real signal.
const fetchIdx = results.length;
results.push(base);
fetchPromises.push((async () => {
try {
const url = node.api_url ? `${node.api_url}/sidecar/${rec.container_id}/status`
const url = node.api_url
? `${node.api_url}/sidecar/${rec.container_id}/status`
: `http://recorder-${rec.id}:3001/capture/status`;
const r = await fetch(url, { signal: AbortSignal.timeout(2500) });
if (r.ok) {
const live = await r.json();
if (live && live.signal) {
results[fetchIdx].signal = live.signal;
results[fetchIdx].signal = live.signal;
results[fetchIdx].framesReceived = live.framesReceived ?? null;
results[fetchIdx].currentFps = live.currentFps ?? null;
results[fetchIdx].currentFps = live.currentFps ?? null;
}
}
} catch (_) { results[fetchIdx].signal = 'connecting'; }
} catch (_) {
results[fetchIdx].signal = 'connecting';
}
})());
}
}
await Promise.all(fetchPromises);
res.json(results);
} catch (err) { next(err); }
});
// GET /:id/ping probe the node's api_url/health endpoint directly
router.get('/:id/ping', async (req, res, next) => {
try {
const r = await pool.query('SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1', [req.params.id]);
const r = await pool.query(
'SELECT id, hostname, api_url FROM cluster_nodes WHERE id = $1',
[req.params.id]
);
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
const node = r.rows[0];
if (!node.api_url) return res.json({ reachable: false, reason: 'no api_url registered' });
const start = Date.now();
try {
const upstream = await fetch(`${node.api_url}/health`, { signal: AbortSignal.timeout(4000) });
const upstream = await fetch(`${node.api_url}/health`, {
signal: AbortSignal.timeout(4000),
});
const latency_ms = Date.now() - start;
const body = await upstream.json().catch(() => ({}));
res.json({ reachable: upstream.ok, latency_ms, status: upstream.status, agent: body });
@ -332,6 +448,8 @@ router.get('/:id/ping', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /metrics - live per-node utilization (CPU, RAM, GPU)
router.get('/metrics', async (req, res, next) => {
try {
const r = await pool.query(
@ -339,37 +457,59 @@ router.get('/metrics', async (req, res, next) => {
cpu_usage, mem_used_mb, mem_total_mb,
capabilities, metrics,
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
FROM cluster_nodes ORDER BY registered_at ASC`
FROM cluster_nodes
ORDER BY registered_at ASC`
);
const nodes = r.rows.map(row => {
const capGpus = (row.capabilities && row.capabilities.gpus) || [];
const capGpus = (row.capabilities && row.capabilities.gpus) || [];
const liveGpus = (row.metrics && row.metrics.gpus) || [];
const gpus = capGpus.map((g, idx) => {
const live = liveGpus.find(l => l.index === g.index) || liveGpus[idx] || {};
return { name: g.name || null, util_pct: live.util_pct != null ? live.util_pct : null,
memory_used_mb: live.memory_used_mb != null ? live.memory_used_mb : null,
memory_total_mb: g.memory_mb != null ? g.memory_mb : (live.memory_total_mb ?? null) };
return {
name: g.name || null,
util_pct: live.util_pct != null ? live.util_pct : null,
memory_used_mb: live.memory_used_mb != null ? live.memory_used_mb : null,
memory_total_mb: g.memory_mb != null ? g.memory_mb : (live.memory_total_mb ?? null),
};
});
// include any live GPUs not in static capabilities
for (const lg of liveGpus) {
if (!capGpus.some(g => g.index === lg.index)) {
gpus.push({ name: lg.name || null, util_pct: lg.util_pct != null ? lg.util_pct : null,
memory_used_mb: lg.memory_used_mb != null ? lg.memory_used_mb : null,
memory_total_mb: lg.memory_total_mb != null ? lg.memory_total_mb : null });
gpus.push({
name: lg.name || null,
util_pct: lg.util_pct != null ? lg.util_pct : null,
memory_used_mb: lg.memory_used_mb != null ? lg.memory_used_mb : null,
memory_total_mb: lg.memory_total_mb != null ? lg.memory_total_mb : null,
});
}
}
return { id: row.id, hostname: row.hostname, role: row.role,
online: Number(row.stale_seconds) < 120, last_seen: row.last_seen,
cpu_util_pct: row.cpu_usage != null ? Number(row.cpu_usage) : null,
ram_used_mb: row.mem_used_mb != null ? row.mem_used_mb : null,
ram_total_mb: row.mem_total_mb != null ? row.mem_total_mb : null, gpus };
return {
id: row.id,
hostname: row.hostname,
role: row.role,
online: Number(row.stale_seconds) < 120,
last_seen: row.last_seen,
cpu_util_pct: row.cpu_usage != null ? Number(row.cpu_usage) : null,
ram_used_mb: row.mem_used_mb != null ? row.mem_used_mb : null,
ram_total_mb: row.mem_total_mb != null ? row.mem_total_mb : null,
gpus,
};
});
res.json({ nodes });
} catch (err) { next(err); }
});
// DELETE /:id deregister a node
router.delete('/:id', async (req, res, next) => {
try {
const r = await pool.query('DELETE FROM cluster_nodes WHERE id = $1 RETURNING id', [req.params.id]);
const r = await pool.query(
'DELETE FROM cluster_nodes WHERE id = $1 RETURNING id',
[req.params.id]
);
if (r.rowCount === 0) return res.status(404).json({ error: 'Node not found' });
res.json({ ok: true });
} catch (err) { next(err); }

View file

@ -20,6 +20,7 @@ import {
const router = express.Router();
// ── BullMQ: media staging queue (S3 -> /media volume) ────────────────────────
const parseRedisUrl = (url) => {
const parsed = new URL(url);
return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
@ -28,6 +29,7 @@ const stageQueue = new Queue('playout-stage', {
connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});
// ── Sidecar orchestration (mirrors recorders.js) ─────────────────────────────
const PLAYOUT_SIDECAR_IMAGE = process.env.PLAYOUT_IMAGE || 'wild-dragon-playout:latest';
function dockerApi(method, path, body = null) {
@ -65,10 +67,16 @@ async function resolveNodeTarget(nodeId) {
return { remote: true, apiUrl: node.api_url, ip: node.ip_address };
}
// The sidecar shim listens on this port inside the container. The mam-api talks
// to it by container alias on the shared docker network (local) or via the
// node-agent's returned host:port (remote).
const SIDECAR_HTTP_PORT = 3002;
function channelAlias(id) { return `playout-${id}`; }
// Resolve the base URL the API uses to reach a running channel's sidecar shim.
// Local: the docker-network alias. Remote: the node-agent reported the host the
// container is published on (stored in container_meta.sidecar_url).
function sidecarBaseUrl(channel) {
if (channel.container_meta && channel.container_meta.sidecar_url) {
return channel.container_meta.sidecar_url;
@ -91,6 +99,7 @@ async function callSidecar(channel, path, method = 'POST', body = null) {
return res.json().catch(() => ({}));
}
// ── Serialization ────────────────────────────────────────────────────────────
function channelToJson(r) {
return {
id: r.id,
@ -113,6 +122,7 @@ function channelToJson(r) {
const OUTPUT_TYPES = new Set(['decklink', 'ndi', 'srt', 'rtmp']);
// ── Param resolver: scope every /:id route to the channel's project ──────────
router.param('id', async (req, res, next) => {
validateUuid('id')(req, res, () => {});
if (res.headersSent) return;
@ -132,6 +142,9 @@ async function requireChannelEdit(req, res, next) {
catch (err) { next(err); }
}
// ── Channels ─────────────────────────────────────────────────────────────────
// GET /playout/channels — list (filtered to accessible projects)
router.get('/channels', async (req, res, next) => {
try {
let rows;
@ -148,6 +161,7 @@ router.get('/channels', async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /playout/channels — create
router.post('/channels', async (req, res, next) => {
try {
const { name, node_id = null, output_type = 'srt', output_config = {},
@ -158,6 +172,8 @@ router.post('/channels', async (req, res, next) => {
if (!OUTPUT_TYPES.has(output_type)) {
return res.status(400).json({ error: `output_type must be one of: ${[...OUTPUT_TYPES].join(', ')}` });
}
// Creating a project-scoped channel requires edit on that project; a
// null-project (admin-only) channel requires admin.
if (project_id) await assertProjectAccess(req.user, project_id, 'edit');
else if (!isAdmin(req.user)) return res.status(403).json({ error: 'admin required for unassigned channel' });
@ -170,6 +186,7 @@ router.post('/channels', async (req, res, next) => {
} catch (err) { next(err); }
});
// PATCH /playout/channels/:id — update config (only while stopped)
router.patch('/channels/:id', requireChannelEdit, async (req, res, next) => {
try {
if (req.channel.status === 'running') {
@ -196,6 +213,7 @@ router.patch('/channels/:id', requireChannelEdit, async (req, res, next) => {
} catch (err) { next(err); }
});
// DELETE /playout/channels/:id
router.delete('/channels/:id', requireChannelEdit, async (req, res, next) => {
try {
if (req.channel.status === 'running') {
@ -206,9 +224,14 @@ router.delete('/channels/:id', requireChannelEdit, async (req, res, next) => {
} catch (err) { next(err); }
});
// ── Port-contention guard (DeckLink) ─────────────────────────────────────────
// A DeckLink device on a node is exclusive: an active recorder OR another active
// channel on the same node+index blocks a new SDI channel. NDI/SRT/RTMP have no
// hardware contention.
async function assertDeckLinkFree(channel) {
if (channel.output_type !== 'decklink') return;
const idx = (channel.output_config && channel.output_config.device_index) || 1;
// Another running channel on the same node + device index?
const chan = await pool.query(
`SELECT id FROM playout_channels
WHERE id <> $1 AND node_id IS NOT DISTINCT FROM $2 AND status = 'running'
@ -218,6 +241,7 @@ async function assertDeckLinkFree(channel) {
if (chan.rows.length > 0) {
throw Object.assign(new Error(`DeckLink device ${idx} already in use by another channel on this node`), { httpStatus: 409 });
}
// An active recorder using the same device index on the same node?
const rec = await pool.query(
`SELECT id FROM recorders
WHERE node_id IS NOT DISTINCT FROM $1 AND device_index = $2
@ -229,6 +253,13 @@ async function assertDeckLinkFree(channel) {
}
}
// Spawn the CasparCG sidecar for a channel and flip it to 'running'. Shared by
// the /start route and the scheduler failover path (restartChannel) so neither
// duplicates the docker/node-agent orchestration. Caller is responsible for the
// pre-flight guards (status check, DeckLink contention) appropriate to its path.
//
// On any spawn failure the channel is left status='error' with a message and an
// Error carrying { httpStatus } is thrown. On success returns the updated row.
async function spawnChannelSidecar(channel) {
await pool.query('UPDATE playout_channels SET status = $1, error_message = NULL WHERE id = $2', ['starting', channel.id]);
@ -237,6 +268,8 @@ async function spawnChannelSidecar(channel) {
`OUTPUT_CONFIG=${JSON.stringify(channel.output_config || {})}`,
`VIDEO_FORMAT=${channel.video_format}`,
`PORT=${SIDECAR_HTTP_PORT}`,
// Drives the HLS preview path (/media/live/<channel_id>/index.m3u8) and
// the per-channel resource naming inside the sidecar.
`CHANNEL_ID=${channel.id}`,
];
@ -267,6 +300,7 @@ async function spawnChannelSidecar(channel) {
}
const data = await sidecarRes.json();
containerId = data.containerId;
// node-agent returns the reachable host:port the shim is published on.
if (data.sidecarUrl || data.host) {
containerMeta.sidecar_url = data.sidecarUrl || `http://${data.host}:${SIDECAR_HTTP_PORT}`;
}
@ -279,10 +313,7 @@ async function spawnChannelSidecar(channel) {
Image: PLAYOUT_SIDECAR_IMAGE,
Env: env,
HostConfig: {
// DeckLink SDI needs raw /dev access (privileged). SRT/NDI/RTMP/HLS run
// unprivileged — privileged exposes host GPUs to CasparCG, and the
// missing in-container NVIDIA driver crashes the engine within seconds.
Privileged: channel.output_type === 'decklink',
Privileged: true,
NetworkMode: dockerNetwork,
Binds: hostBinds,
},
@ -316,6 +347,7 @@ async function spawnChannelSidecar(channel) {
return rows[0];
}
// POST /playout/channels/:id/start — spawn the CasparCG sidecar + bring up output
router.post('/channels/:id/start', requireChannelEdit, async (req, res, next) => {
try {
const channel = req.channel;
@ -331,6 +363,7 @@ router.post('/channels/:id/start', requireChannelEdit, async (req, res, next) =>
}
});
// POST /playout/channels/:id/stop — tear down the sidecar
router.post('/channels/:id/stop', requireChannelEdit, async (req, res, next) => {
try {
const channel = req.channel;
@ -355,6 +388,7 @@ router.post('/channels/:id/stop', requireChannelEdit, async (req, res, next) =>
} catch (err) { next(err); }
});
// GET /playout/channels/:id/status — live engine status (proxied to sidecar)
router.get('/channels/:id/status', async (req, res, next) => {
try {
if (req.channel.status !== 'running') {
@ -367,6 +401,7 @@ router.get('/channels/:id/status', async (req, res, next) => {
}
});
// ── Transport ────────────────────────────────────────────────────────────────
async function transport(req, res, action, body = null) {
if (req.channel.status !== 'running') {
return res.status(409).json({ error: 'Channel is not running' });
@ -375,6 +410,8 @@ async function transport(req, res, action, body = null) {
catch (err) { res.status(502).json({ error: err.message }); }
}
// POST /playout/channels/:id/play — resolve the channel's playlist, stage-check,
// and hand the engine the ordered list of ready clips.
router.post('/channels/:id/play', requireChannelEdit, async (req, res, next) => {
try {
if (req.channel.status !== 'running') {
@ -421,6 +458,7 @@ router.post('/channels/:id/resume', requireChannelEdit, (req, res) => transport(
router.post('/channels/:id/skip', requireChannelEdit, (req, res) => transport(req, res, '/transport/skip'));
router.post('/channels/:id/stop-playback', requireChannelEdit, (req, res) => transport(req, res, '/channel/stop'));
// GET /playout/channels/:id/asrun — as-run log
router.get('/channels/:id/asrun', async (req, res, next) => {
try {
const { rows } = await pool.query(
@ -430,7 +468,10 @@ router.get('/channels/:id/asrun', async (req, res, next) => {
} catch (err) { next(err); }
});
// ── Playlists ────────────────────────────────────────────────────────────────
async function loadChannelForBody(req, res, next) {
// For playlist/item routes the channel is referenced indirectly; resolve it
// and assert edit. Used on create/mutate routes that carry channel_id.
const channelId = req.body.channel_id || req.query.channel_id;
if (!channelId) return res.status(400).json({ error: 'channel_id is required' });
try {
@ -442,6 +483,7 @@ async function loadChannelForBody(req, res, next) {
} catch (err) { next(err); }
}
// GET /playout/playlists?channel_id=...
router.get('/playlists', async (req, res, next) => {
try {
const channelId = req.query.channel_id;
@ -455,6 +497,7 @@ router.get('/playlists', async (req, res, next) => {
} catch (err) { next(err); }
});
// POST /playout/playlists
router.post('/playlists', loadChannelForBody, async (req, res, next) => {
try {
const { name, loop = false } = req.body || {};
@ -466,6 +509,7 @@ router.post('/playlists', loadChannelForBody, async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /playout/playlists/:plid/items
router.get('/playlists/:plid/items', validateUuid('plid'), async (req, res, next) => {
try {
const pl = await pool.query(
@ -481,6 +525,7 @@ router.get('/playlists/:plid/items', validateUuid('plid'), async (req, res, next
} catch (err) { next(err); }
});
// Helper: load a playlist + assert edit on its channel's project.
async function loadPlaylistEdit(plid, user) {
const pl = await pool.query(
`SELECT p.*, c.project_id FROM playout_playlists p
@ -490,6 +535,7 @@ async function loadPlaylistEdit(plid, user) {
return pl.rows[0];
}
// POST /playout/playlists/:plid/items — add an asset to a playlist
router.post('/playlists/:plid/items', validateUuid('plid'), async (req, res, next) => {
try {
await loadPlaylistEdit(req.params.plid, req.user);
@ -497,6 +543,7 @@ router.post('/playlists/:plid/items', validateUuid('plid'), async (req, res, nex
transition = 'cut', transition_ms = 0 } = req.body || {};
if (!asset_id) return res.status(400).json({ error: 'asset_id is required' });
// Append at the end of the playlist.
const ord = await pool.query(
'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM playout_items WHERE playlist_id = $1',
[req.params.plid]);
@ -505,6 +552,8 @@ router.post('/playlists/:plid/items', validateUuid('plid'), async (req, res, nex
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[req.params.plid, asset_id, ord.rows[0].next, in_point, out_point, transition, transition_ms]);
// Kick staging immediately so the clip is air-ready by the time the operator
// hits play.
await stageQueue.add('stage', { itemId: rows[0].id, assetId: asset_id }).catch((e) =>
console.error('[playout] failed to enqueue stage job:', e.message));
@ -515,6 +564,7 @@ router.post('/playlists/:plid/items', validateUuid('plid'), async (req, res, nex
}
});
// PUT /playout/playlists/:plid/reorder — body { order: [itemId, itemId, ...] }
router.put('/playlists/:plid/reorder', validateUuid('plid'), async (req, res, next) => {
const client = await pool.connect();
try {
@ -536,6 +586,7 @@ router.put('/playlists/:plid/reorder', validateUuid('plid'), async (req, res, ne
} finally { client.release(); }
});
// DELETE /playout/items/:itemId
router.delete('/items/:itemId', validateUuid('itemId'), async (req, res, next) => {
try {
const it = await pool.query(
@ -549,6 +600,7 @@ router.delete('/items/:itemId', validateUuid('itemId'), async (req, res, next) =
} catch (err) { next(err); }
});
// POST /playout/items/:itemId/stage — (re)kick staging for one item
router.post('/items/:itemId/stage', validateUuid('itemId'), async (req, res, next) => {
try {
const it = await pool.query(
@ -563,6 +615,13 @@ router.post('/items/:itemId/stage', validateUuid('itemId'), async (req, res, nex
} catch (err) { next(err); }
});
// ── Failover (called by scheduler tick) ──────────────────────────────────────
// Tear down a (presumed dead) sidecar and re-spawn it on another cluster node
// matching the original capability. DeckLink channels are excluded — the
// device-index pinning makes blind re-placement risky, so they alert only.
//
// Returns { restarted: true, new_node_id } on success, or { restarted: false,
// reason } when no eligible node exists or the channel is decklink.
export async function restartChannel(channelId) {
const { rows } = await pool.query('SELECT * FROM playout_channels WHERE id = $1', [channelId]);
if (rows.length === 0) return { restarted: false, reason: 'channel not found' };
@ -572,6 +631,7 @@ export async function restartChannel(channelId) {
return { restarted: false, reason: 'decklink channels are alert-only' };
}
// Best-effort teardown of the old container — it may already be dead.
if (channel.container_id) {
const { remote, apiUrl } = await resolveNodeTarget(channel.node_id);
if (remote && apiUrl) {
@ -585,6 +645,9 @@ export async function restartChannel(channelId) {
}
}
// Pick a different healthy node. For NDI/SRT/RTMP every online node is
// eligible (no hardware contention). Prefer the original if it's still
// online — the failure may have been transient.
const nodes = await pool.query(
`SELECT id, hostname, api_url, last_seen_at FROM cluster_nodes
WHERE id <> $1 AND last_seen_at > NOW() - INTERVAL '60 seconds'
@ -600,6 +663,9 @@ export async function restartChannel(channelId) {
}
const newNodeId = nodes.rows[0].id;
// Move the channel to the new node + bump the restart counters; the operator
// UI surfaces these to flag restarts. container_meta is cleared so the new
// spawn re-derives the sidecar URL.
const { rows: moved } = await pool.query(
`UPDATE playout_channels
SET node_id = $1, status = 'stopped', container_id = NULL, container_meta = '{}'::jsonb,
@ -609,6 +675,10 @@ export async function restartChannel(channelId) {
[newNodeId, channel.id]
);
// Spawn the sidecar directly via the shared helper. We do NOT route through
// the HTTP /start endpoint: its guard rejects status 'starting'/'running' and
// would deadlock the failover. spawnChannelSidecar flips the channel to
// running (or leaves it 'error' and throws on spawn failure).
try {
await spawnChannelSidecar(moved[0]);
return { restarted: true, new_node_id: newNodeId };

View file

@ -3,12 +3,10 @@
import express from 'express';
import pool from '../db/pool.js';
import { hashPassword } from '../auth/passwords.js';
import { DEV_USER_ID, requireAdmin } from '../middleware/auth.js';
import { accessibleProjectIds } from '../auth/authz.js';
import { DEV_USER_ID } from '../middleware/auth.js';
const router = express.Router();
const MIN_PASSWORD_LEN = 12;
const ROLES = ['admin', 'editor', 'viewer'];
function bad(res, msg) { return res.status(400).json({ error: msg }); }
@ -16,7 +14,7 @@ function bad(res, msg) { return res.status(400).json({ error: msg }); }
router.get('/', async (_req, res, next) => {
try {
const { rows } = await pool.query(
`SELECT id, username, display_name, role, totp_enabled, last_login_at, created_at
`SELECT id, username, display_name, role, last_login_at, created_at
FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
res.json(rows);
} catch (err) { next(err); }
@ -28,7 +26,6 @@ router.post('/', async (req, res, next) => {
const { username, password, display_name, role } = req.body || {};
if (!username || typeof username !== 'string') return bad(res, 'username required');
if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
if (role !== undefined && !ROLES.includes(role)) return bad(res, "role must be one of: " + ROLES.join(', '));
const hash = await hashPassword(password);
const { rows } = await pool.query(
`INSERT INTO users (username, password_hash, display_name, role)
@ -79,10 +76,7 @@ router.patch('/:id', async (req, res, next) => {
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
const sets = []; const vals = [];
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
if (typeof req.body?.role === 'string') {
if (!ROLES.includes(req.body.role)) return bad(res, "role must be one of: " + ROLES.join(', '));
sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role);
}
if (typeof req.body?.role === 'string') { sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); }
if (typeof req.body?.password === 'string') {
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
@ -99,88 +93,4 @@ router.patch('/:id', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /:id/access — effective per-project access for one user (admin only).
// Reuses authz.accessibleProjectIds (MAX over direct user grant + every group the
// user belongs to). `via` is 'direct' for a user grant, 'group:<name>' otherwise.
// When the effective level comes from several sources we report the direct grant
// if present, else the first contributing group.
router.get('/:id/access', requireAdmin, async (req, res, next) => {
try {
const { rows: urows } = await pool.query(
`SELECT id, role FROM users WHERE id = $1`, [req.params.id]);
if (urows.length === 0) return res.status(404).json({ error: 'user not found' });
const target = urows[0];
const { rows: groups } = await pool.query(
`SELECT g.id, g.name
FROM user_groups ug JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = $1 ORDER BY g.name`, [target.id]);
// Admins bypass scoping — every project at 'edit', via their role.
const access = await accessibleProjectIds(target);
if (access.all) {
const { rows: projects } = await pool.query(
`SELECT id, name FROM projects ORDER BY name`);
return res.json({
projects: projects.map(p => ({
project_id: p.id, project_name: p.name, level: 'edit', via: 'direct',
})),
groups,
});
}
const ids = [...access.ids];
if (ids.length === 0) return res.json({ projects: [], groups });
// Resolve names + the source of each grant. groupNameById lets us label a
// group-sourced grant; a direct user grant always wins the `via` label.
const groupNameById = new Map(groups.map(g => [g.id, g.name]));
const { rows: grants } = await pool.query(
`SELECT pa.project_id, pa.subject_type, pa.subject_id, pa.level, p.name AS project_name
FROM project_access pa JOIN projects p ON p.id = pa.project_id
WHERE (pa.subject_type = 'user' AND pa.subject_id = $1)
OR (pa.subject_type = 'group' AND pa.subject_id IN (
SELECT group_id FROM user_groups WHERE user_id = $1
))`,
[target.id]);
const byProject = new Map();
for (const g of grants) {
const eff = access.levelByProject.get(g.project_id); // already the MAX
const via = g.subject_type === 'user'
? 'direct'
: 'group:' + (groupNameById.get(g.subject_id) || g.subject_id);
const prev = byProject.get(g.project_id);
// Keep a row only if it carries the effective level; prefer a direct grant
// when both a direct and a group grant hit the same level.
if (g.level === eff && (!prev || (prev.via !== 'direct' && via === 'direct'))) {
byProject.set(g.project_id, {
project_id: g.project_id, project_name: g.project_name, level: eff, via,
});
}
}
res.json({
projects: [...byProject.values()].sort((a, b) => a.project_name.localeCompare(b.project_name)),
groups,
});
} catch (err) { next(err); }
});
// POST /:id/totp/disable — admin clears a locked-out user's 2FA WITHOUT their
// password (the self-service /auth/totp/disable needs the victim's own). Mirrors
// that handler's SQL but targets :id and skips the password check. Dev user blocked.
router.post('/:id/totp/disable', requireAdmin, async (req, res, next) => {
try {
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
const { rowCount } = await pool.query(
`UPDATE users SET totp_enabled = FALSE, totp_secret = NULL, totp_last_counter = 0
WHERE id = $1 AND id <> $2`,
[req.params.id, DEV_USER_ID]);
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
await pool.query(`DELETE FROM user_recovery_codes WHERE user_id = $1`, [req.params.id]);
res.status(204).end();
} catch (err) { next(err); }
});
export default router;

View file

@ -34,7 +34,11 @@ async function callSelf(path, method = 'POST') {
return res.json().catch(() => ({}));
}
const SCHEDULER_LOCK_KEY = 8210301;
// 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]);
@ -53,9 +57,14 @@ async function tick() {
try {
haveLock = await tryAcquireSchedulerLock(client);
if (!haveLock) {
// Another replica is processing this interval — bail silently.
return;
}
// 1) Atomically claim pending schedules whose window has opened. The
// UPDATE...RETURNING flips status to 'running' in the same statement
// so even if another replica got past the lock (it can't, but
// belt-and-braces) each row can only be claimed once.
const dueStart = await client.query(
`UPDATE recorder_schedules
SET status = 'starting', updated_at = NOW()
@ -88,6 +97,7 @@ async function tick() {
}
}
// 2) Atomically claim running schedules whose window has closed.
const dueStop = await client.query(
`UPDATE recorder_schedules
SET status = 'stopping', updated_at = NOW()
@ -110,6 +120,7 @@ async function tick() {
console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
await enqueueNextOccurrence(s, client);
} catch (err) {
// Stop failed — flag as failed but don't keep trying forever.
await client.query(
`UPDATE recorder_schedules
SET status = 'failed', error_message = $2, updated_at = NOW()
@ -120,6 +131,7 @@ async function tick() {
}
}
// 3) If a schedule was cancelled while running, stop the recorder.
const cancelledRunning = await client.query(
`SELECT s.* FROM recorder_schedules s
JOIN recorders r ON r.id = s.recorder_id
@ -135,6 +147,9 @@ async function tick() {
}
}
// 4) Mark stale live assets as 'error' (#66).
// If a capture container crashes without calling mark-empty/mark-complete,
// the asset row stays status='live' indefinitely. Timeout after 2 hours.
const LIVE_TIMEOUT_MINUTES = parseInt(process.env.LIVE_ASSET_TIMEOUT_MINUTES || '120', 10);
const staleResult = await client.query(
`UPDATE assets
@ -151,6 +166,9 @@ async function tick() {
}
}
// 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')
@ -163,6 +181,11 @@ async function tick() {
await syncToAmpp(row.id, row.project_id, row.bin_id);
}
// 6) Playout channel health checks. Ping each running channel's sidecar
// /status; on success bump last_heartbeat_at, on failure increment a
// transient miss counter (in playout_sidecars.last_heartbeat_at age).
// Three consecutive misses → auto-restart on a healthy node (non-
// decklink), or alert-only for decklink.
await playoutHealthTick(client);
} catch (err) {
console.error('[scheduler] tick error:', err);
@ -192,21 +215,18 @@ async function enqueueNextOccurrence(schedule, client) {
// ── Playout channel health + failover ────────────────────────────────────────
// Tick step 6. Reuses the same advisory lock so only one replica probes the
// sidecars. A missed probe is counted via last_heartbeat_at age: > 3 *
// TICK_INTERVAL means 3 consecutive misses.
//
// IMPORTANT: when last_heartbeat_at is NULL (channel just spawned, no
// successful tick yet), use updated_at as the grace anchor — otherwise the
// "0" fallback makes ageMs huge and the channel is instantly failover-killed
// before its first heartbeat can ever land.
// sidecars; multi-replica pings would just waste cycles. A missed probe is
// counted via last_heartbeat_at age: > 3 * TICK_INTERVAL means 3 consecutive
// misses.
async function playoutHealthTick(client) {
let channels;
try {
({ rows: channels } = await client.query(
`SELECT id, output_type, container_meta, node_id, last_heartbeat_at, updated_at, restart_count
`SELECT id, output_type, container_meta, node_id, last_heartbeat_at, restart_count
FROM playout_channels WHERE status = 'running'`
));
} catch (err) {
// Migration 029 may not be applied yet — bail silently rather than crash.
if (err.code === '42P01') return;
throw err;
}
@ -224,11 +244,9 @@ async function playoutHealthTick(client) {
'UPDATE playout_channels SET last_heartbeat_at = NOW() WHERE id = $1', [ch.id]
);
} catch (err) {
const lastSeen = ch.last_heartbeat_at
? new Date(ch.last_heartbeat_at).getTime()
: new Date(ch.updated_at).getTime();
const lastSeen = ch.last_heartbeat_at ? new Date(ch.last_heartbeat_at).getTime() : 0;
const ageMs = Date.now() - lastSeen;
if (ageMs < TIMEOUT_MS) continue;
if (ageMs < TIMEOUT_MS) continue; // not yet 3 misses
if (ch.output_type === 'decklink') {
await client.query(
@ -241,6 +259,8 @@ async function playoutHealthTick(client) {
console.warn(`[scheduler] failover: channel ${ch.id} unreachable (${err.message}), restart #${ch.restart_count + 1}`);
try {
// restartChannel re-places the channel on a healthy node AND spawns the
// new sidecar directly (shared helper) — no /start self-call needed.
const res = await restartChannel(ch.id);
if (res.restarted) {
console.log(`[scheduler] failover: channel ${ch.id} re-placed on node ${res.new_node_id}`);
@ -257,6 +277,8 @@ async function playoutHealthTick(client) {
export function startSchedulerLoop() {
if (_interval) return;
console.log(`[scheduler] tick loop started (interval=${TICK_INTERVAL_MS}ms)`);
// Fire once on startup so a window that opened while the API was down
// doesn't have to wait a full interval.
setTimeout(() => tick().catch(() => {}), 2000);
_interval = setInterval(() => tick().catch(() => {}), TICK_INTERVAL_MS);
}

View file

@ -1,39 +1,46 @@
# Wild Dragon Playout sidecar — CasparCG Server + Node AMCP control shim.
FROM ubuntu:22.04
#
# CasparCG's mixer needs an OpenGL context. On a node with a real GPU we'd pass
# the device + driver through; for the headless / no-GPU case we run a virtual
# framebuffer (Xvfb) so the GL context initialises. The container is launched
# --privileged by mam-api (same as capture) so DeckLink / NDI hardware is
# reachable when present.
#
# NDI + DeckLink SDKs are NOT redistributable. They are fetched at build time
# from URLs supplied as build args (mirror them into your own artifact store);
# the build still succeeds without them (NDI/DeckLink consumers simply won't be
# available — SRT/RTMP/test output still work).
ARG CASPAR_VERSION=2.4.0-stable
ARG CASPAR_URL=https://github.com/CasparCG/server/releases/download/v2.4.0-stable/casparcg-server-v2.4.0-stable-ubuntu22.zip
FROM node:20-bookworm
ARG CASPAR_VERSION=2.3.3-stable
ARG CASPAR_URL=https://github.com/CasparCG/server/releases/download/v2.3.3-stable/CasparCG-Server-2.3.3-stable-Linux.tar.gz
ARG NDI_SDK_URL=
ENV DEBIAN_FRONTEND=noninteractive
# CEF (HTML producer) needs libnss3 + chromium runtime deps. Without these the
# server starts fine but SIGABRTs ~30s in when it lazy-inits CEF (NSS -8023).
# CasparCG 2.3 Linux runtime deps + Xvfb for headless GL + ffmpeg libs for the
# FFMPEG consumer (SRT/RTMP output).
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl unzip tar xz-utils gnupg \
xvfb libgl1-mesa-dri libglu1-mesa fonts-dejavu-core \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
libgbm1 libpango-1.0-0 libcairo2 libasound2 libatspi2.0-0 \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
ca-certificates curl tar xz-utils \
xvfb libgl1-mesa-glx libgl1-mesa-dri libglu1-mesa \
libx11-6 libxext6 libxrandr2 libxcursor1 libxinerama1 libxi6 \
libopenal1 libsndfile1 libavformat59 libavcodec59 libavfilter8 \
libswscale6 libswresample4 libpostproc56 fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /tmp/caspar
RUN set -eux; \
curl -fsSL "$CASPAR_URL" -o caspar.zip; \
unzip -q caspar.zip -d /opt; \
chmod +x /opt/casparcg_server/bin/casparcg /opt/casparcg_server/scanner 2>/dev/null || true; \
ls /opt/casparcg_server/; \
test -x /opt/casparcg_server/bin/casparcg; \
ln -sfn /opt/casparcg_server /opt/casparcg; \
echo "caspar binary: /opt/casparcg_server/bin/casparcg"; \
cd /; rm -rf /tmp/caspar
# ── CasparCG Server ──────────────────────────────────────────────────────────
WORKDIR /opt
RUN curl -fsSL "$CASPAR_URL" -o caspar.tar.gz \
&& mkdir -p /opt/casparcg \
&& tar xzf caspar.tar.gz -C /opt/casparcg --strip-components=1 \
&& rm caspar.tar.gz \
&& (test -f /opt/casparcg/casparcg || test -f /opt/casparcg/CasparCG\ Server || true)
# ── NDI runtime (optional) ───────────────────────────────────────────────────
# If an NDI SDK tarball URL is provided, extract its libs to /opt/ndi-lib and
# point CasparCG at them via NDI_RUNTIME_DIR_V6. Pin the SDK version to what the
# server expects (the common docker failure is a libndi .so version mismatch).
RUN if [ -n "$NDI_SDK_URL" ]; then \
mkdir -p /opt/ndi-lib && \
curl -fsSL "$NDI_SDK_URL" -o /tmp/ndi.tar.gz && \
@ -43,13 +50,16 @@ RUN if [ -n "$NDI_SDK_URL" ]; then \
fi
ENV NDI_RUNTIME_DIR_V6=/opt/ndi-lib
# CasparCG media folder — mam-api stages assets from S3 into this volume.
RUN mkdir -p /media
# ── Node control shim ────────────────────────────────────────────────────────
WORKDIR /app
COPY package*.json ./
RUN npm install --omit=dev
COPY . .
# CasparCG config + entrypoint
COPY casparcg.config /opt/casparcg/casparcg.config
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

View file

@ -2,10 +2,15 @@
<configuration>
<paths>
<media-path>/media/</media-path>
<log-path>/media/casparcg/log/</log-path>
<data-path>/media/casparcg/data/</data-path>
<log-path>/opt/casparcg/log/</log-path>
<data-path>/opt/casparcg/data/</data-path>
<template-path>/media/templates/</template-path>
</paths>
<!-- Single logical channel. The output consumer (DeckLink / NDI / SRT / RTMP)
is added at runtime over AMCP by the Node shim (playout-manager.js), so no
static consumer is declared here. A screen consumer is intentionally
omitted — this is a headless server. -->
<channels>
<channel>
<video-mode>1080i5994</video-mode>
@ -13,7 +18,9 @@
</consumers>
</channel>
</channels>
<controllers>
<!-- AMCP over TCP — the Node shim connects here. -->
<tcp>
<port>5250</port>
<protocol>AMCP</protocol>

View file

@ -1,44 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail
# Headless GL: start a virtual framebuffer unless a real DISPLAY is provided
# (a GPU node may pass through an X socket). CasparCG's mixer needs a GL context.
if [ -z "${DISPLAY:-}" ]; then
echo "[entrypoint] starting Xvfb on :99"
Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp &
export DISPLAY=:99
# Give Xvfb a moment to create the socket.
for i in $(seq 1 20); do
[ -e /tmp/.X11-unix/X99 ] && break
sleep 0.25
done
fi
# Ensure the HLS preview directory exists before CasparCG attaches its second
# FFMPEG consumer (mam-api serves /live/<channel_id>/* from the shared volume).
if [ -n "${CHANNEL_ID:-}" ]; then
mkdir -p "/media/live/${CHANNEL_ID}"
fi
mkdir -p /media/casparcg/log /media/casparcg/data /media/templates
# CEF (HTML producer) initialises an NSS database at /root/.pki/nssdb and
# Chrome caches under HOME. Pre-create writable dirs so CEF doesn't SIGABRT
# ~30s into the run when it first lazily inits.
mkdir -p /root/.pki/nssdb /root/.cache /tmp/cef-cache
chmod 700 /root/.pki/nssdb
export HOME=/root
# 2.4.x zip bundles its own .so files under lib/ — add to LD_LIBRARY_PATH.
export LD_LIBRARY_PATH="/opt/casparcg/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
# Launch CasparCG Server from its install dir (it reads ./casparcg.config and
# resolves relative media paths against the configured media folder).
cd /opt/casparcg
CASPAR_CFG=/opt/casparcg/casparcg.config
# 2.4.x: binary at bin/casparcg. 2.5.x: symlinked to casparcg at root.
if [ -x "./bin/casparcg" ]; then CASPAR_BIN="./bin/casparcg";
elif [ -x "./casparcg" ]; then CASPAR_BIN="./casparcg";
elif [ -x "./CasparCG Server" ]; then CASPAR_BIN="./CasparCG Server";
elif command -v casparcg >/dev/null; then CASPAR_BIN="casparcg";
else echo "[entrypoint] ERROR: casparcg binary not found"; exit 1; fi
echo "[entrypoint] launching CasparCG: $CASPAR_BIN $CASPAR_CFG"
"$CASPAR_BIN" "$CASPAR_CFG" &
CASPAR_BIN="./casparcg"
[ -x "$CASPAR_BIN" ] || CASPAR_BIN="./CasparCG Server"
echo "[entrypoint] launching CasparCG: $CASPAR_BIN"
"$CASPAR_BIN" &
CASPAR_PID=$!
# Forward termination to CasparCG so the channel closes cleanly.
term() {
echo "[entrypoint] terminating CasparCG ($CASPAR_PID)"
kill -TERM "$CASPAR_PID" 2>/dev/null || true
@ -47,6 +38,7 @@ term() {
}
trap term SIGTERM SIGINT
# Launch the Node control shim (foreground). If it exits, stop the container.
cd /app
node src/index.js &
NODE_PID=$!

View file

@ -38,12 +38,6 @@ window.PREMIERE_RELEASES = [
];
window.PREMIERE_LATEST = window.PREMIERE_RELEASES.find(r => r.latest) || window.PREMIERE_RELEASES[0];
// Teams ISO workstation installer. Placeholder slot: the .exe is not in the
// repo yet, so `available` is false and the Downloads modal renders the row
// disabled with a "coming soon" note. Drop the file into public/downloads/
// and flip `available: true` (set `version`) to finish it.
window.TEAMS_ISO = { version: null, url: '/downloads/TeamsISO.exe', available: false };
window.ZAMPP_DATA = {
PROJECTS: [],
ASSETS: [],

View file

@ -8,7 +8,7 @@ const ICONS = {
upload: <><path d="M12 16V4" /><path d="M6 10l6-6 6 6" /><path d="M4 20h16" /></>,
record: <><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M22 8l-6 4 6 4V8z" /></>,
capture: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="4" /><circle cx="12" cy="12" r="1" /></>,
jobs: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
jobs: <><path d="M3 6h18" /><path d="M3 12h18" /><path d="M3 18h12" /></>,
editor: <><path d="M14.06 2.94l7 7-11 11H3v-7.06l11.06-10.94z" /><path d="M13 4l7 7" /></>,
users: <><circle cx="9" cy="8" r="4" /><path d="M2 21a7 7 0 0 1 14 0" /><circle cx="17" cy="6" r="3" /><path d="M22 18a5 5 0 0 0-7-4.5" /></>,
token: <><circle cx="8" cy="15" r="4" /><path d="M10.85 12.15L19 4" /><path d="M18 5l3 3" /><path d="M15 8l3 3" /></>,
@ -38,14 +38,14 @@ const ICONS = {
x: <path d="M6 6l12 12M6 18L18 6" />,
filter: <path d="M3 5h18l-7 9v6l-4-2v-4L3 5z" />,
sort: <><path d="M3 6h13M3 12h9M3 18h5" /><path d="M17 14l3 3 3-3M20 9v8" /></>,
grid: <><rect x="3" y="3" width="7" height="7" rx="1" /><rect x="14" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" /><rect x="14" y="14" width="7" height="7" rx="1" /></>,
grid: <><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></>,
list: <><path d="M8 6h13M8 12h13M8 18h13" /><circle cx="4" cy="6" r="1" fill="currentColor" /><circle cx="4" cy="12" r="1" fill="currentColor" /><circle cx="4" cy="18" r="1" fill="currentColor" /></>,
comment: <path d="M21 11.5a8.4 8.4 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.4 8.4 0 0 1-3.8-.9L3 21l1.9-5.7a8.4 8.4 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.4 8.4 0 0 1 3.8-.9h.5a8.5 8.5 0 0 1 8 8v.5z" />,
clock: <><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></>,
layers: <><path d="M12 2L2 7l10 5 10-5-10-5z" /><path d="M2 17l10 5 10-5M2 12l10 5 10-5" /></>,
gpu: <><rect x="3" y="7" width="18" height="10" rx="1" /><rect x="6" y="10" width="4" height="4" /><rect x="14" y="10" width="4" height="4" /><path d="M3 11H1M3 13H1M23 11h-2M23 13h-2" /></>,
cpu: <><rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" /><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3" /></>,
hdd: <><ellipse cx="12" cy="6" rx="9" ry="3" /><path d="M3 6v12c0 1.66 4.03 3 9 3s9-1.34 9-3V6" /></>,
hdd: <><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="1" fill="currentColor" /></>,
sun: <><circle cx="12" cy="12" r="4" /><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" /></>,
moon: <path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />,
signal: <><path d="M2 20h.01M7 20v-4M12 20v-8M17 20V8M22 20V4" /></>,
@ -66,7 +66,7 @@ const ICONS = {
power: <><path d="M18.36 6.64a9 9 0 1 1-12.73 0" /><path d="M12 2v10" /></>,
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></>,
package: <><path d="M3 7l9-4 9 4M3 7v10l9 4 9-4V7M3 7l9 4 9-4M12 11v10" /></>,
proxy: <><path d="M4 6h11M19 6h1M4 12h2M10 12h10M4 18h7M15 18h5" /><circle cx="17" cy="6" r="2" /><circle cx="8" cy="12" r="2" /><circle cx="13" cy="18" r="2" /></>,
proxy: <><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 12l3-3 3 3M12 9v8" /></>,
};
function Icon({ name, size = 16, className, style }) {

View file

@ -258,7 +258,28 @@ function Users() {
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
{tab === 'policies' && (
<PoliciesPanel users={users} onChange={refreshUsers} />
<div className="panel" style={{ padding: '32px 24px', color: 'var(--text-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Icon name="lock" size={16} />
<div style={{ fontWeight: 600, fontSize: 14 }}>Access model</div>
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-3)', lineHeight: 1.7, maxWidth: 640 }}>
<div style={{ marginBottom: 8 }}>
<strong style={{ color: 'var(--text-2)' }}>admin</strong> full access to every
project plus user, group, cluster, and system administration.
</div>
<div style={{ marginBottom: 8 }}>
<strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> see only the
projects they've been granted. A <em>view</em> grant is read-only; an
<em> edit</em> grant allows changes. Grants can target an individual user or a group.
</div>
<div>
Manage a project's grants from the <strong style={{ color: 'var(--text-2)' }}>Projects</strong> page
a project's <em>Manage access</em> menu. Group membership is managed on the
Groups tab above.
</div>
</div>
</div>
)}
</div>
{showInvite && <InviteUserModal onCreated={onCreated} onClose={() => setShowInvite(false)} />}
@ -278,204 +299,6 @@ function Users() {
);
}
//
// PoliciesPanel - interactive per-user permission matrix for the Policies tab.
// Keeps the access-model explainer as a small header, then renders one row per
// user with: inline role <select> (PATCH /users/:id), a 2FA badge driven by
// totp_enabled, an admin-only "Reset 2FA" action (POST /users/:id/totp/disable,
// 204), and an Access expander backed by GET /users/:id/access.
//
function PoliciesPanel({ users, onChange }) {
const [expandedId, setExpandedId] = React.useState(null);
const [err, setErr] = React.useState(null);
const changeRole = (u, newRole) => {
if (u.role === newRole) return;
setErr(null);
window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) })
.then(() => onChange && onChange())
.catch(e => setErr('Role change failed: ' + (e.message || e)));
};
// Reset 2FA uses a raw fetch because ZAMPP_API.fetch throws on the 204 (no JSON
// body). Mirrors the disable() pattern in TotpSection.
const resetTotp = (u) => {
if (!confirm(`Reset two-factor for "${u.name}" (@${u.username})?\nThey will be able to sign in without a code until they re-enrol.`)) return;
setErr(null);
fetch('/api/v1/users/' + u.id + '/totp/disable', {
method: 'POST',
credentials: 'include',
headers: { 'X-Requested-With': 'dragonflight-ui' },
})
.then(r => {
if (r.status === 204) { onChange && onChange(); return; }
return r.json().catch(() => ({})).then(b => { throw new Error(b.error || ('Failed (' + r.status + ')')); });
})
.catch(e => setErr('Reset 2FA failed: ' + (e.message || e)));
};
return (
<div>
{/* Access-model explainer (kept from the old static tab, condensed) */}
<div className="panel" style={{ padding: '16px 20px', marginBottom: 12, color: 'var(--text-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<Icon name="lock" size={15} />
<div style={{ fontWeight: 600, fontSize: 13.5 }}>Access model</div>
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.6, maxWidth: 720 }}>
<strong style={{ color: 'var(--text-2)' }}>admin</strong> has full access to every project plus
user, group, cluster, and system administration. <strong style={{ color: 'var(--text-2)' }}>editor / viewer</strong> see
only the projects they're granted a <em>view</em> grant is read-only, an <em>edit</em> grant
allows changes, and grants can target a user or a group. Edit per-project grants from the{' '}
<a href="#" onClick={e => { e.preventDefault(); window.dispatchEvent(new CustomEvent('df:nav', { detail: 'projects' })); }}
style={{ color: 'var(--accent-text)' }}>Projects</a> page; manage group membership on the Groups tab above.
</div>
</div>
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginBottom: 8 }}>{err}</div>}
<div className="panel">
<div className="user-row head">
<div>User</div>
<div>Role</div>
<div>2FA</div>
<div>Access</div>
<div></div>
</div>
{users.length === 0 && (
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)' }}>No users found</div>
)}
{users.map(u => (
<UserPolicyRow key={u.id} user={u}
expanded={expandedId === u.id}
onToggle={() => setExpandedId(expandedId === u.id ? null : u.id)}
onChangeRole={changeRole}
onResetTotp={resetTotp} />
))}
</div>
</div>
);
}
function UserPolicyRow({ user: u, expanded, onToggle, onChangeRole, onResetTotp }) {
const [access, setAccess] = React.useState(null); // null = not loaded, {} once fetched
const [loading, setLoading] = React.useState(false);
const [accessErr, setAccessErr] = React.useState(null);
// Lazily fetch GET /users/:id/access the first time the row is expanded.
React.useEffect(() => {
if (!expanded || access !== null) return;
setLoading(true); setAccessErr(null);
window.ZAMPP_API.fetch('/users/' + u.id + '/access')
.then(d => setAccess(d || {}))
.catch(e => { setAccess({}); setAccessErr(e.message || 'Failed to load access'); })
.finally(() => setLoading(false));
}, [expanded, access, u.id]);
const projects = (access && access.projects) || [];
const memberships = (access && (access.groups || access.memberships)) || [];
return (
<div style={{ borderBottom: '1px solid var(--border)' }}>
<div className="user-row" style={{ borderBottom: 'none' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
<div>
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>@{u.username}</div>
</div>
</div>
<div>
<select value={u.role || 'viewer'}
onChange={e => onChangeRole(u, e.target.value)}
className="field-input"
style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}>
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="viewer">viewer</option>
</select>
</div>
<div>
{u.totp_enabled
? <span className="badge success"><Icon name="key" size={10} /> 2FA on</span>
: <span className="badge neutral">2FA off</span>}
</div>
<div>
<button className="btn ghost sm" onClick={onToggle}>
{expanded ? 'Hide' : 'View'}
</button>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
{u.totp_enabled && (
<button className="btn ghost sm danger" onClick={() => onResetTotp(u)} title="Disable this user's two-factor">
<Icon name="key" size={11} />Reset 2FA
</button>
)}
</div>
</div>
{expanded && (
<div style={{ padding: '0 16px 16px 16px', background: 'var(--bg-2)' }}>
{loading && <div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)' }}>Loading access</div>}
{accessErr && <div style={{ padding: '8px 0', fontSize: 12, color: 'var(--danger)' }}>{accessErr}</div>}
{!loading && !accessErr && (u.role === 'admin') && (
<div style={{ padding: '12px 0', fontSize: 12.5, color: 'var(--text-3)', display: 'flex', alignItems: 'center', gap: 6 }}>
<Icon name="check" size={12} style={{ color: 'var(--success)' }} />
Admin full access to every project.
</div>
)}
{!loading && !accessErr && u.role !== 'admin' && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, paddingTop: 12 }}>
{/* Accessible projects */}
<div>
<div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
Projects ({projects.length})
</div>
{projects.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--text-4)' }}>No project access granted.</div>
)}
{projects.map(p => {
// Backend `via` is 'direct' for a user grant, or 'group:<name>'
// when inherited from a group. Split the label off the prefix.
const via = p.via || 'direct';
const isGroup = via.indexOf('group') === 0;
const viaLabel = isGroup ? (via.indexOf(':') >= 0 ? via.slice(via.indexOf(':') + 1) : 'group') : 'direct';
return (
<div key={(p.project_id || p.id) + ':' + via}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 0', borderBottom: '1px solid var(--border)' }}>
<span style={{ fontSize: 12.5, flex: 1 }}>{p.project_name || p.name || p.project_id || p.id}</span>
<span className={`badge ${(p.level === 'edit') ? 'accent' : 'neutral'}`}>{p.level || 'view'}</span>
<span className="badge neutral" title={isGroup ? 'Inherited from group ' + viaLabel : 'Granted directly'}>
<Icon name={isGroup ? 'users' : 'user'} size={9} /> {viaLabel}
</span>
</div>
);
})}
</div>
{/* Group memberships */}
<div>
<div style={{ fontSize: 11, color: 'var(--text-3)', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, marginBottom: 8 }}>
Groups ({memberships.length})
</div>
{memberships.length === 0 && (
<div style={{ fontSize: 12, color: 'var(--text-4)' }}>Not a member of any group.</div>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{memberships.map(g => (
<span key={g.id || g.group_id || g.name} className="badge neutral" style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<Icon name="users" size={9} />{g.name || g.group_name || g.group_id}
</span>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
function EditUserModal({ user, onClose, onSaved }) {
const [name, setName] = React.useState(user.display_name || user.name || '');
const [saving, setSaving] = React.useState(false);

View file

@ -18,24 +18,17 @@
// Anything that would just say "all clear" is hidden, not rendered.
function Home({ navigate }) {
const [showDownloads, setShowDownloads] = React.useState(false);
const [showPremiereDownload, setShowPremiereDownload] = React.useState(false);
// Pull live counts so the tile subtitles ("34 assets", "0 live", "3 running")
// reflect what's actually in the DB right now, not a stale boot-time cache.
const [cards, setCards] = React.useState({});
// Playout has no /metrics/home card yet (and the playout schema may not be
// migrated on every install); fetch /playout/channels separately and degrade
// silently the tile just shows "No channels" if the endpoint isn't there.
const [playoutChannels, setPlayoutChannels] = React.useState(null);
React.useEffect(() => {
let cancelled = false;
const load = () => {
window.ZAMPP_API.fetch('/metrics/home?hours=1')
.then(d => { if (!cancelled) setCards(d?.cards || {}); })
.catch(() => {});
window.ZAMPP_API.fetch('/playout/channels')
.then(d => { if (!cancelled) setPlayoutChannels(Array.isArray(d) ? d : []); })
.catch(() => { if (!cancelled) setPlayoutChannels([]); });
};
load();
const t = setInterval(load, 30_000);
@ -71,27 +64,12 @@ function Home({ navigate }) {
desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.',
},
{
id: 'playout',
label: 'Playout',
icon: 'signal',
tone: 'accent',
sub: (() => {
if (playoutChannels === null) return '·';
const total = playoutChannels.length;
const onAir = playoutChannels.filter(c => c.status === 'running').length;
if (total === 0) return 'No channels';
if (onAir > 0) return onAir + ' on air · ' + total + ' channel' + (total === 1 ? '' : 's');
return total + ' channel' + (total === 1 ? '' : 's');
})(),
desc: 'Master Control. SDI · NDI · SRT · RTMP playout, playlists, as-run.',
},
{
id: '__downloads',
label: 'Downloads',
icon: 'download',
id: '__premiere',
label: 'Premiere panel',
icon: 'editor',
tone: 'purple',
sub: 'Plugin · Teams ISO',
desc: 'Download the Premiere Pro UXP plugin and the Teams ISO installer.',
sub: 'v' + ((window.PREMIERE_LATEST || {}).version || '·'),
desc: 'Download the Adobe Premiere Pro panel for frame-accurate editing.',
},
{
id: 'jobs',
@ -140,10 +118,7 @@ function Home({ navigate }) {
/>
<h1 className="launcher-wordmark">DRAGONFLIGHT</h1>
<p className="launcher-tagline">
Media Asset Management &amp; Production Platform
</p>
<p className="launcher-tagline launcher-tagline-motto">
Let's create
Self-hosted broadcast media-asset management
</p>
</div>
@ -152,7 +127,7 @@ function Home({ navigate }) {
<button
key={t.id}
className={'launcher-tile tone-' + t.tone}
onClick={() => t.id === '__downloads' ? setShowDownloads(true) : navigate(t.id)}
onClick={() => t.id === '__premiere' ? setShowPremiereDownload(true) : navigate(t.id)}
>
<span className="launcher-tile-icon">
<Icon name={t.icon} size={26} />
@ -171,7 +146,7 @@ function Home({ navigate }) {
onClick={() => navigate('dashboard')}
>
<span className="launcher-tile-icon">
<Icon name="layout" size={22} />
<Icon name="home" size={22} />
</span>
<span className="launcher-tile-label">Dashboard</span>
<span className="launcher-tile-sub">Operations view</span>
@ -266,17 +241,15 @@ function Home({ navigate }) {
)}
</div>
</div>
{showDownloads && <DownloadsModal onClose={() => setShowDownloads(false)} />}
{showPremiereDownload && <PremiereDownloadModal onClose={() => setShowPremiereDownload(false)} />}
</div>
);
}
// Modal listing all downloads: the Premiere Pro UXP plugin (.ccx, one per
// released version, sourced from window.PREMIERE_RELEASES written by the
// Settings SDKs section in screens-admin.jsx) plus the Teams ISO installer
// (window.TEAMS_ISO; the .exe slot is wired but the file may still be pending).
function DownloadsModal({ onClose }) {
const teamsIso = window.TEAMS_ISO || {};
// Modal listing all Premiere panel downloads (ZXP + Windows installer for
// each released version). Sourced from window.PREMIERE_RELEASES, written by
// the Settings SDKs section in screens-admin.jsx.
function PremiereDownloadModal({ onClose }) {
const releases = (window.PREMIERE_RELEASES || []).slice().sort((a, b) => {
// Newest first; fall back to lexicographic compare on version string.
const av = String(a.version || ''), bv = String(b.version || '');
@ -289,40 +262,15 @@ function DownloadsModal({ onClose }) {
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 560 }}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Downloads</div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Premiere panel</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>
The Premiere Pro (UXP) plugin and the Teams ISO installer. Install the .ccx per workstation via the Adobe UXP Developer Tool, or double-click it with Creative Cloud installed.
Adobe Premiere Pro (UXP) integration. Install the .ccx per workstation via the Adobe UXP Developer Tool, or double-click it with Creative Cloud installed.
</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
<div className="premiere-release">
<div className="premiere-release-head">
<span className="premiere-release-version mono">Teams ISO</span>
{teamsIso.version && (
<span className="premiere-release-date mono">v{teamsIso.version}</span>
)}
</div>
<div className="premiere-release-notes">
Windows installer for the Teams ISO workstation build.
</div>
<div className="premiere-release-actions">
{teamsIso.available && teamsIso.url ? (
<a href={teamsIso.url} download className="btn primary sm">
<Icon name="download" />Teams ISO (.exe)
</a>
) : (
<>
<span className="btn primary sm" aria-disabled="true" style={{ opacity: 0.5, pointerEvents: 'none' }}>
<Icon name="download" />Teams ISO (.exe)
</span>
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>coming soon file pending</span>
</>
)}
</div>
</div>
{releases.length === 0 && (
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12 }}>
No releases registered yet. Upload one from Settings Capture SDKs.

View file

@ -20,7 +20,7 @@ const NAV_SECTIONS = [
{ id: "upload", label: "Upload", icon: "upload" },
{ id: "youtube", label: "YouTube", icon: "download" },
{ id: "recorders", label: "Recorders", icon: "record" },
{ id: "schedule", label: "Schedule", icon: "clock" },
{ id: "schedule", label: "Schedule", icon: "jobs" },
{ id: "monitors", label: "Monitors", icon: "monitor" },
],
},
@ -28,7 +28,7 @@ const NAV_SECTIONS = [
label: "Operations",
items: [
{ id: "capture", label: "Capture", icon: "capture" },
{ id: "playout", label: "Playout", icon: "signal" },
{ id: "playout", label: "Playout", icon: "monitor" },
{ id: "jobs", label: "Jobs", icon: "jobs" },
],
},

View file

@ -324,14 +324,6 @@
letter-spacing: 0.02em;
}
.launcher-tagline-motto {
margin-top: 6px;
color: var(--accent);
font-style: italic;
font-size: 15px;
letter-spacing: 0.04em;
}
.launcher-grid {
width: 100%;
display: grid;