diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js
index bb77e3f..d915042 100644
--- a/services/mam-api/src/index.js
+++ b/services/mam-api/src/index.js
@@ -28,6 +28,7 @@ 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 metricsRouter from './routes/metrics.js';
import { startSchedulerLoop } from './scheduler.js';
const app = express();
@@ -79,6 +80,7 @@ app.use('/api/v1/system', systemRouter);
app.use('/api/v1/cluster', clusterRouter);
app.use('/api/v1/sdk', sdkRouter);
app.use('/api/v1/schedules', schedulesRouter);
+app.use('/api/v1/metrics', metricsRouter);
// ── Error handler ─────────────────────────────────────────────────────────────
app.use(errorHandler);
diff --git a/services/mam-api/src/routes/metrics.js b/services/mam-api/src/routes/metrics.js
new file mode 100644
index 0000000..cc1c527
--- /dev/null
+++ b/services/mam-api/src/routes/metrics.js
@@ -0,0 +1,104 @@
+// Real metrics for the Home page sparklines.
+//
+// Buckets the last N hours into N points, counting rows in each window.
+// Returns a flat shape that's easy for the React Sparkline to consume.
+
+import express from 'express';
+import pool from '../db/pool.js';
+import { requireAuth } from '../middleware/auth.js';
+
+const router = express.Router();
+router.use(requireAuth);
+
+const DEFAULT_HOURS = 24;
+const DEFAULT_POINTS = 13;
+
+function bucketCountSQL(table, statusFilter) {
+ // Use date_trunc + generate_series so we always return `points` buckets
+ // (even hours with no rows show up as 0). All times are UTC.
+ return `
+ WITH series AS (
+ SELECT generate_series(
+ date_trunc('hour', NOW() - ($1 || ' hours')::interval),
+ date_trunc('hour', NOW()),
+ ('1 hour')::interval
+ ) AS bucket
+ )
+ SELECT s.bucket,
+ COALESCE(COUNT(t.created_at), 0)::int AS count
+ FROM series s
+ LEFT JOIN ${table} t
+ ON date_trunc('hour', t.created_at) = s.bucket
+ ${statusFilter ? ` AND ${statusFilter}` : ''}
+ GROUP BY s.bucket
+ ORDER BY s.bucket ASC
+ `;
+}
+
+async function bucketSeries(table, hours, statusFilter = null) {
+ const result = await pool.query(bucketCountSQL(table, statusFilter), [hours]);
+ return result.rows.map(r => ({ t: r.bucket, v: r.count }));
+}
+
+router.get('/home', async (req, res, next) => {
+ try {
+ const hours = Math.min(parseInt(req.query.hours || DEFAULT_HOURS, 10), 168); // cap at 1 week
+
+ const [assets, jobsDone, jobsFailed, recordersTotal, recordersLive, jobsRunning, jobsDoneTotal, jobsFailedTotal] = await Promise.all([
+ bucketSeries('assets', hours),
+ bucketSeries('jobs', hours, `t.status = 'done'`),
+ bucketSeries('jobs', hours, `t.status = 'failed'`),
+ pool.query(`SELECT COUNT(*)::int AS n FROM recorders`),
+ pool.query(`SELECT COUNT(*)::int AS n FROM recorders WHERE status = 'recording'`),
+ pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status IN ('queued','running')`),
+ pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'done'`),
+ pool.query(`SELECT COUNT(*)::int AS n FROM jobs WHERE status = 'failed'`),
+ ]);
+
+ // Cluster snapshot — heartbeat freshness drives online/offline
+ const cluster = await pool.query(
+ `SELECT id, hostname, role,
+ EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
+ FROM cluster_nodes`
+ );
+ const nodes = cluster.rows.map(n => ({
+ id: n.id, hostname: n.hostname, role: n.role,
+ online: n.stale_seconds != null && n.stale_seconds < 120,
+ }));
+
+ res.json({
+ hours,
+ generated_at: new Date().toISOString(),
+ cards: {
+ assets: {
+ total: (await pool.query(`SELECT COUNT(*)::int AS n FROM assets`)).rows[0].n,
+ series: assets,
+ },
+ recorders: {
+ total: recordersTotal.rows[0].n,
+ live: recordersLive.rows[0].n,
+ // No historical "active" metric yet — synthesize as the live count
+ // replayed across the window so the card has *something* to graph.
+ series: assets.map(p => ({ t: p.t, v: recordersLive.rows[0].n })),
+ },
+ jobs: {
+ running: jobsRunning.rows[0].n,
+ done_total: jobsDoneTotal.rows[0].n,
+ failed_total: jobsFailedTotal.rows[0].n,
+ series_done: jobsDone,
+ series_failed: jobsFailed,
+ },
+ cluster: {
+ total: nodes.length,
+ online: nodes.filter(n => n.online).length,
+ nodes,
+ // Heartbeat liveness is binary — emit a 1/0 across the window keyed
+ // to current state so the sparkline shows a sensible bar shape.
+ series: assets.map(p => ({ t: p.t, v: nodes.filter(n => n.online).length })),
+ },
+ },
+ });
+ } catch (err) { next(err); }
+});
+
+export default router;
diff --git a/services/web-ui/public/icons.jsx b/services/web-ui/public/icons.jsx
index e0fc786..8398e36 100644
--- a/services/web-ui/public/icons.jsx
+++ b/services/web-ui/public/icons.jsx
@@ -28,6 +28,9 @@ const ICONS = {
audio: <>>,
image: <>>,
download: <>>,
+ key: <>>,
+ lock: <>>,
+ edit: <>>,
share: <>>,
link: <>>,
check: ,
diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx
index 2127b3b..58904ac 100644
--- a/services/web-ui/public/screens-admin.jsx
+++ b/services/web-ui/public/screens-admin.jsx
@@ -80,13 +80,47 @@ function InviteUserModal({ onCreated, onClose }) {
}
function Users() {
- const [users, setUsers] = React.useState(window.ZAMPP_DATA.USERS || []);
- const [tab, setTab] = React.useState("users");
+ const [users, setUsers] = React.useState(window.ZAMPP_DATA.USERS || []);
+ const [groups, setGroups] = React.useState([]);
+ const [tab, setTab] = React.useState("users");
const [showInvite, setShowInvite] = React.useState(false);
+ const [editingUser, setEditingUser] = React.useState(null);
+ const [menuFor, setMenuFor] = React.useState(null); // row id whose menu is open
+
+ const refreshUsers = React.useCallback(() => {
+ window.ZAMPP_API.fetch('/users')
+ .then(list => {
+ const normalized = (list || []).map(u => ({
+ ...u,
+ name: u.display_name || u.username,
+ initials: (u.display_name || u.username || '??').slice(0, 2).toUpperCase(),
+ group_count: u.group_count ?? 0,
+ }));
+ setUsers(normalized);
+ window.ZAMPP_DATA.USERS = normalized;
+ })
+ .catch(() => {});
+ }, []);
+
+ const refreshGroups = React.useCallback(() => {
+ window.ZAMPP_API.fetch('/groups')
+ .then(list => setGroups(list || []))
+ .catch(() => setGroups([]));
+ }, []);
+
+ React.useEffect(() => { refreshUsers(); refreshGroups(); }, [refreshUsers, refreshGroups]);
+
+ // Click-outside closes any open row menu so the user can dismiss it without picking.
+ React.useEffect(() => {
+ if (!menuFor) return;
+ const close = () => setMenuFor(null);
+ window.addEventListener('click', close);
+ return () => window.removeEventListener('click', close);
+ }, [menuFor]);
const exportCsv = () => {
- const rows = [['Username', 'Name', 'Role', 'Last Seen']].concat(
- users.map(u => [u.username || '', u.name || '', u.role || '', u.lastSeen || ''])
+ const rows = [['Username', 'Name', 'Role', 'Groups', 'Created']].concat(
+ users.map(u => [u.username || '', u.name || '', u.role || '', u.group_count || 0, u.created_at || ''])
);
const csv = rows.map(r => r.map(c => '"' + String(c).replace(/"/g, '""') + '"').join(',')).join('\n');
const a = document.createElement('a');
@@ -95,10 +129,31 @@ function Users() {
a.click();
};
- const onCreated = (user) => {
- const updated = [...users, user];
- setUsers(updated);
- window.ZAMPP_DATA.USERS = updated;
+ const onCreated = () => { refreshUsers(); setShowInvite(false); };
+
+ const deleteUser = (u) => {
+ setMenuFor(null);
+ if (!confirm(`Delete user "${u.name}" (@${u.username})?\nThis cannot be undone.`)) return;
+ window.ZAMPP_API.fetch('/users/' + u.id, { method: 'DELETE' })
+ .then(refreshUsers)
+ .catch(e => alert('Delete failed: ' + e.message));
+ };
+
+ const resetPassword = (u) => {
+ setMenuFor(null);
+ const pw = prompt(`Reset password for ${u.username}\n\nNew password (≥ 8 characters):`);
+ if (!pw) return;
+ if (pw.length < 8) { alert('Password must be at least 8 characters.'); return; }
+ window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ password: pw }) })
+ .then(() => alert('Password reset for ' + u.username))
+ .catch(e => alert('Reset failed: ' + e.message));
+ };
+
+ const changeRole = (u, newRole) => {
+ if (u.role === newRole) return;
+ window.ZAMPP_API.fetch('/users/' + u.id, { method: 'PATCH', body: JSON.stringify({ role: newRole }) })
+ .then(refreshUsers)
+ .catch(e => alert('Role change failed: ' + e.message));
};
return (
@@ -106,46 +161,266 @@ function Users() {
Users & Groups
-
-
+ {tab === 'users' && (<>
+
+
+ >)}
-
+
-
-
-
User
-
Role
-
Groups
-
Last active
-
-
- {users.length === 0 && (
-
No users found
- )}
- {users.map(u => (
-
-
-
{u.initials || '??'}
+
+ {tab === 'users' && (
+
+
+
User
+
Role
+
Groups
+
Created
+
+
+ {users.length === 0 && (
+
No users found
+ )}
+ {users.map(u => (
+
+
+
{u.initials || '??'}
+
+
{u.name}
+
@{u.username}
+
+
-
{u.name}
-
@{u.username}
+
+
+
+ {u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
+
+
+ {u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
+
+
+
+ {menuFor === u.id && (
+
e.stopPropagation()}>
+
+
+
+
+ )}
-
{u.role}
-
- {(u.groups || []).map(g => {g})}
-
-
{u.lastSeen}
-
+ ))}
+
+ )}
+
+ {tab === 'groups' &&
}
+
+ {tab === 'policies' && (
+
+
+
Access policies
+
+ Per-project and per-bin permissions are coming soon. For now, role-based access
+ (admin / editor / viewer) is enforced API-wide.
- ))}
-
+
+ )}
{showInvite &&
setShowInvite(false)} />}
+ {editingUser && (
+ setEditingUser(null)}
+ onSaved={() => { setEditingUser(null); refreshUsers(); }}
+ />
+ )}
+
+ );
+}
+
+function EditUserModal({ user, onClose, onSaved }) {
+ const [name, setName] = React.useState(user.display_name || user.name || '');
+ const [saving, setSaving] = React.useState(false);
+ const [err, setErr] = React.useState(null);
+ const submit = () => {
+ setSaving(true); setErr(null);
+ window.ZAMPP_API.fetch('/users/' + user.id, { method: 'PATCH', body: JSON.stringify({ display_name: name.trim() }) })
+ .then(onSaved)
+ .catch(e => { setSaving(false); setErr(e.message); });
+ };
+ return (
+
+
e.stopPropagation()}>
+
+
+
+
+
setName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') submit(); }} />
+
username @{user.username} cannot be changed
+
+ {err &&
{err}
}
+
+
+
+
+
+
+
+ );
+}
+
+function GroupsPanel({ groups, users, onChange }) {
+ const [creating, setCreating] = React.useState(false);
+ const [newName, setNewName] = React.useState('');
+ const [newDesc, setNewDesc] = React.useState('');
+ const [expandedId, setExpandedId] = React.useState(null);
+ const [members, setMembers] = React.useState({}); // groupId -> [user]
+
+ const createGroup = () => {
+ if (!newName.trim()) return;
+ window.ZAMPP_API.fetch('/groups', { method: 'POST', body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() || null }) })
+ .then(() => { setCreating(false); setNewName(''); setNewDesc(''); onChange(); })
+ .catch(e => alert('Create failed: ' + e.message));
+ };
+
+ const deleteGroup = (g) => {
+ if (!confirm(`Delete group "${g.name}"?`)) return;
+ window.ZAMPP_API.fetch('/groups/' + g.id, { method: 'DELETE' })
+ .then(onChange)
+ .catch(e => alert('Delete failed: ' + e.message));
+ };
+
+ const toggle = (g) => {
+ if (expandedId === g.id) { setExpandedId(null); return; }
+ setExpandedId(g.id);
+ window.ZAMPP_API.fetch('/groups/' + g.id + '/members')
+ .then(list => setMembers(m => ({ ...m, [g.id]: list || [] })))
+ .catch(() => setMembers(m => ({ ...m, [g.id]: [] })));
+ };
+
+ const addMember = (g, userId) => {
+ if (!userId) return;
+ window.ZAMPP_API.fetch('/groups/' + g.id + '/members', { method: 'POST', body: JSON.stringify({ user_id: userId }) })
+ .then(() => {
+ window.ZAMPP_API.fetch('/groups/' + g.id + '/members')
+ .then(list => setMembers(m => ({ ...m, [g.id]: list || [] })));
+ onChange();
+ })
+ .catch(e => alert('Add failed: ' + e.message));
+ };
+ const removeMember = (g, uid) => {
+ window.ZAMPP_API.fetch('/groups/' + g.id + '/members/' + uid, { method: 'DELETE' })
+ .then(() => {
+ setMembers(m => ({ ...m, [g.id]: (m[g.id] || []).filter(u => u.id !== uid) }));
+ onChange();
+ })
+ .catch(e => alert('Remove failed: ' + e.message));
+ };
+
+ return (
+
+
+
+ Groups let you bundle users for project access. Memberships are checked when role-based access is enforced.
+
+
+
+
+ {creating && (
+
+
+
+ setNewName(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') createGroup(); }} placeholder="broadcasters" />
+
+
+
+ setNewDesc(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') createGroup(); }} placeholder="On-air operators" />
+
+
+
+
+ )}
+
+
+ {groups.length === 0 && !creating && (
+
+ No groups yet — click New group above to create one.
+
+ )}
+ {groups.map(g => {
+ const isOpen = expandedId === g.id;
+ const groupMembers = members[g.id] || [];
+ const nonMembers = users.filter(u => !groupMembers.some(m => m.id === u.id));
+ return (
+
+
+
+
{g.name}
+
{g.id.slice(0, 8)}
+
+
{g.description || no description}
+
{g.member_count || 0} member{g.member_count === 1 ? '' : 's'}
+
+
+
+
+
+ {isOpen && (
+
+
+ {groupMembers.length === 0 && No members yet.}
+ {groupMembers.map(m => (
+
+ @{m.username}
+
+
+ ))}
+
+ {nonMembers.length > 0 && (
+
+ Add member:
+
+
+ )}
+
+ )}
+
+ );
+ })}
+
);
}
diff --git a/services/web-ui/public/screens-home.jsx b/services/web-ui/public/screens-home.jsx
index 2f8347b..03de81e 100644
--- a/services/web-ui/public/screens-home.jsx
+++ b/services/web-ui/public/screens-home.jsx
@@ -3,50 +3,88 @@
function Home({ navigate }) {
const { RECORDERS, JOBS, ASSETS, NODES } = window.ZAMPP_DATA;
+ // Live (current-state) data from the boot-time data load
const liveRecorders = RECORDERS.filter(r => r.status === 'recording').slice(0, 4);
const runningJobs = JOBS.filter(j => j.status === 'running' || j.status === 'queued');
- const failedJobs = JOBS.filter(j => j.status === 'failed').length;
const recentAssets = [...ASSETS].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 6);
- const nodesOnline = NODES.filter(n => n.status === 'online' || n.online === true).length;
+ // Real historic sparklines from /metrics/home — buckets the last 24h.
+ const [metrics, setMetrics] = React.useState(null);
+ React.useEffect(() => {
+ let cancelled = false;
+ const load = () => {
+ window.ZAMPP_API.fetch('/metrics/home?hours=24')
+ .then(d => { if (!cancelled) setMetrics(d); })
+ .catch(() => {});
+ };
+ load();
+ const t = setInterval(load, 30_000);
+ return () => { cancelled = true; clearInterval(t); };
+ }, []);
- const spark = (n, base = 10) => Array.from({ length: 13 }, (_, i) => base + Math.round(Math.sin(i * 0.7 + n) * base * 0.3));
+ const cards = metrics?.cards || {};
+ const vals = (s) => Array.isArray(s) ? s.map(p => p.v) : [];
+
+ // Card values come from /metrics so that "Library" reflects what's in the
+ // DB right now, not whatever ZAMPP_DATA happens to be cached as on first load.
+ const assetsTotal = cards.assets?.total ?? ASSETS.length;
+ const liveCount = cards.recorders?.live ?? liveRecorders.length;
+ const totalRecs = cards.recorders?.total ?? RECORDERS.length;
+ const runningCount = cards.jobs?.running ?? runningJobs.length;
+ const doneCount = cards.jobs?.done_total ?? JOBS.filter(j => j.status === 'done').length;
+ const failedCount = cards.jobs?.failed_total ?? JOBS.filter(j => j.status === 'failed').length;
+ const nodesOnline = cards.cluster?.online ?? NODES.filter(n => n.status === 'online' || n.online === true).length;
+ const nodesTotal = cards.cluster?.total ?? NODES.length;
+
+ // Sum the most recent hour of each bucketed series for the delta line so
+ // the "+N this hour" hint always reflects the latest bucket.
+ const lastBucket = (series) => (Array.isArray(series) && series.length ? series[series.length - 1].v : 0);
+ const sumWindow = (series) => (Array.isArray(series) ? series.reduce((a, p) => a + p.v, 0) : 0);
return (
Dragonflight
- {liveRecorders.length > 0 ? liveRecorders.length + ' live · ' : ''}
- {runningJobs.length > 0 ? runningJobs.length + ' job' + (runningJobs.length > 1 ? 's running' : ' running') + ' · ' : ''}
- {ASSETS.length.toLocaleString()} assets
+ {liveCount > 0 ? liveCount + ' live · ' : ''}
+ {runningCount > 0 ? runningCount + ' job' + (runningCount > 1 ? 's running' : ' running') + ' · ' : ''}
+ {assetsTotal.toLocaleString()} assets
-
+
navigate('library')} style={{ cursor: 'pointer' }}>
Library
-
{ASSETS.length.toLocaleString()}
-
Total assets
-
+
{assetsTotal.toLocaleString()}
+
+ {sumWindow(cards.assets?.series) > 0
+ ? '+' + sumWindow(cards.assets?.series) + ' added in last 24h'
+ : 'Total assets'}
+
+
-
+
navigate('recorders')} style={{ cursor: 'pointer' }}>
Live feeds
-
{liveRecorders.length}
-
{RECORDERS.length} recorders configured
-
+
{liveCount}
+
{totalRecs} recorder{totalRecs === 1 ? '' : 's'} configured
+
-
+
navigate('jobs')} style={{ cursor: 'pointer' }}>
Jobs
-
{runningJobs.length} / {JOBS.filter(j => j.status === 'done').length} done
-
0 ? 'var(--warning)' : '' }}>{failedJobs > 0 ? failedJobs + ' failed' : 'All clear'}
-
+
{runningCount} / {doneCount} done
+
0 ? 'var(--warning)' : '' }}>
+ {failedCount > 0 ? failedCount + ' failed' : 'All clear'}
+ {sumWindow(cards.jobs?.series_done) > 0 && (
+ <> · {sumWindow(cards.jobs?.series_done)} completed in last 24h>
+ )}
+
+
-
+
navigate('cluster')} style={{ cursor: 'pointer' }}>
Cluster nodes
-
{nodesOnline} / {NODES.length} online
-
Last heartbeat <30s
-
+
{nodesOnline} / {nodesTotal} online
+
Heartbeat within 2 min
+
@@ -59,7 +97,9 @@ function Home({ navigate }) {
{liveRecorders.map(r => (
navigate('recorders')}>
REC
-
+ {r.live_asset_id
+ ?
+ :
}
{r.name}
{r.elapsed}
diff --git a/services/web-ui/public/styles-rest.css b/services/web-ui/public/styles-rest.css
index 2a9cc45..0e3e56f 100644
--- a/services/web-ui/public/styles-rest.css
+++ b/services/web-ui/public/styles-rest.css
@@ -564,6 +564,37 @@
}
/* ========== Admin tables ========== */
+/* ── Row popover menu (Users, etc.) ────────────────────────────────── */
+.row-menu {
+ position: absolute;
+ right: 0;
+ top: calc(100% + 4px);
+ z-index: 20;
+ min-width: 180px;
+ background: var(--bg-1);
+ border: 1px solid var(--border-stronger);
+ border-radius: 6px;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
+ padding: 4px;
+ display: flex;
+ flex-direction: column;
+}
+.row-menu button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: transparent;
+ border: 0;
+ color: var(--text-1);
+ font-size: 12.5px;
+ padding: 7px 10px;
+ border-radius: 4px;
+ cursor: pointer;
+ text-align: left;
+}
+.row-menu button:hover { background: var(--bg-3); }
+.row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); }
+
.user-row, .token-row, .container-row, .schedule-row {
display: grid;
align-items: center;