From 6a1d27157664f75c6855f7ab0680d9e8606a0204 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sat, 23 May 2026 14:52:04 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20polish=20round=202=20=E2=80=94=20li?= =?UTF-8?q?ve=20refresh,=20schedule=20calendar,=20jobs=20times,=20real=20s?= =?UTF-8?q?idebar=20user?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recorders: dispatch df:recorders-changed on create/start/stop/delete so the list updates immediately instead of waiting for the 10s poll tick - library: poll every 4s while any asset is live/processing (15s otherwise) and listen for df:assets-changed so a stopped recorder's LIVE badge drops and the thumbnail appears without a manual refresh - auth: synthetic /auth/me (AUTH_ENABLED=false) now uses LOCAL_OPERATOR / USER / USERNAME instead of hardcoding "Admin", and flags synthetic:true - shell: Sidebar takes `me` as a prop, drops the misleading "Admin" fallback, and surfaces an "auth off" hint when the response is synthetic - jobs: replace the always-empty ETA column with a Time column that shows queued/started/done/failed N ago (full timestamp on hover); widen column - schedule: new month-calendar view (default) with events plotted on day cells by status; clicking a day pre-fills the new-schedule modal with a 30-min window on that day; List view kept behind a toggle Co-Authored-By: Claude Opus 4.7 (1M context) --- services/mam-api/src/routes/auth.js | 18 +- services/web-ui/public/app.jsx | 2 +- services/web-ui/public/data.jsx | 15 +- services/web-ui/public/modal-new-recorder.jsx | 8 +- services/web-ui/public/screens-ingest.jsx | 219 ++++++++++++++++-- services/web-ui/public/screens-jobs.jsx | 32 ++- services/web-ui/public/screens-library.jsx | 18 ++ services/web-ui/public/shell.jsx | 10 +- services/web-ui/public/styles-rest.css | 111 ++++++++- 9 files changed, 395 insertions(+), 38 deletions(-) diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index efbb4f3..54d3e4c 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -74,10 +74,22 @@ router.post('/logout', (req, res, next) => { // GET /me // --------------------------------------------------------------------------- router.get('/me', async (req, res) => { - // When auth is disabled return a synthetic guest/admin user so the frontend - // auth-guard never receives a 401 and never redirects to login.html. + // When auth is disabled return a synthetic user so the frontend auth-guard + // never receives a 401. Prefer LOCAL_OPERATOR (explicit) or the OS user + // running the server over a generic "Admin" — that label is misleading + // because it implies an actual admin account is signed in. if (process.env.AUTH_ENABLED !== 'true') { - return res.json({ id: null, username: 'admin', display_name: 'Admin', role: 'admin' }); + const osUser = process.env.LOCAL_OPERATOR + || process.env.USER + || process.env.USERNAME + || 'operator'; + return res.json({ + id: null, + username: osUser.toLowerCase().replace(/[^a-z0-9._-]/g, ''), + display_name: osUser, + role: 'admin', + synthetic: true, + }); } if (!req.session || !req.session.userId) { diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx index 8a06567..b144017 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -92,7 +92,7 @@ function App() { return (
- +
{!openAsset && !hideTopbar && ( { setSubmitting(false); onClose(); }) + .then(() => { + setSubmitting(false); + // Recorders list listens for this and re-fetches; otherwise the + // operator has to wait for the next 10s poll tick to see the new row. + window.dispatchEvent(new CustomEvent('df:recorders-changed')); + onClose(); + }) .catch(e => { setSubmitting(false); setSubmitErr(e.message || 'Failed to create recorder'); }); }; diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index a8008ca..191c4f1 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -274,8 +274,15 @@ function Recorders({ navigate, onNew }) { React.useEffect(() => { refresh(); const id = setInterval(refresh, 10000); - return () => clearInterval(id); - }, []); + // Any screen that creates/starts/stops/deletes a recorder dispatches + // df:recorders-changed; refresh immediately instead of waiting for the tick. + const onChange = () => refresh(); + window.addEventListener('df:recorders-changed', onChange); + return () => { + clearInterval(id); + window.removeEventListener('df:recorders-changed', onChange); + }; + }, [refresh]); const liveCount = recorders.filter(r => r.status === 'recording').length; const errCount = recorders.filter(r => r.status === 'error').length; @@ -368,6 +375,12 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) { // Clear the input on a successful stop so the next take starts fresh. if (action === 'stop') setClipName(''); onRefresh(); + window.dispatchEvent(new CustomEvent('df:recorders-changed')); + // Stopping a recorder flips its asset from 'live' to 'ready' on the + // server side; tell the library/dashboard to re-pull. + if (action === 'stop') { + window.dispatchEvent(new CustomEvent('df:assets-changed')); + } }) .catch(e => { setPending(false); setErr(e.message || 'Failed'); setRecorder(initialRecorder); }); }; @@ -375,7 +388,11 @@ function RecorderRow({ recorder: initialRecorder, onRefresh }) { const handleDelete = () => { if (!window.confirm('Delete recorder "' + recorder.name + '"?\nThis will stop any active recording and cannot be undone.')) return; window.ZAMPP_API.fetch('/recorders/' + recorder.id, { method: 'DELETE' }) - .then(() => onRefresh()) + .then(() => { + onRefresh(); + window.dispatchEvent(new CustomEvent('df:recorders-changed')); + window.dispatchEvent(new CustomEvent('df:assets-changed')); + }) .catch(e => setErr(e.message || 'Delete failed')); }; @@ -652,18 +669,114 @@ function _durationMin(startISO, endISO) { return Math.round((new Date(endISO) - new Date(startISO)) / 60000); } +// ── Calendar helpers ───────────────────────────────────────────────────────── +function _ymd(d) { + // Local-zone yyyy-mm-dd key for grouping events into day cells. + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return y + '-' + m + '-' + day; +} +function _startOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); } +function _addMonths(d, n) { return new Date(d.getFullYear(), d.getMonth() + n, 1); } +function _gridStart(viewMonth) { + // Sunday before (or equal to) the 1st of viewMonth — gives a fixed 6-row grid. + const first = _startOfMonth(viewMonth); + return new Date(first.getFullYear(), first.getMonth(), 1 - first.getDay()); +} +function _sameDay(a, b) { + return a.getFullYear() === b.getFullYear() + && a.getMonth() === b.getMonth() + && a.getDate() === b.getDate(); +} +function _fmtTime(d) { + return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); +} + +function ScheduleCalendar({ schedules, viewMonth, onDayClick, onEventClick }) { + const today = new Date(); + const gridStart = _gridStart(viewMonth); + const days = []; + for (let i = 0; i < 42; i++) { + const d = new Date(gridStart); + d.setDate(gridStart.getDate() + i); + days.push(d); + } + + const byDay = React.useMemo(() => { + const m = {}; + (schedules || []).forEach(s => { + const key = _ymd(new Date(s.start_at)); + (m[key] || (m[key] = [])).push(s); + }); + Object.values(m).forEach(list => list.sort((a, b) => new Date(a.start_at) - new Date(b.start_at))); + return m; + }, [schedules]); + + return ( +
+
+ {['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map(w =>
{w}
)} +
+
+ {days.map(d => { + const inMonth = d.getMonth() === viewMonth.getMonth(); + const isToday = _sameDay(d, today); + const dayEvents = byDay[_ymd(d)] || []; + const visible = dayEvents.slice(0, 3); + const overflow = dayEvents.length - visible.length; + return ( +
onDayClick(d)} + title="Click to schedule on this day"> +
+ {d.getDate()} + {isToday && today} +
+
+ {visible.map(s => { + const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending; + return ( + + ); + })} + {overflow > 0 && ( +
+{overflow} more
+ )} +
+
+ ); + })} +
+
+ ); +} + function Schedule({ navigate }) { const [schedules, setSchedules] = React.useState(null); const [recorders, setRecorders] = React.useState([]); const [showNew, setShowNew] = React.useState(false); + const [newDefaults, setNewDefaults] = React.useState(null); // { start: Date, end: Date } const [editing, setEditing] = React.useState(null); const [filter, setFilter] = React.useState('upcoming'); + const [view, setView] = React.useState('calendar'); // 'calendar' | 'list' + const [viewMonth, setViewMonth] = React.useState(() => _startOfMonth(new Date())); + // Calendar mode wants every schedule in the visible window — the upcoming/past + // filter only applies to the list view, so swap the API query accordingly. + const apiFilter = view === 'calendar' ? 'all' : filter; const load = React.useCallback(() => { - window.ZAMPP_API.fetch('/schedules?status=' + filter) + window.ZAMPP_API.fetch('/schedules?status=' + apiFilter) .then(d => setSchedules(d.schedules || [])) .catch(() => setSchedules([])); - }, [filter]); + }, [apiFilter]); React.useEffect(() => { load(); }, [load]); React.useEffect(() => { window.ZAMPP_API.fetch('/recorders').then(setRecorders).catch(() => setRecorders([])); }, []); @@ -687,28 +800,87 @@ function Schedule({ navigate }) { .catch(e => alert('Delete failed: ' + e.message)); }; + // Calendar: clicking a day pre-fills the new-schedule modal with a 30-minute + // window starting at 10:00 AM that day (or +5min if the day is today and + // 10:00 has already passed). Gives the operator a sensible starting point + // instead of dropping them into empty datetime-local fields. + const openNewOnDay = (day) => { + const now = new Date(); + const isToday = _sameDay(day, now); + const start = new Date(day); + if (isToday && now.getHours() >= 10) { + start.setHours(now.getHours(), now.getMinutes() + 5, 0, 0); + } else { + start.setHours(10, 0, 0, 0); + } + const end = new Date(start.getTime() + 30 * 60 * 1000); + setNewDefaults({ start, end }); + setShowNew(true); + }; + const openNewBlank = () => { setNewDefaults(null); setShowNew(true); }; + + const monthLabel = viewMonth.toLocaleString(undefined, { month: 'long', year: 'numeric' }); + return (

Schedule

- Plan recorder windows · {schedules?.length || 0} {filter} + + {view === 'calendar' + ? 'Click a day to schedule · ' + (schedules?.length || 0) + ' total' + : 'Plan recorder windows · ' + (schedules?.length || 0) + ' ' + filter} +
- - - + +
+ {view === 'list' && ( +
+ + + +
+ )} -
- {schedules === null && ( + {view === 'calendar' && ( + <> +
+ +
{monthLabel}
+ + +
+ {recorders.length === 0 && ( +
Create a recorder before scheduling.
+ )} +
+ {schedules === null + ?
Loading…
+ : setEditing(s)} />} + + )} + + {view === 'list' && schedules === null && (
Loading…
)} - {schedules !== null && schedules.length === 0 && ( + {view === 'list' && schedules !== null && schedules.length === 0 && (
📅
No {filter} recordings
@@ -720,7 +892,7 @@ function Schedule({ navigate }) { )}
)} - {schedules !== null && schedules.length > 0 && ( + {view === 'list' && schedules !== null && schedules.length > 0 && (
Name
@@ -764,7 +936,12 @@ function Schedule({ navigate }) { )}
- {showNew && setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />} + {showNew && { setShowNew(false); setNewDefaults(null); }} + onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />} {editing && setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
); @@ -859,17 +1036,17 @@ function EditScheduleModal({ schedule, onClose, onSaved }) { ); } -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); +function NewScheduleModal({ recorders, onClose, onCreated, defaultStart, defaultEnd }) { + // If the user clicked a day on the calendar we honour that; otherwise default + // to "start in 5 minutes, run for 30 min" so the modal is immediately usable. const toLocalInput = (d) => { const tz = d.getTimezoneOffset() * 60_000; return new Date(d.getTime() - tz).toISOString().slice(0, 16); }; + const now = new Date(); + now.setSeconds(0, 0); + const startDefault = defaultStart || new Date(now.getTime() + 5 * 60 * 1000); + const endDefault = defaultEnd || new Date(startDefault.getTime() + 30 * 60 * 1000); const [form, setForm] = React.useState({ name: '', diff --git a/services/web-ui/public/screens-jobs.jsx b/services/web-ui/public/screens-jobs.jsx index 44c8aa0..f1dd172 100644 --- a/services/web-ui/public/screens-jobs.jsx +++ b/services/web-ui/public/screens-jobs.jsx @@ -1,5 +1,26 @@ // screens-jobs.jsx +// Pick the most-meaningful timestamp + label for a job's current state. +// Returns { label, iso } — caller renders "