@bhooai/nexus-cli 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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/src/commands/add.ts +1 -1
  3. package/src/commands/dev.ts +74 -125
  4. package/src/commands/init.ts +87 -136
  5. package/src/devPanel.ts +696 -0
  6. package/src/devServiceManager.ts +229 -0
  7. package/src/dispatcher.ts +22 -1
  8. package/src/examples.ts +90 -0
  9. package/src/features.ts +261 -0
  10. package/src/launcher.ts +164 -0
  11. package/src/layout.ts +101 -0
  12. package/src/templating/tree.ts +66 -0
  13. package/src/tui.ts +170 -0
  14. package/src/wizard.ts +691 -0
  15. package/templates/base/Dockerfile.ejs +1 -0
  16. package/templates/base/apps/admin/nginx.conf.ejs +30 -1
  17. package/templates/base/apps/admin/package.json.ejs +7 -2
  18. package/templates/base/apps/admin/postcss.config.js +5 -0
  19. package/templates/base/apps/admin/src/App.tsx +4127 -0
  20. package/templates/base/apps/admin/src/alertCenter.tsx +150 -0
  21. package/templates/base/apps/admin/src/api.ts +474 -0
  22. package/templates/base/apps/admin/src/assets/bhooai-nexus-logo.svg +25 -0
  23. package/templates/base/apps/admin/src/index.css +3481 -0
  24. package/templates/base/apps/admin/src/main.tsx.ejs +3 -3
  25. package/templates/base/apps/admin/src/vite-env.d.ts +19 -0
  26. package/templates/base/apps/admin/tailwind.config.js +9 -0
  27. package/templates/base/apps/admin/vite.config.ts.ejs +21 -2
  28. package/templates/base/apps/ai-server/main.py.ejs +94 -6
  29. package/templates/base/apps/backend/package.json.ejs +27 -0
  30. package/templates/base/apps/frontend/package.json.ejs +7 -0
  31. package/templates/base/apps/frontend/vite.config.ts.ejs +0 -1
  32. package/templates/base/docker-compose.yml.ejs +6 -1
  33. package/templates/base/nexus.config.ts.ejs +4 -4
  34. package/templates/features/auth/apps/backend/src/models/User.ts +21 -0
  35. package/templates/features/auth/apps/backend/src/routes/auth.ts +95 -0
  36. package/templates/features/email/apps/backend/src/mail/mailables/WelcomeMail.ts +25 -0
  37. package/templates/features/email/apps/backend/src/mail/templates/welcome.ejs.ejs +10 -0
  38. package/templates/features/graphql/apps/backend/src/graphql/post.graph.ts +61 -0
  39. package/templates/features/graphql/apps/backend/src/models/Post.ts +15 -0
  40. package/templates/features/payments/apps/backend/src/routes/payments.ts +45 -0
  41. package/templates/features/queue/apps/backend/src/events/JobQueued.ts +14 -0
  42. package/templates/features/queue/apps/backend/src/jobs/ExampleJob.ts +18 -0
  43. package/templates/features/queue/apps/backend/src/listeners/OnJobQueued.ts +12 -0
  44. package/templates/features/realtime/apps/backend/src/models/Message.ts +14 -0
  45. package/templates/features/realtime/apps/backend/src/ws/chat.room.ts +56 -0
  46. package/templates/features/storage/apps/backend/src/routes/uploads.ts +91 -0
