From ef4c3011493f07ed68c5de438d6f2fbad3f86b5e Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 23 May 2026 03:30:10 +0000 Subject: [PATCH] feat(home,users): real metrics, working Users row actions + Groups CRUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Home: new /api/v1/metrics/home endpoint buckets last 24h of assets, jobs done/failed into hourly counts; sparklines now render real time-series instead of decorative sine waves - Home stat cards are now clickable (route to relevant page) and the delta lines show real activity ("+N added in last 24h", "N completed") - Home live-feed tiles use HlsPreview for recorders with a live_asset_id - Users: row 3-dot menu is now a real popover with Rename / Reset password / Delete actions wired to PATCH /users/:id and DELETE - Users: role is now an inline changeRole(u, e.target.value)} + className="field-input" + style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}> + + + + + +
+ {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;