@bhooai/nexus-admin 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/App.tsx +4127 -72
- package/src/alertCenter.tsx +150 -0
- package/src/api.ts +474 -77
- package/src/assets/bhooai-nexus-logo.svg +25 -0
- package/src/index.css +3481 -78
- package/src/components/components.tsx +0 -51
- package/src/tabs/Ai.tsx +0 -70
- package/src/tabs/Apps.tsx +0 -53
- package/src/tabs/Config.tsx +0 -31
- package/src/tabs/Health.tsx +0 -38
- package/src/tabs/Logs.tsx +0 -52
- package/src/tabs/Overview.tsx +0 -43
- package/src/tabs/Payments.tsx +0 -62
- package/src/tabs/Users.tsx +0 -45
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
|
|
3
|
-
export function Card({ title, children, className = '' }: { title?: string; children: React.ReactNode; className?: string }) {
|
|
4
|
-
return (
|
|
5
|
-
<section className={`admin-card ${className}`}>
|
|
6
|
-
{title && <div className="admin-card-title">{title}</div>}
|
|
7
|
-
{children}
|
|
8
|
-
</section>
|
|
9
|
-
);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function Pill({ ok, label }: { ok: boolean | null; label: string }) {
|
|
13
|
-
const cls = ok === null ? 'pill-unknown' : ok ? 'pill-ok' : 'pill-bad';
|
|
14
|
-
return <span className={`pill ${cls}`}>{label}</span>;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function BackendSelector({ apps, active, onSelect }: { apps: AppEntry[]; active: number | null; onSelect: (port: number | null) => void }) {
|
|
18
|
-
return (
|
|
19
|
-
<select
|
|
20
|
-
className="backend-selector"
|
|
21
|
-
value={active ?? ''}
|
|
22
|
-
onChange={(e) => onSelect(e.target.value ? parseInt(e.target.value, 10) : null)}
|
|
23
|
-
title="Active backend for per-backend tabs"
|
|
24
|
-
>
|
|
25
|
-
<option value="">All (current backend)</option>
|
|
26
|
-
{apps
|
|
27
|
-
.filter((a) => a.kind === 'backend')
|
|
28
|
-
.map((a) => (
|
|
29
|
-
<option key={a.name} value={a.port}>
|
|
30
|
-
{a.name} :{a.port}
|
|
31
|
-
</option>
|
|
32
|
-
))}
|
|
33
|
-
</select>
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface AppEntry {
|
|
38
|
-
name: string;
|
|
39
|
-
port: number;
|
|
40
|
-
healthy: boolean | null;
|
|
41
|
-
kind: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function ErrorCard({ message }: { message: string }) {
|
|
45
|
-
return (
|
|
46
|
-
<div className="error-card">
|
|
47
|
-
<strong>Not available</strong>
|
|
48
|
-
<span>{message}</span>
|
|
49
|
-
</div>
|
|
50
|
-
);
|
|
51
|
-
}
|
package/src/tabs/Ai.tsx
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { adminGet, adminPost } from '../api.js';
|
|
3
|
-
|
|
4
|
-
interface AiModelsResp { ok: boolean; models?: { data?: Array<{ id: string }> }; reason?: string }
|
|
5
|
-
interface AiChatResp { ok: boolean; choices?: Array<{ message: { role: string; content: string } }>; reason?: string }
|
|
6
|
-
|
|
7
|
-
export function Ai() {
|
|
8
|
-
const [models, setModels] = useState<string[] | null>(null);
|
|
9
|
-
const [error, setError] = useState<string | null>(null);
|
|
10
|
-
const [prompt, setPrompt] = useState('');
|
|
11
|
-
const [model, setModel] = useState('');
|
|
12
|
-
const [reply, setReply] = useState('');
|
|
13
|
-
const [busy, setBusy] = useState(false);
|
|
14
|
-
|
|
15
|
-
useEffect(() => {
|
|
16
|
-
let alive = true;
|
|
17
|
-
void (async () => {
|
|
18
|
-
try {
|
|
19
|
-
const data = await adminGet<AiModelsResp>('/ai/models');
|
|
20
|
-
if (!alive) return;
|
|
21
|
-
if (data?.ok) {
|
|
22
|
-
const ids = (data.models?.data ?? []).map((m) => m.id);
|
|
23
|
-
setModels(ids);
|
|
24
|
-
if (ids[0]) setModel(ids[0]);
|
|
25
|
-
} else {
|
|
26
|
-
setError(data?.reason ?? 'AI server not reachable');
|
|
27
|
-
}
|
|
28
|
-
} catch (e) {
|
|
29
|
-
if (alive) setError(String((e as Error).message));
|
|
30
|
-
}
|
|
31
|
-
})();
|
|
32
|
-
return () => { alive = false; };
|
|
33
|
-
}, []);
|
|
34
|
-
|
|
35
|
-
const send = async () => {
|
|
36
|
-
if (!prompt.trim() || busy) return;
|
|
37
|
-
setBusy(true);
|
|
38
|
-
setReply('');
|
|
39
|
-
try {
|
|
40
|
-
const data = await adminPost<AiChatResp>('/ai/chat', { model: model || undefined, messages: [{ role: 'user', content: prompt }] });
|
|
41
|
-
if (data?.ok) {
|
|
42
|
-
setReply(data.choices?.[0]?.message?.content ?? '(empty reply)');
|
|
43
|
-
} else {
|
|
44
|
-
setReply(`Error: ${data?.reason ?? 'AI server not reachable'}`);
|
|
45
|
-
}
|
|
46
|
-
} catch (e) {
|
|
47
|
-
setReply(`Error: ${String((e as Error).message)}`);
|
|
48
|
-
}
|
|
49
|
-
setBusy(false);
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
return (
|
|
53
|
-
<div>
|
|
54
|
-
<h2>AI</h2>
|
|
55
|
-
{error && <div className="error-card"><strong>AI server not reachable</strong><span>{error} — start it with <code>docker compose --profile ai up</code> or <code>nexus dev</code>.</span></div>}
|
|
56
|
-
{models && (
|
|
57
|
-
<div style={{ marginBottom: '1rem' }}>
|
|
58
|
-
<label className="muted">Model: </label>
|
|
59
|
-
<select value={model} onChange={(e) => setModel(e.target.value)}>
|
|
60
|
-
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
|
61
|
-
</select>
|
|
62
|
-
<div className="model-count muted">{models.length} models</div>
|
|
63
|
-
</div>
|
|
64
|
-
)}
|
|
65
|
-
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="Ask the AI server…" rows={3} style={{ width: '100%' }} />
|
|
66
|
-
<button onClick={send} disabled={busy || !models}>{busy ? 'Thinking…' : 'Send'}</button>
|
|
67
|
-
{reply && <pre className="config-tree" style={{ marginTop: '1rem' }}>{reply}</pre>}
|
|
68
|
-
</div>
|
|
69
|
-
);
|
|
70
|
-
}
|
package/src/tabs/Apps.tsx
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { getRegistry, probeApp, type AppEntry } from '../api.js';
|
|
3
|
-
import { Pill } from '../components/components.js';
|
|
4
|
-
|
|
5
|
-
export function Apps() {
|
|
6
|
-
const [apps, setApps] = useState<Array<AppEntry & { live: boolean }> | null>(null);
|
|
7
|
-
const [error, setError] = useState<string | null>(null);
|
|
8
|
-
|
|
9
|
-
useEffect(() => {
|
|
10
|
-
let alive = true;
|
|
11
|
-
const load = async () => {
|
|
12
|
-
try {
|
|
13
|
-
const reg = await getRegistry();
|
|
14
|
-
const probed = await Promise.all(
|
|
15
|
-
reg.map(async (a) => ({ ...a, live: (await probeApp(a.port)).ok })),
|
|
16
|
-
);
|
|
17
|
-
if (alive) setApps(probed);
|
|
18
|
-
setError(null);
|
|
19
|
-
} catch (e) {
|
|
20
|
-
if (alive) setError(String((e as Error).message));
|
|
21
|
-
}
|
|
22
|
-
};
|
|
23
|
-
void load();
|
|
24
|
-
const id = setInterval(load, 5000);
|
|
25
|
-
return () => { alive = false; clearInterval(id); };
|
|
26
|
-
}, []);
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<div>
|
|
30
|
-
<h2>Apps</h2>
|
|
31
|
-
<p className="muted">Registry from <code>.nexus-ports.json</code>, liveness probed every 5s.</p>
|
|
32
|
-
{error && <div className="error-card"><strong>Error</strong><span>{error}</span></div>}
|
|
33
|
-
{!apps && !error && <p>Loading…</p>}
|
|
34
|
-
{apps && apps.length === 0 && (
|
|
35
|
-
<p>No apps registered. Run <code>nexus add backend <name></code> to add one.</p>
|
|
36
|
-
)}
|
|
37
|
-
<div className="app-grid">
|
|
38
|
-
{apps?.map((a) => (
|
|
39
|
-
<div key={a.name} className="app-card">
|
|
40
|
-
<div className="app-card-head">
|
|
41
|
-
<strong>{a.name}</strong>
|
|
42
|
-
<Pill ok={a.live} label={a.live ? '● healthy' : '● down'} />
|
|
43
|
-
</div>
|
|
44
|
-
<div className="app-card-body">
|
|
45
|
-
<div>port <code>{a.port}</code></div>
|
|
46
|
-
<div>kind <code>{a.kind}</code></div>
|
|
47
|
-
</div>
|
|
48
|
-
</div>
|
|
49
|
-
))}
|
|
50
|
-
</div>
|
|
51
|
-
</div>
|
|
52
|
-
);
|
|
53
|
-
}
|
package/src/tabs/Config.tsx
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { adminGet } from '../api.js';
|
|
3
|
-
|
|
4
|
-
export function Config() {
|
|
5
|
-
const [tree, setTree] = useState<Record<string, unknown> | null>(null);
|
|
6
|
-
const [error, setError] = useState<string | null>(null);
|
|
7
|
-
|
|
8
|
-
useEffect(() => {
|
|
9
|
-
let alive = true;
|
|
10
|
-
void (async () => {
|
|
11
|
-
try {
|
|
12
|
-
const data = await adminGet<{ config: Record<string, unknown> }>('/admin/config');
|
|
13
|
-
if (alive) setTree(data?.config ?? null);
|
|
14
|
-
} catch (e) {
|
|
15
|
-
if (alive) setError(String((e as Error).message));
|
|
16
|
-
}
|
|
17
|
-
})();
|
|
18
|
-
return () => { alive = false; };
|
|
19
|
-
}, []);
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<div>
|
|
23
|
-
<h2>Config</h2>
|
|
24
|
-
<p className="muted">Read-only; secrets are masked.</p>
|
|
25
|
-
{error && <div className="error-card"><strong>Not available</strong><span>{error}</span></div>}
|
|
26
|
-
{tree && (
|
|
27
|
-
<pre className="config-tree">{JSON.stringify(tree, null, 2)}</pre>
|
|
28
|
-
)}
|
|
29
|
-
</div>
|
|
30
|
-
);
|
|
31
|
-
}
|
package/src/tabs/Health.tsx
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { adminGet } from '../api.js';
|
|
3
|
-
import { Card, Pill } from '../components/components.js';
|
|
4
|
-
|
|
5
|
-
interface ServiceState { id: string; label: string; ok: boolean; detail?: string }
|
|
6
|
-
|
|
7
|
-
export function Health() {
|
|
8
|
-
const [services, setServices] = useState<ServiceState[] | null>(null);
|
|
9
|
-
const [error, setError] = useState<string | null>(null);
|
|
10
|
-
|
|
11
|
-
useEffect(() => {
|
|
12
|
-
let alive = true;
|
|
13
|
-
void (async () => {
|
|
14
|
-
try {
|
|
15
|
-
const data = await adminGet<{ services: ServiceState[] }>('/admin/health/services');
|
|
16
|
-
if (alive) setServices(data?.services ?? []);
|
|
17
|
-
} catch (e) {
|
|
18
|
-
if (alive) setError(String((e as Error).message));
|
|
19
|
-
}
|
|
20
|
-
})();
|
|
21
|
-
return () => { alive = false; };
|
|
22
|
-
}, []);
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<div>
|
|
26
|
-
<h2>Health</h2>
|
|
27
|
-
{error && <div className="error-card"><strong>Not available</strong><span>{error}</span></div>}
|
|
28
|
-
<div className="stat-row">
|
|
29
|
-
{services?.map((s) => (
|
|
30
|
-
<Card key={s.id} title={s.label}>
|
|
31
|
-
<div className="stat-num"><Pill ok={s.ok} label={s.ok ? 'OK' : 'DOWN'} /></div>
|
|
32
|
-
<div className="muted">{s.detail}</div>
|
|
33
|
-
</Card>
|
|
34
|
-
))}
|
|
35
|
-
</div>
|
|
36
|
-
</div>
|
|
37
|
-
);
|
|
38
|
-
}
|
package/src/tabs/Logs.tsx
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { adminGet, adminPost } from '../api.js';
|
|
3
|
-
|
|
4
|
-
interface LogEntry { ts: number; method: string; path: string; status: number; latencyMs: number }
|
|
5
|
-
|
|
6
|
-
export function Logs() {
|
|
7
|
-
const [entries, setEntries] = useState<LogEntry[] | null>(null);
|
|
8
|
-
const [error, setError] = useState<string | null>(null);
|
|
9
|
-
|
|
10
|
-
const load = async () => {
|
|
11
|
-
try {
|
|
12
|
-
const data = await adminGet<{ entries: LogEntry[] }>('/admin/logs/tail?lines=200');
|
|
13
|
-
setEntries(data?.entries ?? []);
|
|
14
|
-
setError(null);
|
|
15
|
-
} catch (e) {
|
|
16
|
-
setError(String((e as Error).message));
|
|
17
|
-
}
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
useEffect(() => {
|
|
21
|
-
void load();
|
|
22
|
-
const id = setInterval(load, 3000);
|
|
23
|
-
return () => clearInterval(id);
|
|
24
|
-
}, []);
|
|
25
|
-
|
|
26
|
-
const clear = async () => {
|
|
27
|
-
await adminPost('/admin/logs/clear');
|
|
28
|
-
setEntries([]);
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
<div>
|
|
33
|
-
<h2>Request logs <button className="btn-ghost small" onClick={clear}>clear</button></h2>
|
|
34
|
-
{error && <div className="error-card"><strong>Not available</strong><span>{error}</span></div>}
|
|
35
|
-
<table className="log-table">
|
|
36
|
-
<thead><tr><th>time</th><th>method</th><th>path</th><th>status</th><th>ms</th></tr></thead>
|
|
37
|
-
<tbody>
|
|
38
|
-
{entries?.map((e, i) => (
|
|
39
|
-
<tr key={i}>
|
|
40
|
-
<td>{new Date(e.ts).toLocaleTimeString()}</td>
|
|
41
|
-
<td><code>{e.method}</code></td>
|
|
42
|
-
<td>{e.path}</td>
|
|
43
|
-
<td><span className={e.status >= 500 ? 'status-err' : e.status >= 400 ? 'status-warn' : ''}>{e.status}</span></td>
|
|
44
|
-
<td>{e.latencyMs}</td>
|
|
45
|
-
</tr>
|
|
46
|
-
))}
|
|
47
|
-
{entries?.length === 0 && <tr><td colSpan={5} className="muted">No requests yet.</td></tr>}
|
|
48
|
-
</tbody>
|
|
49
|
-
</table>
|
|
50
|
-
</div>
|
|
51
|
-
);
|
|
52
|
-
}
|
package/src/tabs/Overview.tsx
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { getRegistry, probeApp, type AppEntry } from '../api.js';
|
|
3
|
-
import { Card, Pill } from '../components/components.js';
|
|
4
|
-
|
|
5
|
-
export function Overview() {
|
|
6
|
-
const [apps, setApps] = useState<AppEntry[] | null>(null);
|
|
7
|
-
const [error, setError] = useState<string | null>(null);
|
|
8
|
-
|
|
9
|
-
useEffect(() => {
|
|
10
|
-
let alive = true;
|
|
11
|
-
void (async () => {
|
|
12
|
-
try {
|
|
13
|
-
const reg = await getRegistry();
|
|
14
|
-
const probed = await Promise.all(
|
|
15
|
-
reg.map(async (a) => ({ ...a, healthy: (await probeApp(a.port)).ok })),
|
|
16
|
-
);
|
|
17
|
-
if (alive) setApps(probed);
|
|
18
|
-
} catch (e) {
|
|
19
|
-
if (alive) setError(String((e as Error).message));
|
|
20
|
-
}
|
|
21
|
-
})();
|
|
22
|
-
return () => { alive = false; };
|
|
23
|
-
}, []);
|
|
24
|
-
|
|
25
|
-
const healthy = apps?.filter((a) => a.healthy).length ?? 0;
|
|
26
|
-
const backends = apps?.filter((a) => a.kind === 'backend').length ?? 0;
|
|
27
|
-
|
|
28
|
-
return (
|
|
29
|
-
<div>
|
|
30
|
-
<h2>Overview</h2>
|
|
31
|
-
{error && <div className="error-card"><strong>Error</strong><span>{error}</span></div>}
|
|
32
|
-
<div className="stat-row">
|
|
33
|
-
<Card title="Apps"><div className="stat-num">{apps?.length ?? '…'}</div></Card>
|
|
34
|
-
<Card title="Healthy"><div className="stat-num">{apps ? healthy : '…'}</div></Card>
|
|
35
|
-
<Card title="Backends"><div className="stat-num">{apps ? backends : '…'}</div></Card>
|
|
36
|
-
<Card title="Ports"><div className="stat-num">{apps?.map((a) => a.port).join(' · ') ?? '…'}</div></Card>
|
|
37
|
-
</div>
|
|
38
|
-
{apps && apps.length === 0 && (
|
|
39
|
-
<p>No apps registered. Run <code>nexus add backend <name></code> to create one.</p>
|
|
40
|
-
)}
|
|
41
|
-
</div>
|
|
42
|
-
);
|
|
43
|
-
}
|
package/src/tabs/Payments.tsx
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { adminGet } from '../api.js';
|
|
3
|
-
import { Pill } from '../components/components.js';
|
|
4
|
-
|
|
5
|
-
interface OrderRow { id: string; userId?: string; provider?: string; amount?: number; currency?: string; status?: string; createdAt?: string }
|
|
6
|
-
interface ProviderView { id: string; enabled: boolean; sandbox: boolean }
|
|
7
|
-
|
|
8
|
-
export function Payments() {
|
|
9
|
-
const [orders, setOrders] = useState<OrderRow[] | null>(null);
|
|
10
|
-
const [providers, setProviders] = useState<ProviderView[] | null>(null);
|
|
11
|
-
const [error, setError] = useState<string | null>(null);
|
|
12
|
-
|
|
13
|
-
useEffect(() => {
|
|
14
|
-
let alive = true;
|
|
15
|
-
void (async () => {
|
|
16
|
-
try {
|
|
17
|
-
const data = await adminGet<{ ok: boolean; orders?: OrderRow[]; providers?: ProviderView[]; reason?: string }>('/admin/payments');
|
|
18
|
-
if (!alive) return;
|
|
19
|
-
if (data?.ok) {
|
|
20
|
-
setOrders(data.orders ?? []);
|
|
21
|
-
setProviders(data.providers ?? []);
|
|
22
|
-
} else {
|
|
23
|
-
setError(data?.reason ?? 'MongoDB not reachable');
|
|
24
|
-
}
|
|
25
|
-
} catch (e) {
|
|
26
|
-
if (alive) setError(String((e as Error).message));
|
|
27
|
-
}
|
|
28
|
-
})();
|
|
29
|
-
return () => { alive = false; };
|
|
30
|
-
}, []);
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<div>
|
|
34
|
-
<h2>Payments</h2>
|
|
35
|
-
{error && <div className="error-card"><strong>Not available</strong><span>{error}</span></div>}
|
|
36
|
-
{providers && (
|
|
37
|
-
<div className="stat-row">
|
|
38
|
-
{providers.map((p) => (
|
|
39
|
-
<div key={p.id} className="app-card" style={{ padding: '1rem' }}>
|
|
40
|
-
<div className="app-card-head"><strong>{p.id}</strong><Pill ok={p.enabled} label={p.enabled ? 'enabled' : 'disabled'} /></div>
|
|
41
|
-
<div className="app-card-body muted">{p.sandbox ? 'sandbox mode' : 'live mode'}</div>
|
|
42
|
-
</div>
|
|
43
|
-
))}
|
|
44
|
-
</div>
|
|
45
|
-
)}
|
|
46
|
-
<table className="data-table" style={{ marginTop: '1rem' }}>
|
|
47
|
-
<thead><tr><th>id</th><th>provider</th><th>amount</th><th>status</th></tr></thead>
|
|
48
|
-
<tbody>
|
|
49
|
-
{orders?.map((o) => (
|
|
50
|
-
<tr key={o.id}>
|
|
51
|
-
<td><code>{o.id.slice(0, 12)}…</code></td>
|
|
52
|
-
<td>{o.provider ?? '—'}</td>
|
|
53
|
-
<td>{o.currency ?? ''} {o.amount ?? '—'}</td>
|
|
54
|
-
<td>{o.status ?? '—'}</td>
|
|
55
|
-
</tr>
|
|
56
|
-
))}
|
|
57
|
-
{orders?.length === 0 && !error && <tr><td colSpan={4} className="muted">No orders yet.</td></tr>}
|
|
58
|
-
</tbody>
|
|
59
|
-
</table>
|
|
60
|
-
</div>
|
|
61
|
-
);
|
|
62
|
-
}
|
package/src/tabs/Users.tsx
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { adminGet } from '../api.js';
|
|
3
|
-
|
|
4
|
-
interface UserRow { id: string; email?: string; name?: string; roles: string[]; createdAt?: string }
|
|
5
|
-
|
|
6
|
-
export function Users() {
|
|
7
|
-
const [users, setUsers] = useState<UserRow[] | null>(null);
|
|
8
|
-
const [error, setError] = useState<string | null>(null);
|
|
9
|
-
|
|
10
|
-
useEffect(() => {
|
|
11
|
-
let alive = true;
|
|
12
|
-
void (async () => {
|
|
13
|
-
try {
|
|
14
|
-
const data = await adminGet<{ ok: boolean; users?: UserRow[]; reason?: string }>('/admin/users');
|
|
15
|
-
if (!alive) return;
|
|
16
|
-
if (data?.ok) setUsers(data.users ?? []);
|
|
17
|
-
else setError(data?.reason ?? 'MongoDB not reachable');
|
|
18
|
-
} catch (e) {
|
|
19
|
-
if (alive) setError(String((e as Error).message));
|
|
20
|
-
}
|
|
21
|
-
})();
|
|
22
|
-
return () => { alive = false; };
|
|
23
|
-
}, []);
|
|
24
|
-
|
|
25
|
-
return (
|
|
26
|
-
<div>
|
|
27
|
-
<h2>Users</h2>
|
|
28
|
-
{error && <div className="error-card"><strong>MongoDB not reachable</strong><span>{error} — start Mongo or add a <code>users</code> collection.</span></div>}
|
|
29
|
-
<table className="data-table">
|
|
30
|
-
<thead><tr><th>id</th><th>email</th><th>name</th><th>roles</th></tr></thead>
|
|
31
|
-
<tbody>
|
|
32
|
-
{users?.map((u) => (
|
|
33
|
-
<tr key={u.id}>
|
|
34
|
-
<td><code>{u.id.slice(0, 12)}…</code></td>
|
|
35
|
-
<td>{u.email ?? '—'}</td>
|
|
36
|
-
<td>{u.name ?? '—'}</td>
|
|
37
|
-
<td>{u.roles.map((r) => <span key={r} className="role-chip">{r}</span>)}</td>
|
|
38
|
-
</tr>
|
|
39
|
-
))}
|
|
40
|
-
{users?.length === 0 && !error && <tr><td colSpan={4} className="muted">No users yet.</td></tr>}
|
|
41
|
-
</tbody>
|
|
42
|
-
</table>
|
|
43
|
-
</div>
|
|
44
|
-
);
|
|
45
|
-
}
|