@bhooai/nexus-cli 2.0.6 → 2.0.7

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 (61) hide show
  1. package/package.json +1 -1
  2. package/src/commands/add.ts +8 -3
  3. package/src/commands/dev.ts +18 -2
  4. package/src/commands/init.ts +3 -0
  5. package/src/devPanel.ts +138 -11
  6. package/src/devServiceManager.ts +210 -11
  7. package/src/index.ts +19 -0
  8. package/templates/base/apps/admin/package.json.ejs +1 -0
  9. package/templates/base/apps/admin/src/App.tsx +38 -4127
  10. package/templates/base/apps/admin/src/alertCenter.tsx +3 -2
  11. package/templates/base/apps/admin/src/api.ts +11 -11
  12. package/templates/base/apps/admin/src/components/ThemeCentre.tsx +201 -0
  13. package/templates/base/apps/admin/src/components/shell/AppearancePanel.tsx +37 -0
  14. package/templates/base/apps/admin/src/components/shell/Dashboard.tsx +441 -0
  15. package/templates/base/apps/admin/src/components/shell/Login.tsx +69 -0
  16. package/templates/base/apps/admin/src/components/shell/NotifyPanel.tsx +102 -0
  17. package/templates/base/apps/admin/src/components/shell/RailPanel.tsx +133 -0
  18. package/templates/base/apps/admin/src/components/shell/SettingsDialog.tsx +217 -0
  19. package/templates/base/apps/admin/src/components/ui/ConfirmDialog.tsx +38 -0
  20. package/templates/base/apps/admin/src/components/ui/LintChecks.tsx +54 -0
  21. package/templates/base/apps/admin/src/components/ui/PageHead.tsx +13 -0
  22. package/templates/base/apps/admin/src/components/ui/PageHero.tsx +15 -0
  23. package/templates/base/apps/admin/src/icons.tsx +104 -0
  24. package/templates/base/apps/admin/src/index.css +77 -19
  25. package/templates/base/apps/admin/src/lib/constants.ts +94 -0
  26. package/templates/base/apps/admin/src/lib/theme.ts +80 -0
  27. package/templates/base/apps/admin/src/lib/types.ts +39 -0
  28. package/templates/base/apps/admin/src/lib/utils.ts +147 -0
  29. package/templates/base/apps/admin/src/main.tsx.ejs +3 -2
  30. package/templates/base/apps/admin/src/pages/Config.tsx +108 -0
  31. package/templates/base/apps/admin/src/pages/Databases.tsx +122 -0
  32. package/templates/base/apps/admin/src/pages/Environment.tsx +69 -0
  33. package/templates/base/apps/admin/src/pages/Logs.tsx +270 -0
  34. package/templates/base/apps/admin/src/pages/Monitoring.tsx +88 -0
  35. package/templates/base/apps/admin/src/pages/Overview.tsx +108 -0
  36. package/templates/base/apps/admin/src/pages/Payments.tsx +68 -0
  37. package/templates/base/apps/admin/src/pages/Plugins.tsx +36 -0
  38. package/templates/base/apps/admin/src/pages/Processes.tsx +67 -0
  39. package/templates/base/apps/admin/src/pages/Schema.tsx +130 -0
  40. package/templates/base/apps/admin/src/pages/Users.tsx +144 -0
  41. package/templates/base/apps/admin/src/pages/ai/AddProviderDialog.tsx +150 -0
  42. package/templates/base/apps/admin/src/pages/ai/AiAgents.tsx +31 -0
  43. package/templates/base/apps/admin/src/pages/ai/AiChatPlayground.tsx +208 -0
  44. package/templates/base/apps/admin/src/pages/ai/AiProviders.tsx +276 -0
  45. package/templates/base/apps/admin/src/pages/ai/AiSettings.tsx +84 -0
  46. package/templates/base/apps/admin/src/pages/ai/ProviderCard.tsx +145 -0
  47. package/templates/base/apps/admin/src/pages/ai/ProviderGroup.tsx +50 -0
  48. package/templates/base/apps/admin/src/pages/ai/ProviderKeyDialog.tsx +66 -0
  49. package/templates/base/apps/admin/src/pages/monitoring/RequestSeriesChart.tsx +70 -0
  50. package/templates/base/apps/admin/src/pages/overview/TrafficChart.tsx +32 -0
  51. package/templates/base/apps/admin/src/pages/payments/OrdersTable.tsx +27 -0
  52. package/templates/base/apps/admin/src/pages/payments/PaymentKeysDialog.tsx +74 -0
  53. package/templates/base/apps/admin/src/pages/payments/TestConsole.tsx +173 -0
  54. package/templates/base/apps/admin/src/pages/payments/TransactionsTable.tsx +26 -0
  55. package/templates/base/apps/admin/src/style.css +6517 -0
  56. package/templates/base/apps/admin/vite.config.ts.ejs +13 -8
  57. package/templates/base/apps/ai-server/main.py.ejs +2 -2
  58. package/templates/base/apps/backend/src/routes/index.ts.ejs +1 -1
  59. package/templates/base/apps/frontend/src/App.tsx.ejs +10 -0
  60. package/templates/base/apps/frontend/src/main.tsx.ejs +2 -10
  61. package/templates/base/apps/frontend/vite.config.ts.ejs +9 -4