@@ -0,0 +1,4127 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import logoUrl from './assets/bhooai-nexus-logo.svg';
3
+ import { SafeGotoDialog, SafeGotoLink } from '@bhooai/nexus-safe-goto';
4
+ import { AlertProvider, useAlerts, useAdminAlert, ToastStack } from './alertCenter.js';
5
+ import {
6
+ login, registerAndLogin, setAccessToken, getAccessToken, refresh,
7
+ getAdminConfig, putAdminConfig, putAdminConfigFile, getAdminEnv, putAdminEnv, getPlugins, getUsers, getRoles, putUserRoles, getMetrics, updateProfile, changePassword, updateProject,
8
+ getServices, controlService, getServiceLogs,
9
+ runPreflight,
10
+ runLintEnv, runLintConfig,
11
+ getPaymentOrders, getPaymentTransactions, getPaymentStatus, createPaymentOrder, updatePaymentProvider, savePaymentProviderKeys,
12
+ getDatabases, createDatabase, deleteDatabase,
13
+ createCollection, modifyCollection, dropCollection, getCollectionDocs,
14
+ generateSchema, getAiStatus, getAiModels, aiChat, aiChatStream, getAiProviders, updateAiProvider, addAiProvider, deleteAiProvider, testAiProvider,
15
+ getAllLogs, clearLogs, type LogEntry,
16
+ getRequestLogs, getRequestSeries, type RequestLogEntry, type RequestSeries, type RequestSeriesRange,
17
+ type ServiceState, type AdminConfig, type AdminEnv, type EnvEntry, type DatabaseInfo, type GeneratedSchema, type PaymentProviderStatus, type PaymentProviderField, type AiStatus, type AiModelInfo, type AiChatMessage, type AiProviderView, type AiProviderPersistence, type AiProviderTestResult, type UserRecord, type RoleDefinition, type PreflightReport, type PreflightCheck, type LintReport,
18
+ } from './api.js';
19
+
20
+
21
+ type Tab = 'overview' | 'processes' | 'logs' | 'config' | 'env' | 'plugins' | 'users' | 'monitoring' | 'payments' | 'databases' | 'schema' | 'ai' | 'theme';
22
+ type AdminTheme = 'aurora' | 'midnight' | 'violet';
23
+ type UiMode = 'workspace' | 'compact' | 'focus';
24
+ type ToggleStyle = 'chip' | 'neon' | 'icon' | 'orb' | 'track';
25
+
26
+ const TOGGLE_STYLES: Array<{ id: ToggleStyle; label: string; hint: string }> = [
27
+ { id: 'chip', label: 'Chip ON/OFF', hint: 'Default · glass chip' },
28
+ { id: 'neon', label: 'Neon glass', hint: 'Blue→violet border' },
29
+ { id: 'icon', label: 'Icon knob', hint: '⏻ → ⚡ glow' },
30
+ { id: 'orb', label: 'Orb dot', hint: 'Pulse halo' },
31
+ { id: 'track', label: 'Gradient-track', hint: 'Sliding trail' },
32
+ ];
33
+
34
+ interface CustomTheme { active: boolean; blue: string; violet: string; pink: string; bg: string; ink: string; muted: string; mesh: number; glass: number; radius: number; toggle: ToggleStyle; }
35
+
36
+ const CUSTOM_THEME_KEY = 'nexus-admin-custom-theme';
37
+ const THEME_PRESETS: Record<AdminTheme, Omit<CustomTheme, 'active'>> = {
38
+ aurora: { blue: '#54b7ff', violet: '#9f7bff', pink: '#f36eb8', bg: '#0b0d1a', ink: '#eef6ff', muted: '#98aec9', mesh: 0.32, glass: 0.35, radius: 13, toggle: 'chip' },
39
+ midnight: { blue: '#78bfff', violet: '#7da5d3', pink: '#b8c7db', bg: '#02050b', ink: '#eaf3ff', muted: '#8fa6c2', mesh: 0.2, glass: 0.25, radius: 11, toggle: 'chip' },
40
+ violet: { blue: '#b4a1ff', violet: '#d78bff', pink: '#ff8ed1', bg: '#0b071b', ink: '#f3eaff', muted: '#a592c9', mesh: 0.4, glass: 0.3, radius: 15, toggle: 'chip' },
41
+ };
42
+
43
+ function loadCustomTheme(): CustomTheme {
44
+ const fallback: CustomTheme = { ...THEME_PRESETS.aurora, active: false };
45
+ if (typeof window === 'undefined') return fallback;
46
+ try {
47
+ const raw = window.localStorage.getItem(CUSTOM_THEME_KEY);
48
+ if (raw) {
49
+ const parsed = JSON.parse(raw) as Partial<CustomTheme>;
50
+ if (parsed && typeof parsed === 'object') {
51
+ return { ...THEME_PRESETS.aurora, ...parsed, active: Boolean(parsed.active) };
52
+ }
53
+ }
54
+ } catch { /* ignore corrupt value */ }
55
+ return fallback;
56
+ }
57
+
58
+ interface ParticleConfig { amount: number; speed: number; colors: string[]; }
59
+ const PARTICLES_KEY = 'nexus-admin-particles';
60
+ const DEFAULT_PARTICLE_COLORS = ['#59d7ff', '#c68dff', '#ff8fd4', '#5ef0b3', '#d3a0ff', '#5fb6ff'];
61
+ const DEFAULT_PARTICLES: ParticleConfig = { amount: 14, speed: 1, colors: [...DEFAULT_PARTICLE_COLORS] };
62
+ const SWATCH_CPLAETTE = ['#59d7ff', '#c68dff', '#ff8fd4', '#5ef0b3', '#ffd479', '#f0995a', '#7fe3c8', '#f2a7ff'];
63
+
64
+ function loadParticles(): ParticleConfig {
65
+ const fallback: ParticleConfig = { amount: 14, speed: 1, colors: [...DEFAULT_PARTICLE_COLORS] };
66
+ if (typeof window === 'undefined') return fallback;
67
+ try {
68
+ const raw = window.localStorage.getItem(PARTICLES_KEY);
69
+ if (raw) {
70
+ const p = JSON.parse(raw) as Partial<ParticleConfig>;
71
+ if (p && typeof p === 'object') {
72
+ const amount = Math.max(0, Math.min(48, Math.round(Number(p.amount) || 14)));
73
+ const speed = Math.min(3, Math.max(0.2, Number(p.speed) || 1));
74
+ const colors = Array.isArray(p.colors) ? p.colors.filter((c): c is string => typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c)) : [];
75
+ return { amount, speed, colors: colors.length ? colors : [...DEFAULT_PARTICLE_COLORS] };
76
+ }
77
+ }
78
+ } catch { /* ignore corrupt value */ }
79
+ return fallback;
80
+ }
81
+
82
+ function buildParticleLayout(amount: number) {
83
+ const list: { left: number; size: number; duration: number; delay: number; dx: number; dx2: number; opacity: string }[] = [];
84
+ for (let i = 0; i < amount; i++) {
85
+ list.push({
86
+ left: 1 + Math.random() * 98,
87
+ size: 3 + Math.random() * 7,
88
+ duration: 18 + Math.random() * 18,
89
+ delay: Math.random() * 32,
90
+ dx: (Math.random() - 0.5) * 70,
91
+ dx2: (Math.random() - 0.5) * 70,
92
+ opacity: (0.45 + Math.random() * 0.4).toFixed(2),
93
+ });
94
+ }
95
+ return list;
96
+ }
97
+
98
+ function hslToHex(h: number, s: number, l: number): string {
99
+ const a = s * Math.min(l, 1 - l);
100
+ const f = (n: number) => {
101
+ const k = (n + h / 30) % 12;
102
+ const color = l - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
103
+ return Math.round(255 * color).toString(16).padStart(2, '0');
104
+ };
105
+ return `#${f(0)}${f(8)}${f(4)}`;
106
+ }
107
+ function randomAccentPalette() {
108
+ const base = Math.round(Math.random() * 360);
109
+ return {
110
+ blue: hslToHex((base + 150) % 360, 72, 64),
111
+ violet: hslToHex((base + 60) % 360, 62, 68),
112
+ pink: hslToHex((base + 25) % 360, 80, 72),
113
+ };
114
+ }
115
+ function randomParticleColors(): string[] {
116
+ const base = Math.round(Math.random() * 360);
117
+ const count = 4 + Math.round(Math.random() * 3);
118
+ const colors: string[] = [];
119
+ for (let i = 0; i < count; i++) {
120
+ colors.push(hslToHex((base + i * 55) % 360, 74 + Math.round(Math.random() * 12), 60 + Math.round(Math.random() * 16)));
121
+ }
122
+ return colors;
123
+ }
124
+
125
+ function storedPreference<T extends string>(key: string, fallback: T, allowed: readonly T[]): T {
126
+ const value = typeof window === 'undefined' ? null : window.localStorage.getItem(key);
127
+ return value && allowed.includes(value as T) ? value as T : fallback;
128
+ }
129
+
130
+ function formatProjectName(name: string): string {
131
+ const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
132
+ if (!words.length) return name;
133
+ return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
134
+ }
135
+
136
+ interface AdminUser { email?: string; name?: string; roles?: string[]; }
137
+
138
+ const USER_KEY = 'nexus-admin-user';
139
+ function readUser(): AdminUser | null {
140
+ if (typeof window === 'undefined') return null;
141
+ try { const raw = window.localStorage.getItem(USER_KEY); return raw ? JSON.parse(raw) as AdminUser : null; } catch { return null; }
142
+ }
143
+
144
+ export function App() {
145
+ const [boot, setBoot] = useState<'loading' | 'ready'>('loading');
146
+ const [authed, setAuthed] = useState(() => !!getAccessToken());
147
+ const [user, setUser] = useState<AdminUser | null>(readUser);
148
+
149
+ // Restore the session from the HttpOnly refresh cookie on boot, so a page
150
+ // reload stays logged in instead of bouncing back to the login screen.
151
+ useEffect(() => {
152
+ if (getAccessToken()) {
153
+ setBoot('ready');
154
+ return;
155
+ }
156
+ void refresh().then((ok) => {
157
+ if (ok) {
158
+ setAuthed(true);
159
+ // Reuse any stored user; refresh() only restores the token + identity.
160
+ setUser(readUser());
161
+ }
162
+ setBoot('ready');
163
+ });
164
+ }, []);
165
+
166
+ if (boot === 'loading') {
167
+ return <div className="admin-boot bg-mesh"><span className="admin-overline">Loading…</span></div>;
168
+ }
169
+ if (!authed) return <Login onAuthed={(u) => { setUser(u); window.localStorage.setItem(USER_KEY, JSON.stringify(u)); setAuthed(true); }} />;
170
+ const onUserChange = (u: AdminUser) => { setUser(u); try { window.localStorage.setItem(USER_KEY, JSON.stringify(u)); } catch { /* ignore */ } };
171
+ return <AlertProvider><Dashboard user={user} onUserChange={onUserChange} onLogout={() => { setAccessToken(''); setUser(null); window.localStorage.removeItem(USER_KEY); setAuthed(false); }} /></AlertProvider>;
172
+ }
173
+
174
+ function PageHead({ title, children }: { title: string; children?: React.ReactNode }) {
175
+ return (
176
+ <div className="section-heading">
177
+ <div className="section-heading-title">
178
+ <span className="section-heading-mark" />
179
+ <h2>{title}</h2>
180
+ </div>
181
+ {children}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ function PageHero({ kicker, title, desc, glyph, art }: { kicker: string; title: React.ReactNode; desc: string; glyph: string; art?: string }) {
187
+ return (
188
+ <div className="config-intro">
189
+ <div><span className="admin-overline">{kicker}</span><h1>{title}</h1><p>{desc}</p></div>
190
+ {art ? (
191
+ <div className={`hero-art hero-art--${art}`}><strong>{glyph}</strong><span className="ha-a" /><span className="ha-b" /><span className="ha-c" /></div>
192
+ ) : (
193
+ <div className="hero-orbit-art"><span /><i /><b /><strong>{glyph}</strong></div>
194
+ )}
195
+ </div>
196
+ );
197
+ }
198
+
199
+ function ConfirmDialog(props: {
200
+ open: boolean;
201
+ title: string;
202
+ message: React.ReactNode;
203
+ confirmLabel?: string;
204
+ danger?: boolean;
205
+ busy?: boolean;
206
+ onConfirm: () => void;
207
+ onCancel: () => void;
208
+ }) {
209
+ if (!props.open) return null;
210
+ return (
211
+ <div className="confirm-overlay" onClick={props.onCancel}>
212
+ <div className="confirm-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
213
+ <div className="confirm-dialog-head">
214
+ <span className="confirm-dialog-mark">{props.danger ? '!' : 'â—ˆ'}</span>
215
+ <strong>{props.title}</strong>
216
+ <button className="confirm-dialog-close" onClick={props.onCancel} aria-label="Cancel">×</button>
217
+ </div>
218
+ <p className="confirm-dialog-message">{props.message}</p>
219
+ <div className="confirm-dialog-actions">
220
+ <button className="glass-chip-btn" onClick={props.onCancel}>Cancel</button>
221
+ <button
222
+ className={`glass-chip-btn-danger confirm-dialog-primary${props.danger ? ' danger' : ''}`}
223
+ onClick={props.onConfirm}
224
+ disabled={props.busy}
225
+ autoFocus
226
+ >
227
+ {props.busy ? 'Working…' : (props.confirmLabel ?? 'Confirm')}
228
+ </button>
229
+ </div>
230
+ </div>
231
+ </div>
232
+ );
233
+ }
234
+
235
+ function Login({ onAuthed }: { onAuthed: (user: AdminUser) => void }) {
236
+ const [email, setEmail] = useState('');
237
+ const [password, setPassword] = useState('');
238
+ const [name, setName] = useState('');
239
+ const [err, setErr] = useState('');
240
+ const [busy, setBusy] = useState(false);
241
+ const [mode, setMode] = useState<'login' | 'register'>('login');
242
+ const [theme] = useState<AdminTheme>(() => storedPreference('nexus-admin-theme', 'aurora', ['aurora', 'midnight', 'violet']));
243
+
244
+ const submit = async (e: React.FormEvent) => {
245
+ e.preventDefault();
246
+ if (busy) return;
247
+ setErr('');
248
+ setBusy(true);
249
+ try {
250
+ const data = mode === 'login'
251
+ ? await login(email, password)
252
+ : await registerAndLogin(email, password, name || email.split('@')[0]);
253
+ onAuthed({ email: data?.user?.email ?? email, name: data?.user?.name ?? email.split('@')[0] });
254
+ } catch (e: any) {
255
+ const message = e?.message as string | undefined;
256
+ setErr(
257
+ message
258
+ || (mode === 'login'
259
+ ? 'Sign in failed. Please check your email and password.'
260
+ : 'Registration failed. Please try again.'),
261
+ );
262
+ } finally {
263
+ setBusy(false);
264
+ }
265
+ };
266
+
267
+ return (
268
+ <div className={`admin-login bg-mesh theme-${theme} flex min-h-screen items-center justify-center p-4`}>
269
+ <form onSubmit={submit} className="admin-login-card glass-card w-96 space-y-4 p-8">
270
+ <div className="space-y-1">
271
+ <img src={logoUrl} alt="BhooAI Nexus Admin" className="mb-4 h-auto w-64 max-w-full" />
272
+ <h1 className="brand-text text-2xl font-bold">Admin control panel</h1>
273
+ <p className="text-sm text-slate-400">
274
+ {mode === 'login' ? 'Sign in to manage the project.' : 'The first registered user becomes admin.'}
275
+ </p>
276
+ </div>
277
+ {mode === 'register' && (
278
+ <input className="glass-input" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
279
+ )}
280
+ <input className="glass-input" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required autoComplete="username" />
281
+ <input className="glass-input" type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required autoComplete="current-password" />
282
+ {err && (
283
+ <div role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-400">
284
+ {err}
285
+ </div>
286
+ )}
287
+ <button className="glass-btn-primary w-full disabled:opacity-60 disabled:cursor-not-allowed" type="submit" disabled={busy}>
288
+ {busy ? 'Please wait…' : mode === 'login' ? 'Sign in' : 'Register & sign in'}
289
+ </button>
290
+ <button type="button" className="w-full cursor-pointer text-sm text-slate-400 transition-colors hover:text-slate-200" onClick={() => setMode(mode === 'login' ? 'register' : 'login')}>
291
+ {mode === 'login' ? 'Need an account? Register' : 'Already have one? Sign in'}
292
+ </button>
293
+ </form>
294
+ </div>
295
+ );
296
+ }
297
+
298
+ function Dashboard({ user, onUserChange, onLogout }: { user: AdminUser | null; onUserChange: (u: AdminUser) => void; onLogout: () => void }) {
299
+ const [tab, setTab] = useState<Tab>('overview');
300
+ const [settingsOpen, setSettingsOpen] = useState(false);
301
+ const [search, setSearch] = useState('');
302
+ const [searchOpen, setSearchOpen] = useState(false);
303
+ const searchRef = useRef<HTMLDivElement | null>(null);
304
+ const searchInputRef = useRef<HTMLInputElement | null>(null);
305
+ const [mobileNav, setMobileNav] = useState(false);
306
+ const [mobileSearch, setMobileSearch] = useState(false);
307
+ const sidebarRef = useRef<HTMLElement | null>(null);
308
+ const [railOpen, setRailOpen] = useState<string | null>(null);
309
+ const railRef = useRef<HTMLDivElement | null>(null);
310
+ const railPanelRef = useRef<HTMLDivElement | null>(null);
311
+ const notifyRef = useRef<HTMLDivElement | null>(null);
312
+ const FAVORITES_KEY = 'nexus-admin-favorites';
313
+ const ACTIVITY_KEY = 'nexus-admin-activity';
314
+ const NOTES_KEY = 'nexus-admin-notes';
315
+ const [favorites, setFavorites] = useState<Tab[]>(() => {
316
+ try {
317
+ const raw = window.localStorage.getItem(FAVORITES_KEY);
318
+ if (raw) {
319
+ const arr = JSON.parse(raw) as Tab[];
320
+ if (Array.isArray(arr) && arr.every((x) => typeof x === 'string')) return arr;
321
+ }
322
+ } catch { /* ignore */ }
323
+ return ['overview', 'config', 'env', 'logs'];
324
+ });
325
+ const [notes, setNotes] = useState<string>(() => {
326
+ try { return window.localStorage.getItem(NOTES_KEY) ?? ''; } catch { return ''; }
327
+ });
328
+ const [activity, setActivity] = useState<{ at: string; label: string }[]>(() => {
329
+ try {
330
+ const raw = window.localStorage.getItem(ACTIVITY_KEY);
331
+ if (raw) {
332
+ const arr = JSON.parse(raw) as { at: string; label: string }[];
333
+ if (Array.isArray(arr)) return arr.slice(0, 20);
334
+ }
335
+ } catch { /* ignore */ }
336
+ return [];
337
+ });
338
+ const saveNotes = (v: string) => { setNotes(v); try { window.localStorage.setItem(NOTES_KEY, v); } catch { /* ignore */ } };
339
+ const toggleFavorite = (id: Tab) => {
340
+ setFavorites((prev) => {
341
+ const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id];
342
+ try { window.localStorage.setItem(FAVORITES_KEY, JSON.stringify(next)); } catch { /* ignore */ }
343
+ return next;
344
+ });
345
+ };
346
+ const goTab = (id: Tab) => {
347
+ setTab(id);
348
+ setMobileNav(false);
349
+ setSearch('');
350
+ setSearchOpen(false);
351
+ setRailOpen(null);
352
+ const label = nav.find((n) => n.id === id)?.label ?? id;
353
+ setActivity((prev) => {
354
+ const next = [{ at: new Date().toLocaleTimeString(), label }, ...prev].slice(0, 20);
355
+ try { window.localStorage.setItem(ACTIVITY_KEY, JSON.stringify(next)); } catch { /* ignore */ }
356
+ return next;
357
+ });
358
+ };
359
+ const [theme, setTheme] = useState<AdminTheme>(() => storedPreference('nexus-admin-theme', 'aurora', ['aurora', 'midnight', 'violet']));
360
+ const [uiMode, setUiMode] = useState<UiMode>(() => storedPreference('nexus-admin-ui', 'workspace', ['workspace', 'compact', 'focus']));
361
+ const [customTheme, setCustomTheme] = useState<CustomTheme>(() => loadCustomTheme());
362
+ const updateCustomTheme = (partial: Partial<CustomTheme>) => {
363
+ setCustomTheme((prev) => {
364
+ const next = { ...prev, ...partial };
365
+ try { window.localStorage.setItem(CUSTOM_THEME_KEY, JSON.stringify(next)); } catch { /* storage unavailable */ }
366
+ return next;
367
+ });
368
+ };
369
+ const [particles, setParticles] = useState<ParticleConfig>(() => loadParticles());
370
+ const updateParticles = (partial: Partial<ParticleConfig>) => {
371
+ setParticles((prev) => {
372
+ const next = { ...prev, ...partial };
373
+ try { window.localStorage.setItem(PARTICLES_KEY, JSON.stringify(next)); } catch { /* storage unavailable */ }
374
+ return next;
375
+ });
376
+ };
377
+ const particleLayout = useMemo(() => buildParticleLayout(particles.amount), [particles.amount]);
378
+ const [appearanceOpen, setAppearanceOpen] = useState(false);
379
+ const [accountOpen, setAccountOpen] = useState(false);
380
+ const { alerts } = useAlerts();
381
+ const accountRef = useRef<HTMLDivElement | null>(null);
382
+ const appearanceRef = useRef<HTMLDivElement | null>(null);
383
+ useEffect(() => {
384
+ if (!accountOpen && !appearanceOpen) return;
385
+ const onDown = (e: MouseEvent) => {
386
+ const target = e.target as Node;
387
+ if (accountOpen && accountRef.current && !accountRef.current.contains(target)) setAccountOpen(false);
388
+ if (appearanceOpen && appearanceRef.current && !appearanceRef.current.contains(target)) setAppearanceOpen(false);
389
+ };
390
+ document.addEventListener('mousedown', onDown);
391
+ return () => document.removeEventListener('mousedown', onDown);
392
+ }, [accountOpen, appearanceOpen]);
393
+ useEffect(() => {
394
+ const onDown = (e: MouseEvent) => {
395
+ if (railOpen && !(railRef.current?.contains(e.target as Node) || railPanelRef.current?.contains(e.target as Node) || notifyRef.current?.contains(e.target as Node))) setRailOpen(null);
396
+ };
397
+ document.addEventListener('mousedown', onDown);
398
+ return () => document.removeEventListener('mousedown', onDown);
399
+ }, [railOpen]);
400
+ useEffect(() => {
401
+ const onDown = (e: MouseEvent) => {
402
+ if (searchOpen && searchRef.current && !searchRef.current.contains(e.target as Node)) setSearchOpen(false);
403
+ };
404
+ document.addEventListener('mousedown', onDown);
405
+ return () => document.removeEventListener('mousedown', onDown);
406
+ }, [searchOpen]);
407
+ useEffect(() => {
408
+ if (!mobileNav) return;
409
+ const onDown = (e: MouseEvent) => {
410
+ const el = e.target as HTMLElement;
411
+ if (el && sidebarRef.current && !sidebarRef.current.contains(el) && !el.closest('.admin-mobile-menu')) setMobileNav(false);
412
+ };
413
+ document.addEventListener('mousedown', onDown);
414
+ return () => document.removeEventListener('mousedown', onDown);
415
+ }, [mobileNav]);
416
+ useEffect(() => {
417
+ const onKey = (e: KeyboardEvent) => {
418
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
419
+ e.preventDefault();
420
+ searchInputRef.current?.focus();
421
+ setSearchOpen(true);
422
+ }
423
+ };
424
+ window.addEventListener('keydown', onKey);
425
+ return () => window.removeEventListener('keydown', onKey);
426
+ }, []);
427
+ const [projectName, setProjectName] = useState('');
428
+ const [projectDb, setProjectDb] = useState('');
429
+ const [projectVersion, setProjectVersion] = useState('');
430
+ useEffect(() => {
431
+ let cancelled = false;
432
+ let tries = 0;
433
+ const load = async () => {
434
+ if (cancelled) return;
435
+ try {
436
+ const c = await getAdminConfig();
437
+ const name = c.project?.name ?? '';
438
+ if (name) { setProjectName(name); setProjectDb(c.project?.dbName ?? ''); setProjectVersion(c.project?.version ?? ''); return; }
439
+ } catch { /* retry below */ }
440
+ if (!cancelled && tries < 15) { tries += 1; window.setTimeout(load, 1500); }
441
+ };
442
+ void load();
443
+ return () => { cancelled = true; };
444
+ }, []);
445
+ const [shellWidth] = useState(() => Math.round((typeof window === 'undefined' ? 0 : window.screen.width) * 0.8));
446
+ const displayName = user?.name || user?.email?.split('@')[0] || 'Admin';
447
+ const initials = displayName.trim().split(/\s+/).map((p) => p[0]).join('').slice(0, 2).toUpperCase() || '?';
448
+ const nav: { id: Tab; label: string; icon: string; keywords?: string[]; desc?: string }[] = [
449
+ { id: 'overview', label: 'Overview', icon: '⌂', keywords: ['home', 'dashboard', 'project', 'summary'], desc: 'Project overview, services pulse and quick links.' },
450
+ { id: 'processes', label: 'Processes', icon: 'â—ˆ', keywords: ['services', 'supervisor', 'dev', 'run', 'terminal', 'server'], desc: 'Live service states with start/stop/restart control.' },
451
+ { id: 'logs', label: 'Logs', icon: 'â–¤', keywords: ['console', 'error', 'output', 'stdout', 'stderr', 'supervisor'], desc: 'Aggregated service output, filterable and searchable.' },
452
+ { id: 'config', label: 'Config JSON', icon: '{}', keywords: ['configuration', 'runtime', 'settings', 'json', 'source file', 'nexus.config'], desc: 'Runtime overrides are stored in nexus.runtime.json.' },
453
+ { id: 'env', label: 'Environment', icon: '⌁', keywords: ['dotenv', 'variables', 'secrets', 'keys', '.env'], desc: 'Masked environment values are ready to edit.' },
454
+ { id: 'plugins', label: 'Plugins', icon: 'â—‡', keywords: ['extensions', 'modules', 'addons', 'gallery'], desc: 'Registered plugin pages, slots and surfaces.' },
455
+ { id: 'users', label: 'Users', icon: 'â—Ž', keywords: ['roles', 'permissions', 'accounts', 'team', 'access', 'rbac'], desc: 'Accounts, roles and permission grants.' },
456
+ { id: 'monitoring', label: 'Monitoring', icon: '⌁', keywords: ['metrics', 'telemetry', 'health', 'uptime', 'traffic', 'stats'], desc: 'Live backend metrics and request traffic.' },
457
+ { id: 'payments', label: 'Payments', icon: '$', keywords: ['orders', 'transactions', 'money', 'ledger', 'checkout', 'razorpay', 'paypal', 'stripe'], desc: 'Orders, transactions and provider status.' },
458
+ { id: 'databases', label: 'Databases', icon: 'â–¦', keywords: ['mongo', 'mongodb', 'collections', 'data', 'tables', 'docs'], desc: 'Databases, collections and their documents.' },
459
+ { id: 'schema', label: 'AI Schema', icon: '✦', keywords: ['generate', 'ai', 'model', 'json schema', 'validator'], desc: 'Generate MongoDB schemas from plain English.' },
460
+ { id: 'ai', label: 'AI Agents', icon: 'â—Ž', keywords: ['providers', 'chat', 'playground', 'ollama', 'openai', 'models', 'settings'], desc: 'AI settings, providers and chat playground.' },
461
+ { id: 'theme', label: 'Theme Centre', icon: 'â—’', keywords: ['appearance', 'colors', 'palette', 'ui', 'customize', 'surface'], desc: 'Curated themes, palettes and surface tuning.' },
462
+ ];
463
+ const searchResults = useMemo(() => {
464
+ const q = search.trim().toLowerCase();
465
+ if (!q) return [];
466
+ return nav.filter((n) => {
467
+ if (n.label.toLowerCase().includes(q)) return true;
468
+ return (n.keywords ?? []).some((k) => k.toLowerCase().includes(q));
469
+ });
470
+ }, [search, nav]);
471
+ const title = nav.find((item) => item.id === tab)?.label ?? 'Overview';
472
+ const displayProject = projectName ? formatProjectName(projectName) : '';
473
+ const shellStyle: React.CSSProperties = {
474
+ ['--admin-shell-width' as string]: `${shellWidth}px`,
475
+ ['--admin-mesh' as string]: String(customTheme.mesh),
476
+ ['--admin-glass' as string]: String(customTheme.glass),
477
+ ['--admin-radius' as string]: `${customTheme.radius}px`,
478
+ ['--admin-mesh-opacity' as string]: String(0.2 + customTheme.mesh * 0.8),
479
+ };
480
+ if (customTheme.active) {
481
+ Object.assign(shellStyle, {
482
+ ['--admin-blue' as string]: customTheme.blue,
483
+ ['--admin-violet' as string]: customTheme.violet,
484
+ ['--admin-pink' as string]: customTheme.pink,
485
+ ['--admin-bg' as string]: customTheme.bg,
486
+ ['--admin-ink' as string]: customTheme.ink,
487
+ ['--admin-muted' as string]: customTheme.muted,
488
+ background: `radial-gradient(60rem 60rem at 85% -10%, color-mix(in srgb, ${customTheme.violet} ${Math.round(28 + customTheme.mesh * 34)}%, transparent), transparent 70%), radial-gradient(50rem 50rem at -10% 110%, color-mix(in srgb, ${customTheme.blue} ${Math.round(24 + customTheme.mesh * 30)}%, transparent), transparent 70%), radial-gradient(45rem 45rem at 50% 50%, color-mix(in srgb, ${customTheme.pink} ${Math.round(14 + customTheme.mesh * 22)}%, transparent), transparent 70%), ${customTheme.bg}`,
489
+ });
490
+ }
491
+ const particleColors = particles.colors.length ? particles.colors : DEFAULT_PARTICLE_COLORS;
492
+ return (
493
+ <div className={`admin-shell bg-mesh theme-${theme} ui-${uiMode}`} style={shellStyle}>
494
+ <div className="admin-particles" aria-hidden="true">
495
+ {particleLayout.map((p, i) => {
496
+ const color = particleColors[i % particleColors.length];
497
+ return (
498
+ <i key={i} style={{
499
+ left: `${p.left}%`,
500
+ width: `${p.size}px`,
501
+ height: `${p.size}px`,
502
+ background: `radial-gradient(circle, ${color}, transparent 72%)`,
503
+ animation: `admin-float ${(p.duration / particles.speed).toFixed(2)}s linear infinite`,
504
+ animationDelay: `-${p.delay.toFixed(2)}s`,
505
+ ['--dx' as string]: `${p.dx}px`,
506
+ ['--dx2' as string]: `${p.dx2}px`,
507
+ ['--o' as string]: p.opacity,
508
+ }} />
509
+ );
510
+ })}
511
+ </div>
512
+ <aside ref={sidebarRef} className={`admin-sidebar ${mobileNav ? 'is-open' : ''}`}>
513
+ <div className="admin-brand">
514
+ <img src={logoUrl} alt="BhooAI Nexus" />
515
+ <div>
516
+ <strong>NEXUS</strong>
517
+ <span>Admin workspace</span>
518
+ </div>
519
+ <button className="admin-close-mobile" onClick={() => setMobileNav(false)} aria-label="Close navigation">×</button>
520
+ </div>
521
+ <div className="admin-project-card">
522
+ <span className="admin-overline">ACTIVE PROJECT</span>
523
+ <strong>{displayProject || 'Loading…'}</strong>
524
+ {projectDb && <em className="admin-project-db">db · {projectDb}</em>}
525
+ <span><i className="status-dot good" /> Development</span>
526
+ </div>
527
+ <div className="admin-nav-label">Workspace</div>
528
+ <nav className="admin-nav">
529
+ {nav.slice(0, 5).map((n) => (
530
+ <button key={n.id} onClick={() => { setTab(n.id); setMobileNav(false); }} className={tab === n.id ? 'is-active' : ''}>
531
+ <span className="admin-nav-icon">{n.icon}</span>{n.label}
532
+ {n.id === 'env' && <b>NEW</b>}
533
+ </button>
534
+ ))}
535
+ </nav>
536
+ <div className="admin-nav-label">Operations</div>
537
+ <nav className="admin-nav">
538
+ {nav.slice(5).map((n) => (
539
+ <button key={n.id} onClick={() => { setTab(n.id); setMobileNav(false); }} className={tab === n.id ? 'is-active' : ''}>
540
+ <span className="admin-nav-icon">{n.icon}</span>{n.label}
541
+ </button>
542
+ ))}
543
+ </nav>
544
+ </aside>
545
+ <div className="admin-main-shell">
546
+ <header className={`admin-topbar${mobileSearch ? ' is-mobile-search' : ''}`}>
547
+ <button className="admin-mobile-menu" onClick={() => setMobileNav(true)} aria-label="Open navigation">☰</button>
548
+ <div className="admin-window-tab"><span className="window-tab-mark">â—ˆ</span><strong>{title}</strong></div>
549
+ <div className="admin-search" ref={searchRef}>
550
+ <span>⌕</span>
551
+ <input
552
+ ref={searchInputRef}
553
+ value={search}
554
+ onChange={(e) => { setSearch(e.target.value); setSearchOpen(true); }}
555
+ onFocus={() => setSearchOpen(true)}
556
+ onKeyDown={(e) => {
557
+ if (e.key === 'Enter' && searchResults.length) {
558
+ e.preventDefault();
559
+ setTab(searchResults[0].id);
560
+ setSearch('');
561
+ setSearchOpen(false);
562
+ setMobileSearch(false);
563
+ } else if (e.key === 'Escape') {
564
+ setSearch('');
565
+ setSearchOpen(false);
566
+ setMobileSearch(false);
567
+ searchInputRef.current?.blur();
568
+ }
569
+ }}
570
+ placeholder="Search workspace"
571
+ />
572
+ <kbd>⌘ K</kbd>
573
+ {searchOpen && search.length > 0 && (
574
+ <div className="admin-search-results">
575
+ {searchResults.length === 0 ? (
576
+ <div className="admin-search-empty">No matches for “{search}”</div>
577
+ ) : (
578
+ searchResults.map((n) => (
579
+ <button key={n.id} className={tab === n.id ? 'is-active' : ''} onMouseDown={(e) => { e.preventDefault(); setTab(n.id); setSearch(''); setSearchOpen(false); setMobileSearch(false); }}>
580
+ <span className="admin-search-result-icon">{n.icon}</span>{n.label}
581
+ </button>
582
+ ))
583
+ )}
584
+ </div>
585
+ )}
586
+ </div>
587
+ <div className="admin-top-actions">
588
+ <span className="top-live"><i className="status-dot good" /> All systems nominal</span>
589
+ <button className="admin-mobile-search" aria-label="Search" aria-expanded={mobileSearch} onClick={() => {
590
+ const next = !mobileSearch;
591
+ setMobileSearch(next);
592
+ if (next) {
593
+ setAppearanceOpen(false);
594
+ setAccountOpen(false);
595
+ window.setTimeout(() => { searchInputRef.current?.focus(); setSearchOpen(true); }, 60);
596
+ } else {
597
+ setSearch('');
598
+ setSearchOpen(false);
599
+ }
600
+ }}>{mobileSearch ? '×' : '⌕'}</button>
601
+ <div ref={appearanceRef} className="admin-account">
602
+ <button className="admin-icon-button" onClick={() => { setAppearanceOpen(!appearanceOpen); setAccountOpen(false); }} aria-label="Open appearance settings" aria-expanded={appearanceOpen}>â—’</button>
603
+ {appearanceOpen && <AppearancePanel theme={theme} uiMode={uiMode} onThemeChange={(value) => { setTheme(value); window.localStorage.setItem('nexus-admin-theme', value); }} onUiModeChange={(value) => { setUiMode(value); window.localStorage.setItem('nexus-admin-ui', value); }} />}
604
+ </div>
605
+ <div className="admin-notify" ref={notifyRef}>
606
+ <button className={`admin-icon-button${railOpen === 'notify' ? ' is-active' : ''}`} aria-label="Notifications" aria-expanded={railOpen === 'notify'} onClick={() => setRailOpen(railOpen === 'notify' ? null : 'notify')}>⌁{alerts.length > 0 && <b>{alerts.length > 99 ? '99+' : alerts.length}</b>}</button>
607
+ {railOpen === 'notify' && <NotifyPanel onClose={() => setRailOpen(null)} />}
608
+ </div>
609
+ <div className="admin-account" ref={accountRef}>
610
+ <button className="admin-user-avatar" aria-label="Account" aria-expanded={accountOpen} onClick={() => { setAccountOpen(!accountOpen); setAppearanceOpen(false); }}>{initials}</button>
611
+ {accountOpen && (
612
+ <div className="account-panel">
613
+ <div className="account-head">
614
+ <span className="account-avatar">{initials}</span>
615
+ <span className="account-meta">
616
+ <strong>{displayName}</strong>
617
+ <small>{user?.email ?? 'admin@bhooai.local'}</small>
618
+ </span>
619
+ </div>
620
+ <div className="account-divider" />
621
+ <button className="account-item" onClick={() => { setSettingsOpen(true); setAccountOpen(false); }}>
622
+ <span className="account-item-icon">âš™</span><span><b>Settings</b><small>Profile, security, project & package</small></span>
623
+ </button>
624
+ <button className="account-item account-danger" onClick={onLogout}>
625
+ <span className="account-item-icon">↪</span><span><b>Sign out</b><small>End this session</small></span>
626
+ </button>
627
+ </div>
628
+ )}
629
+ </div>
630
+ </div>
631
+ </header>
632
+ <div className="admin-breadcrumb"><strong>{displayProject || 'Project'}</strong><b>›</b><span>{title}</span><button>⋯</button></div>
633
+ <div className="admin-content-layout">
634
+ <main className="admin-content">
635
+ {tab === 'overview' && <Overview onNavigate={setTab} />}
636
+ {tab === 'processes' && <Processes />}
637
+ {tab === 'logs' && <Logs onNavigate={setTab} />}
638
+ {tab === 'config' && <Config onNavigate={setTab} />}
639
+ {tab === 'env' && <Environment onNavigate={setTab} />}
640
+ {tab === 'plugins' && <Plugins />}
641
+ {tab === 'users' && <Users currentUserEmail={user?.email} />}
642
+ {tab === 'monitoring' && <Monitoring />}
643
+ {tab === 'payments' && <Payments toggle={customTheme.toggle} />}
644
+ {tab === 'databases' && <Databases />}
645
+ {tab === 'schema' && <Schema />}
646
+ {tab === 'ai' && <AiAgents toggle={customTheme.toggle} />}
647
+ {tab === 'theme' && <ThemeCentre theme={theme} onThemeChange={(value) => { setTheme(value); window.localStorage.setItem('nexus-admin-theme', value); }} uiMode={uiMode} onUiModeChange={(value) => { setUiMode(value); window.localStorage.setItem('nexus-admin-ui', value); }} custom={customTheme} onCustom={updateCustomTheme} particles={particles} onParticles={updateParticles} />}
648
+ </main>
649
+ <aside className="admin-inspector">
650
+ <div className="inspector-heading"><span>WORKSPACE INSPECTOR</span><button aria-label="Close inspector">×</button></div>
651
+ <div className="inspector-preview"><div className="preview-orbit"><span /><i /><b /></div><strong>Project Nexus</strong><span>Full-stack application</span></div>
652
+ <div className="inspector-tabs"><b>Summary</b><span>Activity</span></div>
653
+ <div className="inspector-card"><span className="admin-overline">CURRENT SURFACE</span><strong>{title}</strong><p>{nav.find((item) => item.id === tab)?.desc ?? 'Manage the running Nexus project from this workspace.'}</p></div>
654
+ <div className="inspector-card inspector-list"><span className="admin-overline">QUICK LINKS</span><button onClick={() => setTab('config')}>Configuration <b>→</b></button><button onClick={() => setTab('env')}>Environment <b>→</b></button><button onClick={() => setTab('logs')}>Logs <b>→</b></button></div>
655
+ <button className="glass-chip-btn-primary w-full justify-between" onClick={() => setTab('overview')}>⌁ Open project overview <span>→</span></button>
656
+ <div className="admin-side-widget">
657
+ <div className="admin-widget-heading"><span>RUNTIME LOAD</span><b>LIVE</b></div>
658
+ <div className="load-line"><span>CPU</span><i><b style={{ width: '32%' }} /></i><strong>32%</strong></div>
659
+ <div className="load-line"><span>MEM</span><i><b style={{ width: '61%' }} /></i><strong>61%</strong></div>
660
+ <div className="load-line"><span>DB</span><i><b className="violet" style={{ width: '44%' }} /></i><strong>44%</strong></div>
661
+ </div>
662
+ </aside>
663
+ </div>
664
+ <div className="admin-tool-rail" ref={railRef}>
665
+ <button data-tip="Smart search" className={railOpen === 'search' ? 'is-active' : ''} onClick={() => { setRailOpen(railOpen === 'search' ? null : 'search'); if (railOpen !== 'search') { searchInputRef.current?.focus(); setSearchOpen(true); } }}>⌕</button>
666
+ <button data-tip="AI Notes" className={railOpen === 'notes' ? 'is-active' : ''} onClick={() => setRailOpen(railOpen === 'notes' ? null : 'notes')}>â–£</button>
667
+ <button data-tip="Screen recall" className={railOpen === 'activity' ? 'is-active' : ''} onClick={() => setRailOpen(railOpen === 'activity' ? null : 'activity')}>â—«</button>
668
+ </div>
669
+ {railOpen && railOpen !== 'notify' && (
670
+ <div ref={railPanelRef}>
671
+ <RailPanel
672
+ open={railOpen}
673
+ onClose={() => setRailOpen(null)}
674
+ nav={nav}
675
+ tab={tab}
676
+ favorites={favorites}
677
+ onToggleFavorite={toggleFavorite}
678
+ onGo={goTab}
679
+ notes={notes}
680
+ onNotes={saveNotes}
681
+ activity={activity}
682
+ onClearActivity={() => { setActivity([]); try { window.localStorage.removeItem(ACTIVITY_KEY); } catch { /* ignore */ } }}
683
+ onSearch={() => { setRailOpen(null); searchInputRef.current?.focus(); setSearchOpen(true); }}
684
+ theme={theme}
685
+ onThemeChange={(value) => { setTheme(value); window.localStorage.setItem('nexus-admin-theme', value); }}
686
+ uiMode={uiMode}
687
+ onUiModeChange={(value) => { setUiMode(value); window.localStorage.setItem('nexus-admin-ui', value); }}
688
+ />
689
+ </div>
690
+ )}
691
+ <footer className="admin-dock"><span><b className="dock-logo">◈</b> BhooAI Nexus</span><span>Connected to <strong>localhost:4000</strong></span><span className="dock-right"><i className="status-dot good" /> Secure admin session · {title}</span></footer>
692
+ </div>
693
+ <SafeGotoDialog />
694
+ <SettingsDialog
695
+ open={settingsOpen}
696
+ onClose={() => setSettingsOpen(false)}
697
+ user={user}
698
+ onUserChange={onUserChange}
699
+ projectName={projectName}
700
+ projectDb={projectDb}
701
+ projectVersion={projectVersion}
702
+ onProjectChange={(name, version) => {
703
+ if (name) setProjectName(name);
704
+ if (version) setProjectVersion(version);
705
+ }}
706
+ />
707
+ <ToastStack />
708
+ </div>
709
+ );
710
+ }
711
+
712
+ function formatUptime(seconds?: number): string {
713
+ if (!seconds || seconds <= 0) return '—';
714
+ const d = Math.floor(seconds / 86400);
715
+ const h = Math.floor((seconds % 86400) / 3600);
716
+ const m = Math.floor((seconds % 3600) / 60);
717
+ const s = Math.floor(seconds % 60);
718
+ if (d > 0) return `${d}d ${h}h ${m}m`;
719
+ if (h > 0) return `${h}h ${m}m ${s}s`;
720
+ if (m > 0) return `${m}m ${s}s`;
721
+ return `${s}s`;
722
+ }
723
+
724
+ function SettingsDialog({ open, onClose, user, onUserChange, projectName, projectDb, projectVersion, onProjectChange }: {
725
+ open: boolean;
726
+ onClose: () => void;
727
+ user: AdminUser | null;
728
+ onUserChange: (u: AdminUser) => void;
729
+ projectName: string;
730
+ projectDb: string;
731
+ projectVersion: string;
732
+ onProjectChange: (name?: string, version?: string) => void;
733
+ }) {
734
+ const isAdmin = Array.isArray(user?.roles) && (user!.roles as string[]).includes('admin');
735
+ const [nameDraft, setNameDraft] = useState(user?.name ?? '');
736
+ const [profileMsg, setProfileMsg] = useState<{ text: string; kind: 'ok' | 'err' } | null>(null);
737
+ const [profileBusy, setProfileBusy] = useState(false);
738
+ const [curPw, setCurPw] = useState('');
739
+ const [newPw, setNewPw] = useState('');
740
+ const [confirmPw, setConfirmPw] = useState('');
741
+ const [pwMsg, setPwMsg] = useState<{ text: string; kind: 'ok' | 'err' } | null>(null);
742
+ const [pwBusy, setPwBusy] = useState(false);
743
+ const [pkgName, setPkgName] = useState(projectName);
744
+ const [pkgVersion, setPkgVersion] = useState(projectVersion);
745
+ const [pkgMsg, setPkgMsg] = useState<{ text: string; kind: 'ok' | 'err' } | null>(null);
746
+ const [pkgBusy, setPkgBusy] = useState(false);
747
+ const [about, setAbout] = useState<{ path?: string; version?: string | null; node?: string; uptime?: number; pid?: number } | null>(null);
748
+ const { push } = useAlerts();
749
+
750
+ useEffect(() => {
751
+ if (!open) return;
752
+ setNameDraft(user?.name ?? '');
753
+ setCurPw(''); setNewPw(''); setConfirmPw('');
754
+ setProfileMsg(null); setPwMsg(null); setPkgMsg(null);
755
+ setPkgName(projectName); setPkgVersion(projectVersion);
756
+ setAbout(null);
757
+ let cancelled = false;
758
+ void Promise.all([getAdminConfig().catch(() => null), getMetrics().catch(() => null)]).then(([c, m]) => {
759
+ if (cancelled) return;
760
+ const proj = c?.project as AdminConfig['project'] | undefined;
761
+ setPkgName(proj?.name ?? projectName);
762
+ setPkgVersion(proj?.version ?? m?.version ?? projectVersion);
763
+ setAbout({
764
+ path: proj?.path,
765
+ version: m?.version ?? proj?.version ?? null,
766
+ node: m?.node ?? null,
767
+ uptime: m?.uptime,
768
+ pid: m?.pid,
769
+ });
770
+ });
771
+ return () => { cancelled = true; };
772
+ }, [open, user, projectName, projectVersion]);
773
+
774
+ useEffect(() => {
775
+ if (!open) return;
776
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
777
+ window.addEventListener('keydown', onKey);
778
+ return () => window.removeEventListener('keydown', onKey);
779
+ }, [open, onClose]);
780
+
781
+ if (!open) return null;
782
+
783
+ const saveProfile = async () => {
784
+ const name = nameDraft.trim();
785
+ if (!name) { setProfileMsg({ text: 'Name is required.', kind: 'err' }); return; }
786
+ const prevName = user?.name ?? '';
787
+ if (name === prevName) { setProfileMsg({ text: 'Display name is unchanged.', kind: 'ok' }); return; }
788
+ setProfileBusy(true); setProfileMsg(null);
789
+ try {
790
+ const res = await updateProfile(name);
791
+ onUserChange((res.user ?? { ...(user ?? {}), name }) as AdminUser);
792
+ setProfileMsg({ text: `Display name changed to "${name}".`, kind: 'ok' });
793
+ push('ok', `Display name changed to "${name}".`, 'settings');
794
+ } catch (e) {
795
+ setProfileMsg({ text: e instanceof Error ? e.message : 'Failed to save profile.', kind: 'err' });
796
+ } finally {
797
+ setProfileBusy(false);
798
+ }
799
+ };
800
+
801
+ const savePassword = async () => {
802
+ if (newPw.length < 8) { setPwMsg({ text: 'New password must be at least 8 characters.', kind: 'err' }); return; }
803
+ if (newPw !== confirmPw) { setPwMsg({ text: 'Passwords do not match.', kind: 'err' }); return; }
804
+ setPwBusy(true); setPwMsg(null);
805
+ try {
806
+ await changePassword(curPw, newPw);
807
+ setCurPw(''); setNewPw(''); setConfirmPw('');
808
+ setPwMsg({ text: 'Password updated.', kind: 'ok' });
809
+ } catch (e) {
810
+ setPwMsg({ text: e instanceof Error ? e.message : 'Failed to change password.', kind: 'err' });
811
+ } finally {
812
+ setPwBusy(false);
813
+ }
814
+ };
815
+
816
+ const savePackage = async () => {
817
+ const patch: { name?: string; version?: string } = {};
818
+ const name = pkgName.trim();
819
+ if (name && name !== projectName) patch.name = name;
820
+ const version = pkgVersion.trim();
821
+ if (version && version !== (about?.version ?? projectVersion)) patch.version = version;
822
+ if (!patch.name && !patch.version) { setPkgMsg({ text: 'Nothing to update.', kind: 'err' }); return; }
823
+ setPkgBusy(true); setPkgMsg(null);
824
+ try {
825
+ const res = await updateProject(patch);
826
+ const proj = res.project as { name?: string; version?: string } | undefined;
827
+ if (proj?.name) onProjectChange(proj.name);
828
+ if (proj?.version) onProjectChange(undefined, proj.version);
829
+ if (proj?.version) setAbout((prev) => ({ ...(prev ?? {}), version: proj!.version ?? prev?.version }));
830
+ setPkgName(proj?.name ?? projectName);
831
+ setPkgVersion(proj?.version ?? about?.version ?? projectVersion);
832
+ setPkgMsg({ text: 'package.json updated. Restart the backend to pick up changes.', kind: 'ok' });
833
+ } catch (e) {
834
+ setPkgMsg({ text: e instanceof Error ? e.message : 'Failed to update package.', kind: 'err' });
835
+ } finally {
836
+ setPkgBusy(false);
837
+ }
838
+ };
839
+
840
+ const statusLine = (msg: { text: string; kind: 'ok' | 'err' } | null) =>
841
+ msg ? <div role="status" className={`settings-status ${msg.kind}`}>{msg.text}</div> : null;
842
+
843
+ return (
844
+ <div className="provider-modal-backdrop" onClick={onClose}>
845
+ <div className="settings-dialog" role="dialog" aria-modal="true" aria-labelledby="settings-title" onClick={(event) => event.stopPropagation()}>
846
+ <div className="settings-header">
847
+ <div>
848
+ <span className="admin-overline">ACCOUNT & WORKSPACE</span>
849
+ <h3 id="settings-title">Settings</h3>
850
+ </div>
851
+ <button type="button" onClick={onClose} className="glass-chip-btn" aria-label="Close settings">✕</button>
852
+ </div>
853
+
854
+ <div className="settings-body">
855
+ <section className="settings-section">
856
+ <h4>Profile</h4>
857
+ <p className="settings-desc">Your display name across the admin workspace.</p>
858
+ <div className="settings-row">
859
+ <label className="settings-field">
860
+ <span className="admin-overline">DISPLAY NAME</span>
861
+ <input className="glass-input" type="text" value={nameDraft} onChange={(e) => setNameDraft(e.target.value)} />
862
+ </label>
863
+ <label className="settings-field">
864
+ <span className="admin-overline">EMAIL</span>
865
+ <input className="glass-input" type="text" readOnly value={user?.email ?? ''} aria-readonly="true" />
866
+ </label>
867
+ </div>
868
+ {statusLine(profileMsg)}
869
+ <div className="settings-actions">
870
+ <button type="button" onClick={saveProfile} disabled={profileBusy} className="glass-chip-btn-primary">{profileBusy ? 'Saving…' : 'Save profile'}</button>
871
+ </div>
872
+ </section>
873
+
874
+ <section className="settings-section">
875
+ <h4>Security</h4>
876
+ <p className="settings-desc">Change the password for this account.</p>
877
+ <div className="settings-row">
878
+ <label className="settings-field">
879
+ <span className="admin-overline">CURRENT PASSWORD</span>
880
+ <input className="glass-input" type="password" autoComplete="current-password" value={curPw} onChange={(e) => setCurPw(e.target.value)} />
881
+ </label>
882
+ </div>
883
+ <div className="settings-row">
884
+ <label className="settings-field">
885
+ <span className="admin-overline">NEW PASSWORD</span>
886
+ <input className="glass-input" type="password" autoComplete="new-password" value={newPw} onChange={(e) => setNewPw(e.target.value)} />
887
+ </label>
888
+ <label className="settings-field">
889
+ <span className="admin-overline">CONFIRM PASSWORD</span>
890
+ <input className="glass-input" type="password" autoComplete="new-password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} />
891
+ </label>
892
+ </div>
893
+ {statusLine(pwMsg)}
894
+ <div className="settings-actions">
895
+ <button type="button" onClick={savePassword} disabled={pwBusy} className="glass-chip-btn-primary">{pwBusy ? 'Updating…' : 'Change password'}</button>
896
+ </div>
897
+ </section>
898
+
899
+ <section className="settings-section">
900
+ <h4>About</h4>
901
+ <div className="settings-facts">
902
+ <div><span>Package</span><b>{projectName || '—'}</b></div>
903
+ <div><span>Version</span><b>{about?.version ?? (projectVersion || '—')}</b></div>
904
+ <div><span>Database</span><b>{projectDb || '—'}</b></div>
905
+ <div><span>Path</span><b>{about?.path ?? '—'}</b></div>
906
+ <div><span>Node.js</span><b>{about?.node ?? '—'}</b></div>
907
+ <div><span>Uptime</span><b>{formatUptime(about?.uptime)}</b></div>
908
+ <div><span>PID</span><b>{about?.pid ?? '—'}</b></div>
909
+ </div>
910
+ {isAdmin && (
911
+ <div className="settings-package">
912
+ <div className="settings-row">
913
+ <label className="settings-field">
914
+ <span className="admin-overline">PACKAGE NAME</span>
915
+ <input className="glass-input" type="text" value={pkgName} onChange={(e) => setPkgName(e.target.value)} />
916
+ </label>
917
+ <label className="settings-field">
918
+ <span className="admin-overline">VERSION</span>
919
+ <input className="glass-input" type="text" value={pkgVersion} onChange={(e) => setPkgVersion(e.target.value)} placeholder="1.0.0" />
920
+ </label>
921
+ </div>
922
+ {statusLine(pkgMsg)}
923
+ <div className="settings-actions">
924
+ <button type="button" onClick={savePackage} disabled={pkgBusy} className="glass-chip-btn-primary">{pkgBusy ? 'Updating…' : 'Update package'}</button>
925
+ </div>
926
+ </div>
927
+ )}
928
+ </section>
929
+ </div>
930
+ </div>
931
+ </div>
932
+ );
933
+ }
934
+
935
+ function AppearancePanel({
936
+ theme,
937
+ uiMode,
938
+ onThemeChange,
939
+ onUiModeChange,
940
+ }: {
941
+ theme: AdminTheme;
942
+ uiMode: UiMode;
943
+ onThemeChange: (theme: AdminTheme) => void;
944
+ onUiModeChange: (mode: UiMode) => void;
945
+ }) {
946
+ return (
947
+ <div className="appearance-panel">
948
+ <div className="appearance-heading"><span className="admin-overline">APPEARANCE</span><span>LOCAL</span></div>
949
+ <strong>Choose your surface</strong>
950
+ <div className="appearance-options">
951
+ {([['aurora', 'Aurora', 'Electric blue'], ['midnight', 'Midnight', 'Deep navy'], ['violet', 'Violet', 'Indigo glow']] as const).map(([id, label, detail]) => (
952
+ <button key={id} onClick={() => onThemeChange(id)} className={theme === id ? 'is-selected' : ''}>
953
+ <span className={`theme-preview ${id}`} />
954
+ <span><b>{label}</b><small>{detail}</small></span>
955
+ {theme === id && <i>✓</i>}
956
+ </button>
957
+ ))}
958
+ </div>
959
+ <div className="appearance-divider" />
960
+ <span className="admin-overline">INTERFACE MODE</span>
961
+ <div className="ui-mode-options">
962
+ {([['workspace', 'Workspace', 'Inspector + tools'], ['compact', 'Compact', 'More content'], ['focus', 'Focus', 'Canvas first']] as const).map(([id, label, detail]) => (
963
+ <button key={id} onClick={() => onUiModeChange(id)} className={uiMode === id ? 'is-selected' : ''}><span>{label}</span><small>{detail}</small></button>
964
+ ))}
965
+ </div>
966
+ </div>
967
+ );
968
+ }
969
+
970
+ function RailPanel({
971
+ open,
972
+ onClose,
973
+ nav,
974
+ tab,
975
+ favorites,
976
+ onToggleFavorite,
977
+ onGo,
978
+ notes,
979
+ onNotes,
980
+ activity,
981
+ onClearActivity,
982
+ onSearch,
983
+ theme,
984
+ onThemeChange,
985
+ uiMode,
986
+ onUiModeChange,
987
+ }: {
988
+ open: string;
989
+ onClose: () => void;
990
+ nav: { id: Tab; label: string; icon: string; keywords?: string[]; desc?: string }[];
991
+ tab: Tab;
992
+ favorites: Tab[];
993
+ onToggleFavorite: (id: Tab) => void;
994
+ onGo: (id: Tab) => void;
995
+ notes: string;
996
+ onNotes: (v: string) => void;
997
+ activity: { at: string; label: string }[];
998
+ onClearActivity: () => void;
999
+ onSearch: () => void;
1000
+ theme: AdminTheme;
1001
+ onThemeChange: (theme: AdminTheme) => void;
1002
+ uiMode: UiMode;
1003
+ onUiModeChange: (mode: UiMode) => void;
1004
+ }) {
1005
+ useEffect(() => {
1006
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
1007
+ window.addEventListener('keydown', onKey);
1008
+ return () => window.removeEventListener('keydown', onKey);
1009
+ }, [onClose]);
1010
+
1011
+ if (!open || open === 'notify') return null;
1012
+
1013
+ return (
1014
+ <div className="rail-panel" role="dialog" aria-modal="false">
1015
+ {open === 'search' && (
1016
+ <>
1017
+ <div className="rail-panel-head"><span className="admin-overline">SMART SEARCH</span></div>
1018
+ <p className="rail-panel-text">Press <kbd>⌘ K</kbd> (or <kbd>Ctrl K</kbd>) to jump to any surface from the top search bar — or click below to focus it now.</p>
1019
+ <button type="button" className="glass-chip-btn-primary w-full justify-center" onClick={onSearch}>⌕ Focus search</button>
1020
+ <div className="rail-divider" />
1021
+ <span className="rail-panel-hint">Try: roles, mongo, palette, stripe, ollama, payments…</span>
1022
+ </>
1023
+ )}
1024
+
1025
+ {open === 'notes' && (
1026
+ <>
1027
+ <div className="rail-panel-head"><span className="admin-overline">AI NOTES</span><button type="button" className="rail-x" onClick={onClose} aria-label="Close">×</button></div>
1028
+ <p className="rail-panel-text">Jot quick notes — saved locally in your browser.</p>
1029
+ <textarea className="rail-notes" value={notes} onChange={(e) => onNotes(e.target.value)} placeholder="Type a note…" spellCheck={false} />
1030
+ <div className="rail-row">
1031
+ <button type="button" className="glass-chip-btn" onClick={() => onNotes('')}>Clear</button>
1032
+ <span className="rail-panel-hint">{notes.length} chars</span>
1033
+ </div>
1034
+ </>
1035
+ )}
1036
+
1037
+ {open === 'activity' && (
1038
+ <>
1039
+ <div className="rail-panel-head"><span className="admin-overline">SCREEN RECALL</span><button type="button" className="rail-x" onClick={onClose} aria-label="Close">×</button></div>
1040
+ <p className="rail-panel-text">Your recent admin activity.</p>
1041
+ {activity.length === 0 ? (
1042
+ <p className="rail-empty">No activity yet.</p>
1043
+ ) : (
1044
+ <ul className="rail-list">
1045
+ {activity.map((a, i) => (
1046
+ <li key={i}><b>{a.label}</b><span>{a.at}</span></li>
1047
+ ))}
1048
+ </ul>
1049
+ )}
1050
+ <div className="rail-row"><button type="button" className="glass-chip-btn" onClick={onClearActivity}>Clear activity</button></div>
1051
+ </>
1052
+ )}
1053
+
1054
+ {open === 'appearance' && (
1055
+ <>
1056
+ <div className="rail-panel-head"><span className="admin-overline">APPEARANCE</span><button type="button" className="rail-x" onClick={onClose} aria-label="Close">×</button></div>
1057
+ <span className="rail-panel-hint">Choose your surface</span>
1058
+ <div className="rail-appearance">
1059
+ {([['aurora', 'Aurora', 'Electric blue'], ['midnight', 'Midnight', 'Deep navy'], ['violet', 'Violet', 'Indigo glow']] as const).map(([id, label, detail]) => (
1060
+ <button key={id} onClick={() => onThemeChange(id)} className={theme === id ? 'is-selected' : ''}>
1061
+ <span className={`theme-preview ${id}`} />
1062
+ <span className="rail-appearance-label"><b>{label}</b><small>{detail}</small></span>
1063
+ {theme === id && <i>✓</i>}
1064
+ </button>
1065
+ ))}
1066
+ </div>
1067
+ <div className="rail-divider" />
1068
+ <span className="rail-panel-hint">Interface mode</span>
1069
+ <div className="rail-appearance">
1070
+ {([['workspace', 'Workspace', 'Inspector + tools'], ['compact', 'Compact', 'More content'], ['focus', 'Focus', 'Canvas first']] as const).map(([id, label, detail]) => (
1071
+ <button key={id} onClick={() => onUiModeChange(id)} className={uiMode === id ? 'is-selected' : ''}>
1072
+ <span className="rail-appearance-label"><b>{label}</b><small>{detail}</small></span>
1073
+ {uiMode === id && <i>✓</i>}
1074
+ </button>
1075
+ ))}
1076
+ </div>
1077
+ </>
1078
+ )}
1079
+
1080
+ {open === 'launcher' && (
1081
+ <>
1082
+ <div className="rail-panel-head"><span className="admin-overline">ADD TOOL</span><button type="button" className="rail-x" onClick={onClose} aria-label="Close">×</button></div>
1083
+ <p className="rail-panel-text">Pin your most-used surfaces to the tool rail. Click to toggle.</p>
1084
+ <ul className="rail-list rail-list--nav">
1085
+ {nav.map((n) => (
1086
+ <li key={n.id}>
1087
+ <button type="button" className={favorites.includes(n.id) ? 'is-on' : ''} onClick={() => onToggleFavorite(n.id)}>
1088
+ <span className="rail-fav-icon">{n.icon}</span><span className="rail-fav-label">{n.label}</span><span className="rail-fav-check">{favorites.includes(n.id) ? '✓' : '+'}</span>
1089
+ </button>
1090
+ </li>
1091
+ ))}
1092
+ </ul>
1093
+ </>
1094
+ )}
1095
+ </div>
1096
+ );
1097
+ }
1098
+
1099
+ function NotifyPanel({ onClose }: { onClose: () => void }) {
1100
+ const [notify, setNotify] = useState<LogEntry[] | null>(null);
1101
+ const [notifyBusy, setNotifyBusy] = useState(false);
1102
+ const { alerts, clear: clearAlerts, dismiss: dismissAlert } = useAlerts();
1103
+
1104
+ const loadNotify = async () => {
1105
+ setNotifyBusy(true);
1106
+ try { setNotify(await getAllLogs({ level: 'error' })); } catch { setNotify([]); }
1107
+ finally { setNotifyBusy(false); }
1108
+ };
1109
+
1110
+ useEffect(() => {
1111
+ void loadNotify();
1112
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
1113
+ window.addEventListener('keydown', onKey);
1114
+ return () => window.removeEventListener('keydown', onKey);
1115
+ }, [onClose]);
1116
+
1117
+ const errs = alerts.filter((a) => a.kind === 'err').length;
1118
+ const warns = alerts.filter((a) => a.kind === 'warn').length;
1119
+ const oks = alerts.length - errs - warns;
1120
+ const loading = notify === null;
1121
+ const hasAlerts = alerts.length > 0;
1122
+ const hasLogs = (notify?.length ?? 0) > 0;
1123
+ const hasAny = hasAlerts || hasLogs;
1124
+
1125
+ return (
1126
+ <div className="notify-panel" role="dialog" aria-modal="false">
1127
+ <div className="notify-head">
1128
+ <span className="notify-head-icon">⌁</span>
1129
+ <div className="notify-head-titles">
1130
+ <span className="admin-overline">NOTIFICATIONS</span>
1131
+ <strong>{hasAny ? `${alerts.length + (notify?.length ?? 0)} notification${alerts.length + (notify?.length ?? 0) === 1 ? '' : 's'}` : 'All clear'}</strong>
1132
+ </div>
1133
+ <button type="button" className="notify-refresh" aria-label="Refresh notifications" onClick={() => void loadNotify()} disabled={notifyBusy}>{notifyBusy ? '…' : '↻'}</button>
1134
+ <button type="button" className="rail-x" aria-label="Close notifications" onClick={onClose}>×</button>
1135
+ </div>
1136
+
1137
+ <div className="notify-body">
1138
+ {hasAny ? (
1139
+ <>
1140
+ <div className="notify-summary">
1141
+ {errs > 0 && <span className="is-err">● {errs} error{errs === 1 ? '' : 's'}</span>}
1142
+ {warns > 0 && <span className="is-warn">● {warns} warning{warns === 1 ? '' : 's'}</span>}
1143
+ {oks > 0 && <span className="is-ok">● {oks} notice{oks === 1 ? '' : 's'}</span>}
1144
+ {hasLogs && <span className="is-log">● {notify!.length} log line{(notify?.length ?? 0) === 1 ? '' : 's'}</span>}
1145
+ </div>
1146
+
1147
+ {hasAlerts && (
1148
+ <>
1149
+ <div className="notify-section-title">
1150
+ <span>ADMIN ALERTS</span>
1151
+ <button type="button" onClick={clearAlerts} disabled={alerts.length === 0}>Clear all</button>
1152
+ </div>
1153
+ <ul className="notify-alerts">
1154
+ {alerts.slice(0, 8).map((a) => (
1155
+ <li key={a.id} className={`is-${a.kind}`}>
1156
+ <span className="notify-alert-bar" />
1157
+ <span className="notify-alert-dot" />
1158
+ <span className="notify-alert-body">
1159
+ <b>{a.message}</b>
1160
+ <small>{a.source} · {a.at}</small>
1161
+ </span>
1162
+ <button type="button" className="notify-alert-x" aria-label="Dismiss alert" onClick={() => dismissAlert(a.id)}>×</button>
1163
+ </li>
1164
+ ))}
1165
+ </ul>
1166
+ </>
1167
+ )}
1168
+
1169
+ {hasLogs && (
1170
+ <>
1171
+ <div className="notify-section-title">
1172
+ <span>SYSTEM ERRORS</span>
1173
+ <span className="notify-section-meta">supervisor · error level</span>
1174
+ </div>
1175
+ <ul className="notify-logs">
1176
+ {notify!.slice(0, 8).map((l, i) => (
1177
+ <li key={i}>
1178
+ <b>{l.service}</b><span>{new Date(l.ts).toLocaleTimeString()}</span>
1179
+ <p>{l.line.length > 96 ? `${l.line.slice(0, 96)}…` : l.line}</p>
1180
+ </li>
1181
+ ))}
1182
+ </ul>
1183
+ </>
1184
+ )}
1185
+ </>
1186
+ ) : loading ? (
1187
+ <div className="notify-empty"><span className="notify-empty-icon">◍</span><strong>Loading notifications…</strong></div>
1188
+ ) : (
1189
+ <div className="notify-empty"><span className="notify-empty-icon">◍</span><strong>All clear</strong><p>No errors or alerts right now.</p></div>
1190
+ )}
1191
+ </div>
1192
+ </div>
1193
+ );
1194
+ }
1195
+
1196
+ function ThemeCentre({
1197
+ theme,
1198
+ onThemeChange,
1199
+ uiMode,
1200
+ onUiModeChange,
1201
+ custom,
1202
+ onCustom,
1203
+ particles,
1204
+ onParticles,
1205
+ }: {
1206
+ theme: AdminTheme;
1207
+ onThemeChange: (theme: AdminTheme) => void;
1208
+ uiMode: UiMode;
1209
+ onUiModeChange: (mode: UiMode) => void;
1210
+ custom: CustomTheme;
1211
+ onCustom: (partial: Partial<CustomTheme>) => void;
1212
+ particles: ParticleConfig;
1213
+ onParticles: (partial: Partial<ParticleConfig>) => void;
1214
+ }) {
1215
+ const [copied, setCopied] = useState(false);
1216
+ const colors: { key: keyof Omit<CustomTheme, 'active'>; label: string; detail: string }[] = [
1217
+ { key: 'blue', label: 'Primary blue', detail: 'Buttons, focus rings & links' },
1218
+ { key: 'violet', label: 'Accent violet', detail: 'Highlights & gradients' },
1219
+ { key: 'pink', label: 'Accent pink', detail: 'Special surfaces & marks' },
1220
+ { key: 'bg', label: 'Background', detail: 'Base canvas of the shell' },
1221
+ { key: 'ink', label: 'Foreground text', detail: 'Headings & body ink' },
1222
+ { key: 'muted', label: 'Muted text', detail: 'Captions & secondary labels' },
1223
+ ];
1224
+ const setColor = (key: keyof Omit<CustomTheme, 'active'>, value: string) => onCustom({ [key]: value });
1225
+ const setParticleColor = (index: number, value: string) => {
1226
+ const next = [...particles.colors];
1227
+ next[index] = value;
1228
+ onParticles({ colors: next });
1229
+ };
1230
+ const addParticleColor = () => {
1231
+ const next = [...particles.colors, SWATCH_CPLAETTE[particles.colors.length % SWATCH_CPLAETTE.length]];
1232
+ onParticles({ colors: next });
1233
+ };
1234
+ const removeParticleColor = (index: number) => {
1235
+ if (particles.colors.length <= 1) return;
1236
+ onParticles({ colors: particles.colors.filter((_, i) => i !== index) });
1237
+ };
1238
+ const exportJson = () => {
1239
+ const payload = JSON.stringify({ version: 1, theme, uiMode, custom, particles }, null, 2);
1240
+ const write = () => { navigator.clipboard.writeText(payload).then(() => setCopied(true)).catch(() => fallback(payload)); };
1241
+ const fallback = (text: string) => {
1242
+ const el = document.createElement('textarea');
1243
+ el.value = text; document.body.appendChild(el); el.select();
1244
+ try { document.execCommand('copy'); setCopied(true); } catch { /* ignore */ }
1245
+ document.body.removeChild(el);
1246
+ };
1247
+ write();
1248
+ window.setTimeout(() => setCopied(false), 1600);
1249
+ };
1250
+ const importJson = async () => {
1251
+ let text = '';
1252
+ try { text = await navigator.clipboard.readText(); } catch { text = window.prompt('Paste theme JSON to import:') ?? ''; }
1253
+ if (!text) return;
1254
+ try {
1255
+ const parsed = JSON.parse(text) as { theme?: AdminTheme; uiMode?: UiMode; custom?: Partial<CustomTheme>; particles?: Partial<ParticleConfig> };
1256
+ if (parsed.theme && ['aurora', 'midnight', 'violet'].includes(parsed.theme)) onThemeChange(parsed.theme);
1257
+ if (parsed.uiMode && ['workspace', 'compact', 'focus'].includes(parsed.uiMode)) onUiModeChange(parsed.uiMode);
1258
+ if (parsed.custom) onCustom(parsed.custom);
1259
+ if (parsed.particles) onParticles(parsed.particles);
1260
+ } catch { /* ignore bad import */ }
1261
+ };
1262
+ const resetAll = () => {
1263
+ onCustom({ ...THEME_PRESETS[theme], active: false });
1264
+ onParticles({ ...DEFAULT_PARTICLES });
1265
+ try { localStorage.removeItem(CUSTOM_THEME_KEY); localStorage.removeItem(PARTICLES_KEY); } catch { /* ignore */ }
1266
+ };
1267
+ const slider = (label: string, detail: string, value: number, min: number, max: number, step: number, display: string, onChange: (v: number) => void) => (
1268
+ <label className="surface-row">
1269
+ <span className="surface-meta"><b>{label}</b><small>{detail}</small></span>
1270
+ <input type="range" className="surface-range" min={min} max={max} step={step} value={value} onChange={(e) => onChange(Number(e.target.value))} />
1271
+ <em className="surface-value">{display}</em>
1272
+ </label>
1273
+ );
1274
+ return (
1275
+ <div className="space-y-6">
1276
+ <PageHead title="Theme Centre">
1277
+ <div className="heading-actions">
1278
+ <button type="button" className="workspace-action" onClick={() => { void importJson(); }}>⇩ Import</button>
1279
+ <button type="button" className="workspace-action" onClick={exportJson}>{copied ? 'Copied' : '⇪ Export'}</button>
1280
+ <button type="button" className="workspace-action" onClick={resetAll}>↻ Reset all</button>
1281
+ </div>
1282
+ </PageHead>
1283
+ <PageHero kicker="SURFACE STUDIO" title={<>Make it <em>yours.</em></>} desc="Curated themes, your own palette, particle fields and surface tuning — applied live to this workspace and remembered locally in your browser." glyph="◒" art="palette" />
1284
+ <section className="glass-card space-y-4">
1285
+ <div className="flex items-center justify-between">
1286
+ <div><span className="admin-overline">CURATED THEMES</span><h3 className="glass-h3">Start from a surface</h3></div>
1287
+ <span className="live-badge"><i className="status-dot good" /> LIVE</span>
1288
+ </div>
1289
+ <div className="theme-gallery">
1290
+ {([['aurora', 'Aurora', 'Electric blue glow'], ['midnight', 'Midnight', 'Deep navy calm'], ['violet', 'Violet', 'Indigo shimmer']] as const).map(([id, label, detail]) => {
1291
+ const preset = THEME_PRESETS[id];
1292
+ const isActive = !custom.active && theme === id;
1293
+ return (
1294
+ <button key={id} type="button" className={`theme-card${isActive ? ' is-active' : ''}`} onClick={() => { onThemeChange(id); onCustom({ ...preset, active: false }); }}>
1295
+ <span className={`theme-preview ${id}`} />
1296
+ <span className="theme-card-body"><b>{label}</b><small>{detail}</small></span>
1297
+ {isActive && <i className="theme-card-check">✓</i>}
1298
+ </button>
1299
+ );
1300
+ })}
1301
+ </div>
1302
+ <div className="ui-mode-options ui-mode-row">
1303
+ {([['workspace', 'Workspace', 'Inspector + tools'], ['compact', 'Compact', 'More content'], ['focus', 'Focus', 'Canvas first']] as const).map(([id, label, detail]) => (
1304
+ <button key={id} onClick={() => onUiModeChange(id)} className={uiMode === id ? 'is-selected' : ''}><span>{label}</span><small>{detail}</small></button>
1305
+ ))}
1306
+ </div>
1307
+ </section>
1308
+ <section className="glass-card space-y-4">
1309
+ <div className="flex items-center justify-between">
1310
+ <div><span className="admin-overline">CUSTOM THEME</span><h3 className="glass-h3">Your own palette</h3></div>
1311
+ <div className="flex items-center gap-2">
1312
+ <button type="button" className="glass-chip-btn" onClick={() => { const p = randomAccentPalette(); onCustom({ ...p, active: !custom.active ? true : custom.active }); }}>✦ Randomize</button>
1313
+ <button type="button" className={`custom-toggle${custom.active ? ' is-active' : ''}`} onClick={() => onCustom({ active: !custom.active })}>{custom.active ? 'Applied · live' : 'Apply custom'}</button>
1314
+ <button type="button" className="glass-chip-btn" onClick={() => onCustom({ ...THEME_PRESETS[theme], active: true })}>Seed from {theme}</button>
1315
+ </div>
1316
+ </div>
1317
+ <p className="text-sm text-slate-400">Tune each color below — the workspace re-themes instantly while custom is applied. Stored locally in your browser.</p>
1318
+ <div className="custom-grid">
1319
+ {colors.map((c) => (
1320
+ <label key={c.key} className="color-row">
1321
+ <span className="color-swatch"><input type="color" value={custom[c.key]} disabled={!custom.active} onChange={(e) => setColor(c.key, e.target.value)} /></span>
1322
+ <span className="color-meta"><b>{c.label}</b><small>{c.detail}</small></span>
1323
+ <input className="glass-input color-hex" value={custom[c.key]} disabled={!custom.active} onChange={(e) => { const v = e.target.value.trim(); if (/^#[0-9a-fA-F]{3,8}$/.test(v)) setColor(c.key, v); }} />
1324
+ </label>
1325
+ ))}
1326
+ </div>
1327
+ <div className="theme-preview-tile" style={custom.active ? { ['--p-blue' as string]: custom.blue, ['--p-violet' as string]: custom.violet, ['--p-pink' as string]: custom.pink, ['--p-bg' as string]: custom.bg } : undefined}>
1328
+ <div className="theme-preview-glow" />
1329
+ <div className="theme-preview-card">
1330
+ <span className="theme-preview-overline">LIVE PREVIEW</span>
1331
+ <b className="theme-preview-title">Surface preview</b>
1332
+ <div className="theme-preview-actions"><button className="glass-chip-btn-primary flex-1 justify-center">Primary</button><button className="glass-chip-btn flex-1 justify-center">chip</button></div>
1333
+ <div className="theme-preview-dots"><i /><i /><i /></div>
1334
+ </div>
1335
+ </div>
1336
+ </section>
1337
+ <section className="glass-card space-y-4">
1338
+ <div className="flex items-center justify-between">
1339
+ <div><span className="admin-overline">SURFACE TUNING</span><h3 className="glass-h3">Glow, glass & shape</h3></div>
1340
+ <span className="live-badge"><i className="status-dot good" /> LIVE</span>
1341
+ </div>
1342
+ {slider('Ambient glow', 'Mesh & aura strength behind the shell', custom.mesh, 0, 1, 0.02, `${Math.round(custom.mesh * 100)}%`, (v) => onCustom({ mesh: v }))}
1343
+ {slider('Panel glass', 'Frosted blur behind cards & panels', custom.glass, 0, 1, 0.02, `${Math.round(custom.glass * 100)}%`, (v) => onCustom({ glass: v }))}
1344
+ {slider('Corner radius', 'Roundness of cards, panels & buttons', custom.radius, 0, 24, 1, `${custom.radius}px`, (v) => onCustom({ radius: v }))}
1345
+ <div>
1346
+ <span className="admin-overline">TOGGLE STYLE</span>
1347
+ <div className="toggle-style-row">
1348
+ {TOGGLE_STYLES.map((t) => (
1349
+ <button
1350
+ key={t.id}
1351
+ type="button"
1352
+ className={`toggle-style-btn${custom.toggle === t.id ? ' is-selected' : ''}`}
1353
+ onClick={() => onCustom({ toggle: t.id })}
1354
+ >
1355
+ <span className={`ai-toggle tg-${t.id} is-on toggle-style-preview`}><span className="ai-toggle-knob" /></span>
1356
+ <span className="toggle-style-meta"><b>{t.label}</b><small>{t.hint}</small></span>
1357
+ {custom.toggle === t.id && <i className="toggle-style-check">✓</i>}
1358
+ </button>
1359
+ ))}
1360
+ </div>
1361
+ </div>
1362
+ </section>
1363
+ <section className="glass-card space-y-4">
1364
+ <div className="flex items-center justify-between">
1365
+ <div><span className="admin-overline">PARTICLE FIELD</span><h3 className="glass-h3">Floating background dust</h3></div>
1366
+ <div className="flex items-center gap-2">
1367
+ <button type="button" className="glass-chip-btn" onClick={() => onParticles({ colors: randomParticleColors() })}>✦ Randomize</button>
1368
+ <button type="button" className={`custom-toggle${particles.amount > 0 ? ' is-active' : ''}`} onClick={() => onParticles({ amount: particles.amount > 0 ? 0 : 14 })}>{particles.amount > 0 ? 'On' : 'Off'}</button>
1369
+ </div>
1370
+ </div>
1371
+ <p className="text-sm text-slate-400">Control how many particles drift behind the workspace, how fast they rise, and build a custom color list for them.</p>
1372
+ {slider('Particle amount', 'Count of dots in the field', particles.amount, 0, 48, 1, String(particles.amount), (v) => onParticles({ amount: v }))}
1373
+ {slider('Particle speed', 'Rise speed multiplier', particles.speed, 0.2, 3, 0.05, `${particles.speed.toFixed(2)}×`, (v) => onParticles({ speed: v }))}
1374
+ <div className="palette-list">
1375
+ {particles.colors.map((c, i) => (
1376
+ <div className="palette-chip" key={i}>
1377
+ <span className="color-swatch"><input type="color" value={c} onChange={(e) => setParticleColor(i, e.target.value)} /></span>
1378
+ <input className="glass-input color-hex" value={c} onChange={(e) => { const v = e.target.value.trim(); if (/^#[0-9a-fA-F]{3,8}$/.test(v)) setParticleColor(i, v); }} />
1379
+ <button type="button" className="row-delete" onClick={() => removeParticleColor(i)} aria-label="Remove color" disabled={particles.colors.length <= 1}>×</button>
1380
+ </div>
1381
+ ))}
1382
+ <button type="button" className="add-color" onClick={addParticleColor}>+ Add color</button>
1383
+ </div>
1384
+ </section>
1385
+ </div>
1386
+ );
1387
+ }
1388
+
1389
+ const CATEGORY_LABEL: Record<string, { label: string; hint: string }> = {
1390
+ refused: { label: 'Connection refused', hint: 'the target address is reachable but nothing is listening — is the service running?' },
1391
+ timeout: { label: 'Timed out', hint: 'the target did not respond in time — it may be down or filtering traffic.' },
1392
+ dns: { label: 'DNS lookup failed', hint: 'the host could not be resolved.' },
1393
+ ssl: { label: 'TLS handshake failed', hint: 'the target answered but the TLS handshake failed.' },
1394
+ http: { label: 'HTTP error status', hint: 'the endpoint responded with an error status code.' },
1395
+ engine_offline: { label: 'Diagnostics engine offline', hint: 'the Python engine is not running — start it with "python main.py" or "nexus dev".' },
1396
+ };
1397
+
1398
+ function friendlyPreflightError(error?: string, category?: string): string {
1399
+ if (!error) return 'unreachable';
1400
+ const meta = category ? CATEGORY_LABEL[category] : undefined;
1401
+ if (!meta || error.toLowerCase().startsWith(meta.label.toLowerCase())) return error;
1402
+ return `${meta.label} — ${error}`;
1403
+ }
1404
+
1405
+ function preflightAddress(c: PreflightCheck): string {
1406
+ if (c.kind === 'http' && c.url) return c.url;
1407
+ if (c.host != null && c.port != null) return `${c.host}:${c.port}`;
1408
+ return '';
1409
+ }
1410
+
1411
+ function Overview({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
1412
+ const [metrics, setMetrics] = useState<any>(null);
1413
+ const [services, setServices] = useState<ServiceState[]>([]);
1414
+ const [preflight, setPreflight] = useState<PreflightReport | null>(null);
1415
+ const [preflightBusy, setPreflightBusy] = useState(false);
1416
+ const [preflightErr, setPreflightErr] = useState('');
1417
+ const [traffic, setTraffic] = useState<number[]>([]);
1418
+ const prevTotal = useRef<number | null>(null);
1419
+ useEffect(() => {
1420
+ const load = async () => {
1421
+ try { setMetrics(await getMetrics()); } catch { setMetrics(null); }
1422
+ try { setServices(await getServices()); } catch { setServices([]); }
1423
+ };
1424
+ load();
1425
+ const timer = setInterval(load, 5000);
1426
+ return () => clearInterval(timer);
1427
+ }, []);
1428
+ useEffect(() => {
1429
+ if (!metrics?.metrics?.http_requests_total?.values) return;
1430
+ const values = metrics.metrics.http_requests_total.values as Record<string, number>;
1431
+ const total = Object.values(values).reduce((sum: number, value) => sum + Number(value ?? 0), 0);
1432
+ const prev = prevTotal.current;
1433
+ prevTotal.current = total;
1434
+ if (prev == null) return;
1435
+ const delta = total - prev;
1436
+ if (delta < 0) return;
1437
+ setTraffic((t) => [...t, delta].slice(-20));
1438
+ }, [metrics]);
1439
+ const running = services.filter((service) => service.status === 'running').length;
1440
+ const requests = metrics?.metrics?.http_requests_total?.values;
1441
+ const requestCount = requests ? Object.values(requests).reduce((sum: number, value) => sum + Number(value ?? 0), 0) : 0;
1442
+ const runChecks = async () => {
1443
+ setPreflightBusy(true);
1444
+ setPreflightErr('');
1445
+ try {
1446
+ setPreflight(await runPreflight());
1447
+ } catch (e: any) {
1448
+ setPreflight(null);
1449
+ setPreflightErr(e?.message || 'Preflight request failed');
1450
+ }
1451
+ setPreflightBusy(false);
1452
+ };
1453
+ return (
1454
+ <div className="admin-view-stack">
1455
+ <PageHead title="Project Overview"><button onClick={() => onNavigate('processes')} className="workspace-action">Open services <span>→</span></button></PageHead>
1456
+ <div className="overview-hero"><div><span className="admin-overline">GOOD MORNING, ADMIN</span><h1>Your project is <em>in orbit.</em></h1><p>One calm surface for the services, configuration, data, and AI that power your Nexus application.</p></div><div className="hero-orbit-art"><span /><i /><b /><strong>NX</strong></div></div>
1457
+ <div className="overview-stat-grid"><div className="overview-stat"><span>ACTIVE SERVICES</span><strong>{running}<small>/{services.length || 4}</small></strong><b className="stat-good">● running now</b></div><div className="overview-stat"><span>HTTP REQUESTS</span><strong>{requestCount || '—'}</strong><b>since boot</b></div><div className="overview-stat"><span>UPTIME</span><strong>{metrics ? `${Math.round((metrics.uptime ?? 0) / 60)}m` : '—'}</strong><b>backend process</b></div><div className="overview-stat"><span>ENVIRONMENT</span><strong>DEV</strong><b className="stat-violet">local workspace</b></div></div>
1458
+ <section className="workspace-panel live-traffic-panel"><div className="panel-title-row"><div><span className="admin-overline">HTTP REQUESTS</span><h3>Live traffic</h3></div><span className="live-badge"><i className="status-dot good" /> LIVE</span></div><TrafficChart samples={traffic} total={requestCount} last={traffic.length ? traffic[traffic.length - 1] : 0} onNavigate={onNavigate} /></section>
1459
+ <div className="overview-columns"><section className="workspace-panel"><div className="panel-title-row"><div><span className="admin-overline">PROJECT SURFACES</span><h3>Everything in one place</h3></div><button onClick={() => onNavigate('config')} className="text-action">View config →</button></div><div className="surface-grid"><button className="surface-card acc-blue" onClick={() => onNavigate('config')}><span className="surface-icon">{'{ }'}</span><strong>Runtime config</strong><small>nexus.runtime.json overrides</small><i className="surface-go">→</i></button><button className="surface-card acc-violet" onClick={() => onNavigate('env')}><span className="surface-icon">⌁</span><strong>Environment</strong><small>Masked secrets editor</small><i className="surface-go">→</i></button><button className="surface-card acc-pink" onClick={() => onNavigate('databases')}><span className="surface-icon">▦</span><strong>Data layer</strong><small>Mongo databases &amp; collections</small><i className="surface-go">→</i></button><button className="surface-card acc-blue" onClick={() => onNavigate('theme')}><span className="surface-icon">✦</span><strong>AI theme</strong><small>Generate schemas from English</small><i className="surface-go">→</i></button><button className="surface-card acc-violet" onClick={() => onNavigate('users')}><span className="surface-icon">◎</span><strong>Users &amp; roles</strong><small>Accounts, roles &amp; grants</small><i className="surface-go">→</i></button><button className="surface-card acc-pink" onClick={() => onNavigate('payments')}><span className="surface-icon">$</span><strong>Payments</strong><small>Orders, transactions &amp; providers</small><i className="surface-go">→</i></button><button className="surface-card acc-blue" onClick={() => onNavigate('ai')}><span className="surface-icon">◇</span><strong>AI agents</strong><small>Providers &amp; chat playground</small><i className="surface-go">→</i></button></div></section><section className="workspace-panel pulse-panel"><div className="panel-title-row"><div><span className="admin-overline">SYSTEM PULSE</span><h3>Services are moving</h3></div><span className="live-badge"><i className="status-dot good" /> LIVE</span></div><div className="pulse-bars">{[34,55,42,78,62,88,52,72,48,66,84,58].map((height, index) => <i key={index} style={{ height: `${height}%` }} />)}</div><p>Live supervisor status refreshes every five seconds.</p><button onClick={() => onNavigate('monitoring')} className="text-action">Open monitoring →</button></section></div>
1460
+ <section className="workspace-panel">
1461
+ <div className="panel-title-row">
1462
+ <div><span className="admin-overline">PREFLIGHT DIAGNOSTICS</span><h3>Dependencies are being probed</h3></div>
1463
+ <button onClick={runChecks} disabled={preflightBusy} className="glass-chip-btn">{preflightBusy ? 'probing…' : '↻ run checks'}</button>
1464
+ </div>
1465
+ <p className="preflight-note">The Python server pings the backend API, AI server, GraphQL endpoint and data-store ports, and reports latency per target.</p>
1466
+ {preflightErr && (
1467
+ <div role="alert" className="mb-3 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-400">
1468
+ Preflight failed — {preflightErr}
1469
+ </div>
1470
+ )}
1471
+ {preflight ? (
1472
+ <>
1473
+ <div className="preflight-summary">
1474
+ <span className="preflight-count ok">● {preflight.passed} ok</span>
1475
+ <span className="preflight-count warn">◐ {preflight.warnings} slow</span>
1476
+ <span className="preflight-count bad">● {preflight.failed} failed</span>
1477
+ <span className="preflight-meta">{preflight.durationMs}ms · {preflight.ranAt}</span>
1478
+ </div>
1479
+ {preflight.engineOk === false || (preflight.checks.length > 0 && preflight.failed === preflight.checks.length) ? (
1480
+ <div className="preflight-guidance">
1481
+ <strong>Nothing responded.</strong>
1482
+ <span>The stack may not be running. Start everything with <code>nexus dev</code>, or Python-only with <code>python main.py</code>. Then press “run checks” again.</span>
1483
+ <button onClick={() => onNavigate('processes')} className="text-action">Open services →</button>
1484
+ </div>
1485
+ ) : null}
1486
+ </>
1487
+ ) : preflightBusy ? (
1488
+ <p className="preflight-empty">Probing dependencies…</p>
1489
+ ) : (
1490
+ <p className="preflight-empty">No checks run yet — press “run checks”.</p>
1491
+ )}
1492
+ {preflight && (
1493
+ <div className="preflight-list">
1494
+ {preflight.checks.map((c) => (
1495
+ <div key={c.name} className="preflight-row">
1496
+ <span className={`preflight-dot ${c.ok ? 'good' : 'bad'}`} />
1497
+ <span className="preflight-check-name">{c.name}</span>
1498
+ <span className="preflight-kind">{c.kind}</span>
1499
+ {preflightAddress(c) && <span className="preflight-address">{preflightAddress(c)}</span>}
1500
+ {c.latencyMs != null && <span className="preflight-latency">{c.latencyMs}ms</span>}
1501
+ <span className={`preflight-state ${c.ok ? 'ok' : 'fail'}`}>{c.ok ? 'ok' : 'fail'}</span>
1502
+ {c.error && <span className="preflight-error" title={friendlyPreflightError(c.error, c.errorCategory)}>{friendlyPreflightError(c.error, c.errorCategory)}</span>}
1503
+ </div>
1504
+ ))}
1505
+ </div>
1506
+ )}
1507
+ </section>
1508
+ </div>
1509
+ );
1510
+ }
1511
+
1512
+ function TrafficChart({ samples, total, last, onNavigate }: { samples: number[]; total: number; last: number; onNavigate: (tab: Tab) => void }) {
1513
+ const W = 720;
1514
+ const H = 96;
1515
+ const PAD = 4;
1516
+ const pts = samples.length ? samples : [0, 0];
1517
+ const max = Math.max(4, ...pts);
1518
+ const stepX = (W - PAD * 2) / (Math.max(pts.length - 1, 1));
1519
+ const coords = pts.map((v, i) => [PAD + i * stepX, H - PAD - (v / max) * (H - PAD * 2)] as const);
1520
+ const line = coords.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
1521
+ const area = `${line} L${(W - PAD).toFixed(1)},${H - PAD} L${PAD},${H - PAD} Z`;
1522
+ const lastPt = coords[coords.length - 1];
1523
+ return (
1524
+ <div className="traffic-chart">
1525
+ <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label="Live HTTP request rate">
1526
+ <defs>
1527
+ <linearGradient id="trafficFill" x1="0" y1="0" x2="0" y2="1">
1528
+ <stop offset="0%" stopColor="var(--admin-blue, #54b7ff)" stopOpacity="0.35" />
1529
+ <stop offset="100%" stopColor="var(--admin-blue, #54b7ff)" stopOpacity="0" />
1530
+ </linearGradient>
1531
+ </defs>
1532
+ <line x1={PAD} y1={H - PAD} x2={W - PAD} y2={H - PAD} stroke="var(--admin-border, rgba(125,174,235,0.2))" strokeWidth="1" />
1533
+ <path d={area} fill="url(#trafficFill)" />
1534
+ <path d={line} fill="none" stroke="var(--admin-blue, #54b7ff)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
1535
+ <circle cx={lastPt[0]} cy={lastPt[1]} r="3.5" fill="var(--admin-blue, #54b7ff)" />
1536
+ </svg>
1537
+ <div className="traffic-meta"><span>{last} req / 5s</span><span>{total} since boot</span><button onClick={() => onNavigate('monitoring')} className="text-action">Open monitoring →</button></div>
1538
+ </div>
1539
+ );
1540
+ }
1541
+
1542
+ function Processes() {
1543
+ const [services, setServices] = useState<ServiceState[]>([]);
1544
+ const [logs, setLogs] = useState<Record<string, string[]>>({});
1545
+ const [sel, setSel] = useState<string | null>(null);
1546
+
1547
+ const refresh = async () => {
1548
+ try { setServices(await getServices()); } catch { setServices([]); }
1549
+ };
1550
+ useEffect(() => { refresh(); const t = setInterval(refresh, 3000); return () => clearInterval(t); }, []);
1551
+
1552
+ const showLogs = async (name: string) => {
1553
+ setSel(name);
1554
+ setLogs({ ...logs, [name]: await getServiceLogs(name) });
1555
+ };
1556
+
1557
+ const color = (s: string) => (s === 'running' ? 'text-emerald-400' : s === 'errored' ? 'text-rose-400' : 'text-slate-400');
1558
+
1559
+ return (
1560
+ <div className="space-y-6">
1561
+ <PageHead title="Processes">
1562
+ <button onClick={refresh} className="glass-chip-btn">↻ refresh</button>
1563
+ </PageHead>
1564
+ <PageHero kicker="SERVICE CONTROL PLANE" title={<>Your services, <em>in check.</em></>} desc="Every backend, frontend and AI process the supervisor is tending — live status, uptime, PID and one-click control." glyph="◈" art="bars" />
1565
+ <div className="glass-panel overflow-hidden p-2">
1566
+ <table className="glass-table">
1567
+ <thead><tr className="text-slate-400"><th>Service</th><th className="text-center">Status</th><th className="text-center">PID</th><th className="text-center">Uptime</th><th className="text-center">Actions</th></tr></thead>
1568
+ <tbody>
1569
+ {services.map((s) => (
1570
+ <tr key={s.name}>
1571
+ <td className="font-mono text-slate-200">{s.name}</td>
1572
+ <td className={`text-center ${color(s.status)}`}>
1573
+ <span className="inline-flex items-center gap-2">
1574
+ <span className={`inline-block h-2 w-2 rounded-full ${s.status === 'running' ? 'bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.9)]' : s.status === 'errored' ? 'bg-rose-500' : 'bg-slate-500'}`} />
1575
+ {s.status}
1576
+ </span>
1577
+ </td>
1578
+ <td className="text-center text-slate-400">{s.pid ?? '—'}</td>
1579
+ <td className="text-center text-slate-400">{s.startedAt ? `${Math.round((Date.now() - s.startedAt) / 1000)}s` : '—'}</td>
1580
+ <td className="text-center">
1581
+ <span className="inline-flex gap-1.5">
1582
+ <button onClick={() => controlService('start', s.name)} className="glass-chip-btn">start</button>
1583
+ <button onClick={() => controlService('stop', s.name)} className="glass-chip-btn-danger">stop</button>
1584
+ <button onClick={() => controlService('restart', s.name)} className="glass-chip-btn">restart</button>
1585
+ <button onClick={() => showLogs(s.name)} className="glass-chip-btn">logs</button>
1586
+ </span>
1587
+ </td>
1588
+ </tr>
1589
+ ))}
1590
+ {!services.length && <tr><td colSpan={5} className="py-8 text-center text-slate-500">No services reported by the supervisor.</td></tr>}
1591
+ </tbody>
1592
+ </table>
1593
+ </div>
1594
+ {sel && (
1595
+ <div className="glass-card space-y-2">
1596
+ <h3 className="glass-h3">{sel} logs</h3>
1597
+ <pre className="glass-code max-h-80 overflow-auto p-3">{((logs[sel] ?? []).join('\n')) || '— empty —'}</pre>
1598
+ </div>
1599
+ )}
1600
+ </div>
1601
+ );
1602
+ }
1603
+
1604
+ /** Color of a service tag in the log viewer (mirrors the supervisor prefixes). */
1605
+ const SERVICE_TAG_COLORS: Record<string, string> = {
1606
+ backend: 'text-emerald-400 border-emerald-400/30 bg-emerald-400/10',
1607
+ frontend: 'text-cyan-300 border-cyan-300/30 bg-cyan-300/10',
1608
+ 'ai-server': 'text-amber-300 border-amber-300/30 bg-amber-300/10',
1609
+ admin: 'text-pink-300 border-pink-300/30 bg-pink-300/10',
1610
+ };
1611
+ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
1612
+ error: 'log-row--error',
1613
+ warn: 'log-row--warn',
1614
+ info: 'log-row--info',
1615
+ };
1616
+
1617
+ function Logs({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
1618
+ const [entries, setEntries] = useState<LogEntry[]>([]);
1619
+ const [services, setServices] = useState<string[]>([]);
1620
+ const [service, setService] = useState('all');
1621
+ const [level, setLevel] = useState<'all' | LogEntry['level']>('all');
1622
+ const [q, setQ] = useState('');
1623
+ const [live, setLive] = useState(true);
1624
+ const [sticky, setSticky] = useState(true);
1625
+ const [copied, setCopied] = useState(false);
1626
+ const [err, setErr] = useState('');
1627
+ const [criticalFirst, setCriticalFirst] = useState(false);
1628
+ const [aiSummary, setAiSummary] = useState('');
1629
+ const [aiSummaryBusy, setAiSummaryBusy] = useState(false);
1630
+ const [aiSummaryErr, setAiSummaryErr] = useState('');
1631
+ const [aiSummaryOpen, setAiSummaryOpen] = useState(false);
1632
+ const [aiProvider, setAiProvider] = useState('');
1633
+ const [aiModel, setAiModel] = useState('');
1634
+ const aiAbortRef = useRef<AbortController | null>(null);
1635
+ const scrollRef = useRef<HTMLDivElement | null>(null);
1636
+
1637
+ const refresh = useCallback(async () => {
1638
+ try {
1639
+ const [logs, svc] = await Promise.all([getAllLogs(), getServices()]);
1640
+ setEntries(logs);
1641
+ setServices(svc.map((s) => s.name));
1642
+ setErr('');
1643
+ } catch (e: any) {
1644
+ setErr(String(e?.message ?? e));
1645
+ }
1646
+ }, []);
1647
+
1648
+ useEffect(() => {
1649
+ void refresh();
1650
+ if (!live) return;
1651
+ const t = setInterval(() => void refresh(), 1500);
1652
+ return () => clearInterval(t);
1653
+ }, [refresh, live]);
1654
+
1655
+ const filtered = useMemo(() => {
1656
+ const needle = q.trim().toLowerCase();
1657
+ return entries.filter((e) => {
1658
+ if (service !== 'all' && e.service !== service) return false;
1659
+ if (level !== 'all' && e.level !== level) return false;
1660
+ if (needle && !e.line.toLowerCase().includes(needle)) return false;
1661
+ return true;
1662
+ });
1663
+ }, [entries, service, level, q]);
1664
+
1665
+ const counts = useMemo(() => {
1666
+ const c = { error: 0, warn: 0, info: 0 };
1667
+ for (const e of entries) c[e.level] += 1;
1668
+ return c;
1669
+ }, [entries]);
1670
+
1671
+ const ordered = useMemo(() => {
1672
+ if (!criticalFirst) return filtered;
1673
+ const rank = (e: LogEntry) => {
1674
+ const lvl = e.level === 'error' ? 0 : e.level === 'warn' ? 1 : 2;
1675
+ const src = e.source === 'stderr' ? 0 : e.source === 'system' ? 1 : 2;
1676
+ return lvl * 3 + src;
1677
+ };
1678
+ return [...filtered].sort((a, b) => rank(a) - rank(b) || b.ts - a.ts);
1679
+ }, [filtered, criticalFirst]);
1680
+
1681
+ const criticalRunEnd = useMemo(() => {
1682
+ if (!criticalFirst) return 0;
1683
+ let i = 0;
1684
+ while (i < ordered.length && ordered[i].level !== 'info') i += 1;
1685
+ return i;
1686
+ }, [ordered, criticalFirst]);
1687
+
1688
+ useEffect(() => {
1689
+ const el = scrollRef.current;
1690
+ if (el && sticky) el.scrollTop = criticalFirst ? 0 : el.scrollHeight;
1691
+ }, [ordered, sticky, criticalFirst]);
1692
+
1693
+ const onScroll = () => {
1694
+ const el = scrollRef.current;
1695
+ if (!el) return;
1696
+ setSticky(criticalFirst ? el.scrollTop < 80 : el.scrollHeight - el.scrollTop - el.clientHeight < 80);
1697
+ };
1698
+
1699
+ const copyAll = async () => {
1700
+ const text = filtered.map((e) => `[${e.service}] ${e.line}`).join('\n');
1701
+ try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
1702
+ setCopied(true);
1703
+ window.setTimeout(() => setCopied(false), 1200);
1704
+ };
1705
+
1706
+ const runAiSummary = async () => {
1707
+ if (aiSummaryBusy) return;
1708
+ if (!ordered.length) {
1709
+ setAiSummary('');
1710
+ setAiSummaryErr('No log lines to summarize — widen the filters first.');
1711
+ setAiSummaryOpen(true);
1712
+ return;
1713
+ }
1714
+ try {
1715
+ let provider = aiProvider;
1716
+ let model = aiModel;
1717
+ if (!provider || !model) {
1718
+ const s = await getAiStatus();
1719
+ let pv: AiProviderView | undefined;
1720
+ try { pv = (await getAiProviders()).providers?.find((p) => p.enabled); } catch { /* fall back to status */ }
1721
+ if (pv) {
1722
+ provider = pv.id;
1723
+ model = pv.defaultModel ?? '';
1724
+ }
1725
+ if (!provider) provider = s.autoResolvesTo ?? (s.ollama?.ok ? 'ollama' : s.openai?.ok ? 'openai' : '');
1726
+ if (!provider) throw new Error('No AI provider is available. Configure one in the AI Agents tab.');
1727
+ if (!model) {
1728
+ try { model = (await getAiModels(provider)).data?.[0]?.id ?? ''; } catch { /* fall back below */ }
1729
+ }
1730
+ if (!model) model = 'llama3:latest';
1731
+ setAiProvider(provider);
1732
+ setAiModel(model);
1733
+ }
1734
+ const sample = ordered.slice(-400).map((e) => `[${new Date(e.ts).toLocaleTimeString()}] ${e.service} ${e.source} ${e.level}: ${e.line}`).join('\n');
1735
+ const messages: AiChatMessage[] = [
1736
+ { role: 'system', content: 'You are a log analyzer for BhooAI Nexus. Summarize the provided service logs concisely. FIRST list the most critical errors (service, source, likely cause and next step), then warnings, then notable patterns. Use short bullet points and do not invent details that are not present in the logs.' },
1737
+ { role: 'user', content: `Service logs (${sample.split('\n').length} lines):\n${sample}` },
1738
+ ];
1739
+ const controller = new AbortController();
1740
+ aiAbortRef.current = controller;
1741
+ setAiSummary('');
1742
+ setAiSummaryErr('');
1743
+ setAiSummaryOpen(true);
1744
+ setAiSummaryBusy(true);
1745
+ let acc = '';
1746
+ try {
1747
+ await aiChatStream(model, messages, provider, (chunk) => { acc += chunk; setAiSummary(acc); }, controller.signal);
1748
+ setAiSummary(acc || 'AI returned an empty summary.');
1749
+ } catch (e: any) {
1750
+ if (e?.name === 'AbortError') return;
1751
+ setAiSummaryErr(String(e?.message ?? e));
1752
+ } finally {
1753
+ aiAbortRef.current = null;
1754
+ setAiSummaryBusy(false);
1755
+ }
1756
+ } catch (e: any) {
1757
+ setAiSummary('');
1758
+ setAiSummaryErr(String(e?.message ?? e));
1759
+ setAiSummaryOpen(true);
1760
+ setAiSummaryBusy(false);
1761
+ }
1762
+ };
1763
+
1764
+ const stopAiSummary = () => { aiAbortRef.current?.abort(); };
1765
+
1766
+ const copyAiSummary = async () => {
1767
+ try { await navigator.clipboard.writeText(aiSummary); } catch { /* ignore */ }
1768
+ };
1769
+
1770
+ return (
1771
+ <div className="space-y-6">
1772
+ <PageHead title="Logs">
1773
+ <div className="flex items-center gap-2">
1774
+ <button aria-label="Refresh logs" onClick={() => void refresh()} className="glass-chip-btn">↻ refresh</button>
1775
+ <button aria-pressed={live} onClick={() => setLive(!live)} className={`glass-chip-btn log-live-btn${live ? ' is-live' : ''}`}>{live ? '● live' : '○ paused'}</button>
1776
+ <button aria-label="Copy filtered logs" onClick={() => void copyAll()} className="glass-chip-btn">{copied ? 'Copied' : '⇪ copy'}</button>
1777
+ <button aria-label="Summarize logs with AI" onClick={() => void runAiSummary()} disabled={aiSummaryBusy} className="glass-chip-btn log-ai-btn">✦ {aiSummaryBusy ? 'summarizing…' : 'ai summary'}</button>
1778
+ <button aria-label="Clear all logs" onClick={async () => { try { await clearLogs(); await refresh(); } catch { /* supervisor may not be reachable */ } }} className="glass-chip-btn-danger">⌫ clear</button>
1779
+ </div>
1780
+ </PageHead>
1781
+ <PageHero kicker="ERROR CONSOLE" title={<>Every failure, <em>in one stream.</em></>} desc="Aggregated stdout, stderr and supervisor events from every service — live as the supervisor records them, filterable and searchable." glyph="▤" art="wave" />
1782
+ {err && (
1783
+ <div role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-400">
1784
+ Logs unavailable — {err}. Is <code className="font-mono">nexus dev</code> running?
1785
+ </div>
1786
+ )}
1787
+ <div className="glass-card log-card">
1788
+ <div className="log-stat-strip">
1789
+ <span className="log-stat log-stat--all"><i /><b>{entries.length}</b><em>Total lines</em></span>
1790
+ <span className="log-stat log-stat--error"><i /><b>{counts.error}</b><em>Errors</em></span>
1791
+ <span className="log-stat log-stat--warn"><i /><b>{counts.warn}</b><em>Warnings</em></span>
1792
+ <span className="log-stat log-stat--info"><i /><b>{counts.info}</b><em>Info</em></span>
1793
+ </div>
1794
+ <div className="log-toolbar">
1795
+ <div className="glass-panel log-filter-group" role="group" aria-label="Filter by level">
1796
+ {([['all', 'All'], ['error', 'Errors'], ['warn', 'Warnings'], ['info', 'Info']] as const).map(([id, label]) => (
1797
+ <button key={id} aria-pressed={level === id} onClick={() => { setLevel(id); setSticky(true); }} className={level === id ? 'log-filter-btn is-active' : 'log-filter-btn'}>
1798
+ {label} <b>{id === 'all' ? entries.length : counts[id]}</b>
1799
+ </button>
1800
+ ))}
1801
+ </div>
1802
+ <button aria-pressed={criticalFirst} onClick={() => { setCriticalFirst(!criticalFirst); setSticky(true); }} className={criticalFirst ? 'log-crit-btn is-active' : 'log-crit-btn'}>⤓ critical first</button>
1803
+ <div className="log-toolbar-right">
1804
+ <select value={service} onChange={(e) => { setService(e.target.value); setSticky(true); }} aria-label="Filter by service" className="glass-input log-service-input">
1805
+ <option value="all" className="bg-slate-900">All services</option>
1806
+ {services.map((s) => <option key={s} value={s} className="bg-slate-900">{s}</option>)}
1807
+ </select>
1808
+ <input value={q} onChange={(e) => { setQ(e.target.value); setSticky(true); }} placeholder="Filter lines…" aria-label="Filter log lines" className="glass-input log-search-input" />
1809
+ </div>
1810
+ </div>
1811
+ {aiSummaryOpen && (
1812
+ <div className="log-ai-panel">
1813
+ <div className="log-ai-head">
1814
+ <span className="log-ai-title">✦ AI summary{aiModel ? ` · ${aiModel}` : ''}</span>
1815
+ <div className="log-ai-actions">
1816
+ {aiSummaryBusy && <button onClick={stopAiSummary} className="glass-chip-btn-danger">â–  stop</button>}
1817
+ {!aiSummaryBusy && aiSummary && <button onClick={() => void copyAiSummary()} className="glass-chip-btn">⇪ copy</button>}
1818
+ <button onClick={() => { setAiSummaryOpen(false); setAiSummary(''); setAiSummaryErr(''); }} className="glass-chip-btn">✕ dismiss</button>
1819
+ </div>
1820
+ </div>
1821
+ {aiSummaryErr && (
1822
+ <div role="alert" className="log-ai-err">
1823
+ AI summary failed: {aiSummaryErr}
1824
+ {aiSummaryErr.includes('AI provider') && (
1825
+ <button onClick={() => onNavigate('ai')} className="glass-chip-btn log-ai-open">Open AI Agents →</button>
1826
+ )}
1827
+ </div>
1828
+ )}
1829
+ {aiSummaryBusy && !aiSummary && <div className="log-ai-note">Analyzing {ordered.length} lines…</div>}
1830
+ {aiSummary && <pre className="log-ai-body">{aiSummary}</pre>}
1831
+ </div>
1832
+ )}
1833
+ <div ref={scrollRef} onScroll={onScroll} className="log-viewer" role="list" aria-label="Service log output" tabIndex={0}>
1834
+ <div className="log-colhead" aria-hidden="true">
1835
+ <span className="log-time">Time</span>
1836
+ <span className="log-service">Service</span>
1837
+ <span className="log-source">Source</span>
1838
+ <span className="log-level">Level</span>
1839
+ <span className="log-line">Message</span>
1840
+ </div>
1841
+ {ordered.length === 0 ? (
1842
+ <div className="log-empty">
1843
+ <span className="log-empty-glyph">â–¤</span>
1844
+ <strong>No log lines match the current filters.</strong>
1845
+ <p>Try widening the level filter or clearing the search text.</p>
1846
+ </div>
1847
+ ) : (
1848
+ ordered.map((e, i) => (
1849
+ <div key={`${e.ts}-${i}`} className={`log-row ${LEVEL_CLASS[e.level]}${criticalFirst && i < criticalRunEnd ? ' log-row--critical' : ''}`} role="listitem">
1850
+ <span className="log-time">{new Date(e.ts).toLocaleTimeString()}</span>
1851
+ <span className={`log-service log-tag ${SERVICE_TAG_COLORS[e.service] ?? 'log-tag--default'}`}>{e.service}</span>
1852
+ <span className={`log-source log-source--${e.source}`}>{e.source}</span>
1853
+ <span className={`log-level log-level--${e.level}`}>{e.level}</span>
1854
+ <span className="log-line">{e.line || ' '}</span>
1855
+ </div>
1856
+ ))
1857
+ )}
1858
+ </div>
1859
+ <div className="log-footer">
1860
+ <span className="log-foot-left">Aggregated across services · last {entries.length} lines · {live ? <span className="log-foot-left"><span className="log-live-dot" /> polling every 1.5s</span> : 'paused'}</span>
1861
+ {!sticky && <button onClick={() => setSticky(true)} className="glass-chip-btn">{criticalFirst ? 'â–² jump to top' : 'â–¼ jump to latest'}</button>}
1862
+ </div>
1863
+ </div>
1864
+ </div>
1865
+ );
1866
+ }
1867
+
1868
+
1869
+ type ConfigValueType = 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
1870
+ type ConfigEntry = { key: string; value: string; type: ConfigValueType };
1871
+
1872
+ function flattenConfig(value: unknown, prefix = ''): ConfigEntry[] {
1873
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
1874
+ const rows = Object.entries(value as Record<string, unknown>);
1875
+ if (!rows.length && prefix) return [{ key: prefix, value: '{}', type: 'object' }];
1876
+ return rows.flatMap(([key, child]) => flattenConfig(child, prefix ? `${prefix}.${key}` : key));
1877
+ }
1878
+ if (!prefix) return [];
1879
+ if (Array.isArray(value)) return [{ key: prefix, value: JSON.stringify(value), type: 'array' }];
1880
+ if (value === null) return [{ key: prefix, value: '', type: 'null' }];
1881
+ if (typeof value === 'number') return [{ key: prefix, value: String(value), type: 'number' }];
1882
+ if (typeof value === 'boolean') return [{ key: prefix, value: String(value), type: 'boolean' }];
1883
+ return [{ key: prefix, value: String(value), type: 'string' }];
1884
+ }
1885
+
1886
+ function configValue(entry: ConfigEntry): unknown {
1887
+ if (entry.type === 'null') return null;
1888
+ if (entry.type === 'number') return Number(entry.value);
1889
+ if (entry.type === 'boolean') return entry.value === 'true';
1890
+ if (entry.type === 'object' || entry.type === 'array') return JSON.parse(entry.value);
1891
+ return entry.value;
1892
+ }
1893
+
1894
+ function buildConfig(entries: ConfigEntry[]): Record<string, unknown> {
1895
+ const root: Record<string, unknown> = {};
1896
+ for (const entry of entries) {
1897
+ const parts = entry.key.trim().split('.').filter(Boolean);
1898
+ if (!parts.length || parts.some((part) => ['__proto__', 'constructor', 'prototype'].includes(part))) continue;
1899
+ const value = configValue(entry);
1900
+ let node = root;
1901
+ parts.forEach((part, index) => {
1902
+ if (index === parts.length - 1) node[part] = value;
1903
+ else {
1904
+ const child = node[part];
1905
+ if (!child || typeof child !== 'object' || Array.isArray(child)) node[part] = {};
1906
+ node = node[part] as Record<string, unknown>;
1907
+ }
1908
+ });
1909
+ }
1910
+ return root;
1911
+ }
1912
+
1913
+ function topGroup(key: string): string {
1914
+ const trimmed = key.trim();
1915
+ if (!trimmed) return 'unsorted';
1916
+ if (trimmed.startsWith('NEXUS_')) return trimmed.slice(6).split('_')[0].toLowerCase() || 'root';
1917
+ return trimmed.split('.')[0] || 'root';
1918
+ }
1919
+
1920
+ function groupEntries<T extends { key: string }>(entries: T[]): Array<{ group: string; items: Array<{ index: number; entry: T }> }> {
1921
+ const order: string[] = [];
1922
+ const map = new Map<string, Array<{ index: number; entry: T }>>();
1923
+ entries.forEach((entry, index) => {
1924
+ const group = topGroup(entry.key);
1925
+ if (!map.has(group)) { map.set(group, []); order.push(group); }
1926
+ map.get(group)!.push({ index, entry });
1927
+ });
1928
+ return order.map((group) => ({ group, items: map.get(group)! }));
1929
+ }
1930
+
1931
+ function LintChecks({ run, title, overline }: { run: () => Promise<LintReport>; title: string; overline: string }) {
1932
+ const [report, setReport] = useState<LintReport | null>(null);
1933
+ const [busy, setBusy] = useState(false);
1934
+ const [err, setErr] = useState('');
1935
+ const doRun = async () => {
1936
+ setBusy(true); setErr('');
1937
+ try { setReport(await run()); } catch (e: any) { setErr(String(e?.message ?? e)); setReport(null); }
1938
+ setBusy(false);
1939
+ };
1940
+ return (
1941
+ <section className="workspace-panel lint-panel">
1942
+ <div className="panel-title-row">
1943
+ <div><span className="admin-overline">{overline}</span><h3>{title}</h3></div>
1944
+ <button onClick={doRun} disabled={busy} className="glass-chip-btn">{busy ? 'linting…' : '↻ run lint'}</button>
1945
+ </div>
1946
+ {err && <p className="preflight-empty">{err}</p>}
1947
+ {report ? (
1948
+ <>
1949
+ <div className="preflight-summary">
1950
+ <span className="preflight-count bad">● {report.summary.error} error{report.summary.error === 1 ? '' : 's'}</span>
1951
+ <span className="preflight-count warn">◐ {report.summary.warning} warning{report.summary.warning === 1 ? '' : 's'}</span>
1952
+ <span className="preflight-count inf">◌ {report.summary.info} info</span>
1953
+ {report.summary.ok > 0 && <span className="preflight-count ok">✓ {report.summary.ok} ok</span>}
1954
+ <span className="preflight-meta">{report.ranAt}</span>
1955
+ </div>
1956
+ {report.engineOk === false && (
1957
+ <div className="preflight-guidance">
1958
+ <strong>Diagnostics engine offline.</strong>
1959
+ <span>The Python engine that runs these checks is not running. Start it with <code>python main.py</code>, or the whole stack with <code>nexus dev</code>.</span>
1960
+ </div>
1961
+ )}
1962
+ </>
1963
+ ) : busy ? <p className="preflight-empty">Linting…</p> : <p className="preflight-empty">Run the linter to validate.</p>}
1964
+ {report && (
1965
+ <div className="preflight-list">
1966
+ {report.checks.map((c) => (
1967
+ <div key={`${c.severity}-${c.key}-${c.message}`} className="preflight-row">
1968
+ <span className={`preflight-dot ${c.severity === 'error' ? 'bad' : c.severity === 'warning' ? 'warn' : 'good'}`} />
1969
+ <span className="preflight-check-name">{c.key}</span>
1970
+ <span className="preflight-kind">{c.kind}</span>
1971
+ <span className={`preflight-message ${c.severity === 'ok' ? 'is-ok' : ''}`}>{c.message}</span>
1972
+ <span className={`preflight-state ${c.severity === 'error' ? 'fail' : c.severity === 'warning' ? 'is-warn' : c.severity === 'ok' ? 'ok' : ''}`}>{c.severity}</span>
1973
+ </div>
1974
+ ))}
1975
+ </div>
1976
+ )}
1977
+ </section>
1978
+ );
1979
+ }
1980
+
1981
+ function AiAgents({ toggle }: { toggle: ToggleStyle }) {
1982
+ const [aiTab, setAiTab] = useState<'settings' | 'providers' | 'chat'>('settings');
1983
+ return (
1984
+ <div className="space-y-6">
1985
+ <PageHead title="AI Agents" />
1986
+ <PageHero kicker="AI WORKSPACE" title={<>Agents, <em>at your command.</em></>} desc="Manage AI settings, configure providers with API keys, and test agents with a live chat playground." glyph="✦" art="spark" />
1987
+
1988
+ {/* Sub-tab nav */}
1989
+ <div className="workspace-tabs">
1990
+ {([['settings', 'AI Settings', '⚙'], ['providers', 'AI Providers', '◇'], ['chat', 'Chat Playground', '✦']] as const).map(([id, label, icon]) => (
1991
+ <button key={id} onClick={() => setAiTab(id)} className={aiTab === id ? 'is-active' : ''}>
1992
+ <span className="mr-1.5">{icon}</span>{label}
1993
+ </button>
1994
+ ))}
1995
+ </div>
1996
+
1997
+ {aiTab === 'settings' && <AiSettings />}
1998
+ {aiTab === 'providers' && <AiProviders toggle={toggle} />}
1999
+ {aiTab === 'chat' && <AiChatPlayground />}
2000
+ </div>
2001
+ );
2002
+ }
2003
+
2004
+ function AiSettings() {
2005
+ const [config, setConfig] = useState<AdminConfig | null>(null);
2006
+ const [aiStatus, setAiStatus] = useState<AiStatus | null>(null);
2007
+ const [aiErr, setAiErr] = useState('');
2008
+ const [msg, setMsg] = useAdminAlert('ai-settings');
2009
+
2010
+ useEffect(() => {
2011
+ getAdminConfig().then((c) => setConfig(c)).catch(() => {});
2012
+ checkAi();
2013
+ }, []);
2014
+
2015
+ const checkAi = async () => {
2016
+ setAiErr('');
2017
+ try { setAiStatus(await getAiStatus()); }
2018
+ catch (e: any) { setAiErr(String(e?.message ?? e)); }
2019
+ };
2020
+
2021
+ const ai = config?.config?.ai as { serverUrl: string; timeoutMs: number; defaultProvider: string; schemaModel: string } | undefined;
2022
+
2023
+ return (
2024
+ <div className="space-y-4">
2025
+
2026
+ <section className="glass-card space-y-3">
2027
+ <h3 className="glass-h3">AI configuration</h3>
2028
+ {ai ? (
2029
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
2030
+ {([
2031
+ ['Server URL', ai.serverUrl],
2032
+ ['Timeout', `${(ai.timeoutMs / 1000).toFixed(0)}s`],
2033
+ ['Default provider', ai.defaultProvider],
2034
+ ['Schema model', ai.schemaModel],
2035
+ ] as const).map(([label, value]) => (
2036
+ <div key={label} className="glass rounded-xl px-3 py-2">
2037
+ <span className="admin-overline">{label}</span>
2038
+ <code className="block mt-1 truncate font-mono text-sm text-slate-200">{value}</code>
2039
+ </div>
2040
+ ))}
2041
+ </div>
2042
+ ) : (
2043
+ <p className="text-xs text-slate-500">Loading settings…</p>
2044
+ )}
2045
+ </section>
2046
+
2047
+ <section className="glass-card space-y-3">
2048
+ <div className="flex items-center justify-between">
2049
+ <h3 className="glass-h3">AI server health</h3>
2050
+ <button onClick={checkAi} className="glass-chip-btn">re-check</button>
2051
+ </div>
2052
+ {aiErr && <p className="text-xs text-rose-400">{aiErr}</p>}
2053
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
2054
+ {aiStatus ? (
2055
+ <>
2056
+ {([
2057
+ ['AI server', aiStatus.aiServer, aiStatus.aiServer.detail ? `health: ${aiStatus.aiServer.detail.status ?? 'unknown'}` : undefined],
2058
+ ['Ollama', aiStatus.ollama, aiStatus.ollama.detail?.modelCount != null ? `${aiStatus.ollama.detail.modelCount} model(s)` : undefined],
2059
+ ] as const).map(([label, s, detail]) => (
2060
+ <div key={label} className="glass flex items-center gap-2 rounded-xl px-3 py-2 text-sm">
2061
+ <span className={s.ok ? 'dot-ok' : 'dot-bad'} />
2062
+ <span className="font-medium text-slate-200">{label}</span>
2063
+ <span className={`text-xs ${s.ok ? 'text-emerald-400' : 'text-rose-400'}`}>{s.ok ? (detail ?? 'ok') : (s.error ?? 'down')}</span>
2064
+ </div>
2065
+ ))}
2066
+ </>
2067
+ ) : (
2068
+ <p className="col-span-3 text-xs text-slate-500">No status yet{aiErr ? '' : ' — run a check'}.</p>
2069
+ )}
2070
+ </div>
2071
+ {aiStatus && <p className="text-xs text-slate-500">"auto" currently resolves to <span className="font-mono text-slate-300">{aiStatus.autoResolvesTo}</span> on the AI server.</p>}
2072
+ {aiStatus?.aiServer?.detail?.providers && (
2073
+ <div className="flex flex-wrap gap-2">
2074
+ <span className="text-xs text-slate-500">Available providers on AI server:</span>
2075
+ {aiStatus.aiServer.detail.providers.map((p) => (
2076
+ <span key={p} className="glass-chip text-xs">{p}</span>
2077
+ ))}
2078
+ </div>
2079
+ )}
2080
+ </section>
2081
+ </div>
2082
+ );
2083
+ }
2084
+
2085
+ /** Known built-in provider ids (from nexus-core defaults). Used to classify custom providers. */
2086
+ const BUILTIN_PROVIDER_IDS = new Set([
2087
+ 'ollama', 'openai', 'anthropic', 'google', 'groq', 'mistral', 'cohere', 'together',
2088
+ 'fireworks', 'deepseek', 'perplexity', 'xai', 'replicate', 'huggingface', 'nvidia',
2089
+ 'openrouter', 'lmstudio', 'alephalpha', 'stability', 'azure',
2090
+ ]);
2091
+
2092
+ /** Display metadata for known providers: a glyph + an HSL hue for the avatar accent. */
2093
+ const PROVIDER_META: Record<string, { glyph: string; hue: number }> = {
2094
+ ollama: { glyph: '🦙', hue: 152 },
2095
+ openai: { glyph: '✺', hue: 165 },
2096
+ anthropic: { glyph: '✦', hue: 265 },
2097
+ google: { glyph: '✸', hue: 217 },
2098
+ groq: { glyph: 'âš¡', hue: 38 },
2099
+ mistral: { glyph: '🌬', hue: 210 },
2100
+ cohere: { glyph: 'â—ˆ', hue: 190 },
2101
+ together: { glyph: '⬢', hue: 230 },
2102
+ fireworks: { glyph: '✦', hue: 12 },
2103
+ deepseek: { glyph: 'â—‡', hue: 200 },
2104
+ perplexity: { glyph: 'â—‰', hue: 280 },
2105
+ xai: { glyph: '✕', hue: 0 },
2106
+ replicate: { glyph: '⟳', hue: 160 },
2107
+ huggingface: { glyph: '🤗', hue: 25 },
2108
+ nvidia: { glyph: 'â–²', hue: 120 },
2109
+ openrouter: { glyph: '⇄', hue: 240 },
2110
+ lmstudio: { glyph: '⌂', hue: 180 },
2111
+ alephalpha: { glyph: 'Α', hue: 300 },
2112
+ stability: { glyph: '✺', hue: 320 },
2113
+ azure: { glyph: '☁', hue: 200 },
2114
+ };
2115
+
2116
+ /** Derive a deterministic HSL hue from an arbitrary string (for custom provider avatars). */
2117
+ function hashHue(s: string): number {
2118
+ let h = 0;
2119
+ for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
2120
+ return h;
2121
+ }
2122
+
2123
+ function providerMeta(id: string): { glyph: string; hue: number } {
2124
+ if (PROVIDER_META[id]) return PROVIDER_META[id]!;
2125
+ const letter = id.charAt(0).toUpperCase() || '?';
2126
+ return { glyph: letter, hue: hashHue(id) };
2127
+ }
2128
+
2129
+ /** Returns true if the provider's baseUrl points to a local host. */
2130
+ function isLocalProvider(baseUrl: string): boolean {
2131
+ try {
2132
+ const u = new URL(baseUrl);
2133
+ return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '0.0.0.0';
2134
+ } catch { return false; }
2135
+ }
2136
+
2137
+ /** Preset for the "Add provider" dialog — popular providers with autofill data
2138
+ * and a list of well-known models (the user can still edit everything after). */
2139
+ interface ProviderPreset { id: string; label: string; baseUrl: string; defaultModel: string; models: string[]; }
2140
+ const POPULAR_PROVIDERS: ProviderPreset[] = [
2141
+ { id: 'ollama', label: 'Ollama (local)', baseUrl: 'http://localhost:11434', defaultModel: 'llama3:latest', models: ['llama3:latest', 'llama3.1:8b', 'mistral:latest', 'phi3:latest', 'qwen2.5:latest'] },
2142
+ { id: 'openai', label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o-mini', models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo', 'o1-mini', 'o1-preview'] },
2143
+ { id: 'anthropic', label: 'Anthropic Claude', baseUrl: 'https://api.anthropic.com/v1', defaultModel: 'claude-sonnet-4-20250514', models: ['claude-sonnet-4-20250514', 'claude-3-7-sonnet-20250219', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'] },
2144
+ { id: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1', defaultModel: 'gemini-2.0-flash', models: ['gemini-2.0-flash', 'gemini-2.0-flash-lite', 'gemini-1.5-pro', 'gemini-1.5-flash'] },
2145
+ { id: 'groq', label: 'Groq', baseUrl: 'https://api.groq.com/openai/v1', defaultModel: 'llama-3.3-70b-versatile', models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768', 'gemma2-9b-it'] },
2146
+ { id: 'mistral', label: 'Mistral AI', baseUrl: 'https://api.mistral.ai/v1', defaultModel: 'mistral-large-latest', models: ['mistral-large-latest', 'mistral-small-latest', 'open-mixtral-8x22b', 'open-mistral-7b', 'codestral-latest'] },
2147
+ { id: 'cohere', label: 'Cohere', baseUrl: 'https://api.cohere.ai/v1', defaultModel: 'command-r-plus', models: ['command-r-plus', 'command-r', 'command', 'command-light'] },
2148
+ { id: 'together', label: 'Together AI', baseUrl: 'https://api.together.xyz/v1', defaultModel: 'meta-llama/Llama-3-70b-chat-hf', models: ['meta-llama/Llama-3-70b-chat-hf', 'meta-llama/Llama-3-8b-chat-hf', 'mistralai/Mixtral-8x7B-Instruct-v0.1', 'Qwen/Qwen2.5-72B-Instruct-Turbo'] },
2149
+ { id: 'fireworks', label: 'Fireworks AI', baseUrl: 'https://api.fireworks.ai/inference/v1', defaultModel: 'accounts/fireworks/models/llama-v3-70b-instruct', models: ['accounts/fireworks/models/llama-v3-70b-instruct', 'accounts/fireworks/models/llama-v3-8b-instruct', 'accounts/fireworks/models/mixtral-8x7b-instruct'] },
2150
+ { id: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: ['deepseek-chat', 'deepseek-reasoner', 'deepseek-coder'] },
2151
+ { id: 'perplexity', label: 'Perplexity', baseUrl: 'https://api.perplexity.ai', defaultModel: 'llama-3.1-sonar-large-128k-online', models: ['llama-3.1-sonar-large-128k-online', 'llama-3.1-sonar-small-128k-online', 'llama-3.1-sonar-huge-128k-online'] },
2152
+ { id: 'xai', label: 'xAI (Grok)', baseUrl: 'https://api.x.ai/v1', defaultModel: 'grok-2-latest', models: ['grok-2-latest', 'grok-2-1212', 'grok-beta', 'grok-vision-beta'] },
2153
+ { id: 'replicate', label: 'Replicate', baseUrl: 'https://api.replicate.com/v1', defaultModel: 'meta/llama-3-70b-instruct', models: ['meta/llama-3-70b-instruct', 'meta/llama-3-8b-instruct', 'mistralai/mixtral-8x7b-instruct-v0.1'] },
2154
+ { id: 'huggingface', label: 'Hugging Face', baseUrl: 'https://api-inference.huggingface.co/models', defaultModel: 'meta-llama/Llama-3-70b-chat-hf', models: ['meta-llama/Llama-3-70b-chat-hf', 'mistralai/Mistral-7B-Instruct-v0.3', 'google/gemma-2-9b'] },
2155
+ { id: 'nvidia', label: 'NVIDIA NIM', baseUrl: 'https://integrate.api.nvidia.com/v1', defaultModel: 'meta/llama-3.1-70b-instruct', models: ['meta/llama-3.1-70b-instruct', 'meta/llama-3.1-8b-instruct', 'nvidia/llama-3.1-nemotron-70b-instruct'] },
2156
+ { id: 'openrouter', label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', defaultModel: 'openai/gpt-4o-mini', models: ['openai/gpt-4o-mini', 'openai/gpt-4o', 'anthropic/claude-3.5-sonnet', 'google/gemini-2.0-flash-exp', 'meta-llama/llama-3.3-70b-instruct'] },
2157
+ { id: 'lmstudio', label: 'LM Studio (local)', baseUrl: 'http://localhost:1234/v1', defaultModel: 'local-model', models: ['local-model', 'loaded-model'] },
2158
+ { id: 'alephalpha', label: 'Aleph Alpha', baseUrl: 'https://api.aleph-alpha.com/v1', defaultModel: 'luminous-supreme-control', models: ['luminous-supreme-control', 'luminous-supreme', 'luminous-base'] },
2159
+ { id: 'stability', label: 'Stability AI', baseUrl: 'https://api.stability.ai/v1', defaultModel: 'stable-diffusion-xl', models: ['stable-diffusion-xl', 'stable-diffusion-3', 'stable-image-core'] },
2160
+ { id: 'azure', label: 'Azure OpenAI', baseUrl: 'https://your-resource.openai.azure.com', defaultModel: 'gpt-4o-mini', models: ['gpt-4o-mini', 'gpt-4o', 'gpt-35-turbo', 'dall-e-3'] },
2161
+ ];
2162
+
2163
+ /** Find a preset by id (returns undefined for "other"/custom). */
2164
+ const PRESET_BY_ID: Record<string, ProviderPreset> = Object.fromEntries(POPULAR_PROVIDERS.map((p) => [p.id, p]));
2165
+
2166
+ type ProviderFilter = 'all' | 'enabled' | 'disabled' | 'custom';
2167
+
2168
+ function AiProviders({ toggle }: { toggle: ToggleStyle }) {
2169
+ const [providers, setProviders] = useState<AiProviderView[]>([]);
2170
+ const [busy, setBusy] = useState(false);
2171
+ const [msg, setMsg] = useAdminAlert('ai-providers');
2172
+ const [showAdd, setShowAdd] = useState(false);
2173
+ const [newProvider, setNewProvider] = useState({ id: '', label: '', baseUrl: '', apiKey: '', defaultModel: '' });
2174
+ const [query, setQuery] = useState('');
2175
+ const [filter, setFilter] = useState<ProviderFilter>('enabled');
2176
+ const [testingId, setTestingId] = useState<string | null>(null);
2177
+ const [testResults, setTestResults] = useState<Record<string, AiProviderTestResult>>({});
2178
+ const [keyEditorProvider, setKeyEditorProvider] = useState<AiProviderView | null>(null);
2179
+ const [keyDraft, setKeyDraft] = useState('');
2180
+ const [switchAnim, setSwitchAnim] = useState<Record<string, 'out' | 'in' | null>>({});
2181
+
2182
+ useEffect(() => { loadProviders(); }, []);
2183
+
2184
+ const wait = (ms: number) => new Promise<void>((resolve) => window.setTimeout(resolve, ms));
2185
+
2186
+ const loadProviders = async () => {
2187
+ try { const r = await getAiProviders(); setProviders(r.providers ?? []); } catch { /* non-fatal */ }
2188
+ };
2189
+
2190
+ const persistenceNote = (persistence?: AiProviderPersistence): string => {
2191
+ if (!persistence) return '';
2192
+ const missing = [
2193
+ !persistence.runtime ? 'nexus.runtime.json' : '',
2194
+ !persistence.database ? 'MongoDB' : '',
2195
+ ].filter(Boolean);
2196
+ return missing.length ? ` Persistence failed: ${missing.join(' and ')}.` : '';
2197
+ };
2198
+
2199
+ const openKeyEditor = (provider: AiProviderView) => {
2200
+ setKeyEditorProvider(provider);
2201
+ setKeyDraft('');
2202
+ };
2203
+
2204
+ const closeKeyEditor = () => {
2205
+ if (busy) return;
2206
+ setKeyEditorProvider(null);
2207
+ setKeyDraft('');
2208
+ };
2209
+
2210
+ const saveKeyFromEditor = async () => {
2211
+ if (!keyEditorProvider || !keyDraft.trim()) return;
2212
+ const saved = await saveApiKey(keyEditorProvider, keyDraft);
2213
+ if (saved) {
2214
+ setKeyEditorProvider(null);
2215
+ setKeyDraft('');
2216
+ }
2217
+ };
2218
+
2219
+ const toggleProvider = async (p: AiProviderView) => {
2220
+ setMsg('');
2221
+ setBusy(true);
2222
+ setSwitchAnim((prev) => ({ ...prev, [p.id]: 'out' }));
2223
+ await wait(280);
2224
+ try {
2225
+ const result = await updateAiProvider(p.id, { enabled: !p.enabled });
2226
+ setProviders((current) => current.map((item) => item.id === p.id ? result.provider : item));
2227
+ setSwitchAnim((prev) => ({ ...prev, [p.id]: 'in' }));
2228
+ setMsg(`${p.label} ${result.provider.enabled ? 'enabled' : 'disabled'}.${persistenceNote(result.persistence)}`);
2229
+ }
2230
+ catch (e: any) {
2231
+ setSwitchAnim((prev) => { const next = { ...prev }; delete next[p.id]; return next; });
2232
+ setMsg(`Failed: ${e?.message ?? e}`);
2233
+ }
2234
+ finally {
2235
+ setBusy(false);
2236
+ await wait(420);
2237
+ setSwitchAnim((prev) => { const next = { ...prev }; delete next[p.id]; return next; });
2238
+ }
2239
+ };
2240
+
2241
+ const saveApiKey = async (p: AiProviderView, key: string): Promise<boolean> => {
2242
+ if (!key.trim()) return false;
2243
+ setBusy(true);
2244
+ try {
2245
+ const result = await updateAiProvider(p.id, { apiKey: key.trim() });
2246
+ // Update only the provider that changed. Refreshing the whole collection
2247
+ // here can replace the visible 20-provider list with a stale/empty read
2248
+ // while the backend is persisting the key.
2249
+ setProviders((current) => current.map((item) => item.id === p.id ? result.provider : item));
2250
+ setMsg(`${p.label} API key saved.${persistenceNote(result.persistence)}`);
2251
+ return true;
2252
+ } catch (e: any) {
2253
+ setMsg(`Failed: ${e?.message ?? e}`);
2254
+ return false;
2255
+ } finally { setBusy(false); }
2256
+ };
2257
+
2258
+ const saveDefaultModel = async (p: AiProviderView, m: string) => {
2259
+ setBusy(true);
2260
+ try {
2261
+ const result = await updateAiProvider(p.id, { defaultModel: m.trim() });
2262
+ setProviders((current) => current.map((item) => item.id === p.id ? result.provider : item));
2263
+ setMsg(`${p.label} model saved.${persistenceNote(result.persistence)}`);
2264
+ }
2265
+ catch (e: any) { setMsg(`Failed: ${e?.message ?? e}`); }
2266
+ finally { setBusy(false); }
2267
+ };
2268
+
2269
+ const addProvider = async () => {
2270
+ if (!newProvider.id.trim() || !newProvider.label.trim() || !newProvider.baseUrl.trim()) { setMsg('id, label and baseUrl are required'); return; }
2271
+ setBusy(true);
2272
+ try {
2273
+ const result = await addAiProvider({ id: newProvider.id.trim().toLowerCase(), label: newProvider.label.trim(), baseUrl: newProvider.baseUrl.trim(), apiKey: newProvider.apiKey.trim() || undefined, defaultModel: newProvider.defaultModel.trim() || undefined, enabled: true });
2274
+ setProviders((current) => [...current, result.provider]);
2275
+ setShowAdd(false);
2276
+ setNewProvider({ id: '', label: '', baseUrl: '', apiKey: '', defaultModel: '' });
2277
+ setMsg(`Provider added.${persistenceNote(result.persistence)}`);
2278
+ } catch (e: any) { setMsg(`Failed: ${e?.message ?? e}`); }
2279
+ finally { setBusy(false); }
2280
+ };
2281
+
2282
+ const removeProvider = async (id: string) => {
2283
+ setBusy(true);
2284
+ try {
2285
+ const result = await deleteAiProvider(id);
2286
+ setProviders((current) => current.filter((item) => item.id !== id));
2287
+ setTestResults((prev) => { const next = { ...prev }; delete next[id]; return next; });
2288
+ setMsg(`Provider removed.${persistenceNote(result.persistence)}`);
2289
+ }
2290
+ catch (e: any) { setMsg(`Failed: ${e?.message ?? e}`); }
2291
+ finally { setBusy(false); }
2292
+ };
2293
+
2294
+ const testProvider = async (p: AiProviderView) => {
2295
+ setTestingId(p.id);
2296
+ setMsg('');
2297
+ try {
2298
+ const result = await testAiProvider(p.id);
2299
+ setTestResults((prev) => ({ ...prev, [p.id]: result }));
2300
+ if (result.ok) setMsg(`${p.label}: reachable — ${result.modelCount ?? 0} model(s).`);
2301
+ else setMsg(`${p.label}: ${result.error ?? 'unreachable'}.`);
2302
+ } catch (e: any) {
2303
+ setTestResults((prev) => ({ ...prev, [p.id]: { ok: false, provider: p.id, error: String(e?.message ?? e), checkedAt: new Date().toISOString() } }));
2304
+ setMsg(`${p.label}: ${e?.message ?? e}`);
2305
+ } finally {
2306
+ setTestingId(null);
2307
+ }
2308
+ };
2309
+
2310
+ // Search + filter logic.
2311
+ const matchesQuery = (p: AiProviderView) => {
2312
+ if (!query.trim()) return true;
2313
+ const q = query.toLowerCase();
2314
+ return p.id.toLowerCase().includes(q) || p.label.toLowerCase().includes(q);
2315
+ };
2316
+
2317
+ const enabled = providers.filter((p) => matchesQuery(p) && p.enabled && BUILTIN_PROVIDER_IDS.has(p.id));
2318
+ const disabled = providers.filter((p) => matchesQuery(p) && !p.enabled && BUILTIN_PROVIDER_IDS.has(p.id));
2319
+ const custom = providers.filter((p) => matchesQuery(p) && !BUILTIN_PROVIDER_IDS.has(p.id));
2320
+
2321
+ const groups: Array<{ id: string; label: string; icon: string; items: AiProviderView[] }> = [];
2322
+ if (filter === 'all' || filter === 'enabled') {
2323
+ if (enabled.length) groups.push({ id: 'enabled', label: 'Enabled', icon: '●', items: enabled });
2324
+ }
2325
+ if (filter === 'all' || filter === 'disabled') {
2326
+ if (disabled.length) groups.push({ id: 'disabled', label: 'Disabled', icon: 'â—‹', items: disabled });
2327
+ }
2328
+ if (filter === 'all' || filter === 'custom') {
2329
+ if (custom.length) groups.push({ id: 'custom', label: 'Custom', icon: '✚', items: custom });
2330
+ }
2331
+
2332
+ const filterPills: Array<[ProviderFilter, string, number]> = [
2333
+ ['all', 'All', providers.length],
2334
+ ['enabled', 'Enabled', enabled.length],
2335
+ ['disabled', 'Disabled', disabled.length],
2336
+ ['custom', 'Custom', custom.length],
2337
+ ];
2338
+
2339
+ return (
2340
+ <div className="space-y-4">
2341
+
2342
+ {/* Header */}
2343
+ <section className="glass-card space-y-3">
2344
+ <div className="flex items-center justify-between">
2345
+ <h3 className="glass-h3">AI providers ({providers.length})</h3>
2346
+ <div className="flex gap-2">
2347
+ <button onClick={loadProviders} disabled={busy} className="glass-chip-btn">refresh</button>
2348
+ <button onClick={() => setShowAdd(true)} disabled={busy} className="glass-chip-btn">+ add provider</button>
2349
+ </div>
2350
+ </div>
2351
+
2352
+ {/* Filter bar */}
2353
+ <div className="provider-filter-bar">
2354
+ <input
2355
+ className="glass-input !py-1.5 text-sm w-full sm:w-64"
2356
+ placeholder="⌕ search providers…"
2357
+ type="search"
2358
+ autoComplete="off"
2359
+ autoCapitalize="none"
2360
+ autoCorrect="off"
2361
+ spellCheck={false}
2362
+ value={query}
2363
+ onChange={(e) => setQuery(e.target.value)}
2364
+ />
2365
+ <div className="flex flex-wrap gap-1.5">
2366
+ {filterPills.map(([id, label, count]) => (
2367
+ <button
2368
+ key={id}
2369
+ onClick={() => setFilter(id)}
2370
+ className={filter === id ? 'glass-btn-primary !py-1 !px-2.5 text-xs' : 'glass-chip-btn'}
2371
+ >
2372
+ {label} <span className="ml-1 opacity-60">{count}</span>
2373
+ </button>
2374
+ ))}
2375
+ </div>
2376
+ </div>
2377
+
2378
+ {/* Groups */}
2379
+ {groups.length === 0 && (
2380
+ <p className="text-xs text-slate-500 py-4 text-center">
2381
+ {providers.length === 0 ? 'No providers configured.' : 'No providers match your search/filter.'}
2382
+ </p>
2383
+ )}
2384
+ {groups.map((g) => (
2385
+ <ProviderGroup
2386
+ key={g.id}
2387
+ label={g.label}
2388
+ icon={g.icon}
2389
+ items={g.items}
2390
+ toggle={toggle}
2391
+ switchAnim={switchAnim}
2392
+ busy={busy}
2393
+ testingId={testingId}
2394
+ testResults={testResults}
2395
+ onToggle={toggleProvider}
2396
+ onSaveModel={saveDefaultModel}
2397
+ onRemove={removeProvider}
2398
+ onTest={testProvider}
2399
+ onOpenKey={openKeyEditor}
2400
+ />
2401
+ ))}
2402
+ </section>
2403
+
2404
+ {/* Add provider modal */}
2405
+ {showAdd && (
2406
+ <AddProviderDialog
2407
+ value={newProvider}
2408
+ onChange={setNewProvider}
2409
+ onSubmit={addProvider}
2410
+ onCancel={() => setShowAdd(false)}
2411
+ busy={busy}
2412
+ />
2413
+ )}
2414
+ {keyEditorProvider && (
2415
+ <ProviderKeyDialog
2416
+ provider={keyEditorProvider}
2417
+ value={keyDraft}
2418
+ onChange={setKeyDraft}
2419
+ onSave={saveKeyFromEditor}
2420
+ onCancel={closeKeyEditor}
2421
+ busy={busy}
2422
+ />
2423
+ )}
2424
+ </div>
2425
+ );
2426
+ }
2427
+
2428
+ function ProviderGroup({ label, icon, items, toggle, switchAnim, busy, testingId, testResults, onToggle, onSaveModel, onRemove, onTest, onOpenKey }: {
2429
+ label: string;
2430
+ icon: string;
2431
+ items: AiProviderView[];
2432
+ toggle: ToggleStyle;
2433
+ switchAnim: Record<string, 'out' | 'in' | null>;
2434
+ busy: boolean;
2435
+ testingId: string | null;
2436
+ testResults: Record<string, AiProviderTestResult>;
2437
+ onToggle: (p: AiProviderView) => void;
2438
+ onSaveModel: (p: AiProviderView, model: string) => void;
2439
+ onRemove: (id: string) => void;
2440
+ onTest: (p: AiProviderView) => void;
2441
+ onOpenKey: (p: AiProviderView) => void;
2442
+ }) {
2443
+ return (
2444
+ <div className="space-y-2">
2445
+ <div className="flex items-center gap-2">
2446
+ <span className="text-sm text-slate-300">{icon}</span>
2447
+ <h4 className="glass-h3">{label}</h4>
2448
+ <span className="glass-chip text-xs">{items.length}</span>
2449
+ </div>
2450
+ <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
2451
+ {items.map((p) => (
2452
+ <ProviderCard
2453
+ key={p.id}
2454
+ provider={p}
2455
+ toggle={toggle}
2456
+ anim={switchAnim[p.id] ?? null}
2457
+ busy={busy}
2458
+ isCustom={!BUILTIN_PROVIDER_IDS.has(p.id)}
2459
+ testing={testingId === p.id}
2460
+ testResult={testResults[p.id]}
2461
+ onToggle={() => onToggle(p)}
2462
+ onSaveModel={(m) => onSaveModel(p, m)}
2463
+ onRemove={() => onRemove(p.id)}
2464
+ onTest={() => onTest(p)}
2465
+ onOpenKey={() => onOpenKey(p)}
2466
+ />
2467
+ ))}
2468
+ </div>
2469
+ </div>
2470
+ );
2471
+ }
2472
+
2473
+ function ProviderCard({ provider, toggle, anim, busy, isCustom, testing, testResult, onToggle, onSaveModel, onRemove, onTest, onOpenKey }: {
2474
+ provider: AiProviderView;
2475
+ toggle: ToggleStyle;
2476
+ anim: 'out' | 'in' | null;
2477
+ busy: boolean;
2478
+ isCustom: boolean;
2479
+ testing: boolean;
2480
+ testResult?: AiProviderTestResult;
2481
+ onToggle: () => void;
2482
+ onSaveModel: (model: string) => void;
2483
+ onRemove: () => void;
2484
+ onTest: () => void;
2485
+ onOpenKey: () => void;
2486
+ }) {
2487
+ const [editingModel, setEditingModel] = useState(false);
2488
+ const [modelInput, setModelInput] = useState(provider.defaultModel ?? '');
2489
+
2490
+ const meta = providerMeta(provider.id);
2491
+ const local = isLocalProvider(provider.baseUrl);
2492
+ const cardClass = provider.enabled
2493
+ ? provider.hasApiKey ? 'is-on' : 'is-warn'
2494
+ : 'is-off';
2495
+
2496
+ return (
2497
+ <div className={`provider-card ${cardClass}${anim === 'out' ? ' is-switch-out' : ''}${anim === 'in' ? ' is-switch-in' : ''}`} style={{ ['--avatar-hue' as string]: String(meta.hue) }}>
2498
+ {/* Header: avatar + label/id + toggle */}
2499
+ <div className="flex items-center gap-2.5">
2500
+ <span className="provider-avatar" style={{ ['--avatar-hue' as string]: String(meta.hue) }}>{meta.glyph}</span>
2501
+ <div className="flex-1 min-w-0">
2502
+ <div className="font-medium text-sm text-slate-200 truncate">{provider.label}</div>
2503
+ <div className="text-[11px] text-slate-500 font-mono truncate">{provider.id}</div>
2504
+ </div>
2505
+ <button
2506
+ type="button"
2507
+ onClick={onToggle}
2508
+ disabled={busy}
2509
+ className={`ai-toggle tg-${toggle} ${provider.enabled ? 'is-on' : ''}`}
2510
+ title={provider.enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}
2511
+ >
2512
+ <span className="ai-toggle-knob" />
2513
+ </button>
2514
+ </div>
2515
+
2516
+ {/* Status badges */}
2517
+ <div className="flex flex-wrap items-center gap-1.5">
2518
+ {provider.hasApiKey
2519
+ ? <span className="provider-badge-ok">✓ key</span>
2520
+ : <span className="provider-badge-warn">no key</span>}
2521
+ {provider.enabled
2522
+ ? <span className="provider-badge-ok">enabled</span>
2523
+ : <span className="provider-badge-mute">disabled</span>}
2524
+ {isCustom && <span className="provider-badge-accent">custom</span>}
2525
+ {local && <span className="provider-badge-local">local</span>}
2526
+ </div>
2527
+
2528
+ {/* Model row */}
2529
+ <div className="space-y-1.5">
2530
+ <div className="flex items-center gap-2">
2531
+ <span className="text-[11px] text-slate-500 shrink-0">Model</span>
2532
+ {editingModel ? (
2533
+ <>
2534
+ <input
2535
+ className="glass-input !py-1 text-xs font-mono flex-1"
2536
+ placeholder="default model"
2537
+ value={modelInput}
2538
+ onChange={(e) => setModelInput(e.target.value)}
2539
+ autoFocus
2540
+ />
2541
+ <button
2542
+ type="button"
2543
+ onClick={() => { onSaveModel(modelInput); setEditingModel(false); }}
2544
+ disabled={busy || !modelInput.trim()}
2545
+ className="glass-chip-btn shrink-0 text-[10px]"
2546
+ >save</button>
2547
+ <button
2548
+ type="button"
2549
+ onClick={() => { setEditingModel(false); setModelInput(provider.defaultModel ?? ''); }}
2550
+ className="glass-chip-btn shrink-0 text-[10px]"
2551
+ >cancel</button>
2552
+ </>
2553
+ ) : (
2554
+ <>
2555
+ <code className="text-xs text-slate-300 font-mono truncate flex-1">{provider.defaultModel || '—'}</code>
2556
+ <button type="button" onClick={() => setEditingModel(true)} disabled={busy} className="glass-chip-btn shrink-0 text-[10px]" title="Edit model">✎</button>
2557
+ </>
2558
+ )}
2559
+ </div>
2560
+ {editingModel && PRESET_BY_ID[provider.id]?.models?.length ? (
2561
+ <select
2562
+ className="glass-input !py-1 text-xs font-mono"
2563
+ value=""
2564
+ onChange={(e) => { if (e.target.value) setModelInput(e.target.value); }}
2565
+ >
2566
+ <option value="" className="bg-slate-900">…or pick a popular model</option>
2567
+ {PRESET_BY_ID[provider.id]!.models.map((m) => (
2568
+ <option key={m} value={m} className="bg-slate-900">{m}</option>
2569
+ ))}
2570
+ </select>
2571
+ ) : null}
2572
+ </div>
2573
+
2574
+ {/* Footer: baseUrl + website + set key */}
2575
+ <div className="flex flex-wrap items-center gap-1.5">
2576
+ <code className="text-[10px] text-slate-500 truncate flex-1 min-w-[100px]">{provider.baseUrl}</code>
2577
+ <SafeGotoLink href={websiteFromBaseUrl(provider.baseUrl)} className="glass-chip-btn shrink-0 text-[10px]">↗</SafeGotoLink>
2578
+ <button
2579
+ type="button"
2580
+ onClick={onOpenKey}
2581
+ disabled={busy}
2582
+ className="glass-chip-btn shrink-0 text-[10px]"
2583
+ >key</button>
2584
+ {isCustom && (
2585
+ <button type="button" onClick={onRemove} disabled={busy} className="glass-chip-btn-danger shrink-0 text-[10px]" title="Remove provider">✕</button>
2586
+ )}
2587
+ </div>
2588
+
2589
+ {/* Test row */}
2590
+ <div className="flex items-center gap-2 pt-0.5 border-t border-white/[0.06]">
2591
+ <button type="button" onClick={onTest} disabled={busy || testing} className="glass-chip-btn shrink-0 text-[10px]">
2592
+ {testing ? 'testing…' : '⚡ test'}
2593
+ </button>
2594
+ {testResult && (
2595
+ testResult.ok ? (
2596
+ <span className="text-[10px] text-emerald-300 truncate">
2597
+ ✓ {testResult.modelCount ?? 0} model{testResult.modelCount === 1 ? '' : 's'}
2598
+ {testResult.models && testResult.models.length > 0 && (
2599
+ <span className="text-slate-500"> — {testResult.models.slice(0, 3).join(', ')}{testResult.models.length > 3 ? '…' : ''}</span>
2600
+ )}
2601
+ </span>
2602
+ ) : (
2603
+ <span className="text-[10px] text-rose-400 truncate" title={testResult.error}>✗ {testResult.error ?? 'unreachable'}</span>
2604
+ )
2605
+ )}
2606
+ </div>
2607
+ </div>
2608
+ );
2609
+ }
2610
+
2611
+ function ProviderKeyDialog({ provider, value, onChange, onSave, onCancel, busy }: {
2612
+ provider: AiProviderView;
2613
+ value: string;
2614
+ onChange: (value: string) => void;
2615
+ onSave: () => void;
2616
+ onCancel: () => void;
2617
+ busy: boolean;
2618
+ }) {
2619
+ useEffect(() => {
2620
+ const onKey = (event: KeyboardEvent) => {
2621
+ if (event.key === 'Escape') onCancel();
2622
+ };
2623
+ window.addEventListener('keydown', onKey);
2624
+ return () => window.removeEventListener('keydown', onKey);
2625
+ }, [onCancel]);
2626
+
2627
+ return (
2628
+ <div className="provider-modal-backdrop" onClick={onCancel}>
2629
+ <div className="provider-modal space-y-4" role="dialog" aria-modal="true" aria-labelledby="provider-key-title" onClick={(event) => event.stopPropagation()}>
2630
+ <div className="flex items-start justify-between gap-3">
2631
+ <div>
2632
+ <span className="admin-overline">API CREDENTIAL</span>
2633
+ <h3 id="provider-key-title" className="mt-1 text-base font-semibold text-slate-100">{provider.label}</h3>
2634
+ <code className="text-xs text-slate-500">{provider.id}</code>
2635
+ </div>
2636
+ <button type="button" onClick={onCancel} disabled={busy} className="glass-chip-btn text-xs" aria-label="Close API key dialog">✕</button>
2637
+ </div>
2638
+
2639
+ <div className="rounded-xl border border-sky-400/20 bg-black/20 px-3 py-2 text-xs text-slate-400">
2640
+ {provider.hasApiKey ? 'A key is already configured. Enter a new key to replace it.' : 'This key is stored server-side in .env and is never written to the config file.'}
2641
+ </div>
2642
+
2643
+ <label className="block">
2644
+ <span className="admin-overline">API key</span>
2645
+ <input
2646
+ className="glass-input mt-1 text-sm"
2647
+ type="password"
2648
+ id="ai-provider-api-key"
2649
+ name="ai-provider-api-key"
2650
+ autoComplete="new-password"
2651
+ autoFocus
2652
+ placeholder={provider.hasApiKey ? '•••••••• (enter new to replace)' : 'paste API key'}
2653
+ value={value}
2654
+ onChange={(event) => onChange(event.target.value)}
2655
+ onKeyDown={(event) => {
2656
+ if (event.key === 'Enter' && value.trim() && !busy) {
2657
+ event.preventDefault();
2658
+ onSave();
2659
+ }
2660
+ }}
2661
+ />
2662
+ </label>
2663
+
2664
+ <div className="flex justify-end gap-2 border-t border-sky-400/20 pt-3">
2665
+ <button type="button" onClick={onCancel} disabled={busy} className="glass-chip-btn">Cancel</button>
2666
+ <button type="button" onClick={onSave} disabled={busy || !value.trim()} className="glass-btn-primary !py-1.5 text-sm">
2667
+ {busy ? 'Saving…' : 'Save API key'}
2668
+ </button>
2669
+ </div>
2670
+ </div>
2671
+ </div>
2672
+ );
2673
+ }
2674
+
2675
+ function AddProviderDialog({ value, onChange, onSubmit, onCancel, busy }: {
2676
+ value: { id: string; label: string; baseUrl: string; apiKey: string; defaultModel: string };
2677
+ onChange: (v: { id: string; label: string; baseUrl: string; apiKey: string; defaultModel: string }) => void;
2678
+ onSubmit: () => void;
2679
+ onCancel: () => void;
2680
+ busy: boolean;
2681
+ }) {
2682
+ const [presetId, setPresetId] = useState('');
2683
+
2684
+ useEffect(() => {
2685
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel(); };
2686
+ window.addEventListener('keydown', onKey);
2687
+ return () => window.removeEventListener('keydown', onKey);
2688
+ }, [onCancel]);
2689
+
2690
+ // Track which field the user is actively editing so we don't clobber their
2691
+ // manual input when the preset changes the model list.
2692
+ const preset = presetId ? PRESET_BY_ID[presetId] : undefined;
2693
+
2694
+ const applyPreset = (id: string) => {
2695
+ setPresetId(id);
2696
+ if (!id) return; // "— select —"
2697
+ if (id === 'other') {
2698
+ // Clear fields for a fully custom provider; user fills everything.
2699
+ onChange({ id: '', label: '', baseUrl: '', apiKey: value.apiKey, defaultModel: '' });
2700
+ return;
2701
+ }
2702
+ const p = PRESET_BY_ID[id];
2703
+ if (p) {
2704
+ onChange({ ...value, id: p.id, label: p.label, baseUrl: p.baseUrl, defaultModel: p.defaultModel });
2705
+ }
2706
+ };
2707
+
2708
+ const missing = {
2709
+ id: !value.id.trim(),
2710
+ label: !value.label.trim(),
2711
+ baseUrl: !value.baseUrl.trim(),
2712
+ };
2713
+
2714
+ return (
2715
+ <div className="provider-modal-backdrop" onClick={onCancel}>
2716
+ <div className="provider-modal space-y-4" onClick={(e) => e.stopPropagation()}>
2717
+ <div className="flex items-center justify-between">
2718
+ <h3 className="glass-h3">Add custom provider</h3>
2719
+ <button onClick={onCancel} className="glass-chip-btn text-xs">✕</button>
2720
+ </div>
2721
+
2722
+ <div className="space-y-3">
2723
+ {/* Preset selector — pick a popular provider or "Other" */}
2724
+ <label className="block">
2725
+ <span className="admin-overline">Choose a provider</span>
2726
+ <select
2727
+ className="glass-input mt-1 text-sm"
2728
+ value={presetId}
2729
+ onChange={(e) => applyPreset(e.target.value)}
2730
+ >
2731
+ <option value="" className="bg-slate-900">— select —</option>
2732
+ {POPULAR_PROVIDERS.map((p) => (
2733
+ <option key={p.id} value={p.id} className="bg-slate-900">{p.label}</option>
2734
+ ))}
2735
+ <option value="other" className="bg-slate-900">Other (custom)</option>
2736
+ </select>
2737
+ <span className="text-[10px] text-slate-500">Selecting a provider autofills the fields below — all editable.</span>
2738
+ </label>
2739
+
2740
+ <label className="block">
2741
+ <span className="admin-overline">Provider ID <span className="text-rose-400">*</span></span>
2742
+ <input
2743
+ className={`glass-input mt-1 text-sm font-mono ${missing.id ? '!border-amber-500/50' : ''}`}
2744
+ placeholder="e.g. myai"
2745
+ value={value.id}
2746
+ onChange={(e) => onChange({ ...value, id: e.target.value })}
2747
+ />
2748
+ {missing.id && <span className="text-[10px] text-amber-400">required — lowercase, no spaces</span>}
2749
+ </label>
2750
+
2751
+ <label className="block">
2752
+ <span className="admin-overline">Label <span className="text-rose-400">*</span></span>
2753
+ <input
2754
+ className={`glass-input mt-1 text-sm ${missing.label ? '!border-amber-500/50' : ''}`}
2755
+ placeholder="e.g. My AI Service"
2756
+ value={value.label}
2757
+ onChange={(e) => onChange({ ...value, label: e.target.value })}
2758
+ />
2759
+ {missing.label && <span className="text-[10px] text-amber-400">required</span>}
2760
+ </label>
2761
+
2762
+ <label className="block">
2763
+ <span className="admin-overline">Base URL <span className="text-rose-400">*</span></span>
2764
+ <input
2765
+ className={`glass-input mt-1 text-sm font-mono ${missing.baseUrl ? '!border-amber-500/50' : ''}`}
2766
+ placeholder="https://api.example.com/v1"
2767
+ value={value.baseUrl}
2768
+ onChange={(e) => onChange({ ...value, baseUrl: e.target.value })}
2769
+ />
2770
+ {missing.baseUrl && <span className="text-[10px] text-amber-400">required</span>}
2771
+ </label>
2772
+
2773
+ <label className="block">
2774
+ <span className="admin-overline">API key <span className="text-slate-500">(optional)</span></span>
2775
+ <input
2776
+ className="glass-input mt-1 text-sm"
2777
+ type="password"
2778
+ placeholder="paste API key"
2779
+ value={value.apiKey}
2780
+ onChange={(e) => onChange({ ...value, apiKey: e.target.value })}
2781
+ />
2782
+ </label>
2783
+
2784
+ {/* Default model — text input + a "pick from popular" select */}
2785
+ <label className="block">
2786
+ <span className="admin-overline">Default model <span className="text-slate-500">(optional)</span></span>
2787
+ <input
2788
+ className="glass-input mt-1 text-sm font-mono"
2789
+ placeholder="e.g. gpt-4o-mini"
2790
+ value={value.defaultModel}
2791
+ onChange={(e) => onChange({ ...value, defaultModel: e.target.value })}
2792
+ />
2793
+ </label>
2794
+ {preset && preset.models.length > 0 && (
2795
+ <label className="block">
2796
+ <span className="admin-overline">…or pick a model</span>
2797
+ <select
2798
+ className="glass-input mt-1 text-sm font-mono"
2799
+ value=""
2800
+ onChange={(e) => { if (e.target.value) onChange({ ...value, defaultModel: e.target.value }); }}
2801
+ >
2802
+ <option value="" className="bg-slate-900">{preset.models.length} popular model{preset.models.length === 1 ? '' : 's'} — click to use</option>
2803
+ {preset.models.map((m) => (
2804
+ <option key={m} value={m} className="bg-slate-900">{m}</option>
2805
+ ))}
2806
+ </select>
2807
+ </label>
2808
+ )}
2809
+ </div>
2810
+
2811
+ <div className="flex justify-end gap-2 pt-2 border-t border-sky-400/20">
2812
+ <button onClick={onCancel} disabled={busy} className="glass-chip-btn">Cancel</button>
2813
+ <button onClick={onSubmit} disabled={busy || missing.id || missing.label || missing.baseUrl} className="glass-btn-primary !py-1.5 text-sm">
2814
+ {busy ? 'Adding…' : '+ Add provider'}
2815
+ </button>
2816
+ </div>
2817
+ </div>
2818
+ </div>
2819
+ );
2820
+ }
2821
+
2822
+ function AiChatPlayground() {
2823
+ const [config, setConfig] = useState<AdminConfig | null>(null);
2824
+ const [providers, setProviders] = useState<AiProviderView[]>([]);
2825
+ const [aiStatus, setAiStatus] = useState<AiStatus | null>(null);
2826
+ const [models, setModels] = useState<AiModelInfo[]>([]);
2827
+ const [model, setModel] = useState('');
2828
+ const [provider, setProvider] = useState('');
2829
+ const [systemPrompt, setSystemPrompt] = useState('You are a helpful assistant.');
2830
+ const [input, setInput] = useState('');
2831
+ const [messages, setMessages] = useState<AiChatMessage[]>([]);
2832
+ const [streaming, setStreaming] = useState(false);
2833
+ const [streamText, setStreamText] = useState('');
2834
+ const [msg, setMsg] = useAdminAlert('ai-playground');
2835
+ const [modelsLoaded, setModelsLoaded] = useState(false);
2836
+ const [loadedFor, setLoadedFor] = useState('');
2837
+ const loadModelsRef = useRef<(p: string) => Promise<void>>(async () => {});
2838
+ const checkAiRef = useRef<() => Promise<void>>(async () => {});
2839
+
2840
+ useEffect(() => {
2841
+ getAdminConfig().then((c) => { setConfig(c); const a = c?.config?.ai as { schemaModel?: string; defaultProvider?: string } | undefined; setModel((x) => x || (a?.schemaModel ?? 'llama3:latest')); }).catch(() => {});
2842
+ getAiProviders().then((r) => {
2843
+ setProviders(r.providers ?? []);
2844
+ // Auto-select the first enabled provider, then load its models.
2845
+ const firstEnabled = (r.providers ?? []).find((p) => p.enabled);
2846
+ if (firstEnabled) {
2847
+ setProvider(firstEnabled.id);
2848
+ setModel(firstEnabled.defaultModel ?? 'llama3:latest');
2849
+ // Load its models once the provider state lands.
2850
+ setTimeout(() => { void loadModelsRef.current(firstEnabled.id); }, 0);
2851
+ } else {
2852
+ // No enabled provider — fall back to the AI server's own detection.
2853
+ void checkAiRef.current();
2854
+ }
2855
+ }).catch(() => { void checkAiRef.current(); });
2856
+ }, []);
2857
+
2858
+ const checkAi = async () => {
2859
+ try {
2860
+ const s = await getAiStatus();
2861
+ setAiStatus(s);
2862
+ // Auto-select ollama if it's the only provider available on the AI server
2863
+ if (s.ollama?.ok && s.ollama.detail?.models?.length && !provider) {
2864
+ setProvider('ollama');
2865
+ setModel(s.ollama.detail.models[0] ?? 'llama3:latest');
2866
+ }
2867
+ // Load models for the current (or resolved) provider.
2868
+ const prov = provider || (s.autoResolvesTo ?? 'ollama');
2869
+ await loadModels(prov);
2870
+ } catch { /* non-fatal */ }
2871
+ };
2872
+
2873
+ const loadModels = async (p: string) => {
2874
+ setProvider(p);
2875
+ // Find the provider's default model
2876
+ const pv = providers.find((x) => x.id === p);
2877
+ if (pv?.defaultModel || modelsLoaded) setModel((cur) => pv?.defaultModel ?? cur);
2878
+ try {
2879
+ const m = await getAiModels(p);
2880
+ setModels(m?.data ?? []);
2881
+ setModelsLoaded(true);
2882
+ setLoadedFor(p);
2883
+ const err = m?.error;
2884
+ if (err) setMsg(`Models unavailable for "${p}": ${err}`);
2885
+ else setMsg('');
2886
+ } catch (e: any) {
2887
+ setModels([]);
2888
+ setModelsLoaded(false);
2889
+ setLoadedFor('');
2890
+ setMsg(`Could not load models for "${p}": ${e?.message ?? e}`);
2891
+ }
2892
+ };
2893
+ loadModelsRef.current = loadModels;
2894
+ checkAiRef.current = () => checkAi();
2895
+
2896
+ const send = async () => {
2897
+ if (!input.trim() || streaming) return;
2898
+ // Resolve the actual provider to send to the AI server
2899
+ const effectiveProvider = provider || aiStatus?.autoResolvesTo || 'ollama';
2900
+ const effectiveModel = model || models[0]?.id || 'llama3:latest';
2901
+
2902
+ const userMsg: AiChatMessage = { role: 'user', content: input.trim() };
2903
+ const chatMsgs: AiChatMessage[] = [];
2904
+ if (systemPrompt.trim()) chatMsgs.push({ role: 'system', content: systemPrompt.trim() });
2905
+ chatMsgs.push(...messages, userMsg);
2906
+ setMessages((m) => [...m, userMsg]);
2907
+ setInput('');
2908
+ setStreaming(true);
2909
+ setStreamText('');
2910
+ setMsg('');
2911
+ let acc = '';
2912
+ try {
2913
+ await aiChatStream(effectiveModel, chatMsgs, effectiveProvider, (chunk) => {
2914
+ acc += chunk;
2915
+ setStreamText(acc);
2916
+ });
2917
+ if (!acc) { setMsg('AI returned an empty response. Check that the model is available and the AI server is running.'); }
2918
+ else { setMessages((m) => [...m, { role: 'assistant', content: acc }]); }
2919
+ setStreamText('');
2920
+ } catch (e: any) {
2921
+ const errMsg = String(e?.message ?? e);
2922
+ setMsg(`Chat failed: ${errMsg}`);
2923
+ if (acc) setMessages((m) => [...m, { role: 'assistant', content: acc }]);
2924
+ setStreamText('');
2925
+ } finally {
2926
+ setStreaming(false);
2927
+ }
2928
+ };
2929
+
2930
+ const clearChat = () => { setMessages([]); setStreamText(''); setMsg(''); };
2931
+ const sendOnEnter = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } };
2932
+
2933
+ const modelOptions = models.map((m) => m.id);
2934
+ const enabledProviders = providers.filter((p) => p.enabled);
2935
+ const ai = config?.config?.ai as { serverUrl: string; timeoutMs: number; defaultProvider: string; schemaModel: string } | undefined;
2936
+
2937
+ // Build provider options: enabled config providers + any from AI server status
2938
+ const providerOptions: { id: string; label: string }[] = [];
2939
+ if (enabledProviders.length) {
2940
+ enabledProviders.forEach((p) => providerOptions.push({ id: p.id, label: p.label }));
2941
+ } else {
2942
+ // Fall back to AI server's known providers
2943
+ if (aiStatus?.aiServer?.ok) providerOptions.push({ id: 'ollama', label: 'Ollama' });
2944
+ if (aiStatus?.openai?.ok) providerOptions.push({ id: 'openai', label: 'OpenAI' });
2945
+ }
2946
+
2947
+ return (
2948
+ <div className="space-y-4">
2949
+
2950
+ <section className="glass-card space-y-4">
2951
+ <div className="flex items-center justify-between">
2952
+ <h3 className="glass-h3">Chat playground</h3>
2953
+ <div className="flex gap-2">
2954
+ <button onClick={checkAi} disabled={streaming} className="glass-chip-btn">↻ refresh models</button>
2955
+ <button onClick={clearChat} disabled={streaming} className="glass-chip-btn">clear</button>
2956
+ </div>
2957
+ </div>
2958
+
2959
+ {/* Controls */}
2960
+ <div className="flex flex-wrap items-center gap-3">
2961
+ <label className="text-xs text-slate-400">Provider
2962
+ <select className="glass-input w-44 !py-1.5 text-sm" value={provider} onChange={(e) => loadModels(e.target.value)}>
2963
+ {!provider && <option value="" className="bg-slate-900">— select —</option>}
2964
+ {providerOptions.map((p) => <option key={p.id} value={p.id} className="bg-slate-900">{p.label}</option>)}
2965
+ </select>
2966
+ </label>
2967
+ <label className="text-xs text-slate-400">Model
2968
+ <input className="glass-input w-52 !py-1.5 font-mono text-sm" value={model} onChange={(e) => setModel(e.target.value)} placeholder="e.g. llama3:latest" />
2969
+ </label>
2970
+ <select
2971
+ className="glass-input w-48 !py-1.5 text-sm"
2972
+ value=""
2973
+ onChange={(e) => { const v = e.target.value; if (v) setModel(v); }}
2974
+ disabled={!modelOptions.length}
2975
+ title={modelOptions.length ? `Available models for "${loadedFor || provider}"` : 'No models — click refresh models'}
2976
+ >
2977
+ <option value="" className="bg-slate-900">{modelOptions.length ? `${modelOptions.length} available for "${loadedFor || provider}"` : 'no models loaded'}</option>
2978
+ {modelOptions.map((m) => <option key={m} value={m} className="bg-slate-900">{m}</option>)}
2979
+ </select>
2980
+ </div>
2981
+
2982
+ {!provider && (
2983
+ <p className="text-xs text-amber-400">âš  Select a provider above. If no providers are listed, go to the AI Providers tab and enable one, or click "refresh models" to detect the AI server's available providers.</p>
2984
+ )}
2985
+
2986
+ {/* System prompt */}
2987
+ <div>
2988
+ <label className="text-xs text-slate-400">System prompt</label>
2989
+ <textarea className="glass-code mt-1 h-16 w-full p-2 text-sm" value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} placeholder="Set the assistant's behaviour…" />
2990
+ </div>
2991
+
2992
+ {/* Chat messages */}
2993
+ <div className="glass-code max-h-96 min-h-[200px] space-y-3 overflow-y-auto p-3 text-sm">
2994
+ {messages.length === 0 && !streamText && <p className="text-slate-500">Start a conversation by typing below.</p>}
2995
+ {messages.map((m, i) => (
2996
+ <div key={i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
2997
+ <div className={`max-w-[80%] rounded-xl px-3 py-2 ${m.role === 'user' ? 'bg-indigo-500/20 text-slate-100' : m.role === 'assistant' ? 'bg-emerald-500/15 text-slate-100' : 'bg-slate-700/30 text-slate-400'}`}>
2998
+ <span className="mb-1 block text-[10px] uppercase tracking-wider opacity-60">{m.role}</span>
2999
+ <span className="whitespace-pre-wrap">{m.content}</span>
3000
+ </div>
3001
+ </div>
3002
+ ))}
3003
+ {streaming && (
3004
+ <div className="flex justify-start">
3005
+ <div className="max-w-[80%] rounded-xl bg-emerald-500/15 px-3 py-2 text-slate-100">
3006
+ <span className="mb-1 block text-[10px] uppercase tracking-wider opacity-60">assistant <span className="animate-pulse">…</span></span>
3007
+ <span className="whitespace-pre-wrap">{streamText}<span className="animate-pulse">â–‹</span></span>
3008
+ </div>
3009
+ </div>
3010
+ )}
3011
+ </div>
3012
+
3013
+ {/* Input */}
3014
+ <div className="flex gap-2">
3015
+ <textarea className="glass-code flex-1 h-12 p-2 text-sm" value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={sendOnEnter} placeholder="Type a message… (Enter to send, Shift+Enter for newline)" disabled={streaming} />
3016
+ <button onClick={send} disabled={streaming || !input.trim()} className="glass-btn-primary shrink-0">{streaming ? '…' : 'Send'}</button>
3017
+ </div>
3018
+ </section>
3019
+ </div>
3020
+ );
3021
+ }
3022
+
3023
+ /** Derive the marketing website from an API base URL (strip /v1, /api, etc). */
3024
+ function websiteFromBaseUrl(baseUrl: string): string {
3025
+ try {
3026
+ const u = new URL(baseUrl);
3027
+ // For local providers (localhost / 127.0.0.1 / 0.0.0.0) just return the origin.
3028
+ const isLocal = u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '0.0.0.0';
3029
+ if (isLocal) return u.origin;
3030
+ // For cloud providers, strip common API path segments and go to the root.
3031
+ return `${u.protocol}//${u.hostname}`;
3032
+ } catch {
3033
+ return baseUrl;
3034
+ }
3035
+ }
3036
+
3037
+ function Config({ onNavigate }: { onNavigate?: (tab: Tab) => void }) {
3038
+ const [cfg, setCfg] = useState<AdminConfig | null>(null);
3039
+ const [entries, setEntries] = useState<ConfigEntry[]>([]);
3040
+ const [fileText, setFileText] = useState('');
3041
+ const [filePath, setFilePath] = useState('');
3042
+ const [mode, setMode] = useState<'runtime' | 'source' | 'effective'>('runtime');
3043
+ const [msg, setMsg] = useAdminAlert('config');
3044
+ const [busy, setBusy] = useState(false);
3045
+ const [restartDialog, setRestartDialog] = useState(false);
3046
+ const [newKey, setNewKey] = useState('');
3047
+ const [newType, setNewType] = useState<ConfigValueType>('string');
3048
+ const [newValue, setNewValue] = useState('');
3049
+
3050
+ const addKey = () => {
3051
+ if (!newKey.trim()) return;
3052
+ setEntries([...entries, { key: newKey.trim(), value: newType === 'null' ? '' : newValue, type: newType }]);
3053
+ setNewKey(''); setNewType('string'); setNewValue('');
3054
+ };
3055
+
3056
+ const load = async () => {
3057
+ try {
3058
+ const c = await getAdminConfig();
3059
+ setCfg(c);
3060
+ const overrides = flattenConfig(c.runtime);
3061
+ // Prefer the runtime overrides if there are any; otherwise seed the
3062
+ // key/value grid from the current effective config so the user starts
3063
+ // with the real values (redacted secret placeholders are skipped so they
3064
+ // are never written back as literal overrides).
3065
+ const base = overrides.length
3066
+ ? overrides
3067
+ : flattenConfig(c.config).filter((entry) => entry.value !== '***');
3068
+ setEntries(base);
3069
+ setFilePath(c.file?.path ?? '');
3070
+ setFileText(c.file?.content ?? '');
3071
+ setMsg(c.file ? '' : 'No human-edited config file found.');
3072
+ } catch (e: any) { setMsg(String(e?.message ?? e)); }
3073
+ };
3074
+ useEffect(() => { load(); }, []);
3075
+
3076
+ const saveRuntime = async () => {
3077
+ setBusy(true); setMsg('');
3078
+ try {
3079
+ const overrides = buildConfig(entries);
3080
+ await putAdminConfig(overrides);
3081
+ setMsg('Saved to nexus.runtime.json. Restart services to apply changes.');
3082
+ setRestartDialog(true);
3083
+ } catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); }
3084
+ finally { setBusy(false); }
3085
+ };
3086
+
3087
+ const saveFile = async () => {
3088
+ if (!filePath) { setMsg('No config file found.'); return; }
3089
+ setBusy(true); setMsg('');
3090
+ try { await putAdminConfigFile(fileText); setMsg(`Saved ${filePath}. Restart services to apply changes.`); setRestartDialog(true); }
3091
+ catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); }
3092
+ finally { setBusy(false); }
3093
+ };
3094
+
3095
+ return (
3096
+ <div className="admin-view-stack">
3097
+ <PageHead title="Configuration"><div className="heading-actions"><span className="restart-badge">RESTART REQUIRED AFTER SAVE</span><button onClick={load} className="workspace-action">↻ Reload</button></div></PageHead>
3098
+ <div className="config-intro"><div><span className="admin-overline">PROJECT CONTROL PLANE</span><h1>Configure your <em>runtime.</em></h1><p>Use key/value rows for safe JSON overrides, or switch to source mode for the typed project configuration.</p></div><div className="hero-art hero-art--cubes"><strong>{'{ }'}</strong><span className="ha-a" /><span className="ha-b" /><span className="ha-c" /></div></div>
3099
+ <div className="workspace-tabs">{([['runtime', 'Runtime JSON'], ['source', 'Source file'], ['effective', 'Effective view']] as const).map(([id, label]) => <button key={id} onClick={() => setMode(id)} className={mode === id ? 'is-active' : ''}>{label}</button>)}</div>
3100
+ {mode === 'runtime' && <section className="workspace-panel config-panel"><div className="panel-title-row"><div><span className="admin-overline">NEXUS CONFIG · KEY/VALUE</span><h3>Configuration overrides</h3></div><button onClick={addKey} className="workspace-action">+ Add key</button></div><p className="panel-description">Existing configuration is loaded into the rows below. Changes are written to nexus.runtime.json and validated against the Nexus schema before saving. Secrets are redacted and managed in the Environment surface.</p><div className="key-add-bar">
3101
+ <input value={newKey} onChange={(event) => setNewKey(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addKey(); }} placeholder="key path — e.g. server.port" />
3102
+ <select value={newType} onChange={(event) => { setNewType(event.target.value as ConfigValueType); if (event.target.value === 'null') setNewValue(''); }}><option value="string">string</option><option value="number">number</option><option value="boolean">boolean</option><option value="null">null</option><option value="object">object</option><option value="array">array</option></select>
3103
+ <input value={newValue} disabled={newType === 'null'} onChange={(event) => setNewValue(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addKey(); }} placeholder={newType === 'object' || newType === 'array' ? '{ }' : 'value'} />
3104
+ </div><div className="key-value-table">
3105
+ {groupEntries(entries).map(({ group, items }) => (
3106
+ <div className="key-group" key={group}>
3107
+ <div className="key-group-head"><span className="key-group-name">{group}</span><small>{items.length} key{items.length === 1 ? '' : 's'}</small></div>
3108
+ <div className="key-value-head"><span>KEY PATH</span><span>TYPE</span><span>VALUE</span><span /></div>
3109
+ {items.map(({ index, entry }) => (
3110
+ <div className="key-value-row" key={`${entry.key}-${index}`}>
3111
+ <input value={entry.key} onChange={(event) => setEntries(entries.map((item, itemIndex) => itemIndex === index ? { ...item, key: event.target.value } : item))} placeholder="server.port" />
3112
+ <select value={entry.type} onChange={(event) => setEntries(entries.map((item, itemIndex) => itemIndex === index ? { ...item, type: event.target.value as ConfigValueType, value: event.target.value === 'null' ? '' : item.value } : item))}><option value="string">string</option><option value="number">number</option><option value="boolean">boolean</option><option value="null">null</option><option value="object">object</option><option value="array">array</option></select>
3113
+ <input value={entry.value} disabled={entry.type === 'null'} onChange={(event) => setEntries(entries.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} placeholder={entry.type === 'object' || entry.type === 'array' ? '{ }' : 'value'} />
3114
+ <button className="row-delete" onClick={() => setEntries(entries.filter((_, itemIndex) => itemIndex !== index))} aria-label={`Delete ${entry.key || 'entry'}`}>×</button>
3115
+ </div>
3116
+ ))}
3117
+ </div>
3118
+ ))}
3119
+ {!entries.length && <div className="key-value-empty">No configuration keys loaded.</div>}
3120
+ </div><div className="config-footer"><span>{entries.length} override{entries.length === 1 ? '' : 's'} · validated on save</span><button onClick={saveRuntime} disabled={busy} className="glass-chip-btn-primary shrink-0">{busy ? 'Saving…' : 'Save runtime JSON'} <span>→</span></button></div></section>}
3121
+ {mode === 'source' && <section className="workspace-panel config-panel"><div className="panel-title-row"><div><span className="admin-overline">HUMAN-EDITED SOURCE</span><h3>{filePath ? filePath.split(/[\\/]/).pop() : 'nexus.config.ts'}</h3></div><button onClick={saveFile} disabled={busy} className="glass-chip-btn-primary shrink-0">{busy ? 'Saving…' : 'Save source'} <span>→</span></button></div><textarea className="source-editor" value={fileText} onChange={(event) => setFileText(event.target.value)} spellCheck={false} /></section>}
3122
+ {mode === 'effective' && <section className="workspace-panel config-panel"><div className="panel-title-row"><div><span className="admin-overline">READ-ONLY · SECRETS REDACTED</span><h3>Effective merged config</h3></div></div><pre className="source-editor effective-editor">{cfg ? JSON.stringify(cfg.config, null, 2) : 'Loading configuration…'}</pre></section>}
3123
+ <LintChecks run={runLintConfig} title="Configuration checks" overline="VALIDATOR · nexus.runtime.json" />
3124
+ <ConfirmDialog
3125
+ open={restartDialog}
3126
+ title="Restart required"
3127
+ message="Your changes have been saved. Restart the backend services to apply the new configuration."
3128
+ confirmLabel="Open services"
3129
+ onConfirm={() => { setRestartDialog(false); onNavigate?.('processes'); }}
3130
+ onCancel={() => setRestartDialog(false)}
3131
+ />
3132
+ </div>
3133
+ );
3134
+ }
3135
+
3136
+ function Environment({ onNavigate }: { onNavigate?: (tab: Tab) => void }) {
3137
+ const [data, setData] = useState<AdminEnv | null>(null);
3138
+ const [entries, setEntries] = useState<EnvEntry[]>([]);
3139
+ const [msg, setMsg] = useAdminAlert('environment');
3140
+ const [busy, setBusy] = useState(false);
3141
+ const [restartDialog, setRestartDialog] = useState(false);
3142
+ const [newKey, setNewKey] = useState('');
3143
+ const [newValue, setNewValue] = useState('');
3144
+ const [file, setFile] = useState('.env');
3145
+ const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
3146
+ const load = async (target: string = file) => { try { const result = await getAdminEnv(target); setData(result); setEntries(result.entries); setMsg(result.exists ? '' : `No ${target} file exists yet. Saving will create it.`); } catch (e: any) { setMsg(String(e?.message ?? e)); } };
3147
+ useEffect(() => { load(); }, []);
3148
+ const addVariable = () => {
3149
+ if (!newKey.trim()) return;
3150
+ const secret = /SECRET|PASSWORD|PASS|TOKEN|PRIVATE|API_KEY|CLIENT_SECRET|ACCESS_KEY|KEY_SECRET/i.test(newKey);
3151
+ setEntries([...entries, { key: newKey.trim(), value: newValue, secret }]);
3152
+ setNewKey(''); setNewValue('');
3153
+ };
3154
+ const save = async () => { setBusy(true); setMsg(''); try { await putAdminEnv(entries, file); setMsg(`Saved ${file}. Restart services to apply environment changes.`); setRestartDialog(true); await load(); } catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); } finally { setBusy(false); } };
3155
+ return (
3156
+ <div className="admin-view-stack">
3157
+ <PageHead title="Environment"><div className="heading-actions"><span className="secret-badge">SECRETS MASKED</span><button onClick={() => load()} className="workspace-action">↻ Reload</button></div></PageHead>
3158
+ <div className="config-intro env-intro"><div><span className="admin-overline">LOCAL ENVIRONMENT · {data?.path ?? '.env'}</span><h1>Your environment, <em>under control.</em></h1><p>Manage project variables as key/value pairs. Secret values stay on disk and are never returned to the browser.</p></div><div className="env-lock-art"><i className="env-lock-glyph">⌁</i><span>PRIVATE</span></div></div>
3159
+ <section className="workspace-panel config-panel"><div className="panel-title-row"><div><span className="admin-overline">{file.toUpperCase()} FILE · PRESERVES COMMENTS</span><h3>Environment variables</h3></div><button onClick={addVariable} className="workspace-action">+ Add variable</button></div><div className="env-add-bar">
3160
+ <input value={newKey} onChange={(event) => setNewKey(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addVariable(); }} placeholder="NEXUS_SERVER_PORT" />
3161
+ <div className="env-value-wrap"><input value={newValue} onChange={(event) => setNewValue(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addVariable(); }} placeholder="value" /><span className="secret-mark">{newKey && /SECRET|PASSWORD|PASS|TOKEN|PRIVATE|API_KEY|CLIENT_SECRET|ACCESS_KEY|KEY_SECRET/i.test(newKey) ? '●' : '○'}</span></div>
3162
+ </div><div className="key-value-table env-table">{groupEntries(entries).map(({ group, items }) => (
3163
+ <div className="key-group" key={group}>
3164
+ <div className="key-group-head"><span className="key-group-name">{group}</span><small>{items.length} variable{items.length === 1 ? '' : 's'}</small></div>
3165
+ <div className="key-value-head"><span>VARIABLE</span><span>VALUE</span></div>
3166
+ {items.map(({ index, entry }) => (
3167
+ <div className="key-value-row" key={`${entry.key}-${index}`}>
3168
+ <input value={entry.key} onChange={(event) => setEntries(entries.map((item, itemIndex) => itemIndex === index ? { ...item, key: event.target.value, secret: /SECRET|PASSWORD|PASS|TOKEN|PRIVATE|API_KEY|CLIENT_SECRET|ACCESS_KEY|KEY_SECRET/i.test(event.target.value) } : item))} placeholder="NEXUS_SERVER_PORT" />
3169
+ <div className="env-value-wrap"><input type={entry.secret ? 'password' : 'text'} value={entry.value ?? ''} placeholder={entry.secret ? '•••••••• · unchanged' : 'value'} onChange={(event) => setEntries(entries.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} /><span className={entry.secret ? 'secret-mark is-secret' : 'secret-mark'}>{entry.secret ? '●' : '○'}</span><button className="row-delete" onClick={() => setDeleteTarget(entry.key)} aria-label={`Delete ${entry.key || 'entry'}`} title={`Delete ${entry.key || 'entry'}`}>×</button></div>
3170
+ </div>
3171
+ ))}
3172
+ </div>
3173
+ ))}
3174
+ {!entries.length && <div className="key-value-empty">No environment variables found.</div>}</div><div className="config-footer"><span>{entries.length} variable{entries.length === 1 ? '' : 's'} · masked secrets are preserved when unchanged</span><button onClick={save} disabled={busy} className="glass-chip-btn-primary shrink-0">{busy ? 'Saving…' : `Save ${file}`} <span>→</span></button></div></section>
3175
+ <LintChecks run={() => runLintEnv(file)} title="Environment checks" overline={`VALIDATOR · ${file}`} />
3176
+ <ConfirmDialog
3177
+ open={restartDialog}
3178
+ title="Restart required"
3179
+ message="Your environment changes have been saved. Restart the backend services to apply the new variables."
3180
+ confirmLabel="Open services"
3181
+ onConfirm={() => { setRestartDialog(false); onNavigate?.('processes'); }}
3182
+ onCancel={() => setRestartDialog(false)}
3183
+ />
3184
+ <ConfirmDialog
3185
+ open={deleteTarget !== null}
3186
+ title="Delete variable"
3187
+ message={`Delete "${deleteTarget}" from ${file}? This removes it from the file on the next save.`}
3188
+ confirmLabel="Delete"
3189
+ onConfirm={() => { if (deleteTarget) setEntries(entries.filter((e) => e.key !== deleteTarget)); setDeleteTarget(null); }}
3190
+ onCancel={() => setDeleteTarget(null)}
3191
+ />
3192
+ </div>
3193
+ );
3194
+ }
3195
+
3196
+ function Plugins() {
3197
+ const [data, setData] = useState<{ pages: any[]; slots: Record<string, any[]> } | null>(null);
3198
+ useEffect(() => { getPlugins().then(setData).catch(() => {}); }, []);
3199
+ if (!data) return <p className="text-slate-400">Loading…</p>;
3200
+ return (
3201
+ <div className="space-y-6">
3202
+ <PageHead title="Plugins" />
3203
+ <PageHero kicker="EXTENSION GALLERY" title={<>Extend Nexus, <em>effortlessly.</em></>} desc="Every admin page, slot and surface that plugin modules register — a living index of what's available to your build." glyph="◇" art="grid" />
3204
+ <div className="glass-card space-y-4">
3205
+ <h3 className="glass-h3">Admin pages</h3>
3206
+ <ul className="space-y-1">
3207
+ {data.pages.map((p) => (
3208
+ <li key={p.path} className="flex items-center gap-2 font-mono text-sm text-slate-200">
3209
+ <span className="chip">{p.group ?? 'Plugins'}</span> {p.title} —{' '}
3210
+ <a href={p.path} target="_blank" rel="noreferrer" className="text-slate-400 underline decoration-dotted underline-offset-4 transition-colors hover:text-sky-300">
3211
+ {p.path}
3212
+ </a>
3213
+ </li>
3214
+ ))}
3215
+ {!data.pages.length && <li className="text-sm text-slate-500">No plugin pages registered.</li>}
3216
+ </ul>
3217
+ {Object.keys(data.slots).length > 0 && (
3218
+ <>
3219
+ <h3 className="glass-h3">Slots</h3>
3220
+ <pre className="glass-code overflow-auto p-3">{JSON.stringify(data.slots, null, 2)}</pre>
3221
+ </>
3222
+ )}
3223
+ </div>
3224
+ </div>
3225
+ );
3226
+ }
3227
+
3228
+ function Users({ currentUserEmail }: { currentUserEmail?: string }) {
3229
+ const [data, setData] = useState<{ users: UserRecord[] } | null>(null);
3230
+ const [roles, setRoles] = useState<RoleDefinition[]>([]);
3231
+ const [msg, setMsg] = useAdminAlert('users');
3232
+ const [editing, setEditing] = useState<UserRecord | null>(null);
3233
+ const [draft, setDraft] = useState<string[]>([]);
3234
+ const [busy, setBusy] = useState(false);
3235
+
3236
+ const reload = useCallback(async () => {
3237
+ try {
3238
+ const [u, r] = await Promise.all([getUsers(), getRoles()]);
3239
+ setData(u);
3240
+ setRoles(r?.roles ?? []);
3241
+ } catch (e: any) {
3242
+ setMsg({ kind: 'err', text: `Could not load users or roles: ${e?.message ?? 'unknown error'}` });
3243
+ }
3244
+ }, []);
3245
+
3246
+ useEffect(() => { void reload(); }, [reload]);
3247
+
3248
+ const openEditor = (u: UserRecord) => { setEditing(u); setDraft([...(u.roles ?? [])]); setMsg(null); };
3249
+ const toggleRole = (id: string) => setDraft((d) => (d.includes(id) ? d.filter((r) => r !== id) : [...d, id]));
3250
+
3251
+ const saveRoles = async () => {
3252
+ if (!editing) return;
3253
+ if (!draft.length) { setMsg({ kind: 'err', text: 'A user must keep at least one role.' }); return; }
3254
+ setBusy(true);
3255
+ try {
3256
+ await putUserRoles(editing._id, draft);
3257
+ setMsg({ kind: 'ok', text: `Saved roles for ${editing.email}.` });
3258
+ setEditing(null);
3259
+ await reload();
3260
+ } catch (e: any) {
3261
+ const raw = String(e?.message ?? 'Update failed');
3262
+ const clean = raw.replace(/^.*\/admin\/users[^\s]*\s*\d+\s*/i, '').trim();
3263
+ setMsg({ kind: 'err', text: clean || 'Update failed.' });
3264
+ } finally { setBusy(false); }
3265
+ };
3266
+
3267
+ const roleDef = (id: string) => roles.find((r) => r.id === id);
3268
+ const roleChip = (id: string) => {
3269
+ const def = roleDef(id);
3270
+ const accent = def?.accent ?? '#8ba0bd';
3271
+ return <span key={id} className="role-chip" style={{ color: accent, borderColor: `${accent}66`, background: `${accent}1a` }}>{def?.icon ?? '◌'} {def?.label ?? id}</span>;
3272
+ };
3273
+
3274
+ if (!data) return <p className="text-slate-400">Loading…</p>;
3275
+
3276
+ return (
3277
+ <div className="space-y-6">
3278
+ <PageHead title="Users & Roles" />
3279
+ <PageHero kicker="ACCESS & PERMISSIONS" title={<>Your team, <em>in control.</em></>} desc="Every account that can reach this Nexus surface — with role grants and restrictions applied in one built-in permission catalog." glyph="◎" art="people" />
3280
+
3281
+ <div className="glass-panel overflow-hidden p-2">
3282
+ <table className="glass-table">
3283
+ <thead><tr><th>Email</th><th className="text-center">Name</th><th>Roles</th><th className="text-center">Verified</th><th className="text-right">Actions</th></tr></thead>
3284
+ <tbody>
3285
+ {data.users.map((u) => (
3286
+ <tr key={String(u._id)}>
3287
+ <td className="text-slate-200">{u.email}{u.email === currentUserEmail && <span className="you-tag">you</span>}</td>
3288
+ <td className="text-slate-300">{u.name ?? '—'}</td>
3289
+ <td>
3290
+ {(u.roles ?? []).map(roleChip)}
3291
+ {!(u.roles ?? []).length && '—'}
3292
+ </td>
3293
+ <td className="text-center text-emerald-400">{u.emailVerified ? '✓' : <span className="text-slate-500">—</span>}</td>
3294
+ <td className="text-right"><button className="glass-chip-btn" onClick={() => openEditor(u)}>â—ˆ Edit roles</button></td>
3295
+ </tr>
3296
+ ))}
3297
+ {!data.users.length && <tr><td colSpan={5} className="py-8 text-center text-slate-500">No users registered.</td></tr>}
3298
+ </tbody>
3299
+ </table>
3300
+ </div>
3301
+
3302
+ <div className="role-catalog">
3303
+ {roles.map((r) => (
3304
+ <div className="role-box" key={r.id} style={{ ['--role-accent' as string]: r.accent }}>
3305
+ <div className="role-box-head">
3306
+ <span className="role-box-icon" style={{ color: r.accent }}>{r.icon}</span>
3307
+ <div>
3308
+ <strong style={{ color: r.accent }}>{r.label}</strong>
3309
+ <code className="role-box-id">{r.id}</code>
3310
+ </div>
3311
+ </div>
3312
+ <p className="role-box-desc">{r.description}</p>
3313
+ <div className="role-section">
3314
+ <span className="role-section-title is-can">✓ Can do</span>
3315
+ <ul className="role-list">
3316
+ {r.grants.map((g) => <li key={g.label}><b>{g.label}</b><span>{g.detail}</span></li>)}
3317
+ </ul>
3318
+ </div>
3319
+ <div className="role-section">
3320
+ <span className="role-section-title is-no">✕ Restricted from</span>
3321
+ <ul className="role-list">
3322
+ {r.restricts.map((g) => <li key={g.label}><b>{g.label}</b><span>{g.detail}</span></li>)}
3323
+ </ul>
3324
+ </div>
3325
+ </div>
3326
+ ))}
3327
+ </div>
3328
+
3329
+ {editing && (
3330
+ <div className="confirm-overlay" onClick={() => !busy && setEditing(null)}>
3331
+ <div className="confirm-dialog role-dialog" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
3332
+ <div className="confirm-dialog-head">
3333
+ <span className="confirm-dialog-mark">â—Ž</span>
3334
+ <strong>Edit roles · {editing.email}</strong>
3335
+ <button className="confirm-dialog-close" onClick={() => !busy && setEditing(null)} aria-label="Close">×</button>
3336
+ </div>
3337
+ <p className="confirm-dialog-message">Choose which roles this account holds — every selected role applies.</p>
3338
+ <div className="role-options">
3339
+ {roles.map((r) => {
3340
+ const on = draft.includes(r.id);
3341
+ return (
3342
+ <button key={r.id} type="button" className={`role-option${on ? ' is-on' : ''}`} style={{ ['--role-accent' as string]: r.accent }} onClick={() => toggleRole(r.id)}>
3343
+ <span className="role-option-icon" style={{ color: r.accent }}>{on ? '✓' : r.icon}</span>
3344
+ <span className="role-option-text">
3345
+ <b>{r.label}</b>
3346
+ <small>{r.description}</small>
3347
+ </span>
3348
+ <span className="role-option-check">{on ? '✓' : ''}</span>
3349
+ </button>
3350
+ );
3351
+ })}
3352
+ </div>
3353
+ {editing.email === currentUserEmail && (
3354
+ <p className="role-hint">Editing your own account — removing <b>Administrator</b> from yourself is blocked server-side.</p>
3355
+ )}
3356
+ <div className="confirm-dialog-actions">
3357
+ <button className="glass-chip-btn" onClick={() => setEditing(null)} disabled={busy}>Cancel</button>
3358
+ <button className="glass-chip-btn-danger confirm-dialog-primary" onClick={saveRoles} disabled={busy}>{busy ? 'Saving…' : 'Save roles'}</button>
3359
+ </div>
3360
+ </div>
3361
+ </div>
3362
+ )}
3363
+ </div>
3364
+ );
3365
+ }
3366
+
3367
+ function formatPointTime(t: number, range: RequestSeriesRange): string {
3368
+ const d = new Date(t);
3369
+ const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
3370
+ if (range === 'today' || range === '5d') return time;
3371
+ if (range === 'week') return `${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${time}`;
3372
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
3373
+ }
3374
+
3375
+ function RequestSeriesChart({ series }: { series: RequestSeries | null }) {
3376
+ const W = 720;
3377
+ const H = 96;
3378
+ const PAD = 4;
3379
+ const svgRef = useRef<SVGSVGElement | null>(null);
3380
+ const [tip, setTip] = useState<{ x: number; y: number; label: string; count: number } | null>(null);
3381
+ const pts = series?.points?.length ? series.points : [];
3382
+ if (!series || !pts.length) {
3383
+ return (
3384
+ <div className="traffic-chart">
3385
+ <div className="traffic-chart-body">
3386
+ <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label="No request data">
3387
+ <line x1={PAD} y1={H - PAD} x2={W - PAD} y2={H - PAD} stroke="var(--admin-border, rgba(125,174,235,0.2))" strokeWidth="1" />
3388
+ </svg>
3389
+ </div>
3390
+ <div className="traffic-meta"><span>no data for this range yet</span></div>
3391
+ </div>
3392
+ );
3393
+ }
3394
+ const max = Math.max(1, ...pts.map((p) => p.count));
3395
+ const minT = pts[0].t;
3396
+ const maxT = pts[pts.length - 1].t;
3397
+ const span = Math.max(1, maxT - minT);
3398
+ const coords = pts.map((p) => [PAD + ((p.t - minT) / span) * (W - PAD * 2), H - PAD - (p.count / max) * (H - PAD * 2)] as const);
3399
+ const line = coords.map(([x, y], i) => `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`).join(' ');
3400
+ const area = `${line} L${(W - PAD).toFixed(1)},${H - PAD} L${PAD},${H - PAD} Z`;
3401
+ const showTip = (e: React.MouseEvent<SVGCircleElement>, i: number) => {
3402
+ const svg = svgRef.current;
3403
+ if (!svg) return;
3404
+ const rect = svg.getBoundingClientRect();
3405
+ const [vx, vy] = coords[i];
3406
+ setTip({
3407
+ x: (vx / W) * rect.width,
3408
+ y: (vy / H) * rect.height,
3409
+ label: formatPointTime(pts[i].t, series.range),
3410
+ count: pts[i].count,
3411
+ });
3412
+ };
3413
+ return (
3414
+ <div className="traffic-chart">
3415
+ <div className="traffic-chart-body">
3416
+ <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label="HTTP request traffic">
3417
+ <defs>
3418
+ <linearGradient id="seriesFill" x1="0" y1="0" x2="0" y2="1">
3419
+ <stop offset="0%" stopColor="var(--admin-violet, #9f7bff)" stopOpacity="0.35" />
3420
+ <stop offset="100%" stopColor="var(--admin-violet, #9f7bff)" stopOpacity="0" />
3421
+ </linearGradient>
3422
+ </defs>
3423
+ <line x1={PAD} y1={H - PAD} x2={W - PAD} y2={H - PAD} stroke="var(--admin-border, rgba(125,174,235,0.2))" strokeWidth="1" />
3424
+ <path d={area} fill="url(#seriesFill)" />
3425
+ <path d={line} fill="none" stroke="var(--admin-violet, #9f7bff)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
3426
+ {pts.map((p, i) => (
3427
+ <circle key={i} cx={coords[i][0]} cy={coords[i][1]} r="7" fill="transparent" style={{ cursor: 'crosshair' }} onMouseEnter={(e) => showTip(e, i)} onMouseMove={(e) => showTip(e, i)} onMouseLeave={() => setTip(null)} />
3428
+ ))}
3429
+ </svg>
3430
+ {tip && (
3431
+ <div className="traffic-tip" style={{ left: tip.x, top: tip.y }}>
3432
+ <span className="traffic-tip-time">{tip.label}</span>
3433
+ <span className="traffic-tip-count">{tip.count} req</span>
3434
+ </div>
3435
+ )}
3436
+ </div>
3437
+ <div className="traffic-meta"><span>{series.total} requests in range</span><span>peak {max} / bucket</span></div>
3438
+ </div>
3439
+ );
3440
+ }
3441
+
3442
+ const SERIES_OPTIONS: Array<{ id: RequestSeriesRange; label: string }> = [
3443
+ { id: 'today', label: 'Today' },
3444
+ { id: '5d', label: '5 days' },
3445
+ { id: 'week', label: 'Week' },
3446
+ { id: 'month', label: 'Month' },
3447
+ { id: 'year', label: 'Year' },
3448
+ ];
3449
+
3450
+ function Monitoring() {
3451
+ const [data, setData] = useState<any>(null);
3452
+ const [series, setSeries] = useState<RequestSeries | null>(null);
3453
+ const [logs, setLogs] = useState<RequestLogEntry[]>([]);
3454
+ const [range, setRange] = useState<RequestSeriesRange>('today');
3455
+ useEffect(() => {
3456
+ getMetrics().then(setData).catch(() => {});
3457
+ const t = setInterval(() => getMetrics().then(setData).catch(() => {}), 5000);
3458
+ return () => clearInterval(t);
3459
+ }, []);
3460
+ useEffect(() => {
3461
+ let alive = true;
3462
+ const load = async () => {
3463
+ try { const s = await getRequestSeries(range); if (alive) setSeries(s); } catch { if (alive) setSeries(null); }
3464
+ };
3465
+ void load();
3466
+ const intervalMs = range === 'today' ? 5000 : range === '5d' ? 30000 : 60000;
3467
+ const t = setInterval(load, intervalMs);
3468
+ return () => { alive = false; clearInterval(t); };
3469
+ }, [range]);
3470
+ useEffect(() => {
3471
+ getRequestLogs().then(setLogs).catch(() => {});
3472
+ const t = setInterval(() => getRequestLogs().then(setLogs).catch(() => {}), 5000);
3473
+ return () => clearInterval(t);
3474
+ }, []);
3475
+ if (!data) return <p className="text-slate-400">Loading…</p>;
3476
+ return (
3477
+ <div className="space-y-6">
3478
+ <PageHead title="Monitoring" />
3479
+ <PageHero kicker="LIVE TELEMETRY" title={<>Watch the whole <em>machine.</em></>} desc="A calm, self-refreshing readout of the backend heartbeat — uptime, PID and request traffic without the noise." glyph="⌁" art="coins" />
3480
+ <div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
3481
+ <div className="glass-card"><div className="glass-h3">Uptime</div><div className="mt-2 text-3xl font-bold text-slate-100">{Math.round(data.uptime ?? 0)}<span className="ml-1 text-sm font-normal text-slate-400">s</span></div></div>
3482
+ <div className="glass-card"><div className="glass-h3">PID</div><div className="mt-2 font-mono text-3xl font-bold text-slate-100">{data.pid}</div></div>
3483
+ <div className="glass-card"><div className="glass-h3">HTTP requests</div><div className="mt-2 text-2xl font-bold text-indigo-300">{(() => { const v = data.metrics?.http_requests_total?.values; return v ? Object.values(v).reduce((a: number, b) => a + Number(b ?? 0), 0) : '—'; })()}</div></div>
3484
+ </div>
3485
+ <div className="glass-card">
3486
+ <div className="flex flex-wrap items-center justify-between gap-3">
3487
+ <h3 className="glass-h3">Request traffic</h3>
3488
+ <div className="series-tabs">
3489
+ {SERIES_OPTIONS.map((o) => (
3490
+ <button key={o.id} type="button" className={range === o.id ? 'is-active' : ''} onClick={() => setRange(o.id)}>{o.label}</button>
3491
+ ))}
3492
+ </div>
3493
+ </div>
3494
+ <RequestSeriesChart series={series} />
3495
+ </div>
3496
+ <div className="glass-card">
3497
+ <h3 className="glass-h3 mb-3">Recent requests</h3>
3498
+ {logs.length ? (
3499
+ <div className="req-log-table-wrap">
3500
+ <table className="glass-table req-log-table">
3501
+ <thead><tr className="text-slate-400"><th>Time</th><th>Method</th><th>Path</th><th className="text-center">Status</th><th className="text-right">Dur</th><th>IP</th><th>Referer</th></tr></thead>
3502
+ <tbody>
3503
+ {logs.map((r, i) => (
3504
+ <tr key={i}>
3505
+ <td className="req-td-time">{new Date(r.time).toLocaleTimeString()}</td>
3506
+ <td className={`req-method req-method-${String(r.method).toLowerCase()}`}>{r.method}</td>
3507
+ <td className="req-td-path" title={r.url ?? r.path}>{r.url ?? r.path}</td>
3508
+ <td className={`text-center req-status ${r.status >= 500 ? 'req-status-err' : r.status >= 400 ? 'req-status-warn' : ''}`}>{r.status}</td>
3509
+ <td className="text-right req-td-dur">{r.durationMs}ms</td>
3510
+ <td className="req-td-ip">{r.ip ?? '—'}</td>
3511
+ <td className="req-td-ref" title={r.referer}>{r.referer ?? '—'}</td>
3512
+ </tr>
3513
+ ))}
3514
+ </tbody>
3515
+ </table>
3516
+ </div>
3517
+ ) : (
3518
+ <p className="text-sm text-slate-400">No requests logged yet.</p>
3519
+ )}
3520
+ </div>
3521
+ </div>
3522
+ );
3523
+ }
3524
+
3525
+ interface PaymentOrder {
3526
+ provider: string;
3527
+ orderId: string;
3528
+ reference: string;
3529
+ status: string;
3530
+ amount: number;
3531
+ currency: string;
3532
+ paymentId?: string;
3533
+ createdAt?: string;
3534
+ }
3535
+ interface PaymentTransaction {
3536
+ provider: string;
3537
+ event: string;
3538
+ verified: boolean;
3539
+ orderId?: string;
3540
+ paymentId?: string;
3541
+ amount?: number;
3542
+ currency?: string;
3543
+ status?: string;
3544
+ createdAt?: string;
3545
+ }
3546
+
3547
+ const ALL_PROVIDERS = ['razorpay', 'paypal', 'payu', 'skrill', 'payoneer'];
3548
+
3549
+ function Payments({ toggle }: { toggle: ToggleStyle }) {
3550
+ const [tab, setTab] = useState<'orders' | 'transactions' | 'test'>('orders');
3551
+ const [orders, setOrders] = useState<PaymentOrder[]>([]);
3552
+ const [transactions, setTransactions] = useState<PaymentTransaction[]>([]);
3553
+ const [status, setStatus] = useState<PaymentProviderStatus[]>([]);
3554
+ const [provider, setProvider] = useState('all');
3555
+ const [msg, setMsg] = useAdminAlert('payments');
3556
+
3557
+ const load = async (q = provider) => {
3558
+ setMsg('');
3559
+ try {
3560
+ const filter = q === 'all' ? undefined : q;
3561
+ const [o, t, s] = await Promise.all([getPaymentOrders(filter), getPaymentTransactions(filter), getPaymentStatus()]);
3562
+ setOrders(o.orders ?? []);
3563
+ setTransactions(t.transactions ?? []);
3564
+ setStatus(s.providers ?? []);
3565
+ } catch (e: any) { setMsg(String(e?.message ?? e)); }
3566
+ };
3567
+ useEffect(() => { load('all'); }, []);
3568
+
3569
+ return (
3570
+ <div className="space-y-6">
3571
+ <PageHead title="Payments">
3572
+ <div className="flex items-center gap-3">
3573
+ <select value={provider} onChange={(e) => { const v = e.target.value; setProvider(v); load(v); }} className="glass-input w-44 !py-1.5">
3574
+ <option value="all" className="bg-slate-900">All providers</option>
3575
+ {ALL_PROVIDERS.map((p) => <option key={p} value={p} className="bg-slate-900">{p}</option>)}
3576
+ </select>
3577
+ <button onClick={() => load()} className="glass-chip-btn">↻ refresh</button>
3578
+ </div>
3579
+ </PageHead>
3580
+ <PageHero kicker="PAYMENT OPERATIONS" title={<>Money flow, <em>on the ledger.</em></>} desc="Every charge, refund and provider event from your payment stack — one surface to reconcile what went through." glyph="$" art="cards" />
3581
+
3582
+ <div className="workspace-tabs">
3583
+ {([
3584
+ ['orders', `Orders (${orders.length})`],
3585
+ ['transactions', `Transactions (${transactions.length})`],
3586
+ ['test', 'Test Console'],
3587
+ ] as const).map(([id, label]) => (
3588
+ <button key={id} onClick={() => setTab(id)} className={tab === id ? 'is-active' : ''}>
3589
+ {label}
3590
+ </button>
3591
+ ))}
3592
+ </div>
3593
+
3594
+ {tab === 'orders' && <OrdersTable orders={orders} />}
3595
+ {tab === 'transactions' && <TransactionsTable transactions={transactions} />}
3596
+ {tab === 'test' && (
3597
+ <TestConsole toggle={toggle} status={status} onRun={() => load()} onNotice={setMsg} onCreated={() => { setTab('orders'); load(); }} />
3598
+ )}
3599
+ </div>
3600
+ );
3601
+ }
3602
+
3603
+ function OrdersTable({ orders }: { orders: PaymentOrder[] }) {
3604
+ const statusColor = (s: string) => (['paid', 'captured'].includes(s) ? 'text-emerald-400' : ['failed', 'refunded', 'cancelled'].includes(s) ? 'text-rose-400' : 'text-slate-300');
3605
+ return (
3606
+ <section className="glass-panel overflow-hidden p-2">
3607
+ <table className="glass-table">
3608
+ <thead><tr><th>Provider</th><th>Order</th><th>Reference</th><th className="text-center">Status</th><th className="text-right">Amount</th><th>Payment ID</th><th>Created</th></tr></thead>
3609
+ <tbody>
3610
+ {orders.map((o, i) => (
3611
+ <tr key={`${o.orderId}-${i}`}>
3612
+ <td><span className="chip">{o.provider}</span></td>
3613
+ <td className="font-mono text-slate-200">{o.orderId || '—'}</td>
3614
+ <td className="font-mono text-slate-400">{o.reference || '—'}</td>
3615
+ <td className={`text-center ${statusColor(o.status)}`}>{o.status}</td>
3616
+ <td className="text-right font-medium text-slate-200">{o.currency} {o.amount}</td>
3617
+ <td className="font-mono text-slate-400">{o.paymentId || '—'}</td>
3618
+ <td className="text-slate-400">{o.createdAt ? new Date(o.createdAt).toLocaleString() : '—'}</td>
3619
+ </tr>
3620
+ ))}
3621
+ {!orders.length && <tr><td colSpan={7} className="py-8 text-center text-slate-500">No orders recorded yet.</td></tr>}
3622
+ </tbody>
3623
+ </table>
3624
+ </section>
3625
+ );
3626
+ }
3627
+
3628
+ function TransactionsTable({ transactions }: { transactions: PaymentTransaction[] }) {
3629
+ return (
3630
+ <section className="glass-panel overflow-hidden p-2">
3631
+ <table className="glass-table">
3632
+ <thead><tr><th>Provider</th><th>Event</th><th className="text-center">Verified</th><th>Order</th><th>Payment</th><th className="text-right">Amount</th><th>Created</th></tr></thead>
3633
+ <tbody>
3634
+ {transactions.map((t, i) => (
3635
+ <tr key={i}>
3636
+ <td><span className="chip">{t.provider}</span></td>
3637
+ <td className="font-mono text-slate-200">{t.event}</td>
3638
+ <td className={`text-center ${t.verified ? 'text-emerald-400' : 'text-rose-400'}`}>{t.verified ? '✓' : '✗'}</td>
3639
+ <td className="font-mono text-slate-400">{t.orderId || '—'}</td>
3640
+ <td className="font-mono text-slate-400">{t.paymentId || '—'}</td>
3641
+ <td className="text-right text-slate-200">{t.currency ? `${t.currency} ${t.amount ?? ''}` : '—'}</td>
3642
+ <td className="text-slate-400">{t.createdAt ? new Date(t.createdAt).toLocaleString() : '—'}</td>
3643
+ </tr>
3644
+ ))}
3645
+ {!transactions.length && <tr><td colSpan={7} className="py-8 text-center text-slate-500">No webhook transactions yet.</td></tr>}
3646
+ </tbody>
3647
+ </table>
3648
+ </section>
3649
+ );
3650
+ }
3651
+
3652
+ function TestConsole({ toggle, status, onRun, onNotice, onCreated }: { toggle: ToggleStyle; status: PaymentProviderStatus[]; onRun: () => Promise<void> | void; onNotice: (msg: string) => void; onCreated: () => void }) {
3653
+ const [probeBusy, setProbeBusy] = useState(false);
3654
+ const [busy, setBusy] = useState(false);
3655
+ const [toggleBusy, setToggleBusy] = useState<string>('');
3656
+ const [switchAnim, setSwitchAnim] = useState<Record<string, 'out' | 'in' | null>>({});
3657
+ const [provider, setProvider] = useState('');
3658
+ const [amount, setAmount] = useState('100');
3659
+ const [currency, setCurrency] = useState('INR');
3660
+ const [err, setErr] = useState('');
3661
+ const [result, setResult] = useState<any>(null);
3662
+ const [keyProvider, setKeyProvider] = useState<PaymentProviderStatus | null>(null);
3663
+ const [keyDraft, setKeyDraft] = useState<Record<string, string>>({});
3664
+ const [keyBusy, setKeyBusy] = useState(false);
3665
+ const [keyMsg, setKeyMsg] = useState('');
3666
+ const [keyMsgKind, setKeyMsgKind] = useState<'ok' | 'err'>('ok');
3667
+
3668
+ const openKeys = (p: PaymentProviderStatus) => {
3669
+ setKeyProvider(p);
3670
+ setKeyDraft({});
3671
+ setKeyMsg('');
3672
+ setKeyMsgKind('ok');
3673
+ };
3674
+
3675
+ const saveKeys = async () => {
3676
+ if (!keyProvider) return;
3677
+ setKeyBusy(true); setKeyMsg(''); setKeyMsgKind('ok');
3678
+ try {
3679
+ const res = await savePaymentProviderKeys(keyProvider.name, keyDraft);
3680
+ setKeyProvider(null);
3681
+ setKeyMsg('');
3682
+ await onRun();
3683
+ onNotice(`Payment provider "${keyProvider.name}" keys saved. Enabled state persisted.`);
3684
+ } catch (e: any) { setKeyMsg(String(e?.message ?? e)); setKeyMsgKind('err'); }
3685
+ finally { setKeyBusy(false); }
3686
+ };
3687
+
3688
+ const testOrder = async (e: React.FormEvent) => {
3689
+ e.preventDefault();
3690
+ setBusy(true); setErr(''); setResult(null);
3691
+ try {
3692
+ const res = await createPaymentOrder({ provider, amount: Number(amount), currency });
3693
+ setResult(res.order ?? res);
3694
+ onCreated();
3695
+ } catch (e: any) { setErr(String(e?.message ?? e)); }
3696
+ finally { setBusy(false); }
3697
+ };
3698
+
3699
+ const toggleProvider = async (p: PaymentProviderStatus) => {
3700
+ setToggleBusy(p.name);
3701
+ setSwitchAnim((prev) => ({ ...prev, [p.name]: 'out' }));
3702
+ await new Promise<void>((r) => window.setTimeout(r, 280));
3703
+ try {
3704
+ await updatePaymentProvider(p.name, { enabled: !p.enabled });
3705
+ setSwitchAnim((prev) => ({ ...prev, [p.name]: 'in' }));
3706
+ await onRun();
3707
+ } catch { /* non-fatal */ }
3708
+ finally {
3709
+ setToggleBusy('');
3710
+ await new Promise<void>((r) => window.setTimeout(r, 420));
3711
+ setSwitchAnim((prev) => { const next = { ...prev }; delete next[p.name]; return next; });
3712
+ }
3713
+ };
3714
+
3715
+ const selected = status.find((p) => p.name === provider);
3716
+
3717
+ return (
3718
+ <div className="space-y-6">
3719
+ <section className="glass-card space-y-3">
3720
+ <div className="flex items-center justify-between">
3721
+ <h3 className="glass-h3">Provider status</h3>
3722
+ <button onClick={async () => { setProbeBusy(true); try { await onRun(); } finally { setProbeBusy(false); } }} className="glass-chip-btn" disabled={probeBusy}>
3723
+ {probeBusy ? 'checking…' : '↻ re-run checks'}
3724
+ </button>
3725
+ </div>
3726
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
3727
+ {status.map((p) => {
3728
+ const anim = switchAnim[p.name] ?? null;
3729
+ return (
3730
+ <div key={p.name} className={`provider-card ${p.enabled ? 'is-on' : 'is-off'}${anim === 'out' ? ' is-switch-out' : ''}${anim === 'in' ? ' is-switch-in' : ''}`}>
3731
+ <div className="flex items-center gap-2.5">
3732
+ <button
3733
+ onClick={() => toggleProvider(p)}
3734
+ disabled={!!toggleBusy || !!anim}
3735
+ className={`ai-toggle tg-${toggle} ${p.enabled ? 'is-on' : ''}`}
3736
+ title={p.enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}
3737
+ >
3738
+ <span className="ai-toggle-knob" />
3739
+ </button>
3740
+ <div className="flex-1 min-w-0">
3741
+ <div className="font-medium text-sm text-slate-200 truncate">{p.name}</div>
3742
+ <div className="text-[11px] text-slate-500 font-mono truncate">{p.sandbox ? 'sandbox mode' : 'live mode'}</div>
3743
+ </div>
3744
+ <span className={p.ok ? 'dot-ok' : 'dot-bad'} title={p.ok ? 'working' : 'not working'} />
3745
+ <button onClick={() => openKeys(p)} className="glass-chip-btn shrink-0 text-[10px]" title="Set API key / secret">🔑 keys</button>
3746
+ </div>
3747
+ <div className="flex flex-wrap items-center gap-1.5">
3748
+ {p.enabled
3749
+ ? <span className="provider-badge-ok">enabled</span>
3750
+ : <span className="provider-badge-mute">disabled</span>}
3751
+ {p.sandbox && <span className="provider-badge-warn">sandbox</span>}
3752
+ {p.configured
3753
+ ? <span className="provider-badge-ok">keys configured</span>
3754
+ : <span className="provider-badge-warn">missing keys</span>}
3755
+ </div>
3756
+ {p.detail && <div className="text-xs text-emerald-400">{p.detail}</div>}
3757
+ {p.error && <div className="text-xs text-rose-400">{p.error}</div>}
3758
+ {p.note && <div className="text-xs text-slate-500">{p.note}</div>}
3759
+ </div>
3760
+ );
3761
+ })}
3762
+ </div>
3763
+ </section>
3764
+
3765
+ <section className="glass-card space-y-3">
3766
+ <div>
3767
+ <h3 className="glass-h3">Create a test order</h3>
3768
+ <p className="mt-1 text-sm text-slate-400">Creates a real order through the selected provider (hosted checkout, no charge) and records it in the orders table — verifies keys, upstream reachability and persistence.</p>
3769
+ </div>
3770
+ <form onSubmit={testOrder} className="flex flex-wrap items-end gap-3">
3771
+ <label className="text-sm text-slate-300">Provider
3772
+ <select value={provider} onChange={(e) => setProvider(e.target.value)} required className="glass-input mt-1 block w-44">
3773
+ <option value="" disabled className="bg-slate-900">choose…</option>
3774
+ {status.map((p) => (
3775
+ <option key={p.name} value={p.name} className="bg-slate-900">{p.enabled ? p.name : `${p.name} (disabled)`}</option>
3776
+ ))}
3777
+ </select>
3778
+ </label>
3779
+ <label className="text-sm text-slate-300">Amount
3780
+ <input type="number" step="0.01" min="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} className="glass-input mt-1 block w-28" />
3781
+ </label>
3782
+ <label className="text-sm text-slate-300">Currency
3783
+ <input value={currency} maxLength={3} onChange={(e) => setCurrency(e.target.value.toUpperCase())} className="glass-input mt-1 block w-20" />
3784
+ </label>
3785
+ <button type="submit" disabled={busy || !provider} className="glass-btn-primary">
3786
+ {busy ? 'Creating…' : 'Create test order'}
3787
+ </button>
3788
+ </form>
3789
+ {err && <p className="text-sm text-rose-400">{err}</p>}
3790
+ {selected && !selected.enabled && (
3791
+ <p className="rounded-lg border border-amber-400/30 bg-amber-400/10 px-3 py-2 text-xs text-amber-200">
3792
+ “{selected.name}” is disabled in the config — it will be rejected by the backend. To enable it, set{' '}
3793
+ <code className="font-mono">NEXUS_PAYMENTS_{selected.name.toUpperCase()}_ENABLED=true</code> (plus its keys) in the project <code className="font-mono">.env</code>, or <code className="font-mono">payments.{selected.name}.enabled: true</code> in <code className="font-mono">nexus.config.ts</code>, then restart the backend. A provider also activates automatically once its keys are present.
3794
+ </p>
3795
+ )}
3796
+ {result && (
3797
+ <pre className="glass-code max-h-64 overflow-auto p-3">{JSON.stringify(result, null, 2)}</pre>
3798
+ )}
3799
+ </section>
3800
+
3801
+ {keyProvider && (
3802
+ <PaymentKeysDialog
3803
+ provider={keyProvider}
3804
+ value={keyDraft}
3805
+ onChange={setKeyDraft}
3806
+ onSave={saveKeys}
3807
+ onCancel={() => { setKeyProvider(null); setKeyMsg(''); }}
3808
+ busy={keyBusy}
3809
+ msg={keyMsg}
3810
+ msgKind={keyMsgKind}
3811
+ />
3812
+ )}
3813
+ </div>
3814
+ );
3815
+ }
3816
+
3817
+ function PaymentKeysDialog({ provider, value, onChange, onSave, onCancel, busy, msg, msgKind }: {
3818
+ provider: PaymentProviderStatus;
3819
+ value: Record<string, string>;
3820
+ onChange: (v: Record<string, string>) => void;
3821
+ onSave: () => void;
3822
+ onCancel: () => void;
3823
+ busy: boolean;
3824
+ msg: string;
3825
+ msgKind: 'ok' | 'err';
3826
+ }) {
3827
+ useEffect(() => {
3828
+ const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onCancel(); };
3829
+ window.addEventListener('keydown', onKey);
3830
+ return () => window.removeEventListener('keydown', onKey);
3831
+ }, [onCancel]);
3832
+
3833
+ const fields = provider.fields?.length ? provider.fields : (provider.name === 'skrill' ? [{ field: 'merchantEmail', label: 'Merchant email', hasValue: false }] : []);
3834
+
3835
+ return (
3836
+ <div className="provider-modal-backdrop" onClick={onCancel}>
3837
+ <div className="provider-modal space-y-4" role="dialog" aria-modal="true" aria-labelledby="payment-keys-title" onClick={(event) => event.stopPropagation()}>
3838
+ <div className="flex items-start justify-between gap-3">
3839
+ <div>
3840
+ <span className="admin-overline">PAYMENT CREDENTIAL</span>
3841
+ <h3 id="payment-keys-title" className="mt-1 text-base font-semibold text-slate-100">{provider.name}</h3>
3842
+ <code className="text-xs text-slate-500">{provider.name}</code>
3843
+ </div>
3844
+ <button type="button" onClick={onCancel} disabled={busy} className="glass-chip-btn text-xs" aria-label="Close payment keys dialog">✕</button>
3845
+ </div>
3846
+
3847
+ <div className="rounded-xl border border-sky-400/20 bg-black/20 px-3 py-2 text-xs text-slate-400">
3848
+ Keys are stored server-side in <code className="font-mono">.env</code> as <code className="font-mono">NEXUS_PAYMENTS_{provider.name.toUpperCase()}_*</code> and never written to the config file.
3849
+ </div>
3850
+
3851
+ {msg && (
3852
+ <div role="alert" className={`rounded-lg border px-3 py-1.5 text-sm ${msgKind === 'ok' ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300' : 'border-rose-500/30 bg-rose-500/10 text-rose-300'}`}>
3853
+ {msg}
3854
+ </div>
3855
+ )}
3856
+
3857
+ <div className="space-y-3">
3858
+ {fields.map((f) => (
3859
+ <label key={f.field} className="block">
3860
+ <span className="admin-overline">{f.label}</span>
3861
+ <input
3862
+ className="glass-input mt-1 text-sm font-mono"
3863
+ type="password"
3864
+ id={`payment-${f.field}`}
3865
+ name={`payment-${f.field}`}
3866
+ autoComplete="new-password"
3867
+ placeholder={f.hasValue ? `•••••••• (enter new to replace)` : `paste ${f.label.toLowerCase()}`}
3868
+ value={value[f.field] ?? ''}
3869
+ onChange={(event) => onChange({ ...value, [f.field]: event.target.value })}
3870
+ onKeyDown={(event) => {
3871
+ if (event.key === 'Enter' && !busy) { event.preventDefault(); onSave(); }
3872
+ }}
3873
+ />
3874
+ </label>
3875
+ ))}
3876
+ </div>
3877
+
3878
+ <div className="flex justify-end gap-2 border-t border-sky-400/20 pt-3">
3879
+ <button type="button" onClick={onCancel} disabled={busy} className="glass-chip-btn">Cancel</button>
3880
+ <button type="button" onClick={onSave} disabled={busy || fields.length === 0} className="glass-chip-btn-primary">
3881
+ {busy ? 'Saving…' : 'Save keys'}
3882
+ </button>
3883
+ </div>
3884
+ </div>
3885
+ </div>
3886
+ );
3887
+ }
3888
+
3889
+ function Databases() {
3890
+ const [databases, setDatabases] = useState<DatabaseInfo[]>([]);
3891
+ const [msg, setMsg] = useAdminAlert('databases');
3892
+ const [newDb, setNewDb] = useState('');
3893
+ const [expanded, setExpanded] = useState<string | null>(null);
3894
+ const [docs, setDocs] = useState<Record<string, { count: number; docs: unknown[] }>>({});
3895
+ const [renames, setRenames] = useState<Record<string, string>>({});
3896
+ const [newColl, setNewColl] = useState<Record<string, string>>({});
3897
+ const [confirm, setConfirm] = useState<{ title: string; message: React.ReactNode; action: () => Promise<void> } | null>(null);
3898
+ const [busy, setBusy] = useState(false);
3899
+
3900
+ const refresh = async () => {
3901
+ try { setDatabases((await getDatabases()).databases ?? []); setMsg(''); } catch (e: any) { setMsg(String(e?.message ?? e)); }
3902
+ };
3903
+ useEffect(() => { refresh(); }, []);
3904
+
3905
+ const withOk = async (fn: () => Promise<any>): Promise<boolean> => {
3906
+ setMsg('');
3907
+ try { await fn(); return true; } catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); return false; }
3908
+ };
3909
+
3910
+ const toggleDocs = async (db: string, coll: string) => {
3911
+ const key = `${db}/${coll}`;
3912
+ if (docs[key]) { const next = { ...docs }; delete next[key]; setDocs(next); return; }
3913
+ try { setDocs({ ...docs, [key]: await getCollectionDocs(db, coll) }); } catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); }
3914
+ };
3915
+
3916
+ const doRename = async (db: string, coll: string) => {
3917
+ const newName = (renames[`${db}/${coll}`] ?? '').trim();
3918
+ if (!newName) return;
3919
+ if (await withOk(() => modifyCollection(db, coll, { newName }))) { await refresh(); setRenames({ ...renames, [`${db}/${coll}`]: '' }); }
3920
+ };
3921
+
3922
+ const doCreateColl = async (db: string) => {
3923
+ const name = (newColl[db] ?? '').trim();
3924
+ if (!name) return;
3925
+ if (await withOk(() => createCollection(db, { name }))) { await refresh(); setNewColl({ ...newColl, [db]: '' }); }
3926
+ };
3927
+
3928
+ const runConfirm = async () => {
3929
+ if (!confirm) return;
3930
+ setBusy(true);
3931
+ try { await confirm.action(); } finally { setBusy(false); setConfirm(null); }
3932
+ };
3933
+
3934
+ return (
3935
+ <div className="space-y-6">
3936
+ <PageHead title="Databases">
3937
+ <button onClick={refresh} className="glass-chip-btn">↻ refresh</button>
3938
+ </PageHead>
3939
+ <PageHero kicker="DATA FOUNDATION" title={<>Your data, <em>organized.</em></>} desc="Databases and collections backing your project — inspect, create and keep the layer that stores everything sane." glyph="▦" art="cubes" />
3940
+
3941
+ <div className="glass-card flex gap-2">
3942
+ <input className="glass-input max-w-xs" placeholder="new database name" value={newDb} onChange={(e) => setNewDb(e.target.value)} />
3943
+ <button onClick={async () => { if (newDb.trim() && await withOk(() => createDatabase(newDb.trim()))) { setNewDb(''); await refresh(); } }} className="glass-btn-primary">Create database</button>
3944
+ </div>
3945
+
3946
+ <div className="space-y-3">
3947
+ {databases.map((db) => (
3948
+ <div key={db.name} className="glass-card">
3949
+ <div className="flex items-center gap-3">
3950
+ <button onClick={() => setExpanded(expanded === db.name ? null : db.name)} className="cursor-pointer font-mono text-base font-semibold text-slate-100 transition-colors hover:text-indigo-300">
3951
+ <span className={`mr-1 inline-block transition-transform ${expanded === db.name ? 'rotate-90' : ''}`}>â–¸</span>{db.name}
3952
+ </button>
3953
+ <span className="text-xs text-slate-500">{(db.sizeOnDisk / 1024).toFixed(1)} KB · {db.collections.length} collections</span>
3954
+ <button onClick={() => setConfirm({ title: `Drop database “${db.name}”?`, message: <>This will permanently delete the database <strong>{db.name}</strong> and all of its collections. This cannot be undone.</>, action: async () => { if (await withOk(() => deleteDatabase(db.name))) await refresh(); } })} className="glass-chip-btn-danger ml-auto">drop</button>
3955
+ </div>
3956
+
3957
+ {expanded === db.name && (
3958
+ <div className="mt-4 space-y-2.5">
3959
+ <div className="flex gap-2">
3960
+ <input className="glass-input max-w-xs !py-1.5 text-sm" placeholder="new collection" value={newColl[db.name] ?? ''} onChange={(e) => setNewColl({ ...newColl, [db.name]: e.target.value })} />
3961
+ <button onClick={() => doCreateColl(db.name)} className="glass-chip-btn">create</button>
3962
+ </div>
3963
+ {db.collections.length === 0 && <p className="text-sm text-slate-500">No collections.</p>}
3964
+ {db.collections.map((c) => (
3965
+ <div key={`${db.name}/${c.name}`} className="flex flex-wrap items-center gap-2 border-t border-white/[0.06] pt-2.5 text-sm">
3966
+ <span className="font-mono text-slate-200">{c.name}</span>
3967
+ <span className="text-xs text-slate-500">{c.count} docs</span>
3968
+ <button onClick={() => toggleDocs(db.name, c.name)} className="glass-chip-btn ml-auto">docs</button>
3969
+ <input className="glass-input w-36 !py-1 text-xs" placeholder="rename to…" value={renames[`${db.name}/${c.name}`] ?? ''} onChange={(e) => setRenames({ ...renames, [`${db.name}/${c.name}`]: e.target.value })} />
3970
+ <button onClick={() => doRename(db.name, c.name)} className="glass-chip-btn">rename</button>
3971
+ <button onClick={() => setConfirm({ title: `Drop collection “${c.name}”?`, message: <>This will permanently delete the collection <strong>{db.name}/{c.name}</strong> and its {c.count} document{c.count === 1 ? '' : 's'}. This cannot be undone.</>, action: async () => { if (await withOk(() => dropCollection(db.name, c.name))) await refresh(); } })} className="glass-chip-btn-danger">drop</button>
3972
+ </div>
3973
+ ))}
3974
+ </div>
3975
+ )}
3976
+ </div>
3977
+ ))}
3978
+ {!databases.length && !msg && <p className="text-sm text-slate-500">No databases found.</p>}
3979
+ {Object.entries(docs).map(([key, val]) => {
3980
+ const [db, coll] = key.split('/') as [string, string];
3981
+ return expanded === db ? (
3982
+ <div key={key} className="glass-code max-h-64 overflow-auto p-3 text-xs">
3983
+ <span className="text-slate-400">{db}/{coll} — {val.count} docs (showing {val.docs.length}):</span>
3984
+ <pre className="mt-1 font-mono">{JSON.stringify(val.docs[0] ?? {}, null, 2)}{val.docs.length > 1 ? `\n… and ${val.docs.length - 1} more` : ''}</pre>
3985
+ </div>
3986
+ ) : null;
3987
+ })}
3988
+ </div>
3989
+
3990
+ <ConfirmDialog
3991
+ open={!!confirm}
3992
+ title={confirm?.title ?? ''}
3993
+ message={confirm?.message ?? ''}
3994
+ danger
3995
+ busy={busy}
3996
+ confirmLabel="Drop"
3997
+ onConfirm={runConfirm}
3998
+ onCancel={() => setConfirm(null)}
3999
+ />
4000
+ </div>
4001
+ );
4002
+ }
4003
+
4004
+ function Schema() {
4005
+ const [prompt, setPrompt] = useState('');
4006
+ const [databases, setDatabases] = useState<DatabaseInfo[]>([]);
4007
+ const [db, setDb] = useState('');
4008
+ const [result, setResult] = useState<GeneratedSchema | null>(null);
4009
+ const [edited, setEdited] = useState('');
4010
+ const [busy, setBusy] = useState(false);
4011
+ const [msg, setMsg] = useAdminAlert('schema');
4012
+ const [aiStatus, setAiStatus] = useState<AiStatus | null>(null);
4013
+ const [aiErr, setAiErr] = useState('');
4014
+
4015
+ useEffect(() => { getDatabases().then((d) => setDatabases(d.databases ?? [])).catch(() => {}); }, []);
4016
+
4017
+ const checkAi = async () => {
4018
+ setAiErr('');
4019
+ try { setAiStatus(await getAiStatus()); } catch (e: any) { setAiErr(String(e?.message ?? e)); }
4020
+ };
4021
+ useEffect(() => { checkAi(); }, []);
4022
+
4023
+ const generate = async () => {
4024
+ if (!prompt.trim()) { setMsg('Describe the data you want to model, e.g. "products with a name, price and category".'); return; }
4025
+ setBusy(true); setMsg('');
4026
+ try {
4027
+ const g = await generateSchema(prompt);
4028
+ setResult(g);
4029
+ setEdited(JSON.stringify(g.jsonSchema, null, 2));
4030
+ setMsg(`Schema generated (${g.model}) — review and create.`);
4031
+ } catch (e: any) { setMsg(`Generation failed: ${e?.message ?? e} — check the AI status below (is the AI server running on :8000?).`); }
4032
+ finally { setBusy(false); }
4033
+ };
4034
+
4035
+ const create = async () => {
4036
+ setMsg('');
4037
+ let schema: unknown;
4038
+ try { schema = JSON.parse(edited); } catch { setMsg('Edited schema is not valid JSON.'); return; }
4039
+ const target = db || databases[0]?.name;
4040
+ if (!target) { setMsg('Create a database first (Databases tab), then pick it here.'); return; }
4041
+ try {
4042
+ await createCollection(target, { name: result?.collection ?? 'ai_schema', jsonSchema: schema });
4043
+ setMsg(`Created collection "${result?.collection ?? 'ai_schema'}" in "${target}" with the validator attached.`);
4044
+ } catch (e: any) { setMsg(`Create failed: ${e?.message ?? e}`); }
4045
+ };
4046
+
4047
+ return (
4048
+ <div className="space-y-6">
4049
+ <PageHead title="AI Schema Creation" />
4050
+ <PageHero kicker="AI WORKSPACE" title={<>Schemas, <em>born from language.</em></>} desc="Describe the data you need in plain English and let the model return a validated MongoDB schema you can review before it's live." glyph="✦" art="bolt" />
4051
+ <p className="text-sm text-slate-400">Describe the data in plain English — the AI returns a MongoDB <code className="rounded bg-white/10 px-1 font-mono text-xs">$jsonSchema</code> validator plus a field list, which you review and create as a validator-protected collection.</p>
4052
+
4053
+ <section className="glass-card space-y-3">
4054
+ <div className="flex items-center justify-between">
4055
+ <h3 className="glass-h3">AI provider status</h3>
4056
+ <button onClick={checkAi} className="glass-chip-btn">re-check</button>
4057
+ </div>
4058
+ {aiErr && <p className="text-xs text-rose-400">{aiErr}</p>}
4059
+ <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
4060
+ {aiStatus ? (
4061
+ <>
4062
+ {([
4063
+ ['AI server', aiStatus.aiServer, aiStatus.aiServer.detail ? `health: ${aiStatus.aiServer.detail.status ?? 'unknown'}` : undefined],
4064
+ ['Ollama', aiStatus.ollama, aiStatus.ollama.detail?.modelCount != null ? `${aiStatus.ollama.detail.modelCount} model(s) reachable` : undefined],
4065
+ ] as const).map(([label, s, detail]) => (
4066
+ <div key={label} className="glass flex items-center gap-2 rounded-xl px-3 py-2 text-sm">
4067
+ <span className={s.ok ? 'dot-ok' : 'dot-bad'} />
4068
+ <span className="font-medium text-slate-200">{label}</span>
4069
+ <span className={`text-xs ${s.ok ? 'text-emerald-400' : 'text-rose-400'}`}>{s.ok ? (detail ?? 'ok') : (s.error ?? 'down')}</span>
4070
+ </div>
4071
+ ))}
4072
+ </>
4073
+ ) : (
4074
+ <p className="col-span-3 text-xs text-slate-500">No status yet{aiErr ? '' : ' — run a check'}.</p>
4075
+ )}
4076
+ </div>
4077
+ {aiStatus && <p className="text-xs text-slate-500">"auto" currently resolves to <span className="font-mono text-slate-300">{aiStatus.autoResolvesTo}</span> on the AI server.</p>}
4078
+ </section>
4079
+
4080
+ <div className="glass-card space-y-3">
4081
+ <div className="flex items-center justify-between">
4082
+ <h3 className="glass-h3">Describe your collection</h3>
4083
+ <span className="inline-flex items-center gap-1.5 rounded-full border border-indigo-300/30 bg-indigo-400/10 px-2.5 py-1 text-[0.6rem] font-semibold uppercase tracking-wider text-indigo-300"><i className="dot-ok" /> AI auto</span>
4084
+ </div>
4085
+ <textarea className="glass-code h-32 w-full p-3 text-sm" placeholder='e.g. "a products collection with name, price (number), in-stock flag, category from a fixed enum, created date and an optional description"' value={prompt} onChange={(e) => setPrompt(e.target.value)} />
4086
+ <div className="flex flex-wrap items-center gap-2">
4087
+ <label className="mr-1.5 text-sm text-slate-400">Database</label>
4088
+ <select className="glass-input w-48 !py-1.5 text-sm" value={db} onChange={(e) => setDb(e.target.value)}>
4089
+ <option value="" className="bg-slate-900">{databases.length ? 'pick a database…' : 'no databases yet'}</option>
4090
+ {databases.map((d) => <option key={d.name} value={d.name} className="bg-slate-900">{d.name}</option>)}
4091
+ </select>
4092
+ <span className="text-xs text-slate-500">Provider &amp; model auto-selected on the AI server.</span>
4093
+ <button onClick={generate} disabled={busy} className="glass-btn-primary ml-auto">{busy ? 'Generating…' : 'Generate with AI'}</button>
4094
+ </div>
4095
+ </div>
4096
+
4097
+ {result && (
4098
+ <div className="space-y-3">
4099
+ <div className="glass-card space-y-3">
4100
+ <h3 className="glass-h3">Generated schema — <span className="font-mono text-indigo-300">{result.collection}</span></h3>
4101
+ <div className="max-h-48 overflow-auto rounded-xl border border-white/10">
4102
+ <table className="glass-table">
4103
+ <thead><tr><th>Field</th><th>Type</th><th className="text-center">Required</th><th className="text-center">Unique</th><th>Description</th></tr></thead>
4104
+ <tbody>
4105
+ {result.fields.map((f) => (
4106
+ <tr key={f.name}>
4107
+ <td className="font-mono text-slate-200">{f.name}</td>
4108
+ <td className="text-slate-300">{f.type}</td>
4109
+ <td className="text-center text-emerald-400">{f.required ? '✓' : ''}</td>
4110
+ <td className="text-center text-indigo-300">{f.unique ? '✓' : ''}</td>
4111
+ <td className="text-slate-400">{f.description ?? ''}</td>
4112
+ </tr>
4113
+ ))}
4114
+ </tbody>
4115
+ </table>
4116
+ </div>
4117
+ <div>
4118
+ <h4 className="glass-h3 mb-2">$jsonSchema validator <span className="ml-1 font-normal normal-case text-slate-500">(editable before creating)</span></h4>
4119
+ <textarea className="glass-code h-56 w-full p-3 text-xs" value={edited} onChange={(e) => setEdited(e.target.value)} spellCheck={false} />
4120
+ </div>
4121
+ <button onClick={create} className="glass-btn-success">Create collection with validator</button>
4122
+ </div>
4123
+ </div>
4124
+ )}
4125
+ </div>
4126
+ );
4127
+ }