Compare commits
No commits in common. "main" and "feat/playout-mcr" have entirely different histories.
main
...
feat/playo
15 changed files with 464 additions and 516 deletions
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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=$!
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 & 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.
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue