polish: live refresh, schedule calendar, jobs times, real sidebar user #20

Merged
zgaetano merged 1 commit from polish/round-2 into main 2026-05-23 15:18:55 -04:00
9 changed files with 395 additions and 38 deletions

View file

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

View file

@ -92,7 +92,7 @@ function App() {
return (
<div className="app" data-density="comfortable" data-grid-size="md" data-sidebar="expanded">
<Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} />
<Sidebar active={openAsset ? 'library' : route} onNavigate={navigate} me={window.ZAMPP_DATA?.ME} />
<div className="main">
{!openAsset && !hideTopbar && (
<Topbar

View file

@ -176,12 +176,17 @@ async function loadData() {
if (meR.status === 'fulfilled' && meR.value) {
const me = meR.value;
const label = me.display_name || me.username || 'User';
window.ZAMPP_DATA.ME = {
id: me.id,
username: me.username,
name: me.display_name || me.username || 'User',
initials: (me.display_name || me.username || 'U').slice(0, 2).toUpperCase(),
role: me.role || 'viewer',
id: me.id,
username: me.username,
name: label,
initials: label.slice(0, 2).toUpperCase(),
role: me.role || 'viewer',
// True when the server returned a synthetic user (AUTH_ENABLED=false).
// Surfaced as a small "auth off" hint in the sidebar so the operator
// understands why the corner shows the OS user instead of a login.
synthetic: !!me.synthetic,
};
}
}

View file

@ -99,7 +99,13 @@ function NewRecorderModal({ open, onClose }) {
}
window.ZAMPP_API.fetch('/recorders', { method: 'POST', body: JSON.stringify(body) })
.then(() => { 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'); });
};

View file

@ -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 (
<div className="cal">
<div className="cal-weekheads">
{['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map(w => <div key={w}>{w}</div>)}
</div>
<div className="cal-grid">
{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 (
<div key={d.toISOString()}
className={'cal-cell' + (inMonth ? '' : ' off-month') + (isToday ? ' today' : '')}
onClick={() => onDayClick(d)}
title="Click to schedule on this day">
<div className="cal-cell-head">
<span className="cal-daynum">{d.getDate()}</span>
{isToday && <span className="cal-today-pip">today</span>}
</div>
<div className="cal-events">
{visible.map(s => {
const badge = _STATUS_BADGE[s.status] || _STATUS_BADGE.pending;
return (
<button key={s.id}
className={'cal-event ' + badge.cls}
onClick={(e) => { e.stopPropagation(); onEventClick(s); }}
title={s.name + ' · ' + _fmtWhen(s.start_at) + ' → ' + _fmtWhen(s.end_at) + ' · ' + badge.label}>
<span className="cal-event-time mono">{_fmtTime(new Date(s.start_at))}</span>
<span className="cal-event-name">{s.name}</span>
</button>
);
})}
{overflow > 0 && (
<div className="cal-event-overflow">+{overflow} more</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
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 (
<div className="page">
<div className="page-header">
<h1>Schedule</h1>
<span className="subtitle">Plan recorder windows · {schedules?.length || 0} {filter}</span>
<span className="subtitle">
{view === 'calendar'
? 'Click a day to schedule · ' + (schedules?.length || 0) + ' total'
: '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>
<button className={view === 'calendar' ? 'active' : ''} onClick={() => setView('calendar')} title="Month calendar"><Icon name="jobs" />Calendar</button>
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} title="Linear list"><Icon name="list" />List</button>
</div>
{view === 'list' && (
<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}>
<button className="btn primary" onClick={openNewBlank} disabled={recorders.length === 0}>
<Icon name="plus" />New schedule
</button>
</div>
<div className="page-body">
{schedules === null && (
{view === 'calendar' && (
<>
<div className="cal-toolbar">
<button className="btn ghost sm" onClick={() => setViewMonth(_addMonths(viewMonth, -1))} title="Previous month">
<Icon name="chevron" style={{ transform: 'rotate(90deg)' }} />
</button>
<div className="cal-month-label">{monthLabel}</div>
<button className="btn ghost sm" onClick={() => setViewMonth(_addMonths(viewMonth, 1))} title="Next month">
<Icon name="chevron" style={{ transform: 'rotate(-90deg)' }} />
</button>
<button className="btn ghost sm" onClick={() => setViewMonth(_startOfMonth(new Date()))} style={{ marginLeft: 8 }}>
Today
</button>
<div style={{ flex: 1 }} />
{recorders.length === 0 && (
<div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Create a recorder before scheduling.</div>
)}
</div>
{schedules === null
? <div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading</div>
: <ScheduleCalendar
schedules={schedules}
viewMonth={viewMonth}
onDayClick={openNewOnDay}
onEventClick={(s) => setEditing(s)} />}
</>
)}
{view === 'list' && schedules === null && (
<div style={{ textAlign: 'center', padding: '64px 0', color: 'var(--text-3)' }}>Loading</div>
)}
{schedules !== null && schedules.length === 0 && (
{view === 'list' && 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>
@ -720,7 +892,7 @@ function Schedule({ navigate }) {
)}
</div>
)}
{schedules !== null && schedules.length > 0 && (
{view === 'list' && schedules !== null && schedules.length > 0 && (
<div className="panel">
<div className="schedule-row head">
<div>Name</div>
@ -764,7 +936,12 @@ function Schedule({ navigate }) {
)}
</div>
{showNew && <NewScheduleModal recorders={recorders} onClose={() => setShowNew(false)} onCreated={() => { setShowNew(false); load(); }} />}
{showNew && <NewScheduleModal
recorders={recorders}
defaultStart={newDefaults?.start}
defaultEnd={newDefaults?.end}
onClose={() => { setShowNew(false); setNewDefaults(null); }}
onCreated={() => { setShowNew(false); setNewDefaults(null); load(); }} />}
{editing && <EditScheduleModal schedule={editing} onClose={() => setEditing(null)} onSaved={() => { setEditing(null); load(); }} />}
</div>
);
@ -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: '',

View file

@ -1,5 +1,26 @@
// screens-jobs.jsx
// Pick the most-meaningful timestamp + label for a job's current state.
// Returns { label, iso } caller renders "<label> <relative-time>" with
// the full ISO as a tooltip.
function _jobTimeFor(job) {
if (job.status === 'done' && job.completed_at) return { label: 'done', iso: job.completed_at };
if (job.status === 'failed' && job.failed_at) return { label: 'failed', iso: job.failed_at };
if (job.status === 'running' && job.started_at) return { label: 'started', iso: job.started_at };
if (job.created_at) return { label: 'queued', iso: job.created_at };
return null;
}
function _fmtAbsolute(iso) {
if (!iso) return '';
try {
return new Date(iso).toLocaleString(undefined, {
month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit', second: '2-digit',
});
} catch { return iso; }
}
function Jobs({ navigate }) {
const [tab, setTab] = React.useState('all');
const [jobs, setJobs] = React.useState(window.ZAMPP_DATA.JOBS);
@ -127,7 +148,7 @@ function Jobs({ navigate }) {
<div className="panel" style={{ marginTop: 12 }}>
<div className="job-row head">
<div></div><div>Job</div><div>Asset</div><div>Node</div><div>Progress</div><div>ETA</div><div>Priority</div><div></div>
<div></div><div>Job</div><div>Asset</div><div>Node</div><div>Progress</div><div>Time</div><div>Priority</div><div></div>
</div>
{filtered.length === 0
? <div style={{ padding: '24px', textAlign: 'center', color: 'var(--text-3)' }}>No jobs in this category.</div>
@ -168,7 +189,14 @@ function JobRow({ job, onRetry, onDelete }) {
</span>
)}
</div>
<div className="mono" style={{ fontSize: 12, color: 'var(--text-3)' }}>{job.eta}</div>
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={(() => { const t = _jobTimeFor(job); return t ? t.label + ' at ' + _fmtAbsolute(t.iso) : ''; })()}>
{(() => {
const t = _jobTimeFor(job);
if (!t) return '—';
return t.label + ' ' + window.ZAMPP_API.fmtRelative(t.iso);
})()}
</div>
<div><span className={'badge ' + (job.priority === 'high' ? 'warning' : 'outline')}>{job.priority}</span></div>
<div style={{ display: 'flex', gap: 4 }}>
{job.status === 'failed' && (

View file

@ -65,6 +65,24 @@ function Library({ navigate, onOpenAsset, openProject }) {
.catch(() => {});
}, [PROJECTS]);
// Auto-refresh: poll the library while it's open so live recordings flip
// to 'ready' (with thumbnail) without a manual reload. Speed up while
// anything is mid-flight so the operator sees the transition right away.
const hasLive = React.useMemo(
() => allAssets.some(a => a.status === 'live' || a.status === 'processing'),
[allAssets]
);
React.useEffect(() => {
const tick = hasLive ? 4000 : 15000;
const id = setInterval(refreshAssets, tick);
const onAssetsChanged = () => refreshAssets();
window.addEventListener('df:assets-changed', onAssetsChanged);
return () => {
clearInterval(id);
window.removeEventListener('df:assets-changed', onAssetsChanged);
};
}, [hasLive, refreshAssets]);
// Dismiss the context menu on any outside click (capture phase so clicking
// a menu item still fires before the menu unmounts).
React.useEffect(() => {

View file

@ -77,7 +77,7 @@ function NavItem({ item, active, onSelect, depth = 0, openGroups, toggleGroup })
);
}
function Sidebar({ active, onNavigate }) {
function Sidebar({ active, onNavigate, me }) {
const [openGroups, setOpenGroups] = React.useState(new Set(["ingest"]));
const toggleGroup = (id) => {
setOpenGroups(prev => {
@ -128,10 +128,12 @@ function Sidebar({ active, onNavigate }) {
))}
</div>
<div className="sidebar-footer">
<div className="avatar">{(window.ZAMPP_DATA.ME?.initials) || 'ZG'}</div>
<div className="avatar">{me?.initials || '—'}</div>
<div className="user-meta">
<div className="user-name">{window.ZAMPP_DATA.ME?.name || 'Admin'}</div>
<div className="user-role">{window.ZAMPP_DATA.ME?.role || 'admin'}</div>
<div className="user-name">{me?.name || 'Not signed in'}</div>
<div className="user-role" title={me?.synthetic ? 'AUTH_ENABLED=false — showing the OS user running the server' : ''}>
{me?.role || '—'}{me?.synthetic ? ' · auth off' : ''}
</div>
</div>
<button className="icon-btn" data-tip="Sign out" title="Sign out"
onClick={() => {

View file

@ -389,7 +389,7 @@
}
.job-row {
display: grid;
grid-template-columns: 20px 110px 1fr 90px 200px 70px 80px 90px;
grid-template-columns: 20px 110px 1fr 90px 200px 130px 80px 90px;
align-items: center;
gap: 12px;
padding: 10px 16px;
@ -617,6 +617,115 @@
.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; }
/* ========== Schedule calendar ========== */
.cal-toolbar {
display: flex; align-items: center; gap: 6px;
margin-bottom: 12px;
}
.cal-month-label {
font-size: 14px; font-weight: 600;
min-width: 140px; text-align: center;
}
.cal {
background: var(--bg-1);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
}
.cal-weekheads {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: var(--bg-2);
border-bottom: 1px solid var(--border);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-4);
}
.cal-weekheads > div { padding: 8px 10px; }
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-auto-rows: minmax(110px, 1fr);
}
.cal-cell {
border-right: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 6px;
display: flex; flex-direction: column;
gap: 4px;
cursor: pointer;
background: var(--bg-1);
transition: background 80ms;
min-height: 110px;
}
.cal-cell:hover { background: var(--bg-2); }
.cal-cell:nth-child(7n) { border-right: 0; }
.cal-cell.off-month { background: var(--bg-0); }
.cal-cell.off-month .cal-daynum { color: var(--text-4); }
.cal-cell.today { background: var(--accent-soft); }
.cal-cell.today:hover { background: var(--accent-soft-2); }
.cal-cell-head {
display: flex; align-items: center; gap: 6px;
}
.cal-daynum {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
min-width: 18px;
}
.cal-today-pip {
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent-text);
background: var(--bg-1);
padding: 1px 5px;
border-radius: 99px;
}
.cal-events {
display: flex; flex-direction: column;
gap: 2px;
overflow: hidden;
}
.cal-event {
display: flex; align-items: center; gap: 6px;
padding: 2px 6px;
border-radius: 3px;
border: 0;
cursor: pointer;
font-size: 11px;
text-align: left;
background: var(--bg-3);
color: var(--text-1);
border-left: 2px solid var(--text-3);
overflow: hidden;
}
.cal-event:hover { background: var(--bg-2); }
.cal-event.success { border-left-color: var(--success); }
.cal-event.danger { border-left-color: var(--danger); }
.cal-event.warning { border-left-color: var(--warning); }
.cal-event.accent { border-left-color: var(--accent); }
.cal-event.neutral { border-left-color: var(--text-3); }
.cal-event-time {
flex-shrink: 0;
font-size: 10.5px;
color: var(--text-3);
}
.cal-event-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cal-event-overflow {
font-size: 10.5px;
color: var(--text-3);
padding: 1px 6px;
}
/* ========== Cluster ========== */
.cluster-canvas {
background: var(--bg-1);