@@ -0,0 +1,108 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import type { Tab, ConfigValueType, ConfigEntry } from '../lib/types.js';
3
+ import { flattenConfig, buildConfig, groupEntries } from '../lib/utils.js';
4
+ import { getAdminConfig, putAdminConfig, putAdminConfigFile, runLintConfig, type AdminConfig } from '../api.js';
5
+ import { useAdminAlert } from '../alertCenter.js';
6
+ import { Icon } from '../icons.js';
7
+ import { PageHead } from '../components/ui/PageHead.js';
8
+ import { LintChecks } from '../components/ui/LintChecks.js';
9
+ import { ConfirmDialog } from '../components/ui/ConfirmDialog.js';
10
+
11
+ export function Config({ onNavigate }: { onNavigate?: (tab: Tab) => void }) {
12
+ const [cfg, setCfg] = useState<AdminConfig | null>(null);
13
+ const [entries, setEntries] = useState<ConfigEntry[]>([]);
14
+ const [fileText, setFileText] = useState('');
15
+ const [filePath, setFilePath] = useState('');
16
+ const [mode, setMode] = useState<'runtime' | 'source' | 'effective'>('runtime');
17
+ const [msg, setMsg] = useAdminAlert('config');
18
+ const [busy, setBusy] = useState(false);
19
+ const [restartDialog, setRestartDialog] = useState(false);
20
+ const [newKey, setNewKey] = useState('');
21
+ const [newType, setNewType] = useState<ConfigValueType>('string');
22
+ const [newValue, setNewValue] = useState('');
23
+
24
+ const addKey = () => {
25
+ if (!newKey.trim()) return;
26
+ setEntries([...entries, { key: newKey.trim(), value: newType === 'null' ? '' : newValue, type: newType }]);
27
+ setNewKey(''); setNewType('string'); setNewValue('');
28
+ };
29
+
30
+ const load = async () => {
31
+ try {
32
+ const c = await getAdminConfig();
33
+ setCfg(c);
34
+ const overrides = flattenConfig(c.runtime);
35
+ // Prefer the runtime overrides if there are any; otherwise seed the
36
+ // key/value grid from the current effective config so the user starts
37
+ // with the real values (redacted secret placeholders are skipped so they
38
+ // are never written back as literal overrides).
39
+ const base = overrides.length
40
+ ? overrides
41
+ : flattenConfig(c.config).filter((entry) => entry.value !== '***');
42
+ setEntries(base);
43
+ setFilePath(c.file?.path ?? '');
44
+ setFileText(c.file?.content ?? '');
45
+ setMsg(c.file ? '' : 'No human-edited config file found.');
46
+ } catch (e: any) { setMsg(String(e?.message ?? e)); }
47
+ };
48
+ useEffect(() => { load(); }, []);
49
+
50
+ const saveRuntime = async () => {
51
+ setBusy(true); setMsg('');
52
+ try {
53
+ const overrides = buildConfig(entries);
54
+ await putAdminConfig(overrides);
55
+ setMsg('Saved to nexus.runtime.json. Restart services to apply changes.');
56
+ setRestartDialog(true);
57
+ } catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); }
58
+ finally { setBusy(false); }
59
+ };
60
+
61
+ const saveFile = async () => {
62
+ if (!filePath) { setMsg('No config file found.'); return; }
63
+ setBusy(true); setMsg('');
64
+ try { await putAdminConfigFile(fileText); setMsg(`Saved ${filePath}. Restart services to apply changes.`); setRestartDialog(true); }
65
+ catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); }
66
+ finally { setBusy(false); }
67
+ };
68
+
69
+ return (
70
+ <div className="admin-view-stack">
71
+ <PageHead title="Configuration"><div className="heading-actions"><span className="restart-badge">RESTART REQUIRED AFTER SAVE</span><button onClick={load} className="workspace-action"><Icon name="refresh" size={12} /> Reload</button></div></PageHead>
72
+ <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>
73
+ <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>
74
+ {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">
75
+ <input value={newKey} onChange={(event) => setNewKey(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addKey(); }} placeholder="key path — e.g. server.port" />
76
+ <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>
77
+ <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'} />
78
+ </div><div className="key-value-table">
79
+ {groupEntries(entries).map(({ group, items }) => (
80
+ <div className="key-group" key={group}>
81
+ <div className="key-group-head"><span className="key-group-name">{group}</span><small>{items.length} key{items.length === 1 ? '' : 's'}</small></div>
82
+ <div className="key-value-head"><span>KEY PATH</span><span>TYPE</span><span>VALUE</span><span /></div>
83
+ {items.map(({ index, entry }) => (
84
+ <div className="key-value-row" key={`${entry.key}-${index}`}>
85
+ <input value={entry.key} onChange={(event) => setEntries(entries.map((item, itemIndex) => itemIndex === index ? { ...item, key: event.target.value } : item))} placeholder="server.port" />
86
+ <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>
87
+ <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'} />
88
+ <button className="row-delete" onClick={() => setEntries(entries.filter((_, itemIndex) => itemIndex !== index))} aria-label={`Delete ${entry.key || 'entry'}`}>×</button>
89
+ </div>
90
+ ))}
91
+ </div>
92
+ ))}
93
+ {!entries.length && <div className="key-value-empty">No configuration keys loaded.</div>}
94
+ </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>}
95
+ {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>}
96
+ {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>}
97
+ <LintChecks run={runLintConfig} title="Configuration checks" overline="VALIDATOR · nexus.runtime.json" />
98
+ <ConfirmDialog
99
+ open={restartDialog}
100
+ title="Restart required"
101
+ message="Your changes have been saved. Restart the backend services to apply the new configuration."
102
+ confirmLabel="Open services"
103
+ onConfirm={() => { setRestartDialog(false); onNavigate?.('processes'); }}
104
+ onCancel={() => setRestartDialog(false)}
105
+ />
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,122 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { getDatabases, createDatabase, deleteDatabase, createCollection, dropCollection, modifyCollection, getCollectionDocs, type DatabaseInfo } from '../api.js';
3
+ import { useAdminAlert } from '../alertCenter.js';
4
+ import { Icon } from '../icons.js';
5
+ import { PageHead } from '../components/ui/PageHead.js';
6
+ import { PageHero } from '../components/ui/PageHero.js';
7
+ import { ConfirmDialog } from '../components/ui/ConfirmDialog.js';
8
+
9
+ export function Databases() {
10
+ const [databases, setDatabases] = useState<DatabaseInfo[]>([]);
11
+ const [msg, setMsg] = useAdminAlert('databases');
12
+ const [newDb, setNewDb] = useState('');
13
+ const [expanded, setExpanded] = useState<string | null>(null);
14
+ const [docs, setDocs] = useState<Record<string, { count: number; docs: unknown[] }>>({});
15
+ const [renames, setRenames] = useState<Record<string, string>>({});
16
+ const [newColl, setNewColl] = useState<Record<string, string>>({});
17
+ const [confirm, setConfirm] = useState<{ title: string; message: React.ReactNode; action: () => Promise<void> } | null>(null);
18
+ const [busy, setBusy] = useState(false);
19
+
20
+ const refresh = async () => {
21
+ try { setDatabases((await getDatabases()).databases ?? []); setMsg(''); } catch (e: any) { setMsg(String(e?.message ?? e)); }
22
+ };
23
+ useEffect(() => { refresh(); }, []);
24
+
25
+ const withOk = async (fn: () => Promise<any>): Promise<boolean> => {
26
+ setMsg('');
27
+ try { await fn(); return true; } catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); return false; }
28
+ };
29
+
30
+ const toggleDocs = async (db: string, coll: string) => {
31
+ const key = `${db}/${coll}`;
32
+ if (docs[key]) { const next = { ...docs }; delete next[key]; setDocs(next); return; }
33
+ try { setDocs({ ...docs, [key]: await getCollectionDocs(db, coll) }); } catch (e: any) { setMsg(`Error: ${e?.message ?? e}`); }
34
+ };
35
+
36
+ const doRename = async (db: string, coll: string) => {
37
+ const newName = (renames[`${db}/${coll}`] ?? '').trim();
38
+ if (!newName) return;
39
+ if (await withOk(() => modifyCollection(db, coll, { newName }))) { await refresh(); setRenames({ ...renames, [`${db}/${coll}`]: '' }); }
40
+ };
41
+
42
+ const doCreateColl = async (db: string) => {
43
+ const name = (newColl[db] ?? '').trim();
44
+ if (!name) return;
45
+ if (await withOk(() => createCollection(db, { name }))) { await refresh(); setNewColl({ ...newColl, [db]: '' }); }
46
+ };
47
+
48
+ const runConfirm = async () => {
49
+ if (!confirm) return;
50
+ setBusy(true);
51
+ try { await confirm.action(); } finally { setBusy(false); setConfirm(null); }
52
+ };
53
+
54
+ return (
55
+ <div className="space-y-6">
56
+ <PageHead title="Databases">
57
+ <button onClick={refresh} className="glass-chip-btn"><Icon name="refresh" size={12} /> refresh</button>
58
+ </PageHead>
59
+ <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="database" art="cubes" />
60
+
61
+ <div className="glass-card flex gap-2">
62
+ <input className="glass-input max-w-xs" placeholder="new database name" value={newDb} onChange={(e) => setNewDb(e.target.value)} />
63
+ <button onClick={async () => { if (newDb.trim() && await withOk(() => createDatabase(newDb.trim()))) { setNewDb(''); await refresh(); } }} className="glass-btn-primary">Create database</button>
64
+ </div>
65
+
66
+ <div className="space-y-3">
67
+ {databases.map((db) => (
68
+ <div key={db.name} className="glass-card">
69
+ <div className="flex items-center gap-3">
70
+ <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">
71
+ <span className={`mr-1 inline-block transition-transform ${expanded === db.name ? 'rotate-90' : ''}`}><Icon name="chevronRight" size={14} /></span>{db.name}
72
+ </button>
73
+ <span className="text-xs text-slate-500">{(db.sizeOnDisk / 1024).toFixed(1)} KB · {db.collections.length} collections</span>
74
+ <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>
75
+ </div>
76
+
77
+ {expanded === db.name && (
78
+ <div className="mt-4 space-y-2.5">
79
+ <div className="flex gap-2">
80
+ <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 })} />
81
+ <button onClick={() => doCreateColl(db.name)} className="glass-chip-btn">create</button>
82
+ </div>
83
+ {db.collections.length === 0 && <p className="text-sm text-slate-500">No collections.</p>}
84
+ {db.collections.map((c) => (
85
+ <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">
86
+ <span className="font-mono text-slate-200">{c.name}</span>
87
+ <span className="text-xs text-slate-500">{c.count} docs</span>
88
+ <button onClick={() => toggleDocs(db.name, c.name)} className="glass-chip-btn ml-auto">docs</button>
89
+ <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 })} />
90
+ <button onClick={() => doRename(db.name, c.name)} className="glass-chip-btn">rename</button>
91
+ <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>
92
+ </div>
93
+ ))}
94
+ </div>
95
+ )}
96
+ </div>
97
+ ))}
98
+ {!databases.length && !msg && <p className="text-sm text-slate-500">No databases found.</p>}
99
+ {Object.entries(docs).map(([key, val]) => {
100
+ const [db, coll] = key.split('/') as [string, string];
101
+ return expanded === db ? (
102
+ <div key={key} className="glass-code max-h-64 overflow-auto p-3 text-xs">
103
+ <span className="text-slate-400">{db}/{coll} — {val.count} docs (showing {val.docs.length}):</span>
104
+ <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>
105
+ </div>
106
+ ) : null;
107
+ })}
108
+ </div>
109
+
110
+ <ConfirmDialog
111
+ open={!!confirm}
112
+ title={confirm?.title ?? ''}
113
+ message={confirm?.message ?? ''}
114
+ danger
115
+ busy={busy}
116
+ confirmLabel="Drop"
117
+ onConfirm={runConfirm}
118
+ onCancel={() => setConfirm(null)}
119
+ />
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,69 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import type { Tab } from '../lib/types.js';
3
+ import { groupEntries } from '../lib/utils.js';
4
+ import { getAdminEnv, putAdminEnv, runLintEnv, type AdminEnv, type EnvEntry } from '../api.js';
5
+ import { useAdminAlert } from '../alertCenter.js';
6
+ import { Icon } from '../icons.js';
7
+ import { PageHead } from '../components/ui/PageHead.js';
8
+ import { LintChecks } from '../components/ui/LintChecks.js';
9
+ import { ConfirmDialog } from '../components/ui/ConfirmDialog.js';
10
+
11
+ export function Environment({ onNavigate }: { onNavigate?: (tab: Tab) => void }) {
12
+ const [data, setData] = useState<AdminEnv | null>(null);
13
+ const [entries, setEntries] = useState<EnvEntry[]>([]);
14
+ const [msg, setMsg] = useAdminAlert('environment');
15
+ const [busy, setBusy] = useState(false);
16
+ const [restartDialog, setRestartDialog] = useState(false);
17
+ const [newKey, setNewKey] = useState('');
18
+ const [newValue, setNewValue] = useState('');
19
+ const [file, setFile] = useState('.env');
20
+ const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
21
+ 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)); } };
22
+ useEffect(() => { load(); }, []);
23
+ const addVariable = () => {
24
+ if (!newKey.trim()) return;
25
+ const secret = /SECRET|PASSWORD|PASS|TOKEN|PRIVATE|API_KEY|CLIENT_SECRET|ACCESS_KEY|KEY_SECRET/i.test(newKey);
26
+ setEntries([...entries, { key: newKey.trim(), value: newValue, secret }]);
27
+ setNewKey(''); setNewValue('');
28
+ };
29
+ 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); } };
30
+ return (
31
+ <div className="admin-view-stack">
32
+ <PageHead title="Environment"><div className="heading-actions"><span className="secret-badge">SECRETS MASKED</span><button onClick={() => load()} className="workspace-action"><Icon name="refresh" size={12} /> Reload</button></div></PageHead>
33
+ <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"><Icon name="lock" size={26} /></i><span>PRIVATE</span></div></div>
34
+ <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">
35
+ <input value={newKey} onChange={(event) => setNewKey(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') addVariable(); }} placeholder="NEXUS_SERVER_PORT" />
36
+ <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>
37
+ </div><div className="key-value-table env-table">{groupEntries(entries).map(({ group, items }) => (
38
+ <div className="key-group" key={group}>
39
+ <div className="key-group-head"><span className="key-group-name">{group}</span><small>{items.length} variable{items.length === 1 ? '' : 's'}</small></div>
40
+ <div className="key-value-head"><span>VARIABLE</span><span>VALUE</span></div>
41
+ {items.map(({ index, entry }) => (
42
+ <div className="key-value-row" key={`${entry.key}-${index}`}>
43
+ <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" />
44
+ <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>
45
+ </div>
46
+ ))}
47
+ </div>
48
+ ))}
49
+ {!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>
50
+ <LintChecks run={() => runLintEnv(file)} title="Environment checks" overline={`VALIDATOR · ${file}`} />
51
+ <ConfirmDialog
52
+ open={restartDialog}
53
+ title="Restart required"
54
+ message="Your environment changes have been saved. Restart the backend services to apply the new variables."
55
+ confirmLabel="Open services"
56
+ onConfirm={() => { setRestartDialog(false); onNavigate?.('processes'); }}
57
+ onCancel={() => setRestartDialog(false)}
58
+ />
59
+ <ConfirmDialog
60
+ open={deleteTarget !== null}
61
+ title="Delete variable"
62
+ message={`Delete "${deleteTarget}" from ${file}? This removes it from the file on the next save.`}
63
+ confirmLabel="Delete"
64
+ onConfirm={() => { if (deleteTarget) setEntries(entries.filter((e) => e.key !== deleteTarget)); setDeleteTarget(null); }}
65
+ onCancel={() => setDeleteTarget(null)}
66
+ />
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,270 @@
1
+ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
+ import type { Tab } from '../lib/types.js';
3
+ import { getAllLogs, getServices, clearLogs, getAiStatus, getAiProviders, getAiModels, aiChatStream, type LogEntry, type AiProviderView, type AiChatMessage } from '../api.js';
4
+ import { Icon } from '../icons.js';
5
+ import { PageHead } from '../components/ui/PageHead.js';
6
+ import { PageHero } from '../components/ui/PageHero.js';
7
+
8
+ /** Color of a service tag in the log viewer (mirrors the supervisor prefixes). */
9
+ const SERVICE_TAG_COLORS: Record<string, string> = {
10
+ backend: 'text-emerald-400 border-emerald-400/30 bg-emerald-400/10',
11
+ frontend: 'text-cyan-300 border-cyan-300/30 bg-cyan-300/10',
12
+ 'ai-server': 'text-amber-300 border-amber-300/30 bg-amber-300/10',
13
+ admin: 'text-pink-300 border-pink-300/30 bg-pink-300/10',
14
+ };
15
+ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
16
+ error: 'log-row--error',
17
+ warn: 'log-row--warn',
18
+ info: 'log-row--info',
19
+ };
20
+
21
+ export function Logs({ onNavigate }: { onNavigate: (tab: Tab) => void }) {
22
+ const [entries, setEntries] = useState<LogEntry[]>([]);
23
+ const [services, setServices] = useState<string[]>([]);
24
+ const [service, setService] = useState('all');
25
+ const [level, setLevel] = useState<'all' | LogEntry['level']>('all');
26
+ const [q, setQ] = useState('');
27
+ const [live, setLive] = useState(true);
28
+ const [sticky, setSticky] = useState(true);
29
+ const [copied, setCopied] = useState(false);
30
+ const [err, setErr] = useState('');
31
+ const [criticalFirst, setCriticalFirst] = useState(false);
32
+ const [aiSummary, setAiSummary] = useState('');
33
+ const [aiSummaryBusy, setAiSummaryBusy] = useState(false);
34
+ const [aiSummaryErr, setAiSummaryErr] = useState('');
35
+ const [aiSummaryOpen, setAiSummaryOpen] = useState(false);
36
+ const [aiProvider, setAiProvider] = useState('');
37
+ const [aiModel, setAiModel] = useState('');
38
+ const aiAbortRef = useRef<AbortController | null>(null);
39
+ const scrollRef = useRef<HTMLDivElement | null>(null);
40
+
41
+ const refresh = useCallback(async () => {
42
+ try {
43
+ const [logs, svc] = await Promise.all([getAllLogs(), getServices()]);
44
+ setEntries(logs);
45
+ setServices(svc.map((s) => s.name));
46
+ setErr('');
47
+ } catch (e: any) {
48
+ setErr(String(e?.message ?? e));
49
+ }
50
+ }, []);
51
+
52
+ useEffect(() => {
53
+ void refresh();
54
+ if (!live) return;
55
+ const t = setInterval(() => void refresh(), 1500);
56
+ return () => clearInterval(t);
57
+ }, [refresh, live]);
58
+
59
+ const filtered = useMemo(() => {
60
+ const needle = q.trim().toLowerCase();
61
+ return entries.filter((e) => {
62
+ if (service !== 'all' && e.service !== service) return false;
63
+ if (level !== 'all' && e.level !== level) return false;
64
+ if (needle && !e.line.toLowerCase().includes(needle)) return false;
65
+ return true;
66
+ });
67
+ }, [entries, service, level, q]);
68
+
69
+ const counts = useMemo(() => {
70
+ const c = { error: 0, warn: 0, info: 0 };
71
+ for (const e of entries) c[e.level] += 1;
72
+ return c;
73
+ }, [entries]);
74
+
75
+ const ordered = useMemo(() => {
76
+ if (!criticalFirst) return filtered;
77
+ const rank = (e: LogEntry) => {
78
+ const lvl = e.level === 'error' ? 0 : e.level === 'warn' ? 1 : 2;
79
+ const src = e.source === 'stderr' ? 0 : e.source === 'system' ? 1 : 2;
80
+ return lvl * 3 + src;
81
+ };
82
+ return [...filtered].sort((a, b) => rank(a) - rank(b) || b.ts - a.ts);
83
+ }, [filtered, criticalFirst]);
84
+
85
+ const criticalRunEnd = useMemo(() => {
86
+ if (!criticalFirst) return 0;
87
+ let i = 0;
88
+ while (i < ordered.length && ordered[i].level !== 'info') i += 1;
89
+ return i;
90
+ }, [ordered, criticalFirst]);
91
+
92
+ useEffect(() => {
93
+ const el = scrollRef.current;
94
+ if (el && sticky) el.scrollTop = criticalFirst ? 0 : el.scrollHeight;
95
+ }, [ordered, sticky, criticalFirst]);
96
+
97
+ const onScroll = () => {
98
+ const el = scrollRef.current;
99
+ if (!el) return;
100
+ setSticky(criticalFirst ? el.scrollTop < 80 : el.scrollHeight - el.scrollTop - el.clientHeight < 80);
101
+ };
102
+
103
+ const copyAll = async () => {
104
+ const text = filtered.map((e) => `[${e.service}] ${e.line}`).join('\n');
105
+ try { await navigator.clipboard.writeText(text); } catch { /* ignore */ }
106
+ setCopied(true);
107
+ window.setTimeout(() => setCopied(false), 1200);
108
+ };
109
+
110
+ const runAiSummary = async () => {
111
+ if (aiSummaryBusy) return;
112
+ if (!ordered.length) {
113
+ setAiSummary('');
114
+ setAiSummaryErr('No log lines to summarize — widen the filters first.');
115
+ setAiSummaryOpen(true);
116
+ return;
117
+ }
118
+ try {
119
+ let provider = aiProvider;
120
+ let model = aiModel;
121
+ if (!provider || !model) {
122
+ const s = await getAiStatus();
123
+ let pv: AiProviderView | undefined;
124
+ try { pv = (await getAiProviders()).providers?.find((p) => p.enabled); } catch { /* fall back to status */ }
125
+ if (pv) {
126
+ provider = pv.id;
127
+ model = pv.defaultModel ?? '';
128
+ }
129
+ if (!provider) provider = s.autoResolvesTo ?? (s.ollama?.ok ? 'ollama' : s.openai?.ok ? 'openai' : '');
130
+ if (!provider) throw new Error('No AI provider is available. Configure one in the AI Agents tab.');
131
+ if (!model) {
132
+ try { model = (await getAiModels(provider)).data?.[0]?.id ?? ''; } catch { /* fall back below */ }
133
+ }
134
+ if (!model) model = 'llama3:latest';
135
+ setAiProvider(provider);
136
+ setAiModel(model);
137
+ }
138
+ const sample = ordered.slice(-400).map((e) => `[${new Date(e.ts).toLocaleTimeString()}] ${e.service} ${e.source} ${e.level}: ${e.line}`).join('\n');
139
+ const messages: AiChatMessage[] = [
140
+ { 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.' },
141
+ { role: 'user', content: `Service logs (${sample.split('\n').length} lines):\n${sample}` },
142
+ ];
143
+ const controller = new AbortController();
144
+ aiAbortRef.current = controller;
145
+ setAiSummary('');
146
+ setAiSummaryErr('');
147
+ setAiSummaryOpen(true);
148
+ setAiSummaryBusy(true);
149
+ let acc = '';
150
+ try {
151
+ await aiChatStream(model, messages, provider, (chunk) => { acc += chunk; setAiSummary(acc); }, controller.signal);
152
+ setAiSummary(acc || 'AI returned an empty summary.');
153
+ } catch (e: any) {
154
+ if (e?.name === 'AbortError') return;
155
+ setAiSummaryErr(String(e?.message ?? e));
156
+ } finally {
157
+ aiAbortRef.current = null;
158
+ setAiSummaryBusy(false);
159
+ }
160
+ } catch (e: any) {
161
+ setAiSummary('');
162
+ setAiSummaryErr(String(e?.message ?? e));
163
+ setAiSummaryOpen(true);
164
+ setAiSummaryBusy(false);
165
+ }
166
+ };
167
+
168
+ const stopAiSummary = () => { aiAbortRef.current?.abort(); };
169
+
170
+ const copyAiSummary = async () => {
171
+ try { await navigator.clipboard.writeText(aiSummary); } catch { /* ignore */ }
172
+ };
173
+
174
+ return (
175
+ <div className="space-y-6">
176
+ <PageHead title="Logs">
177
+ <div className="flex items-center gap-2">
178
+ <button aria-label="Refresh logs" onClick={() => void refresh()} className="glass-chip-btn"><Icon name="refresh" size={12} /> refresh</button>
179
+ <button aria-pressed={live} onClick={() => setLive(!live)} className={`glass-chip-btn log-live-btn${live ? ' is-live' : ''}`}>{live ? '● live' : '○ paused'}</button>
180
+ <button aria-label="Copy filtered logs" onClick={() => void copyAll()} className="glass-chip-btn">{copied ? 'Copied' : <><Icon name="copy" size={12} /> copy</>}</button>
181
+ <button aria-label="Summarize logs with AI" onClick={() => void runAiSummary()} disabled={aiSummaryBusy} className="glass-chip-btn log-ai-btn"><Icon name="sparkles" size={12} /> {aiSummaryBusy ? 'summarizing…' : 'ai summary'}</button>
182
+ <button aria-label="Clear all logs" onClick={async () => { try { await clearLogs(); await refresh(); } catch { /* supervisor may not be reachable */ } }} className="glass-chip-btn-danger"><Icon name="trash" size={12} /> clear</button>
183
+ </div>
184
+ </PageHead>
185
+ <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="terminal" art="wave" />
186
+ {err && (
187
+ <div role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-400">
188
+ Logs unavailable — {err}. Is <code className="font-mono">nexus dev</code> running?
189
+ </div>
190
+ )}
191
+ <div className="glass-card log-card">
192
+ <div className="log-stat-strip">
193
+ <span className="log-stat log-stat--all"><i /><b>{entries.length}</b><em>Total lines</em></span>
194
+ <span className="log-stat log-stat--error"><i /><b>{counts.error}</b><em>Errors</em></span>
195
+ <span className="log-stat log-stat--warn"><i /><b>{counts.warn}</b><em>Warnings</em></span>
196
+ <span className="log-stat log-stat--info"><i /><b>{counts.info}</b><em>Info</em></span>
197
+ </div>
198
+ <div className="log-toolbar">
199
+ <div className="glass-panel log-filter-group" role="group" aria-label="Filter by level">
200
+ {([['all', 'All'], ['error', 'Errors'], ['warn', 'Warnings'], ['info', 'Info']] as const).map(([id, label]) => (
201
+ <button key={id} aria-pressed={level === id} onClick={() => { setLevel(id); setSticky(true); }} className={level === id ? 'log-filter-btn is-active' : 'log-filter-btn'}>
202
+ {label} <b>{id === 'all' ? entries.length : counts[id]}</b>
203
+ </button>
204
+ ))}
205
+ </div>
206
+ <button aria-pressed={criticalFirst} onClick={() => { setCriticalFirst(!criticalFirst); setSticky(true); }} className={criticalFirst ? 'log-crit-btn is-active' : 'log-crit-btn'}>⤓ critical first</button>
207
+ <div className="log-toolbar-right">
208
+ <select value={service} onChange={(e) => { setService(e.target.value); setSticky(true); }} aria-label="Filter by service" className="glass-input log-service-input">
209
+ <option value="all" className="bg-slate-900">All services</option>
210
+ {services.map((s) => <option key={s} value={s} className="bg-slate-900">{s}</option>)}
211
+ </select>
212
+ <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" />
213
+ </div>
214
+ </div>
215
+ {aiSummaryOpen && (
216
+ <div className="log-ai-panel">
217
+ <div className="log-ai-head">
218
+ <span className="log-ai-title"><Icon name="sparkles" size={12} /> AI summary{aiModel ? ` · ${aiModel}` : ''}</span>
219
+ <div className="log-ai-actions">
220
+ {aiSummaryBusy && <button onClick={stopAiSummary} className="glass-chip-btn-danger"><Icon name="square" size={10} /> stop</button>}
221
+ {!aiSummaryBusy && aiSummary && <button onClick={() => void copyAiSummary()} className="glass-chip-btn"><Icon name="copy" size={12} /> copy</button>}
222
+ <button onClick={() => { setAiSummaryOpen(false); setAiSummary(''); setAiSummaryErr(''); }} className="glass-chip-btn"><Icon name="x" size={12} /> dismiss</button>
223
+ </div>
224
+ </div>
225
+ {aiSummaryErr && (
226
+ <div role="alert" className="log-ai-err">
227
+ AI summary failed: {aiSummaryErr}
228
+ {aiSummaryErr.includes('AI provider') && (
229
+ <button onClick={() => onNavigate('ai')} className="glass-chip-btn log-ai-open">Open AI Agents →</button>
230
+ )}
231
+ </div>
232
+ )}
233
+ {aiSummaryBusy && !aiSummary && <div className="log-ai-note">Analyzing {ordered.length} lines…</div>}
234
+ {aiSummary && <pre className="log-ai-body">{aiSummary}</pre>}
235
+ </div>
236
+ )}
237
+ <div ref={scrollRef} onScroll={onScroll} className="log-viewer" role="list" aria-label="Service log output" tabIndex={0}>
238
+ <div className="log-colhead" aria-hidden="true">
239
+ <span className="log-time">Time</span>
240
+ <span className="log-service">Service</span>
241
+ <span className="log-source">Source</span>
242
+ <span className="log-level">Level</span>
243
+ <span className="log-line">Message</span>
244
+ </div>
245
+ {ordered.length === 0 ? (
246
+ <div className="log-empty">
247
+ <span className="log-empty-glyph"><Icon name="terminal" size={26} /></span>
248
+ <strong>No log lines match the current filters.</strong>
249
+ <p>Try widening the level filter or clearing the search text.</p>
250
+ </div>
251
+ ) : (
252
+ ordered.map((e, i) => (
253
+ <div key={`${e.ts}-${i}`} className={`log-row ${LEVEL_CLASS[e.level]}${criticalFirst && i < criticalRunEnd ? ' log-row--critical' : ''}`} role="listitem">
254
+ <span className="log-time">{new Date(e.ts).toLocaleTimeString()}</span>
255
+ <span className={`log-service log-tag ${SERVICE_TAG_COLORS[e.service] ?? 'log-tag--default'}`}>{e.service}</span>
256
+ <span className={`log-source log-source--${e.source}`}>{e.source}</span>
257
+ <span className={`log-level log-level--${e.level}`}>{e.level}</span>
258
+ <span className="log-line">{e.line || ' '}</span>
259
+ </div>
260
+ ))
261
+ )}
262
+ </div>
263
+ <div className="log-footer">
264
+ <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>
265
+ {!sticky && <button onClick={() => setSticky(true)} className="glass-chip-btn">{criticalFirst ? 'â–² jump to top' : 'â–¼ jump to latest'}</button>}
266
+ </div>
267
+ </div>
268
+ </div>
269
+ );
270
+ }