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 (
+