feat(home,users): real metrics, working Users row actions + Groups CRUD
- 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 <select> that PATCHes immediately
- Users: Created column replaces fake 'last active' (no last_login
tracking yet); group count is real
- Groups tab is now functional — list groups, create, expand to
show + manage members (add/remove), delete; backed by existing
/api/v1/groups CRUD
- Policies tab is now an honest 'coming soon' stub
- New icons: key, lock, edit; new .row-menu popover styles
This commit is contained in:
parent
53196d38ce
commit
ef4c301149
6 changed files with 514 additions and 59 deletions
|
|
@ -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);
|
||||
|
|
|
|||
104
services/mam-api/src/routes/metrics.js
Normal file
104
services/mam-api/src/routes/metrics.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -28,6 +28,9 @@ const ICONS = {
|
|||
audio: <><path d="M3 12v-2a2 2 0 0 1 2-2h2l5-4v16l-5-4H5a2 2 0 0 1-2-2z" /><path d="M16 8a5 5 0 0 1 0 8M19 5a9 9 0 0 1 0 14" /></>,
|
||||
image: <><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="2" /><path d="M21 15l-5-5L5 21" /></>,
|
||||
download: <><path d="M12 4v12M6 10l6 6 6-6" /><path d="M4 20h16" /></>,
|
||||
key: <><circle cx="7.5" cy="15.5" r="3.5" /><path d="M10 13l9-9M16 7l3 3M14 9l3 3" /></>,
|
||||
lock: <><rect x="5" y="11" width="14" height="10" rx="2" /><path d="M8 11V7a4 4 0 0 1 8 0v4" /></>,
|
||||
edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></>,
|
||||
share: <><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4" /></>,
|
||||
link: <><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 1 0-7-7l-1 1" /><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 1 0 7 7l1-1" /></>,
|
||||
check: <path d="M5 12l5 5L20 7" />,
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<div className="page-header">
|
||||
<h1>Users & Groups</h1>
|
||||
<div className="spacer" />
|
||||
<button className="btn ghost sm" onClick={exportCsv}><Icon name="download" />Export</button>
|
||||
<button className="btn primary" onClick={() => setShowInvite(true)}><Icon name="plus" />Invite user</button>
|
||||
{tab === 'users' && (<>
|
||||
<button className="btn ghost sm" onClick={exportCsv}><Icon name="download" />Export</button>
|
||||
<button className="btn primary" onClick={() => setShowInvite(true)}><Icon name="plus" />Invite user</button>
|
||||
</>)}
|
||||
</div>
|
||||
<div className="page-body">
|
||||
<div className="tab-group" style={{ width: "fit-content", marginBottom: 12 }}>
|
||||
<button className={tab === "users" ? "active" : ""} onClick={() => setTab("users")}>Users · {users.length}</button>
|
||||
<button className={tab === "groups" ? "active" : ""} onClick={() => setTab("groups")}>Groups</button>
|
||||
<button className={tab === "groups" ? "active" : ""} onClick={() => setTab("groups")}>Groups · {groups.length}</button>
|
||||
<button className={tab === "policies" ? "active" : ""} onClick={() => setTab("policies")}>Policies</button>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<div className="user-row head">
|
||||
<div>User</div>
|
||||
<div>Role</div>
|
||||
<div>Groups</div>
|
||||
<div>Last active</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{users.length === 0 && (
|
||||
<div style={{ padding: "32px 0", textAlign: "center", color: "var(--text-3)" }}>No users found</div>
|
||||
)}
|
||||
{users.map(u => (
|
||||
<div key={u.id} className="user-row">
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
|
||||
|
||||
{tab === 'users' && (
|
||||
<div className="panel">
|
||||
<div className="user-row head">
|
||||
<div>User</div>
|
||||
<div>Role</div>
|
||||
<div>Groups</div>
|
||||
<div>Created</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{users.length === 0 && (
|
||||
<div style={{ padding: "32px 0", textAlign: "center", color: "var(--text-3)" }}>No users found</div>
|
||||
)}
|
||||
{users.map(u => (
|
||||
<div key={u.id} className="user-row" style={{ position: 'relative' }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<div className="avatar" style={{ width: 32, height: 32, fontSize: 11, background: avatarColor(u.initials || u.id) }}>{u.initials || '??'}</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>@{u.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{u.name}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>@{u.username}</div>
|
||||
<select value={u.role || 'viewer'}
|
||||
onChange={e => changeRole(u, e.target.value)}
|
||||
className="field-input"
|
||||
style={{ width: 90, padding: '3px 6px', fontSize: 11.5, appearance: 'auto' }}>
|
||||
<option value="admin">admin</option>
|
||||
<option value="editor">editor</option>
|
||||
<option value="viewer">viewer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
|
||||
{u.group_count || 0} {u.group_count === 1 ? 'group' : 'groups'}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>
|
||||
{u.created_at ? new Date(u.created_at).toLocaleDateString() : u.lastSeen || '—'}
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button className="icon-btn" onClick={e => { e.stopPropagation(); setMenuFor(menuFor === u.id ? null : u.id); }}>
|
||||
<Icon name="more" />
|
||||
</button>
|
||||
{menuFor === u.id && (
|
||||
<div className="row-menu" onClick={e => e.stopPropagation()}>
|
||||
<button onClick={() => { setMenuFor(null); setEditingUser(u); }}>
|
||||
<Icon name="edit" size={12} />Rename
|
||||
</button>
|
||||
<button onClick={() => resetPassword(u)}>
|
||||
<Icon name="key" size={12} />Reset password
|
||||
</button>
|
||||
<button className="danger" onClick={() => deleteUser(u)}>
|
||||
<Icon name="trash" size={12} />Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div><span className={`badge ${u.role === "admin" ? "purple" : u.role === "service" ? "neutral" : "accent"}`}>{u.role}</span></div>
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{(u.groups || []).map(g => <span key={g} className="badge outline" style={{ textTransform: "lowercase" }}>{g}</span>)}
|
||||
</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: "var(--text-3)" }}>{u.lastSeen}</div>
|
||||
<div><button className="icon-btn"><Icon name="more" /></button></div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'groups' && <GroupsPanel groups={groups} users={users} onChange={refreshGroups} />}
|
||||
|
||||
{tab === 'policies' && (
|
||||
<div className="panel" style={{ padding: '48px 24px', textAlign: 'center', color: 'var(--text-3)' }}>
|
||||
<Icon name="lock" size={24} />
|
||||
<div style={{ marginTop: 10, fontWeight: 500, fontSize: 14, color: 'var(--text-2)' }}>Access policies</div>
|
||||
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||
Per-project and per-bin permissions are coming soon. For now, role-based access<br />
|
||||
(admin / editor / viewer) is enforced API-wide.
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showInvite && <InviteUserModal onCreated={onCreated} onClose={() => setShowInvite(false)} />}
|
||||
{editingUser && (
|
||||
<EditUserModal user={editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onSaved={() => { setEditingUser(null); refreshUsers(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" style={{ width: 400 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div style={{ fontSize: 15, fontWeight: 600 }}>Rename user</div>
|
||||
<button className="icon-btn" onClick={onClose}><Icon name="x" /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="field">
|
||||
<label className="field-label">Display name</label>
|
||||
<input className="field-input" autoFocus value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') submit(); }} />
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4 }}>username @{user.username} cannot be changed</div>
|
||||
</div>
|
||||
{err && <div style={{ fontSize: 12, color: 'var(--danger)', marginTop: 4 }}>{err}</div>}
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button className="btn ghost sm" onClick={onClose}>Cancel</button>
|
||||
<button className="btn primary sm" onClick={submit} disabled={saving || !name.trim()}>{saving ? 'Saving…' : 'Save'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12 }}>
|
||||
<div style={{ flex: 1, fontSize: 12, color: 'var(--text-3)' }}>
|
||||
Groups let you bundle users for project access. Memberships are checked when role-based access is enforced.
|
||||
</div>
|
||||
<button className="btn primary sm" onClick={() => setCreating(true)}><Icon name="plus" size={11} />New group</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="panel" style={{ padding: 12, marginBottom: 12, display: 'grid', gridTemplateColumns: '1fr 2fr auto auto', gap: 8, alignItems: 'end' }}>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">Group name</label>
|
||||
<input className="field-input" autoFocus value={newName} onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') createGroup(); }} placeholder="broadcasters" />
|
||||
</div>
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label className="field-label">Description (optional)</label>
|
||||
<input className="field-input" value={newDesc} onChange={e => setNewDesc(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') createGroup(); }} placeholder="On-air operators" />
|
||||
</div>
|
||||
<button className="btn primary sm" onClick={createGroup} disabled={!newName.trim()}>Create</button>
|
||||
<button className="btn ghost sm" onClick={() => { setCreating(false); setNewName(''); setNewDesc(''); }}>Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel">
|
||||
{groups.length === 0 && !creating && (
|
||||
<div style={{ padding: '32px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12.5 }}>
|
||||
No groups yet — click <em>New group</em> above to create one.
|
||||
</div>
|
||||
)}
|
||||
{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 (
|
||||
<div key={g.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<div style={{ padding: '12px 16px', display: 'grid', gridTemplateColumns: '1.6fr 2fr 90px 80px', alignItems: 'center', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, fontSize: 13 }}>{g.name}</div>
|
||||
<div className="mono" style={{ fontSize: 11, color: 'var(--text-3)' }}>{g.id.slice(0, 8)}</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-3)' }}>{g.description || <span style={{ fontStyle: 'italic' }}>no description</span>}</div>
|
||||
<div className="mono" style={{ fontSize: 11.5, color: 'var(--text-3)' }}>{g.member_count || 0} member{g.member_count === 1 ? '' : 's'}</div>
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'flex-end' }}>
|
||||
<button className="btn ghost sm" onClick={() => toggle(g)}>{isOpen ? 'Hide' : 'Members'}</button>
|
||||
<button className="btn ghost sm danger" onClick={() => deleteGroup(g)}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div style={{ padding: '0 16px 16px 16px', background: 'var(--bg-2)' }}>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
{groupMembers.length === 0 && <span style={{ fontSize: 12, color: 'var(--text-3)' }}>No members yet.</span>}
|
||||
{groupMembers.map(m => (
|
||||
<span key={m.id} className="badge outline" style={{ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 8px' }}>
|
||||
@{m.username}
|
||||
<button className="icon-btn" style={{ width: 16, height: 16, padding: 0 }} onClick={() => removeMember(g, m.id)} title="Remove">
|
||||
<Icon name="x" size={9} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{nonMembers.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11.5, color: 'var(--text-3)' }}>Add member:</span>
|
||||
<select className="field-input" defaultValue=""
|
||||
onChange={e => { addMember(g, e.target.value); e.target.value = ''; }}
|
||||
style={{ width: 200, padding: '4px 8px', fontSize: 12, appearance: 'auto' }}>
|
||||
<option value="" disabled>— Pick a user —</option>
|
||||
{nonMembers.map(u => <option key={u.id} value={u.id}>@{u.username} — {u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="page">
|
||||
<div className="home-greeting">
|
||||
<h1>Dragonflight</h1>
|
||||
<p>
|
||||
{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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="stat-row">
|
||||
<div className="stat-card">
|
||||
<div className="stat-card" onClick={() => navigate('library')} style={{ cursor: 'pointer' }}>
|
||||
<div className="label"><Icon name="library" size={12} /> Library</div>
|
||||
<div className="value">{ASSETS.length.toLocaleString()}</div>
|
||||
<div className="delta">Total assets</div>
|
||||
<Sparkline data={spark(1, ASSETS.length || 10)} color="#5B7CFA" />
|
||||
<div className="value">{assetsTotal.toLocaleString()}</div>
|
||||
<div className="delta">
|
||||
{sumWindow(cards.assets?.series) > 0
|
||||
? '+' + sumWindow(cards.assets?.series) + ' added in last 24h'
|
||||
: 'Total assets'}
|
||||
</div>
|
||||
<Sparkline data={vals(cards.assets?.series)} color="#5B7CFA" />
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card" onClick={() => navigate('recorders')} style={{ cursor: 'pointer' }}>
|
||||
<div className="label"><Icon name="record" size={12} /> Live feeds</div>
|
||||
<div className="value">{liveRecorders.length}</div>
|
||||
<div className="delta">{RECORDERS.length} recorders configured</div>
|
||||
<Sparkline data={spark(2, 5)} color="#2DD4A8" />
|
||||
<div className="value">{liveCount}</div>
|
||||
<div className="delta">{totalRecs} recorder{totalRecs === 1 ? '' : 's'} configured</div>
|
||||
<Sparkline data={vals(cards.recorders?.series)} color="#2DD4A8" />
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card" onClick={() => navigate('jobs')} style={{ cursor: 'pointer' }}>
|
||||
<div className="label"><Icon name="jobs" size={12} /> Jobs</div>
|
||||
<div className="value">{runningJobs.length}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {JOBS.filter(j => j.status === 'done').length} done</span></div>
|
||||
<div className="delta" style={{ color: failedJobs > 0 ? 'var(--warning)' : '' }}>{failedJobs > 0 ? failedJobs + ' failed' : 'All clear'}</div>
|
||||
<Sparkline data={spark(3, 8)} color="#B57CFA" />
|
||||
<div className="value">{runningCount}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {doneCount} done</span></div>
|
||||
<div className="delta" style={{ color: failedCount > 0 ? 'var(--warning)' : '' }}>
|
||||
{failedCount > 0 ? failedCount + ' failed' : 'All clear'}
|
||||
{sumWindow(cards.jobs?.series_done) > 0 && (
|
||||
<> · {sumWindow(cards.jobs?.series_done)} completed in last 24h</>
|
||||
)}
|
||||
</div>
|
||||
<Sparkline data={vals(cards.jobs?.series_done)} color="#B57CFA" />
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-card" onClick={() => navigate('cluster')} style={{ cursor: 'pointer' }}>
|
||||
<div className="label"><Icon name="hdd" size={12} /> Cluster nodes</div>
|
||||
<div className="value">{nodesOnline}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {NODES.length} online</span></div>
|
||||
<div className="delta">Last heartbeat <30s</div>
|
||||
<Sparkline data={spark(4, 4)} color="#F5A623" />
|
||||
<div className="value">{nodesOnline}<span style={{ color: 'var(--text-3)', fontWeight: 400, fontSize: 16 }}> / {nodesTotal} online</span></div>
|
||||
<div className="delta">Heartbeat within 2 min</div>
|
||||
<Sparkline data={vals(cards.cluster?.series)} color="#F5A623" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -59,7 +97,9 @@ function Home({ navigate }) {
|
|||
{liveRecorders.map(r => (
|
||||
<div key={r.id} className="live-feed-tile" onClick={() => navigate('recorders')}>
|
||||
<div className="live-feed-tile-badge"><span className="badge live">REC</span></div>
|
||||
<FauxFrame />
|
||||
{r.live_asset_id
|
||||
? <HlsPreview assetId={r.live_asset_id} />
|
||||
: <FauxFrame />}
|
||||
<div className="live-feed-tile-label">
|
||||
<span className="name">{r.name}</span>
|
||||
<span className="time">{r.elapsed}</span>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue