| 1 | "use client"; |
| 2 | import { useEffect, useState } from "react"; |
| 3 | import { api, AdminSettings, ApiKeyRow, Connector, RetentionResult, Source, UserRow } from "@/lib/api"; |
| 4 | |
| 5 | export default function Admin() { |
| 6 | const [settings, setSettings] = useState<AdminSettings | null>(null); |
| 7 | const [users, setUsers] = useState<UserRow[]>([]); |
| 8 | const [sources, setSources] = useState<Source[]>([]); |
| 9 | const [keys, setKeys] = useState<ApiKeyRow[]>([]); |
| 10 | const [connectors, setConnectors] = useState<Connector[]>([]); |
| 11 | const [newKey, setNewKey] = useState<string | null>(null); |
| 12 | const [retention, setRetention] = useState<RetentionResult | null>(null); |
| 13 | const [err, setErr] = useState<string | null>(null); |
| 14 | const [usersErr, setUsersErr] = useState<string | null>(null); |
| 15 | |
| 16 | function loadKeys() { api.apiKeys().then(setKeys).catch(() => {}); } |
| 17 | |
| 18 | useEffect(() => { |
| 19 | api.adminSettings().then(setSettings) |
| 20 | .catch((e) => setErr(e.status === 401 ? "Sign in as admin." : e.status === 403 ? "Admin role required." : String(e))); |
| 21 | api.users().then(setUsers).catch((e) => setUsersErr(e.status === 403 ? "Admin only." : "Unavailable.")); |
| 22 | api.sources().then(setSources).catch(() => {}); |
| 23 | api.connectors().then(setConnectors).catch(() => {}); |
| 24 | loadKeys(); |
| 25 | }, []); |
| 26 | |
| 27 | async function createKey() { |
| 28 | const name = prompt("API key name (e.g. 'edge-device-01')"); |
| 29 | if (!name) return; |
| 30 | try { const r = await api.createApiKey(name); setNewKey(r.api_key); loadKeys(); } |
| 31 | catch { setErr("Could not create API key."); } |
| 32 | } |
| 33 | async function revoke(id: string) { |
| 34 | try { await api.revokeApiKey(id); loadKeys(); } catch { setErr("Revoke failed."); } |
| 35 | } |
| 36 | async function runRetention(dry: boolean) { |
| 37 | try { setRetention(await api.runRetention(dry)); } catch { setErr("Retention run failed (admin only)."); } |
| 38 | } |
| 39 | |
| 40 | return ( |
| 41 | <div> |
| 42 | <h1>Admin & Settings</h1> |
| 43 | {err && <div className="badge danger">{err}</div>} |
| 44 | |
| 45 | <h2>Platform settings</h2> |
| 46 | <div className="grid"> |
| 47 | {settings && [ |
| 48 | ["Environment", settings.environment], |
| 49 | ["Retention (days)", settings.default_retention_days], |
| 50 | ["Rate limit / min", settings.rate_limit_per_minute], |
| 51 | ["Dedup window (s)", settings.dedup_window_seconds], |
| 52 | ["Impossible speed (km/h)", settings.impossible_speed_kmh], |
| 53 | ["Co-travel window (s)", settings.cotravel_window_seconds], |
| 54 | ].map(([k, v]) => ( |
| 55 | <div key={k as string} className="card"><div className="muted">{k}</div><div className="kpi" style={{ fontSize: 20 }}>{v}</div></div> |
| 56 | ))} |
| 57 | </div> |
| 58 | |
| 59 | <h2>Data retention</h2> |
| 60 | <div className="card"> |
| 61 | <div className="row"> |
| 62 | <button onClick={() => runRetention(true)}>Preview purge (dry run)</button> |
| 63 | <button className="primary" onClick={() => { if (confirm("Permanently delete reads past the retention window?")) runRetention(false); }}>Run purge</button> |
| 64 | </div> |
| 65 | {retention && ( |
| 66 | <div style={{ marginTop: 10 }}> |
| 67 | <span className="muted">Cutoff {new Date(retention.cutoff).toLocaleDateString()} · </span> |
| 68 | {retention.dry_run |
| 69 | ? <span><strong>{retention.candidates}</strong> reads would be deleted.</span> |
| 70 | : <span className="badge ok">{retention.deleted} reads deleted.</span>} |
| 71 | </div> |
| 72 | )} |
| 73 | </div> |
| 74 | |
| 75 | <h2>API keys</h2> |
| 76 | <div className="card"> |
| 77 | <button onClick={createKey}>+ Create key</button> |
| 78 | {newKey && ( |
| 79 | <div className="badge ok" style={{ marginTop: 10, display: "block", padding: 10, fontFamily: "monospace", wordBreak: "break-all" }}> |
| 80 | {newKey} |
| 81 | <div className="muted" style={{ fontFamily: "sans-serif" }}>Copy now - shown once. Use header <code>X-API-Key</code>.</div> |
| 82 | </div> |
| 83 | )} |
| 84 | <table style={{ marginTop: 10 }}> |
| 85 | <thead><tr><th>Name</th><th>Last used</th><th>Status</th><th></th></tr></thead> |
| 86 | <tbody> |
| 87 | {keys.map((k) => ( |
| 88 | <tr key={k.id}> |
| 89 | <td>{k.name}</td> |
| 90 | <td className="muted">{k.last_used_at ? new Date(k.last_used_at).toLocaleString() : "never"}</td> |
| 91 | <td>{k.revoked_at ? <span className="badge danger">revoked</span> : <span className="badge ok">active</span>}</td> |
| 92 | <td>{!k.revoked_at && <a style={{ cursor: "pointer" }} onClick={() => revoke(k.id)}>revoke</a>}</td> |
| 93 | </tr> |
| 94 | ))} |
| 95 | {!keys.length && <tr><td colSpan={4} className="muted">No API keys.</td></tr>} |
| 96 | </tbody> |
| 97 | </table> |
| 98 | </div> |
| 99 | |
| 100 | <h2>Connectors</h2> |
| 101 | <div className="card"> |
| 102 | <table> |
| 103 | <thead><tr><th>Name</th><th>Description</th></tr></thead> |
| 104 | <tbody> |
| 105 | {connectors.map((c) => ( |
| 106 | <tr key={c.name}><td><span className="badge">{c.name}</span></td><td className="muted">{c.description}</td></tr> |
| 107 | ))} |
| 108 | {!connectors.length && <tr><td colSpan={2} className="muted">No connectors.</td></tr>} |
| 109 | </tbody> |
| 110 | </table> |
| 111 | <p className="muted" style={{ marginTop: 8, marginBottom: 0 }}> |
| 112 | Run via <code>POST /connectors/run</code> with a connector name and config to pull external reads into the pipeline. |
| 113 | </p> |
| 114 | </div> |
| 115 | |
| 116 | <h2>Users</h2> |
| 117 | <div className="card"> |
| 118 | {usersErr ? <div className="muted">{usersErr}</div> : ( |
| 119 | <table> |
| 120 | <thead><tr><th>Email</th><th>Name</th><th>Role</th><th>Active</th></tr></thead> |
| 121 | <tbody> |
| 122 | {users.map((u) => ( |
| 123 | <tr key={u.id}> |
| 124 | <td>{u.email}</td> |
| 125 | <td className="muted">{u.full_name || "-"}</td> |
| 126 | <td><span className="badge">{u.role}</span></td> |
| 127 | <td>{u.is_active ? <span className="badge ok">yes</span> : <span className="badge danger">no</span>}</td> |
| 128 | </tr> |
| 129 | ))} |
| 130 | {!users.length && <tr><td colSpan={4} className="muted">No users.</td></tr>} |
| 131 | </tbody> |
| 132 | </table> |
| 133 | )} |
| 134 | </div> |
| 135 | |
| 136 | <h2>Sources</h2> |
| 137 | <div className="card"> |
| 138 | <table> |
| 139 | <thead><tr><th>Name</th><th>System</th><th>Organization</th></tr></thead> |
| 140 | <tbody> |
| 141 | {sources.map((s) => ( |
| 142 | <tr key={s.id}><td>{s.name}</td><td className="muted">{s.system_type || "-"}</td><td className="muted">{s.organization || "-"}</td></tr> |
| 143 | ))} |
| 144 | {!sources.length && <tr><td colSpan={3} className="muted">No sources.</td></tr>} |
| 145 | </tbody> |
| 146 | </table> |
| 147 | </div> |
| 148 | </div> |
| 149 | ); |
| 150 | } |