● Zion Boggan
repos/Sightline/frontend/src/app/admin/page.tsx
zionboggan.com ↗
150 lines · tsx
History for this file →
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 &amp; 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
}