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:
claude 2026-05-23 03:19:24 +00:00
parent 6398879b56
commit 53196d38ce
8 changed files with 593 additions and 5 deletions

View file

@ -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);

View file

@ -27,6 +27,8 @@ import sequencesRouter from './routes/sequences.js';
import systemRouter from './routes/system.js';
import clusterRouter from './routes/cluster.js';
import sdkRouter from './routes/sdk.js';
import schedulesRouter from './routes/schedules.js';
import { startSchedulerLoop } from './scheduler.js';
const app = express();
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/cluster', clusterRouter);
app.use('/api/v1/sdk', sdkRouter);
app.use('/api/v1/schedules', schedulesRouter);
// ── Error handler ─────────────────────────────────────────────────────────────
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)';
console.log(`MAM API listening on port ${PORT}`);
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();
});

View 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;

View 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; }
}

View file

@ -38,6 +38,7 @@ function App() {
const labels = {
home: ['Home'], library: ['Library'], projects: ['Projects'],
upload: ['Ingest', 'Upload'], recorders: ['Ingest', 'Recorders'],
schedule: ['Ingest', 'Schedule'],
capture: ['Ingest', 'Capture'], monitors: ['Ingest', 'Monitors'],
jobs: ['Jobs'], editor: ['Editor'],
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 'upload': content = <Upload navigate={navigate} />; 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 'monitors': content = <Monitors navigate={navigate} />; break;
case 'jobs': content = <Jobs navigate={navigate} />; break;

View file

@ -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 });

View file

@ -9,6 +9,7 @@ const NAV_TREE = [
children: [
{ id: "upload", label: "Upload", icon: "upload" },
{ id: "recorders", label: "Recorders", icon: "record" },
{ id: "schedule", label: "Schedule", icon: "jobs" },
{ id: "capture", label: "Capture", icon: "capture" },
{ id: "monitors", label: "Monitors", icon: "monitor" },
],
@ -77,7 +78,7 @@ function Sidebar({ active, onNavigate }) {
};
React.useEffect(() => {
const ingestChildren = ["upload", "recorders", "capture", "monitors"];
const ingestChildren = ["upload", "recorders", "schedule", "capture", "monitors"];
if (ingestChildren.includes(active)) {
setOpenGroups(prev => prev.has("ingest") ? prev : new Set([...prev, "ingest"]));
}

View file

@ -564,7 +564,7 @@
}
/* ========== Admin tables ========== */
.user-row, .token-row, .container-row {
.user-row, .token-row, .container-row, .schedule-row {
display: grid;
align-items: center;
gap: 12px;
@ -575,14 +575,15 @@
.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; }
.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-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
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; }
/* ========== Cluster ========== */