polish: live refresh, schedule calendar, jobs times, real sidebar user #20
9 changed files with 395 additions and 38 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'); });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue