@bhooai/nexus-admin 2.0.0 → 2.0.2
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 +5 -3
- package/src/App.tsx +72 -4187
- package/src/api.ts +77 -478
- package/src/components/components.tsx +51 -0
- package/src/index.css +78 -2401
- package/src/tabs/Ai.tsx +70 -0
- package/src/tabs/Apps.tsx +53 -0
- package/src/tabs/Config.tsx +31 -0
- package/src/tabs/Health.tsx +38 -0
- package/src/tabs/Logs.tsx +52 -0
- package/src/tabs/Overview.tsx +43 -0
- package/src/tabs/Payments.tsx +62 -0
- package/src/tabs/Users.tsx +45 -0
- package/src/alertCenter.tsx +0 -150
- package/src/assets/bhooai-nexus-logo.svg +0 -25
- package/src/vite-env.d.ts +0 -19
package/src/tabs/Ai.tsx
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
}
|
package/src/alertCenter.tsx
DELETED
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
-
|
|
3
|
-
export type AlertKind = 'ok' | 'err' | 'warn';
|
|
4
|
-
|
|
5
|
-
export interface AdminAlert {
|
|
6
|
-
id: string;
|
|
7
|
-
kind: AlertKind;
|
|
8
|
-
message: string;
|
|
9
|
-
source: string;
|
|
10
|
-
at: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
interface AlertContextValue {
|
|
14
|
-
alerts: AdminAlert[];
|
|
15
|
-
toasts: AdminAlert[];
|
|
16
|
-
push: (kind: AlertKind, message: string, source: string) => void;
|
|
17
|
-
clear: () => void;
|
|
18
|
-
dismiss: (id: string) => void;
|
|
19
|
-
dismissToast: (id: string) => void;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export const TOAST_TTL = 4500;
|
|
23
|
-
|
|
24
|
-
const ALERTS_KEY = 'nexus-admin-alerts';
|
|
25
|
-
const MAX_ALERTS = 50;
|
|
26
|
-
|
|
27
|
-
const AlertContext = createContext<AlertContextValue | null>(null);
|
|
28
|
-
|
|
29
|
-
function loadAlerts(): AdminAlert[] {
|
|
30
|
-
if (typeof window === 'undefined') return [];
|
|
31
|
-
try {
|
|
32
|
-
const raw = window.localStorage.getItem(ALERTS_KEY);
|
|
33
|
-
if (!raw) return [];
|
|
34
|
-
const parsed = JSON.parse(raw) as AdminAlert[];
|
|
35
|
-
if (!Array.isArray(parsed)) return [];
|
|
36
|
-
return parsed
|
|
37
|
-
.filter((a) => a && typeof a.message === 'string' && typeof a.source === 'string')
|
|
38
|
-
.slice(0, MAX_ALERTS);
|
|
39
|
-
} catch { /* ignore corrupt value */ }
|
|
40
|
-
return [];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function AlertProvider({ children }: { children: ReactNode }) {
|
|
44
|
-
const [alerts, setAlerts] = useState<AdminAlert[]>(() => loadAlerts());
|
|
45
|
-
const [toasts, setToasts] = useState<AdminAlert[]>([]);
|
|
46
|
-
const idRef = useRef(0);
|
|
47
|
-
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
try { window.localStorage.setItem(ALERTS_KEY, JSON.stringify(alerts)); } catch { /* storage unavailable */ }
|
|
50
|
-
}, [alerts]);
|
|
51
|
-
|
|
52
|
-
const push = useCallback((kind: AlertKind, message: string, source: string) => {
|
|
53
|
-
const text = String(message ?? '').trim();
|
|
54
|
-
if (!text) return;
|
|
55
|
-
idRef.current += 1;
|
|
56
|
-
const alert: AdminAlert = {
|
|
57
|
-
id: `${Date.now()}-${idRef.current}`,
|
|
58
|
-
kind,
|
|
59
|
-
message: text,
|
|
60
|
-
source: source || 'admin',
|
|
61
|
-
at: new Date().toLocaleString(),
|
|
62
|
-
};
|
|
63
|
-
setAlerts((prev) => [alert, ...prev].slice(0, MAX_ALERTS));
|
|
64
|
-
setToasts((prev) => [...prev.slice(-3), alert]);
|
|
65
|
-
window.setTimeout(() => {
|
|
66
|
-
setToasts((prev) => prev.filter((t) => t.id !== alert.id));
|
|
67
|
-
}, TOAST_TTL);
|
|
68
|
-
}, []);
|
|
69
|
-
|
|
70
|
-
const clear = useCallback(() => setAlerts([]), []);
|
|
71
|
-
|
|
72
|
-
const dismiss = useCallback((id: string) => {
|
|
73
|
-
setAlerts((prev) => prev.filter((a) => a.id !== id));
|
|
74
|
-
}, []);
|
|
75
|
-
|
|
76
|
-
const dismissToast = useCallback((id: string) => {
|
|
77
|
-
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
78
|
-
}, []);
|
|
79
|
-
|
|
80
|
-
return (
|
|
81
|
-
<AlertContext.Provider value={{ alerts, toasts, push, clear, dismiss, dismissToast }}>
|
|
82
|
-
{children}
|
|
83
|
-
</AlertContext.Provider>
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function useAlerts(): AlertContextValue {
|
|
88
|
-
const ctx = useContext(AlertContext);
|
|
89
|
-
if (!ctx) throw new Error('useAlerts must be used within <AlertProvider>');
|
|
90
|
-
return ctx;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Pages that default their action messages to "warn" unless they look like errors. */
|
|
94
|
-
const WARN_SOURCES = new Set<string>([]);
|
|
95
|
-
|
|
96
|
-
function kindFor(source: string, value: string): AlertKind {
|
|
97
|
-
if (WARN_SOURCES.has(source)) return 'warn';
|
|
98
|
-
return /fail|error|could not|no .* (found|available)|unavailable|invalid|missing|rejected|failed|did not|unreachable/i.test(value) ? 'err' : 'ok';
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function useAdminAlert(source: string): [
|
|
102
|
-
string | null,
|
|
103
|
-
(value: string | { kind: AlertKind; text: string } | null) => void,
|
|
104
|
-
] {
|
|
105
|
-
const { push } = useAlerts();
|
|
106
|
-
const [msg, setMsg] = useState<string | null>(null);
|
|
107
|
-
const currentRef = useRef<string | null>(null);
|
|
108
|
-
|
|
109
|
-
const set = useCallback((value: string | { kind: AlertKind; text: string } | null) => {
|
|
110
|
-
if (!value) { currentRef.current = null; setMsg(null); return; }
|
|
111
|
-
if (typeof value === 'object') {
|
|
112
|
-
const text = String(value.text ?? '').trim();
|
|
113
|
-
if (!text) return;
|
|
114
|
-
if (text === currentRef.current) { setMsg(text); return; }
|
|
115
|
-
currentRef.current = text;
|
|
116
|
-
setMsg(text);
|
|
117
|
-
push(value.kind, text, source);
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
const text = String(value).trim();
|
|
121
|
-
const current = currentRef.current;
|
|
122
|
-
currentRef.current = text || null;
|
|
123
|
-
setMsg(text || null);
|
|
124
|
-
if (text && text !== current) push(kindFor(source, text), text, source);
|
|
125
|
-
}, [push, source]);
|
|
126
|
-
|
|
127
|
-
return [msg, set];
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const TOAST_ICONS: Record<AlertKind, string> = { ok: '✓', err: '✕', warn: '!' };
|
|
131
|
-
|
|
132
|
-
/** Floating alert popups, top-right, themed via the surrounding admin shell. */
|
|
133
|
-
export function ToastStack() {
|
|
134
|
-
const { toasts, dismissToast } = useAlerts();
|
|
135
|
-
if (!toasts.length) return null;
|
|
136
|
-
return (
|
|
137
|
-
<div className="admin-toasts" role="region" aria-label="Notifications">
|
|
138
|
-
{toasts.map((t) => (
|
|
139
|
-
<div key={t.id} className={`admin-toast is-${t.kind}`} role="status">
|
|
140
|
-
<span className="admin-toast-icon">{TOAST_ICONS[t.kind]}</span>
|
|
141
|
-
<span className="admin-toast-body">
|
|
142
|
-
<b>{t.message}</b>
|
|
143
|
-
<small>{t.source} · {t.at}</small>
|
|
144
|
-
</span>
|
|
145
|
-
<button type="button" className="admin-toast-x" aria-label="Dismiss notification" onClick={() => dismissToast(t.id)}>×</button>
|
|
146
|
-
</div>
|
|
147
|
-
))}
|
|
148
|
-
</div>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1024" viewBox="0 0 1920 1024" role="img" aria-labelledby="title desc">
|
|
2
|
-
<title id="title">BhooAI Nexus</title>
|
|
3
|
-
<desc id="desc">BhooAI Nexus futuristic hexagonal logo and wordmark.</desc>
|
|
4
|
-
<defs>
|
|
5
|
-
<linearGradient id="edge" x1="140" y1="120" x2="820" y2="900" gradientUnits="userSpaceOnUse"><stop stop-color="#67e8f9"/><stop offset=".5" stop-color="#818cf8"/><stop offset="1" stop-color="#d8b4fe"/></linearGradient>
|
|
6
|
-
<linearGradient id="word" x1="900" y1="360" x2="1740" y2="680" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset=".55" stop-color="#a5f3fc"/><stop offset="1" stop-color="#c4b5fd"/></linearGradient>
|
|
7
|
-
<radialGradient id="core" cx="50%" cy="42%" r="65%"><stop stop-color="#172554"/><stop offset=".72" stop-color="#081326"/><stop offset="1" stop-color="#030712"/></radialGradient>
|
|
8
|
-
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%"><feGaussianBlur stdDeviation="16" result="blur"/><feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
|
|
9
|
-
</defs>
|
|
10
|
-
<g transform="translate(95 72)">
|
|
11
|
-
<path d="M385 0 770 222v444L385 888 0 666V222z" fill="url(#core)" stroke="url(#edge)" stroke-width="18" stroke-linejoin="round"/>
|
|
12
|
-
<path d="M385 58 710 246v376L385 810 60 622V246z" fill="none" stroke="#67e8f9" stroke-opacity=".22" stroke-width="6"/>
|
|
13
|
-
<path d="M385 118 654 273v318L385 746 116 591V273z" fill="none" stroke="#a78bfa" stroke-opacity=".16" stroke-width="4"/>
|
|
14
|
-
<path d="M192 660V240l386 420V240" fill="none" stroke="url(#edge)" stroke-linecap="round" stroke-linejoin="round" stroke-width="76" filter="url(#glow)"/>
|
|
15
|
-
<path d="M192 660V240l386 420V240" fill="none" stroke="#071225" stroke-linecap="round" stroke-linejoin="round" stroke-width="38"/>
|
|
16
|
-
<circle cx="192" cy="240" r="30" fill="#67e8f9" filter="url(#glow)"/><circle cx="578" cy="660" r="30" fill="#f0abfc" filter="url(#glow)"/>
|
|
17
|
-
<circle cx="192" cy="240" r="12" fill="#fff"/><circle cx="578" cy="660" r="12" fill="#fff"/>
|
|
18
|
-
</g>
|
|
19
|
-
<g transform="translate(980 250)">
|
|
20
|
-
<text x="0" y="270" fill="url(#word)" font-family="Arial, Helvetica, sans-serif" font-size="230" font-weight="800" letter-spacing="10">BhooAI</text>
|
|
21
|
-
<text x="16" y="465" fill="url(#edge)" font-family="Arial, Helvetica, sans-serif" font-size="126" font-weight="700" letter-spacing="38">NEXUS</text>
|
|
22
|
-
<path d="M18 550h840" stroke="url(#edge)" stroke-width="8" stroke-linecap="round"/><circle cx="890" cy="550" r="11" fill="#d8b4fe"/>
|
|
23
|
-
<text x="18" y="620" fill="#9ab0c9" font-family="Arial, Helvetica, sans-serif" font-size="27" font-weight="500" letter-spacing="8">ONE RUNTIME. EVERY SIGNAL.</text>
|
|
24
|
-
</g>
|
|
25
|
-
</svg>
|
package/src/vite-env.d.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
// Ambient declarations that keep `@bhooai/admin` self-contained: it does
|
|
2
|
-
// not depend on the consumer having `vite/client` types installed. Vite /
|
|
3
|
-
// esbuild inject `import.meta.env` and resolve asset imports at runtime; the
|
|
4
|
-
// host project's build (vite build) handles actual bundling.
|
|
5
|
-
interface ImportMetaEnv {
|
|
6
|
-
readonly VITE_SUPERVISOR_URL?: string;
|
|
7
|
-
readonly [key: string]: string | undefined;
|
|
8
|
-
}
|
|
9
|
-
interface ImportMeta {
|
|
10
|
-
readonly env: ImportMetaEnv;
|
|
11
|
-
}
|
|
12
|
-
declare module '*.svg' {
|
|
13
|
-
const src: string;
|
|
14
|
-
export default src;
|
|
15
|
-
}
|
|
16
|
-
declare module '*.png' {
|
|
17
|
-
const src: string;
|
|
18
|
-
export default src;
|
|
19
|
-
}
|