diff --git a/services/mam-api/src/db/migrations/009-recorder-schedules.sql b/services/mam-api/src/db/migrations/009-recorder-schedules.sql new file mode 100644 index 0000000..67864f4 --- /dev/null +++ b/services/mam-api/src/db/migrations/009-recorder-schedules.sql @@ -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); diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 3f9c144..bb77e3f 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -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(); }); diff --git a/services/mam-api/src/routes/schedules.js b/services/mam-api/src/routes/schedules.js new file mode 100644 index 0000000..aee4e7b --- /dev/null +++ b/services/mam-api/src/routes/schedules.js @@ -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; diff --git a/services/mam-api/src/scheduler.js b/services/mam-api/src/scheduler.js new file mode 100644 index 0000000..5caf3e2 --- /dev/null +++ b/services/mam-api/src/scheduler.js @@ -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; } +} diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx index 68b112d..09a861b 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -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 = { setOpenProject(p); setRoute('library'); }} />; break; case 'upload': content = ; break; case 'recorders': content = setShowNewRecorder(true)} />; break; + case 'schedule': content = ; break; case 'capture': content = ; break; case 'monitors': content = ; break; case 'jobs': content = ; break; diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 51f07b2..ebd5972 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -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 ( +
+
+

Schedule

+ Plan recorder windows · {schedules?.length || 0} {filter} +
+
+ + + +
+ + +
+ +
+ {schedules === null && ( +
Loading…
+ )} + {schedules !== null && schedules.length === 0 && ( +
+
📅
+
No {filter} recordings
+ {filter === 'upcoming' && recorders.length === 0 && ( +
Create a recorder first, then schedule it here.
+ )} + {filter === 'upcoming' && recorders.length > 0 && ( +
Click New schedule to plan a future recording.
+ )} +
+ )} + {schedules !== null && schedules.length > 0 && ( +
+
+
Name
+
Recorder
+
Starts
+
Duration
+
Recurrence
+
Status
+
+
+ {schedules.map(s => { + const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending; + return ( +
+
+ {s.name} + {s.error_message && ( +
{s.error_message}
+ )} +
+
{s.recorder_name || s.recorder_id.slice(0, 8)}
+
{_fmtWhen(s.start_at)}
+
{_durationMin(s.start_at, s.end_at)} min
+
{s.recurrence === 'none' ? 'one-shot' : s.recurrence}
+
{badge.label}
+
+ {(s.status === 'pending' || s.status === 'running') && ( + + )} + {(s.status === 'completed' || s.status === 'failed' || s.status === 'cancelled' || s.status === 'pending') && ( + + )} +
+
+ ); + })} +
+ )} +
+ + {showNew && setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />} +
+ ); +} + +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 ( +
+
e.stopPropagation()}> +
+
New scheduled recording
+ +
+
+
+ + set('name', e.target.value)} + onKeyDown={onKey} placeholder="Morning service stream" /> +
+
+ + +
+
+
+ + set('start_at', e.target.value)} /> +
+
+ + set('end_at', e.target.value)} /> +
+
+
+ + +
+ Recurring schedules queue the next occurrence as soon as the current one completes. +
+
+ {err &&
{err}
} +
+
+ + +
+
+
+ ); +} + +Object.assign(window, { Upload, Recorders, Capture, Monitors, Schedule }); diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx index 0bb5acf..b4ee54e 100644 --- a/services/web-ui/public/shell.jsx +++ b/services/web-ui/public/shell.jsx @@ -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"])); } diff --git a/services/web-ui/public/styles-rest.css b/services/web-ui/public/styles-rest.css index 586f65d..2a9cc45 100644 --- a/services/web-ui/public/styles-rest.css +++ b/services/web-ui/public/styles-rest.css @@ -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 ========== */