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()}> +
+
Rename user
+ +
+
+
+ + 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;