feat(scheduler): recorder scheduling — UI, CRUD, tick loop, recurrence
- New Ingest → Schedule page: upcoming/past/all tabs, status badges (pending / recording / completed / cancelled / failed), 10s auto-refresh, cancel/delete actions - New Schedule modal: name, recorder dropdown, datetime-local start/end, recurrence (one-shot / daily / weekly), sensible defaults (+5min / +35min) - Backend: migration 009 (recorder_schedules), routes/schedules.js (list/create/edit/cancel/delete), scheduler.js tick loop polling every 15s; transitions trigger /recorders/:id/start and /stop via in-process HTTP so we reuse the full container orchestration path - Recurring schedules: tick loop auto-queues the next occurrence on completion (daily = +24h, weekly = +7d) - Sidebar + app.jsx route wired in, schedule-row table style added
This commit is contained in:
parent
6398879b56
commit
53196d38ce
8 changed files with 593 additions and 5 deletions
|
|
@ -0,0 +1,32 @@
|
||||||
|
-- Recorder schedules
|
||||||
|
--
|
||||||
|
-- Lets operators schedule a recorder to start at a future time and stop
|
||||||
|
-- after a duration. The scheduler tick loop in mam-api (src/scheduler.js)
|
||||||
|
-- watches this table every 15s and triggers the existing /recorders/:id
|
||||||
|
-- start + stop endpoints when each schedule's window opens or closes.
|
||||||
|
--
|
||||||
|
-- recurrence: 'none' (one-shot) or 'daily' for the MVP. When a 'daily'
|
||||||
|
-- schedule completes, the tick loop clones it forward by 24h.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recorder_schedules (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
recorder_id UUID NOT NULL REFERENCES recorders(id) ON DELETE CASCADE,
|
||||||
|
start_at TIMESTAMPTZ NOT NULL,
|
||||||
|
end_at TIMESTAMPTZ NOT NULL,
|
||||||
|
recurrence TEXT NOT NULL DEFAULT 'none',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
last_asset_id UUID,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CHECK (end_at > start_at),
|
||||||
|
CHECK (recurrence IN ('none','daily','weekly')),
|
||||||
|
CHECK (status IN ('pending','running','completed','failed','cancelled'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recorder_schedules_status_start
|
||||||
|
ON recorder_schedules (status, start_at);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_recorder_schedules_recorder
|
||||||
|
ON recorder_schedules (recorder_id);
|
||||||
|
|
@ -27,6 +27,8 @@ import sequencesRouter from './routes/sequences.js';
|
||||||
import systemRouter from './routes/system.js';
|
import systemRouter from './routes/system.js';
|
||||||
import clusterRouter from './routes/cluster.js';
|
import clusterRouter from './routes/cluster.js';
|
||||||
import sdkRouter from './routes/sdk.js';
|
import sdkRouter from './routes/sdk.js';
|
||||||
|
import schedulesRouter from './routes/schedules.js';
|
||||||
|
import { startSchedulerLoop } from './scheduler.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
@ -76,6 +78,7 @@ app.use('/api/v1/sequences', sequencesRouter);
|
||||||
app.use('/api/v1/system', systemRouter);
|
app.use('/api/v1/system', systemRouter);
|
||||||
app.use('/api/v1/cluster', clusterRouter);
|
app.use('/api/v1/cluster', clusterRouter);
|
||||||
app.use('/api/v1/sdk', sdkRouter);
|
app.use('/api/v1/sdk', sdkRouter);
|
||||||
|
app.use('/api/v1/schedules', schedulesRouter);
|
||||||
|
|
||||||
// ── Error handler ─────────────────────────────────────────────────────────────
|
// ── Error handler ─────────────────────────────────────────────────────────────
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
@ -184,4 +187,7 @@ app.listen(PORT, () => {
|
||||||
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
|
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
|
||||||
console.log(`MAM API listening on port ${PORT}`);
|
console.log(`MAM API listening on port ${PORT}`);
|
||||||
console.log(`Authentication: ${authMode}`);
|
console.log(`Authentication: ${authMode}`);
|
||||||
|
// 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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
152
services/mam-api/src/routes/schedules.js
Normal file
152
services/mam-api/src/routes/schedules.js
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
// Recorder scheduler — CRUD for upcoming + historic recording windows.
|
||||||
|
//
|
||||||
|
// The actual start/stop transitions happen in src/scheduler.js; this route
|
||||||
|
// just owns the recorder_schedules rows.
|
||||||
|
|
||||||
|
import express from 'express';
|
||||||
|
import pool from '../db/pool.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
const ALLOWED_RECURRENCE = new Set(['none', 'daily', 'weekly']);
|
||||||
|
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
||||||
|
|
||||||
|
function rowToJson(r) {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
recorder_id: r.recorder_id,
|
||||||
|
recorder_name: r.recorder_name || null,
|
||||||
|
start_at: r.start_at,
|
||||||
|
end_at: r.end_at,
|
||||||
|
recurrence: r.recurrence,
|
||||||
|
status: r.status,
|
||||||
|
last_asset_id: r.last_asset_id,
|
||||||
|
error_message: r.error_message,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/schedules?status=upcoming|past|all
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const status = (req.query.status || 'all').toLowerCase();
|
||||||
|
let where = 'TRUE';
|
||||||
|
if (status === 'upcoming') where = `(s.status IN ('pending','running') OR s.end_at >= NOW() - INTERVAL '1 hour')`;
|
||||||
|
else if (status === 'past') where = `s.status IN ('completed','failed','cancelled') AND s.end_at < NOW()`;
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT s.*, r.name AS recorder_name
|
||||||
|
FROM recorder_schedules s
|
||||||
|
LEFT JOIN recorders r ON r.id = s.recorder_id
|
||||||
|
WHERE ${where}
|
||||||
|
ORDER BY s.start_at ASC
|
||||||
|
LIMIT 200`
|
||||||
|
);
|
||||||
|
res.json({ schedules: result.rows.map(rowToJson) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/schedules
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, recorder_id, start_at, end_at, recurrence } = req.body || {};
|
||||||
|
if (!name || !recorder_id || !start_at || !end_at) {
|
||||||
|
return res.status(400).json({ error: 'name, recorder_id, start_at and end_at are required' });
|
||||||
|
}
|
||||||
|
const rec = (recurrence || 'none').toLowerCase();
|
||||||
|
if (!ALLOWED_RECURRENCE.has(rec)) {
|
||||||
|
return res.status(400).json({ error: `recurrence must be one of: ${[...ALLOWED_RECURRENCE].join(', ')}` });
|
||||||
|
}
|
||||||
|
if (new Date(end_at) <= new Date(start_at)) {
|
||||||
|
return res.status(400).json({ error: 'end_at must be after start_at' });
|
||||||
|
}
|
||||||
|
// Make sure the recorder exists before binding to it.
|
||||||
|
const rExists = await pool.query('SELECT id FROM recorders WHERE id = $1', [recorder_id]);
|
||||||
|
if (rExists.rows.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Unknown recorder_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ins = await pool.query(
|
||||||
|
`INSERT INTO recorder_schedules (name, recorder_id, start_at, end_at, recurrence, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'pending')
|
||||||
|
RETURNING *`,
|
||||||
|
[name.trim(), recorder_id, start_at, end_at, rec]
|
||||||
|
);
|
||||||
|
res.status(201).json(rowToJson(ins.rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/v1/schedules/:id — edit a not-yet-started schedule
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const current = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
|
||||||
|
if (current.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
|
||||||
|
if (current.rows[0].status === 'running') {
|
||||||
|
return res.status(400).json({ error: 'Cannot edit a running schedule; cancel it first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = ['name','start_at','end_at','recurrence'];
|
||||||
|
const updates = [];
|
||||||
|
const values = [];
|
||||||
|
let i = 1;
|
||||||
|
for (const f of fields) {
|
||||||
|
if (req.body[f] !== undefined) {
|
||||||
|
if (f === 'recurrence' && !ALLOWED_RECURRENCE.has(String(req.body[f]).toLowerCase())) {
|
||||||
|
return res.status(400).json({ error: 'invalid recurrence' });
|
||||||
|
}
|
||||||
|
updates.push(`${f} = $${i++}`);
|
||||||
|
values.push(req.body[f]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updates.length === 0) return res.json(rowToJson(current.rows[0]));
|
||||||
|
updates.push('updated_at = NOW()');
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE recorder_schedules SET ${updates.join(', ')} WHERE id = $${i} RETURNING *`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
res.json(rowToJson(result.rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v1/schedules/:id/cancel — cancel a pending or running schedule
|
||||||
|
router.post('/:id/cancel', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const cur = await pool.query('SELECT * FROM recorder_schedules WHERE id = $1', [id]);
|
||||||
|
if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
|
||||||
|
if (TERMINAL.has(cur.rows[0].status)) {
|
||||||
|
return res.status(400).json({ error: `Schedule is already ${cur.rows[0].status}` });
|
||||||
|
}
|
||||||
|
// Just mark as cancelled — the tick loop will stop the recorder if it's
|
||||||
|
// currently running and the schedule has just been cancelled.
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE recorder_schedules SET status = 'cancelled', updated_at = NOW()
|
||||||
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
res.json(rowToJson(result.rows[0]));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/v1/schedules/:id — hard delete (terminal schedules only)
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const cur = await pool.query('SELECT status FROM recorder_schedules WHERE id = $1', [id]);
|
||||||
|
if (cur.rows.length === 0) return res.status(404).json({ error: 'Schedule not found' });
|
||||||
|
if (!TERMINAL.has(cur.rows[0].status) && cur.rows[0].status !== 'pending') {
|
||||||
|
return res.status(400).json({ error: 'Cancel a running schedule before deleting' });
|
||||||
|
}
|
||||||
|
await pool.query('DELETE FROM recorder_schedules WHERE id = $1', [id]);
|
||||||
|
res.json({ message: 'Schedule deleted' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
140
services/mam-api/src/scheduler.js
Normal file
140
services/mam-api/src/scheduler.js
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
// Scheduler tick — every TICK_INTERVAL_MS scan the recorder_schedules table
|
||||||
|
// and transition any rows whose window opens or closes. The actual recorder
|
||||||
|
// start/stop is delegated to the existing /recorders/:id/start|stop routes
|
||||||
|
// via an in-process HTTP call, so we reuse all of the existing container
|
||||||
|
// orchestration, growing-files handling, asset row creation, etc.
|
||||||
|
//
|
||||||
|
// On schedule completion: a 'daily' or 'weekly' recurring schedule is cloned
|
||||||
|
// forward by 1 day / 7 days into a new 'pending' row.
|
||||||
|
|
||||||
|
import pool from './db/pool.js';
|
||||||
|
|
||||||
|
const TICK_INTERVAL_MS = parseInt(process.env.SCHEDULER_TICK_MS || '15000', 10);
|
||||||
|
const SELF_URL = process.env.MAM_API_SELF_URL || `http://127.0.0.1:${process.env.PORT || 3000}`;
|
||||||
|
|
||||||
|
let _tickRunning = false;
|
||||||
|
let _interval = null;
|
||||||
|
|
||||||
|
async function callSelf(path, method = 'POST') {
|
||||||
|
const res = await fetch(`${SELF_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal: AbortSignal.timeout(30000),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`${method} ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
return res.json().catch(() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
if (_tickRunning) return;
|
||||||
|
_tickRunning = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) Start any pending schedules whose window has opened
|
||||||
|
const dueStart = await pool.query(
|
||||||
|
`SELECT * FROM recorder_schedules
|
||||||
|
WHERE status = 'pending' AND start_at <= NOW() AND end_at > NOW()
|
||||||
|
ORDER BY start_at ASC`
|
||||||
|
);
|
||||||
|
for (const s of dueStart.rows) {
|
||||||
|
try {
|
||||||
|
const result = await callSelf(`/api/v1/recorders/${s.recorder_id}/start`);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE recorder_schedules
|
||||||
|
SET status = 'running', last_asset_id = NULL, updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[s.id]
|
||||||
|
);
|
||||||
|
console.log(`[scheduler] started schedule "${s.name}" on recorder ${s.recorder_id} (session=${result.current_session_id || '?'})`);
|
||||||
|
} catch (err) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE recorder_schedules
|
||||||
|
SET status = 'failed', error_message = $2, updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[s.id, err.message.slice(0, 500)]
|
||||||
|
);
|
||||||
|
console.error(`[scheduler] start failed for schedule ${s.id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Stop any running schedules whose window has closed
|
||||||
|
const dueStop = await pool.query(
|
||||||
|
`SELECT * FROM recorder_schedules
|
||||||
|
WHERE status = 'running' AND end_at <= NOW()
|
||||||
|
ORDER BY end_at ASC`
|
||||||
|
);
|
||||||
|
for (const s of dueStop.rows) {
|
||||||
|
try {
|
||||||
|
await callSelf(`/api/v1/recorders/${s.recorder_id}/stop`);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE recorder_schedules SET status = 'completed', updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[s.id]
|
||||||
|
);
|
||||||
|
console.log(`[scheduler] stopped schedule "${s.name}" on recorder ${s.recorder_id}`);
|
||||||
|
await enqueueNextOccurrence(s);
|
||||||
|
} catch (err) {
|
||||||
|
// Stop failed — flag as failed but don't keep trying forever.
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE recorder_schedules
|
||||||
|
SET status = 'failed', error_message = $2, updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[s.id, ('stop: ' + err.message).slice(0, 500)]
|
||||||
|
);
|
||||||
|
console.error(`[scheduler] stop failed for schedule ${s.id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) If a schedule was cancelled while running, stop the recorder.
|
||||||
|
const cancelledRunning = await pool.query(
|
||||||
|
`SELECT s.* FROM recorder_schedules s
|
||||||
|
JOIN recorders r ON r.id = s.recorder_id
|
||||||
|
WHERE s.status = 'cancelled' AND r.status = 'recording'
|
||||||
|
AND s.updated_at > NOW() - INTERVAL '5 minutes'`
|
||||||
|
);
|
||||||
|
for (const s of cancelledRunning.rows) {
|
||||||
|
try {
|
||||||
|
await callSelf(`/api/v1/recorders/${s.recorder_id}/stop`);
|
||||||
|
console.log(`[scheduler] cancelled schedule "${s.name}" — stopped recorder ${s.recorder_id}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[scheduler] cancel-stop failed for ${s.id}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[scheduler] tick error:', err);
|
||||||
|
} finally {
|
||||||
|
_tickRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enqueueNextOccurrence(schedule) {
|
||||||
|
if (schedule.recurrence === 'none') return;
|
||||||
|
const days = schedule.recurrence === 'weekly' ? 7 : 1;
|
||||||
|
const start = new Date(schedule.start_at);
|
||||||
|
const end = new Date(schedule.end_at);
|
||||||
|
start.setUTCDate(start.getUTCDate() + days);
|
||||||
|
end.setUTCDate(end.getUTCDate() + days);
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO recorder_schedules
|
||||||
|
(name, recorder_id, start_at, end_at, recurrence, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'pending')`,
|
||||||
|
[schedule.name, schedule.recorder_id, start.toISOString(), end.toISOString(), schedule.recurrence]
|
||||||
|
);
|
||||||
|
console.log(`[scheduler] queued next "${schedule.name}" → ${start.toISOString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopSchedulerLoop() {
|
||||||
|
if (_interval) { clearInterval(_interval); _interval = null; }
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,7 @@ function App() {
|
||||||
const labels = {
|
const labels = {
|
||||||
home: ['Home'], library: ['Library'], projects: ['Projects'],
|
home: ['Home'], library: ['Library'], projects: ['Projects'],
|
||||||
upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
|
upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
|
||||||
|
schedule: ['Ingest', 'Schedule'],
|
||||||
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
|
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
|
||||||
jobs: ['Jobs'], editor: ['Editor'],
|
jobs: ['Jobs'], editor: ['Editor'],
|
||||||
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
|
users: ['Admin', 'Users & Groups'], tokens: ['Admin', 'Tokens'],
|
||||||
|
|
@ -69,6 +70,7 @@ function App() {
|
||||||
case 'projects': content = <Projects navigate={navigate} onOpenProject={(p) => { setOpenProject(p); setRoute('library'); }} />; break;
|
case 'projects': content = <Projects navigate={navigate} onOpenProject={(p) => { setOpenProject(p); setRoute('library'); }} />; break;
|
||||||
case 'upload': content = <Upload navigate={navigate} />; break;
|
case 'upload': content = <Upload navigate={navigate} />; break;
|
||||||
case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
|
case 'recorders': content = <Recorders navigate={navigate} onNew={() => setShowNewRecorder(true)} />; break;
|
||||||
|
case 'schedule': content = <Schedule navigate={navigate} />; break;
|
||||||
case 'capture': content = <Capture navigate={navigate} />; break;
|
case 'capture': content = <Capture navigate={navigate} />; break;
|
||||||
case 'monitors': content = <Monitors navigate={navigate} />; break;
|
case 'monitors': content = <Monitors navigate={navigate} />; break;
|
||||||
case 'jobs': content = <Jobs navigate={navigate} />; break;
|
case 'jobs': content = <Jobs navigate={navigate} />; break;
|
||||||
|
|
|
||||||
|
|
@ -602,4 +602,258 @@ function MonitorTile({ feed, seed }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(window, { Upload, Recorders, Capture, Monitors });
|
/* ===== Schedule ===== */
|
||||||
|
|
||||||
|
const _STATUS_BADGE = {
|
||||||
|
pending: { cls: 'neutral', label: 'pending' },
|
||||||
|
running: { cls: 'success', label: 'recording' },
|
||||||
|
completed: { cls: 'accent', label: 'completed' },
|
||||||
|
cancelled: { cls: 'warning', label: 'cancelled' },
|
||||||
|
failed: { cls: 'danger', label: 'failed' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function _fmtWhen(iso) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
// Local-time, short, human; e.g. "May 22 · 7:30 PM"
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: 'numeric', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _durationMin(startISO, endISO) {
|
||||||
|
return Math.round((new Date(endISO) - new Date(startISO)) / 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Schedule({ navigate }) {
|
||||||
|
const [schedules, setSchedules] = React.useState(null);
|
||||||
|
const [recorders, setRecorders] = React.useState([]);
|
||||||
|
const [showNew, setShowNew] = React.useState(false);
|
||||||
|
const [filter, setFilter] = React.useState('upcoming');
|
||||||
|
|
||||||
|
const load = React.useCallback(() => {
|
||||||
|
window.ZAMPP_API.fetch('/schedules?status=' + filter)
|
||||||
|
.then(d => setSchedules(d.schedules || []))
|
||||||
|
.catch(() => setSchedules([]));
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
React.useEffect(() => { load(); }, [load]);
|
||||||
|
React.useEffect(() => { window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([])); }, []);
|
||||||
|
|
||||||
|
// Auto-refresh every 10s so the list reflects the tick loop's transitions
|
||||||
|
React.useEffect(() => {
|
||||||
|
const t = setInterval(load, 10_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const cancel = (s) => {
|
||||||
|
if (!confirm(`Cancel scheduled recording "${s.name}"?`)) return;
|
||||||
|
window.ZAMPP_API.fetch('/schedules/' + s.id + '/cancel', { method: 'POST' })
|
||||||
|
.then(load)
|
||||||
|
.catch(e => alert('Cancel failed: ' + e.message));
|
||||||
|
};
|
||||||
|
const remove = (s) => {
|
||||||
|
if (!confirm(`Delete schedule "${s.name}"?`)) return;
|
||||||
|
window.ZAMPP_API.fetch('/schedules/' + s.id, { method: 'DELETE' })
|
||||||
|
.then(load)
|
||||||
|
.catch(e => alert('Delete failed: ' + e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1>Schedule</h1>
|
||||||
|
<span className="subtitle">Plan recorder windows · {schedules?.length || 0} {filter}</span>
|
||||||
|
<div className="spacer" />
|
||||||
|
<div className="tab-group" style={{ marginRight: 8 }}>
|
||||||
|
<button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button>
|
||||||
|
<button className={filter === 'past' ? 'active' : ''} onClick={() => setFilter('past')}>Past</button>
|
||||||
|
<button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button>
|
||||||
|
</div>
|
||||||
|
<button className="btn ghost sm" onClick={load}><Icon name="refresh" />Refresh</button>
|
||||||
|
<button className="btn primary" onClick={() => setShowNew(true)} disabled={recorders.length === 0}>
|
||||||
|
<Icon name="plus" />New schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="page-body">
|
||||||
|
{schedules === null && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading…</div>
|
||||||
|
)}
|
||||||
|
{schedules !== null && schedules.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>
|
||||||
|
<div style={{ fontSize: 32, marginBottom: 12 }}>📅</div>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 14 }}>No {filter} recordings</div>
|
||||||
|
{filter === 'upcoming' && recorders.length === 0 && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: 6 }}>Create a recorder first, then schedule it here.</div>
|
||||||
|
)}
|
||||||
|
{filter === 'upcoming' && recorders.length > 0 && (
|
||||||
|
<div style={{ fontSize: 12, marginTop: 6 }}>Click <em>New schedule</em> to plan a future recording.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{schedules !== null && schedules.length > 0 && (
|
||||||
|
<div className="panel">
|
||||||
|
<div className="schedule-row head">
|
||||||
|
<div>Name</div>
|
||||||
|
<div>Recorder</div>
|
||||||
|
<div>Starts</div>
|
||||||
|
<div>Duration</div>
|
||||||
|
<div>Recurrence</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
{schedules.map(s => {
|
||||||
|
const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
|
||||||
|
return (
|
||||||
|
<div key={s.id} className="schedule-row">
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 13 }}>
|
||||||
|
{s.name}
|
||||||
|
{s.error_message && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--danger)', marginTop: 3 }}>{s.error_message}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-2)' }}>{s.recorder_name || s.recorder_id.slice(0, 8)}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11.5 }}>{_fmtWhen(s.start_at)}</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11.5 }}>{_durationMin(s.start_at, s.end_at)} min</div>
|
||||||
|
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{s.recurrence === 'none' ? 'one-shot' : s.recurrence}</div>
|
||||||
|
<div><span className={`badge ${badge.cls}`}>{badge.label}</span></div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
||||||
|
{(s.status === 'pending' || s.status === 'running') && (
|
||||||
|
<button className="btn ghost sm" onClick={() => cancel(s)}>Cancel</button>
|
||||||
|
)}
|
||||||
|
{(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && (
|
||||||
|
<button className="btn ghost sm" onClick={() => remove(s)} title="Delete schedule row">Delete</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showNew && <NewScheduleModal recorders={recorders} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewScheduleModal({ recorders, onClose, onCreated }) {
|
||||||
|
// Default: start in 5 minutes, run for 30 min — gives the operator something
|
||||||
|
// sensible the moment the modal opens.
|
||||||
|
const now = new Date();
|
||||||
|
now.setSeconds(0, 0);
|
||||||
|
const startDefault = new Date(now.getTime() + 5 * 60 * 1000);
|
||||||
|
const endDefault = new Date(now.getTime() + 35 * 60 * 1000);
|
||||||
|
const toLocalInput = (d) => {
|
||||||
|
const tz = d.getTimezoneOffset() * 60_000;
|
||||||
|
return new Date(d.getTime() - tz).toISOString().slice(0, 16);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [form, setForm] = React.useState({
|
||||||
|
name: '',
|
||||||
|
recorder_id: recorders[0]?.id || '',
|
||||||
|
start_at: toLocalInput(startDefault),
|
||||||
|
end_at: toLocalInput(endDefault),
|
||||||
|
recurrence: 'none',
|
||||||
|
});
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
const [err, setErr] = React.useState(null);
|
||||||
|
|
||||||
|
const set = (k, v) => setForm(p => ({ ...p, [k]: v }));
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
setErr(null);
|
||||||
|
if (!form.name.trim()) return setErr('Name is required');
|
||||||
|
if (!form.recorder_id) return setErr('Pick a recorder');
|
||||||
|
if (!form.start_at) return setErr('Start time is required');
|
||||||
|
if (!form.end_at) return setErr('End time is required');
|
||||||
|
if (new Date(form.end_at) <= new Date(form.start_at)) return setErr('End must be after start');
|
||||||
|
|
||||||
|
// Datetime-local inputs are in the browser's local zone; ship as ISO so
|
||||||
|
// Postgres stores them as TIMESTAMPTZ properly.
|
||||||
|
const body = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
recorder_id: form.recorder_id,
|
||||||
|
start_at: new Date(form.start_at).toISOString(),
|
||||||
|
end_at: new Date(form.end_at).toISOString(),
|
||||||
|
recurrence: form.recurrence,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
window.ZAMPP_API.fetch('/schedules', { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
.then(() => onCreated())
|
||||||
|
.catch(e => { setSaving(false); setErr(e.message || 'Failed to schedule'); });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKey = (e) => { if (e.key === 'Enter' && !e.shiftKey) submit(); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop" onClick={onClose}>
|
||||||
|
<div className="modal" style={{ width: 480 }} onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-head">
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600 }}>New scheduled recording</div>
|
||||||
|
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Name</label>
|
||||||
|
<input className="field-input" autoFocus value={form.name}
|
||||||
|
onChange={e => set('name', e.target.value)}
|
||||||
|
onKeyDown={onKey} placeholder="Morning service stream" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Recorder</label>
|
||||||
|
<select className="field-input" value={form.recorder_id}
|
||||||
|
onChange={e => set('recorder_id', e.target.value)}
|
||||||
|
style={{ appearance: 'auto' }}>
|
||||||
|
{recorders.length === 0 && <option value="">— No recorders defined —</option>}
|
||||||
|
{recorders.map(r => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.name} · {r.source_type?.toUpperCase() || '?'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Start</label>
|
||||||
|
<input className="field-input mono" type="datetime-local"
|
||||||
|
value={form.start_at}
|
||||||
|
onChange={e => set('start_at', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">End</label>
|
||||||
|
<input className="field-input mono" type="datetime-local"
|
||||||
|
value={form.end_at}
|
||||||
|
onChange={e => set('end_at', e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Recurrence</label>
|
||||||
|
<select className="field-input" value={form.recurrence}
|
||||||
|
onChange={e => set('recurrence', e.target.value)}
|
||||||
|
style={{ appearance: 'auto' }}>
|
||||||
|
<option value="none">One-shot (no repeat)</option>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="weekly">Weekly</option>
|
||||||
|
</select>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>
|
||||||
|
Recurring schedules queue the next occurrence as soon as the current one completes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="modal-foot">
|
||||||
|
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn primary sm" onClick={submit} disabled={saving}>
|
||||||
|
{saving ? 'Scheduling…' : 'Schedule recording'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule });
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const NAV_TREE = [
|
||||||
children: [
|
children: [
|
||||||
{ id: "upload", label: "Upload", icon: "upload" },
|
{ id: "upload", label: "Upload", icon: "upload" },
|
||||||
{ id: "recorders", label: "Recorders", icon: "record" },
|
{ id: "recorders", label: "Recorders", icon: "record" },
|
||||||
|
{ id: "schedule", label: "Schedule", icon: "jobs" },
|
||||||
{ id: "capture", label: "Capture", icon: "capture" },
|
{ id: "capture", label: "Capture", icon: "capture" },
|
||||||
{ id: "monitors", label: "Monitors", icon: "monitor" },
|
{ id: "monitors", label: "Monitors", icon: "monitor" },
|
||||||
],
|
],
|
||||||
|
|
@ -77,7 +78,7 @@ function Sidebar({ active, onNavigate }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const ingestChildren = ["upload", "recorders", "capture", "monitors"];
|
const ingestChildren = ["upload", "recorders", "schedule", "capture", "monitors"];
|
||||||
if (ingestChildren.includes(active)) {
|
if (ingestChildren.includes(active)) {
|
||||||
setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
|
setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -564,7 +564,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== Admin tables ========== */
|
/* ========== Admin tables ========== */
|
||||||
.user-row, .token-row, .container-row {
|
.user-row, .token-row, .container-row, .schedule-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -575,14 +575,15 @@
|
||||||
.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
|
.user-row { grid-template-columns: 1.5fr 100px 1.5fr 120px 40px; }
|
||||||
.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
|
.token-row { grid-template-columns: 1.4fr 1.4fr 110px 110px 100px 40px; }
|
||||||
.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
|
.container-row { grid-template-columns: 1.4fr 1.4fr 140px 140px 100px 1.4fr 110px; }
|
||||||
.user-row.head, .token-row.head, .container-row.head {
|
.schedule-row { grid-template-columns: 1.6fr 1.2fr 1.2fr 90px 110px 110px 150px; }
|
||||||
|
.user-row.head, .token-row.head, .container-row.head, .schedule-row.head {
|
||||||
font-size: 10.5px;
|
font-size: 10.5px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--text-4);
|
color: var(--text-4);
|
||||||
}
|
}
|
||||||
.user-row:last-child, .token-row:last-child, .container-row:last-child { border-bottom: 0; }
|
.user-row:last-child, .token-row:last-child, .container-row:last-child, .schedule-row:last-child { border-bottom: 0; }
|
||||||
.token-row.revoked { opacity: 0.5; }
|
.token-row.revoked { opacity: 0.5; }
|
||||||
|
|
||||||
/* ========== Cluster ========== */
|
/* ========== Cluster ========== */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue