2ndbrain 2026.1.30 → 2026.1.32

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.
@@ -0,0 +1,1119 @@
1
+ import express from 'express';
2
+ import { createServer } from 'node:http';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { migrate, getMigrationFiles, ensureMigrationsTable } from '../db/migrate.js';
7
+
8
+ // Resolve project root (two directories up from src/web/)
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const DEFAULT_ENV_PATH = path.resolve(__dirname, '..', '..', '.env');
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Settings field definitions -- drives both the form UI and save logic
15
+ // ---------------------------------------------------------------------------
16
+
17
+ const SETTINGS_FIELDS = [
18
+ {
19
+ section: 'Telegram',
20
+ fields: [
21
+ { key: 'TELEGRAM_BOT_TOKEN', label: 'Bot Token', required: true, secret: true, hint: 'Telegram Bot API token from @BotFather' },
22
+ { key: 'TELEGRAM_ALLOWED_USERS', label: 'Allowed Users', required: true, hint: 'Comma-separated Telegram user IDs' },
23
+ ],
24
+ },
25
+ {
26
+ section: 'Database',
27
+ fields: [
28
+ { key: 'DATABASE_URL', label: 'Database URL', required: true, secret: true, hint: 'PostgreSQL connection string (e.g. postgresql://user:pass@localhost/2ndbrain)' },
29
+ ],
30
+ },
31
+ {
32
+ section: 'Claude',
33
+ fields: [
34
+ { key: 'CLAUDE_MODEL', label: 'Model', hint: 'Default: claude-sonnet-4-20250514' },
35
+ { key: 'CLAUDE_THINKING', label: 'Thinking', type: 'boolean', hint: 'Enable extended thinking' },
36
+ { key: 'CLAUDE_TIMEOUT', label: 'Timeout (ms)', hint: 'Default: 120000' },
37
+ { key: 'CLAUDE_MAX_BUDGET', label: 'Max Budget (USD)', hint: 'Max cost per invocation (e.g. 0.50)' },
38
+ ],
39
+ },
40
+ {
41
+ section: 'Storage',
42
+ fields: [
43
+ { key: 'DATA_DIR', label: 'Data Directory', hint: 'Default: ~/data' },
44
+ ],
45
+ },
46
+ {
47
+ section: 'Security',
48
+ fields: [
49
+ { key: 'COMMANDS_WHITELIST', label: 'Commands Whitelist', hint: 'Allowed shell command patterns (comma-separated)' },
50
+ { key: 'MCP_TOOLS_WHITELIST', label: 'MCP Tools Whitelist', hint: 'Allowed MCP tool names (* = all)' },
51
+ { key: 'FILE_EDIT_PATHS', label: 'File Edit Paths', hint: 'Additional writable directories (comma-separated absolute paths)' },
52
+ ],
53
+ },
54
+ {
55
+ section: 'Rate Limits',
56
+ fields: [
57
+ { key: 'RATE_LIMIT_CLAUDE', label: 'Claude Rate Limit', hint: 'Max Claude calls per minute (default: 10)' },
58
+ { key: 'RATE_LIMIT_TELEGRAM', label: 'Telegram Rate Limit', hint: 'Max Telegram sends per minute (default: 30)' },
59
+ ],
60
+ },
61
+ {
62
+ section: 'Conversation',
63
+ fields: [
64
+ { key: 'HISTORY_COMPACT_THRESHOLD', label: 'Compact Threshold', hint: 'Message count before auto-compaction (default: 100)' },
65
+ ],
66
+ },
67
+ {
68
+ section: 'Logging',
69
+ fields: [
70
+ { key: 'LOG_LEVEL', label: 'Log Level', hint: 'debug, info, warn, error (default: info)' },
71
+ ],
72
+ },
73
+ {
74
+ section: 'Web Admin',
75
+ fields: [
76
+ { key: 'WEB_PORT', label: 'Port', hint: 'Default: 3000' },
77
+ { key: 'WEB_BIND', label: 'Bind Address', hint: 'Default: 127.0.0.1' },
78
+ { key: 'AUTO_OPEN_BROWSER', label: 'Auto Open Browser', type: 'boolean', hint: 'Default: true' },
79
+ ],
80
+ },
81
+ {
82
+ section: 'Embeddings',
83
+ fields: [
84
+ { key: 'EMBEDDING_PROVIDER', label: 'Provider', hint: '"openai" or empty to disable' },
85
+ { key: 'EMBEDDING_API_KEY', label: 'API Key', secret: true, hint: 'API key for the embedding provider' },
86
+ { key: 'EMBEDDING_MODEL', label: 'Model', hint: 'Default: text-embedding-3-small' },
87
+ { key: 'EMBEDDING_DIMENSIONS', label: 'Dimensions', hint: 'Override output dimensions (empty = model default)' },
88
+ { key: 'EMBEDDING_BASE_URL', label: 'Base URL', hint: 'Override API base URL (empty = provider default)' },
89
+ ],
90
+ },
91
+ ];
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // WebServer
95
+ // ---------------------------------------------------------------------------
96
+
97
+ class WebServer {
98
+ /**
99
+ * @param {object} opts
100
+ * @param {object} opts.config - Application configuration object
101
+ * @param {object} opts.db - Database interface with query(text, params)
102
+ * @param {object} opts.logger - Logger instance with info/warn/error methods
103
+ */
104
+ constructor({ config, db, logger }) {
105
+ this._config = config;
106
+ this._db = db;
107
+ this._logger = logger;
108
+ this._server = null;
109
+ this._app = null;
110
+ this._envPath = config.ENV_PATH || DEFAULT_ENV_PATH;
111
+ }
112
+
113
+ /**
114
+ * Create the Express app, bind routes, and start listening.
115
+ * @returns {Promise<import('node:http').Server>} The HTTP server instance
116
+ */
117
+ async start() {
118
+ const app = express();
119
+ this._app = app;
120
+
121
+ // Body parsing
122
+ app.use(express.urlencoded({ extended: true }));
123
+ app.use(express.json());
124
+
125
+ // Routes
126
+ app.get('/', (req, res) => this._handleDashboard(req, res));
127
+ app.get('/settings', (req, res) => this._handleSettings(req, res));
128
+ app.post('/settings', (req, res) => this._handleSaveSettings(req, res));
129
+ app.get('/logs', (req, res) => this._handleLogs(req, res));
130
+ app.get('/health', (req, res) => this._handleHealth(req, res));
131
+ app.get('/database', (req, res) => this._handleDatabase(req, res));
132
+ app.post('/database/migrate', (req, res) => this._handleRunMigrations(req, res));
133
+
134
+ // Start listening
135
+ const server = createServer(app);
136
+ this._server = server;
137
+
138
+ return new Promise((resolve, reject) => {
139
+ server.once('error', reject);
140
+ server.listen(this._config.WEB_PORT, this._config.WEB_BIND, () => {
141
+ server.removeListener('error', reject);
142
+ this._logger.info(
143
+ 'web',
144
+ `Admin server listening on http://${this._config.WEB_BIND}:${this._config.WEB_PORT}`,
145
+ );
146
+ resolve(server);
147
+ });
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Gracefully close the HTTP server.
153
+ */
154
+ async stop() {
155
+ if (!this._server) return;
156
+ return new Promise((resolve, reject) => {
157
+ this._server.close((err) => {
158
+ this._server = null;
159
+ if (err) {
160
+ reject(err);
161
+ } else {
162
+ this._logger.info('web', 'Admin server stopped');
163
+ resolve();
164
+ }
165
+ });
166
+ });
167
+ }
168
+
169
+ // -----------------------------------------------------------------------
170
+ // Route handlers
171
+ // -----------------------------------------------------------------------
172
+
173
+ async _handleDashboard(_req, res) {
174
+ const data = {
175
+ uptime: process.uptime(),
176
+ memory: process.memoryUsage(),
177
+ messageCount: 0,
178
+ recentMessages: [],
179
+ activeSessionId: null,
180
+ embeddingStatus: this._config.EMBEDDING_PROVIDER ? 'enabled' : 'disabled',
181
+ recentErrors: [],
182
+ dbAvailable: true,
183
+ };
184
+
185
+ try {
186
+ const countRes = await this._db.query(
187
+ 'SELECT COUNT(*)::int AS count FROM conversation_messages',
188
+ );
189
+ data.messageCount = countRes.rows[0]?.count ?? 0;
190
+ } catch {
191
+ data.dbAvailable = false;
192
+ }
193
+
194
+ if (data.dbAvailable) {
195
+ try {
196
+ const recent = await this._db.query(
197
+ `SELECT id, created_at, session_id, role,
198
+ LEFT(content, 200) AS content
199
+ FROM conversation_messages
200
+ ORDER BY created_at DESC
201
+ LIMIT 10`,
202
+ );
203
+ data.recentMessages = recent.rows;
204
+ } catch { /* query failed, leave empty */ }
205
+
206
+ try {
207
+ const session = await this._db.query(
208
+ `SELECT session_id FROM conversation_messages
209
+ WHERE session_id IS NOT NULL
210
+ ORDER BY created_at DESC LIMIT 1`,
211
+ );
212
+ data.activeSessionId = session.rows[0]?.session_id ?? null;
213
+ } catch { /* leave null */ }
214
+
215
+ try {
216
+ const errors = await this._db.query(
217
+ `SELECT id, created_at, level, source, content
218
+ FROM system_logs
219
+ WHERE level = 'error'
220
+ ORDER BY created_at DESC
221
+ LIMIT 5`,
222
+ );
223
+ data.recentErrors = errors.rows;
224
+ } catch { /* leave empty */ }
225
+ }
226
+
227
+ res.send(dashboardHTML(data));
228
+ }
229
+
230
+ async _handleSettings(req, res) {
231
+ const message = req.query.saved === '1'
232
+ ? { type: 'success', text: 'Settings saved. Restart the service for changes to take effect.' }
233
+ : null;
234
+
235
+ res.send(settingsHTML(this._config, message));
236
+ }
237
+
238
+ async _handleSaveSettings(req, res) {
239
+ try {
240
+ const body = req.body;
241
+ const values = {};
242
+
243
+ for (const section of SETTINGS_FIELDS) {
244
+ for (const field of section.fields) {
245
+ const formValue = body[field.key];
246
+ if (formValue === undefined) continue;
247
+
248
+ // For secret fields, empty submission means "keep existing value"
249
+ if (field.secret && formValue === '') continue;
250
+
251
+ values[field.key] = formValue;
252
+ }
253
+ }
254
+
255
+ this._writeEnvFile(values);
256
+ this._logger.info('web', 'Settings updated via web admin');
257
+ res.redirect('/settings?saved=1');
258
+ } catch (err) {
259
+ this._logger.error('web', `Failed to save settings: ${err.message}`);
260
+ res.status(500).send(
261
+ layoutHTML('Error', `<h1>Failed to Save Settings</h1><p>${esc(err.message)}</p>`),
262
+ );
263
+ }
264
+ }
265
+
266
+ async _handleLogs(req, res) {
267
+ const level = req.query.level || '';
268
+ let logs = [];
269
+
270
+ try {
271
+ const validLevels = ['debug', 'info', 'warn', 'error'];
272
+ let sql = 'SELECT id, created_at, level, source, content FROM system_logs';
273
+ const params = [];
274
+
275
+ if (level && validLevels.includes(level)) {
276
+ sql += ' WHERE level = $1';
277
+ params.push(level);
278
+ }
279
+
280
+ sql += ' ORDER BY created_at DESC LIMIT 100';
281
+ const result = await this._db.query(sql, params);
282
+ logs = result.rows;
283
+ } catch { /* database unavailable, show empty */ }
284
+
285
+ res.send(logsHTML(logs, level));
286
+ }
287
+
288
+ async _handleHealth(_req, res) {
289
+ const health = {
290
+ status: 'ok',
291
+ components: {
292
+ database: 'ok',
293
+ telegram: 'unknown',
294
+ claude: 'unknown',
295
+ },
296
+ uptime: process.uptime(),
297
+ memory: process.memoryUsage(),
298
+ };
299
+
300
+ try {
301
+ await this._db.query('SELECT 1');
302
+ } catch {
303
+ health.components.database = 'error';
304
+ }
305
+
306
+ // Derive overall status from component states
307
+ const states = Object.values(health.components);
308
+ if (states.includes('error')) {
309
+ health.status = states.every((s) => s === 'error') ? 'error' : 'degraded';
310
+ }
311
+
312
+ const httpStatus = health.status === 'error' ? 503 : 200;
313
+ res.status(httpStatus).json(health);
314
+ }
315
+
316
+ // -----------------------------------------------------------------------
317
+ // Database page
318
+ // -----------------------------------------------------------------------
319
+
320
+ async _handleDatabase(req, res) {
321
+ const data = {
322
+ dbAvailable: true,
323
+ migrations: { applied: [], pending: [], total: 0 },
324
+ tables: [],
325
+ dbVersion: '',
326
+ dbSize: '',
327
+ message: null,
328
+ };
329
+
330
+ if (req.query.migrated) {
331
+ const count = parseInt(req.query.migrated, 10) || 0;
332
+ data.message = { type: 'success', text: `Successfully applied ${count} migration(s).` };
333
+ } else if (req.query.error) {
334
+ data.message = { type: 'error', text: req.query.error };
335
+ } else if (req.query.noop === '1') {
336
+ data.message = { type: 'success', text: 'No pending migrations to apply.' };
337
+ }
338
+
339
+ try {
340
+ await ensureMigrationsTable();
341
+
342
+ const appliedResult = await this._db.query(
343
+ 'SELECT name, applied_at FROM schema_migrations ORDER BY name',
344
+ );
345
+ const appliedSet = new Set(appliedResult.rows.map((r) => r.name));
346
+ const allFiles = getMigrationFiles();
347
+
348
+ data.migrations.applied = appliedResult.rows;
349
+ data.migrations.pending = allFiles.filter((f) => !appliedSet.has(f));
350
+ data.migrations.total = allFiles.length;
351
+
352
+ const tablesResult = await this._db.query(`
353
+ SELECT
354
+ relname AS name,
355
+ n_live_tup AS row_count,
356
+ pg_size_pretty(pg_total_relation_size(c.oid)) AS size
357
+ FROM pg_class c
358
+ JOIN pg_namespace n ON n.oid = c.relnamespace
359
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
360
+ WHERE n.nspname = 'public'
361
+ AND c.relkind = 'r'
362
+ ORDER BY pg_total_relation_size(c.oid) DESC
363
+ `);
364
+ data.tables = tablesResult.rows;
365
+
366
+ const versionResult = await this._db.query('SELECT version()');
367
+ data.dbVersion = versionResult.rows[0]?.version || '';
368
+
369
+ const sizeResult = await this._db.query(
370
+ 'SELECT pg_size_pretty(pg_database_size(current_database())) AS size',
371
+ );
372
+ data.dbSize = sizeResult.rows[0]?.size || '';
373
+ } catch (err) {
374
+ data.dbAvailable = false;
375
+ this._logger.error('web', `Database page error: ${err.message}`);
376
+ }
377
+
378
+ res.send(databaseHTML(data));
379
+ }
380
+
381
+ async _handleRunMigrations(_req, res) {
382
+ try {
383
+ const applied = await migrate();
384
+ if (applied.length === 0) {
385
+ res.redirect('/database?noop=1');
386
+ } else {
387
+ this._logger.info('web', `Applied ${applied.length} migration(s) via web admin.`);
388
+ res.redirect(`/database?migrated=${applied.length}`);
389
+ }
390
+ } catch (err) {
391
+ this._logger.error('web', `Migration failed via web admin: ${err.message}`);
392
+ res.redirect(`/database?error=${encodeURIComponent(err.message)}`);
393
+ }
394
+ }
395
+
396
+ // -----------------------------------------------------------------------
397
+ // .env file helpers
398
+ // -----------------------------------------------------------------------
399
+
400
+ _readEnvFile() {
401
+ try {
402
+ return fs.readFileSync(this._envPath, 'utf-8');
403
+ } catch {
404
+ return '';
405
+ }
406
+ }
407
+
408
+ _writeEnvFile(values) {
409
+ const content = this._readEnvFile();
410
+ const lines = content.split('\n');
411
+ const written = new Set();
412
+
413
+ // Pass 1: update existing key=value lines in place
414
+ const updated = lines.map((line) => {
415
+ const match = line.match(/^([A-Z_][A-Z0-9_]*)\s*=/);
416
+ if (match && match[1] in values) {
417
+ written.add(match[1]);
418
+ return fmtEnvLine(match[1], values[match[1]]);
419
+ }
420
+ return line;
421
+ });
422
+
423
+ // Pass 2: append any new keys that were not already in the file
424
+ for (const [key, value] of Object.entries(values)) {
425
+ if (!written.has(key)) {
426
+ updated.push(fmtEnvLine(key, value));
427
+ }
428
+ }
429
+
430
+ let result = updated.join('\n');
431
+ if (!result.endsWith('\n')) result += '\n';
432
+
433
+ fs.writeFileSync(this._envPath, result, 'utf-8');
434
+ }
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // Utility helpers
439
+ // ---------------------------------------------------------------------------
440
+
441
+ /** HTML-escape a string to prevent XSS. */
442
+ function esc(str) {
443
+ return String(str)
444
+ .replace(/&/g, '&amp;')
445
+ .replace(/</g, '&lt;')
446
+ .replace(/>/g, '&gt;')
447
+ .replace(/"/g, '&quot;')
448
+ .replace(/'/g, '&#39;');
449
+ }
450
+
451
+ /** Format a .env line, quoting the value when needed. */
452
+ function fmtEnvLine(key, value) {
453
+ if (value === '' || value === undefined || value === null) {
454
+ return `${key}=`;
455
+ }
456
+ // Quote values containing whitespace, quotes, hashes, or dollar signs
457
+ if (/[\s"'#$\\]/.test(value)) {
458
+ return `${key}="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
459
+ }
460
+ return `${key}=${value}`;
461
+ }
462
+
463
+ /** Format seconds into a human-readable uptime string. */
464
+ function fmtUptime(seconds) {
465
+ const d = Math.floor(seconds / 86400);
466
+ const h = Math.floor((seconds % 86400) / 3600);
467
+ const m = Math.floor((seconds % 3600) / 60);
468
+ const s = Math.floor(seconds % 60);
469
+ const parts = [];
470
+ if (d > 0) parts.push(`${d}d`);
471
+ parts.push(`${h}h`, `${m}m`, `${s}s`);
472
+ return parts.join(' ');
473
+ }
474
+
475
+ /** Format bytes into a human-readable size string. */
476
+ function fmtBytes(bytes) {
477
+ if (bytes === 0) return '0 B';
478
+ const units = ['B', 'KB', 'MB', 'GB'];
479
+ let idx = 0;
480
+ let value = bytes;
481
+ while (value >= 1024 && idx < units.length - 1) {
482
+ value /= 1024;
483
+ idx++;
484
+ }
485
+ return `${value.toFixed(1)} ${units[idx]}`;
486
+ }
487
+
488
+ /** Format a timestamp for display (UTC, no milliseconds). */
489
+ function fmtTime(ts) {
490
+ if (!ts) return '';
491
+ return new Date(ts).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, ' UTC');
492
+ }
493
+
494
+ /** Mask a secret value for display, showing only partial bookends. */
495
+ function maskValue(value) {
496
+ if (!value) return '(not set)';
497
+ const s = String(value);
498
+ if (s.length <= 8) return '*'.repeat(s.length);
499
+ return s.slice(0, 4) + '*'.repeat(Math.min(s.length - 8, 20)) + s.slice(-4);
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // HTML template functions
504
+ // ---------------------------------------------------------------------------
505
+
506
+ /**
507
+ * Base page layout. Wraps content in a full HTML document with navigation,
508
+ * dark-theme CSS, and responsive structure.
509
+ */
510
+ function layoutHTML(title, content) {
511
+ return `<!DOCTYPE html>
512
+ <html lang="en">
513
+ <head>
514
+ <meta charset="UTF-8">
515
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
516
+ <title>${esc(title)} - 2ndbrain</title>
517
+ <style>
518
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
519
+
520
+ body {
521
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, sans-serif;
522
+ background: #0d1117;
523
+ color: #c9d1d9;
524
+ line-height: 1.6;
525
+ min-height: 100vh;
526
+ }
527
+
528
+ /* --- Navigation --- */
529
+ nav {
530
+ background: #161b22;
531
+ border-bottom: 1px solid #30363d;
532
+ padding: 0.75rem 1.5rem;
533
+ display: flex;
534
+ align-items: center;
535
+ gap: 1.5rem;
536
+ }
537
+ nav .brand {
538
+ font-weight: 700;
539
+ font-size: 1.1rem;
540
+ color: #f0f6fc;
541
+ text-decoration: none;
542
+ }
543
+ nav a { color: #58a6ff; text-decoration: none; font-size: 0.9rem; }
544
+ nav a:hover { text-decoration: underline; }
545
+
546
+ /* --- Layout --- */
547
+ .container { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
548
+ h1 { color: #f0f6fc; margin-bottom: 1rem; font-size: 1.5rem; }
549
+ h2 { color: #f0f6fc; margin: 1.5rem 0 0.75rem; font-size: 1.2rem; }
550
+
551
+ /* --- Cards & grid --- */
552
+ .card {
553
+ background: #161b22;
554
+ border: 1px solid #30363d;
555
+ border-radius: 6px;
556
+ padding: 1rem 1.25rem;
557
+ margin-bottom: 1rem;
558
+ }
559
+ .grid {
560
+ display: grid;
561
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
562
+ gap: 1rem;
563
+ margin-bottom: 1.5rem;
564
+ }
565
+ .stat-label {
566
+ font-size: 0.78rem;
567
+ color: #8b949e;
568
+ text-transform: uppercase;
569
+ letter-spacing: 0.05em;
570
+ }
571
+ .stat-value {
572
+ font-size: 1.35rem;
573
+ font-weight: 600;
574
+ color: #f0f6fc;
575
+ word-break: break-all;
576
+ }
577
+ .stat-value.small { font-size: 0.9rem; }
578
+
579
+ /* --- Table --- */
580
+ table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
581
+ th {
582
+ text-align: left;
583
+ padding: 0.5rem 0.75rem;
584
+ border-bottom: 2px solid #30363d;
585
+ color: #8b949e;
586
+ font-weight: 600;
587
+ font-size: 0.78rem;
588
+ text-transform: uppercase;
589
+ }
590
+ td {
591
+ padding: 0.5rem 0.75rem;
592
+ border-bottom: 1px solid #21262d;
593
+ vertical-align: top;
594
+ }
595
+ tr:hover td { background: rgba(88,166,255,0.04); }
596
+
597
+ /* --- Role colours --- */
598
+ .role-user { color: #58a6ff; }
599
+ .role-assistant { color: #3fb950; }
600
+ .role-system { color: #d29922; }
601
+ .role-summary { color: #8b949e; }
602
+
603
+ /* --- Level colours --- */
604
+ .level-debug { color: #8b949e; }
605
+ .level-info { color: #58a6ff; }
606
+ .level-warn { color: #d29922; }
607
+ .level-error { color: #f85149; }
608
+
609
+ /* --- Badges --- */
610
+ .badge {
611
+ display: inline-block;
612
+ padding: 0.15rem 0.5rem;
613
+ border-radius: 3px;
614
+ font-size: 0.75rem;
615
+ font-weight: 600;
616
+ text-transform: uppercase;
617
+ }
618
+ .badge-ok { background: rgba(63,185,80,0.15); color: #3fb950; }
619
+ .badge-error { background: rgba(248,81,73,0.15); color: #f85149; }
620
+ .badge-warn { background: rgba(210,153,34,0.15); color: #d29922; }
621
+ .badge-disabled { background: rgba(139,148,158,0.15);color: #8b949e; }
622
+
623
+ /* --- Settings form --- */
624
+ .form-section { margin-bottom: 1.5rem; }
625
+ .form-section h3 {
626
+ color: #f0f6fc;
627
+ font-size: 1rem;
628
+ margin-bottom: 0.75rem;
629
+ padding-bottom: 0.5rem;
630
+ border-bottom: 1px solid #21262d;
631
+ }
632
+ .form-group {
633
+ margin-bottom: 0.75rem;
634
+ display: grid;
635
+ grid-template-columns: 220px 1fr;
636
+ align-items: start;
637
+ gap: 0.75rem;
638
+ }
639
+ label {
640
+ font-size: 0.88rem;
641
+ color: #c9d1d9;
642
+ padding-top: 0.4rem;
643
+ }
644
+ label .required { color: #f85149; }
645
+ label .hint {
646
+ display: block;
647
+ font-size: 0.74rem;
648
+ color: #8b949e;
649
+ margin-top: 0.15rem;
650
+ }
651
+ input[type="text"],
652
+ input[type="password"] {
653
+ width: 100%;
654
+ padding: 0.4rem 0.6rem;
655
+ background: #0d1117;
656
+ border: 1px solid #30363d;
657
+ border-radius: 4px;
658
+ color: #c9d1d9;
659
+ font-size: 0.88rem;
660
+ font-family: inherit;
661
+ }
662
+ input:focus {
663
+ outline: none;
664
+ border-color: #58a6ff;
665
+ box-shadow: 0 0 0 2px rgba(88,166,255,0.2);
666
+ }
667
+ .secret-current {
668
+ font-size: 0.78rem;
669
+ color: #8b949e;
670
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
671
+ margin-bottom: 0.25rem;
672
+ }
673
+ .checkbox-group {
674
+ display: flex;
675
+ align-items: center;
676
+ gap: 0.5rem;
677
+ padding-top: 0.35rem;
678
+ }
679
+ .checkbox-group input[type="checkbox"] {
680
+ width: 1.1rem;
681
+ height: 1.1rem;
682
+ accent-color: #238636;
683
+ cursor: pointer;
684
+ }
685
+ .checkbox-label {
686
+ font-size: 0.85rem;
687
+ color: #8b949e;
688
+ padding-top: 0;
689
+ cursor: pointer;
690
+ }
691
+ button[type="submit"] {
692
+ background: #238636;
693
+ color: #fff;
694
+ border: none;
695
+ padding: 0.6rem 1.5rem;
696
+ border-radius: 6px;
697
+ font-size: 0.9rem;
698
+ font-weight: 600;
699
+ cursor: pointer;
700
+ margin-top: 1rem;
701
+ }
702
+ button[type="submit"]:hover { background: #2ea043; }
703
+
704
+ /* --- Alerts --- */
705
+ .alert {
706
+ padding: 0.75rem 1rem;
707
+ border-radius: 6px;
708
+ margin-bottom: 1rem;
709
+ font-size: 0.88rem;
710
+ }
711
+ .alert-success {
712
+ background: rgba(63,185,80,0.1);
713
+ border: 1px solid rgba(63,185,80,0.3);
714
+ color: #3fb950;
715
+ }
716
+ .alert-error {
717
+ background: rgba(248,81,73,0.1);
718
+ border: 1px solid rgba(248,81,73,0.3);
719
+ color: #f85149;
720
+ }
721
+
722
+ /* --- Log viewer --- */
723
+ .log-filters {
724
+ display: flex;
725
+ gap: 0.5rem;
726
+ flex-wrap: wrap;
727
+ margin-bottom: 1rem;
728
+ }
729
+ .log-filters a {
730
+ padding: 0.3rem 0.75rem;
731
+ border-radius: 4px;
732
+ font-size: 0.84rem;
733
+ text-decoration: none;
734
+ color: #c9d1d9;
735
+ background: #21262d;
736
+ border: 1px solid #30363d;
737
+ }
738
+ .log-filters a.active {
739
+ background: #388bfd;
740
+ color: #fff;
741
+ border-color: #388bfd;
742
+ }
743
+ .log-filters a:hover { border-color: #58a6ff; }
744
+ .log-entry {
745
+ font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
746
+ font-size: 0.8rem;
747
+ padding: 0.35rem 0;
748
+ border-bottom: 1px solid #21262d;
749
+ display: grid;
750
+ grid-template-columns: 175px 52px 110px 1fr;
751
+ gap: 0.5rem;
752
+ align-items: start;
753
+ }
754
+ .log-time { color: #8b949e; }
755
+ .log-source { color: #d2a8ff; }
756
+ .log-content { color: #c9d1d9; word-break: break-word; white-space: pre-wrap; }
757
+
758
+ /* --- Misc --- */
759
+ .muted { color: #8b949e; }
760
+ .empty { color: #8b949e; text-align: center; padding: 2rem; }
761
+ .db-warn {
762
+ background: rgba(210,153,34,0.1);
763
+ border: 1px solid rgba(210,153,34,0.25);
764
+ color: #d29922;
765
+ padding: 0.6rem 1rem;
766
+ border-radius: 6px;
767
+ margin-bottom: 1rem;
768
+ font-size: 0.88rem;
769
+ }
770
+
771
+ @media (max-width: 768px) {
772
+ .form-group { grid-template-columns: 1fr; gap: 0.25rem; }
773
+ .log-entry { grid-template-columns: 1fr; gap: 0.15rem; }
774
+ .grid { grid-template-columns: 1fr 1fr; }
775
+ }
776
+ </style>
777
+ </head>
778
+ <body>
779
+ <nav>
780
+ <a class="brand" href="/">2ndbrain</a>
781
+ <a href="/">Dashboard</a>
782
+ <a href="/settings">Settings</a>
783
+ <a href="/logs">Logs</a>
784
+ <a href="/database">Database</a>
785
+ </nav>
786
+ <div class="container">
787
+ ${content}
788
+ </div>
789
+ </body>
790
+ </html>`;
791
+ }
792
+
793
+ /**
794
+ * Dashboard page showing system status, recent messages, and errors.
795
+ */
796
+ function dashboardHTML(data) {
797
+ const mem = data.memory;
798
+
799
+ // -- Stats grid ----------------------------------------------------------
800
+ const stats = `
801
+ <div class="grid">
802
+ <div class="card">
803
+ <div class="stat-label">Uptime</div>
804
+ <div class="stat-value">${esc(fmtUptime(data.uptime))}</div>
805
+ </div>
806
+ <div class="card">
807
+ <div class="stat-label">Memory (RSS)</div>
808
+ <div class="stat-value">${fmtBytes(mem.rss)}</div>
809
+ </div>
810
+ <div class="card">
811
+ <div class="stat-label">Heap Used</div>
812
+ <div class="stat-value">${fmtBytes(mem.heapUsed)} / ${fmtBytes(mem.heapTotal)}</div>
813
+ </div>
814
+ <div class="card">
815
+ <div class="stat-label">Messages</div>
816
+ <div class="stat-value">${data.messageCount}</div>
817
+ </div>
818
+ <div class="card">
819
+ <div class="stat-label">Active Session</div>
820
+ <div class="stat-value small">${data.activeSessionId ? esc(data.activeSessionId) : '<span class="muted">none</span>'}</div>
821
+ </div>
822
+ <div class="card">
823
+ <div class="stat-label">Embeddings</div>
824
+ <div class="stat-value">
825
+ <span class="badge ${data.embeddingStatus === 'enabled' ? 'badge-ok' : 'badge-disabled'}">${data.embeddingStatus}</span>
826
+ </div>
827
+ </div>
828
+ </div>`;
829
+
830
+ // -- DB warning ----------------------------------------------------------
831
+ const dbWarn = data.dbAvailable
832
+ ? ''
833
+ : '<div class="db-warn">Database is unavailable. Dashboard data may be incomplete.</div>';
834
+
835
+ // -- Recent messages -----------------------------------------------------
836
+ let messagesSection;
837
+ if (data.recentMessages.length > 0) {
838
+ const rows = data.recentMessages.map((msg) => `
839
+ <tr>
840
+ <td style="white-space:nowrap;">${fmtTime(msg.created_at)}</td>
841
+ <td><span class="role-${esc(msg.role)}">${esc(msg.role)}</span></td>
842
+ <td>${esc(msg.content || '')}</td>
843
+ </tr>`).join('');
844
+
845
+ messagesSection = `
846
+ <h2>Recent Messages</h2>
847
+ <div class="card" style="overflow-x:auto;">
848
+ <table>
849
+ <thead><tr><th>Time</th><th>Role</th><th>Content</th></tr></thead>
850
+ <tbody>${rows}</tbody>
851
+ </table>
852
+ </div>`;
853
+ } else {
854
+ messagesSection = `
855
+ <h2>Recent Messages</h2>
856
+ <div class="card"><p class="empty">No messages yet.</p></div>`;
857
+ }
858
+
859
+ // -- Recent errors -------------------------------------------------------
860
+ let errorsSection = '';
861
+ if (data.recentErrors.length > 0) {
862
+ const rows = data.recentErrors.map((log) => `
863
+ <tr>
864
+ <td style="white-space:nowrap;">${fmtTime(log.created_at)}</td>
865
+ <td>${esc(log.source || '')}</td>
866
+ <td>${esc(log.content)}</td>
867
+ </tr>`).join('');
868
+
869
+ errorsSection = `
870
+ <h2>Recent Errors</h2>
871
+ <div class="card" style="overflow-x:auto;">
872
+ <table>
873
+ <thead><tr><th>Time</th><th>Source</th><th>Content</th></tr></thead>
874
+ <tbody>${rows}</tbody>
875
+ </table>
876
+ </div>`;
877
+ }
878
+
879
+ return layoutHTML('Dashboard', `
880
+ <h1>Dashboard</h1>
881
+ ${dbWarn}
882
+ ${stats}
883
+ ${messagesSection}
884
+ ${errorsSection}
885
+ `);
886
+ }
887
+
888
+ /**
889
+ * Settings page with a form for every configurable env var.
890
+ * Secret fields are shown as password inputs with a masked current-value hint.
891
+ */
892
+ function settingsHTML(config, message) {
893
+ const alert = message
894
+ ? `<div class="alert alert-${esc(message.type)}">${esc(message.text)}</div>`
895
+ : '';
896
+
897
+ const sections = SETTINGS_FIELDS.map((section) => {
898
+ const fields = section.fields.map((field) => {
899
+ const value = config[field.key] ?? '';
900
+ let inputArea;
901
+
902
+ if (field.secret) {
903
+ const status = value
904
+ ? `Current: ${esc(maskValue(String(value)))}`
905
+ : '(not set)';
906
+ inputArea = `
907
+ <div>
908
+ <div class="secret-current">${status}</div>
909
+ <input type="password" name="${esc(field.key)}"
910
+ value="" placeholder="Enter new value to change"
911
+ autocomplete="off" />
912
+ </div>`;
913
+ } else if (field.type === 'boolean') {
914
+ const checked = String(value).toLowerCase() === 'true' ? ' checked' : '';
915
+ inputArea = `
916
+ <div class="checkbox-group">
917
+ <input type="hidden" name="${esc(field.key)}" value="false" />
918
+ <input type="checkbox" id="${esc(field.key)}" name="${esc(field.key)}"
919
+ value="true"${checked} />
920
+ <label for="${esc(field.key)}" class="checkbox-label">Enabled</label>
921
+ </div>`;
922
+ } else {
923
+ inputArea = `<input type="text" name="${esc(field.key)}"
924
+ value="${esc(String(value))}" />`;
925
+ }
926
+
927
+ return `
928
+ <div class="form-group">
929
+ <label>
930
+ ${esc(field.label)}${field.required ? ' <span class="required">*</span>' : ''}
931
+ ${field.hint ? `<span class="hint">${esc(field.hint)}</span>` : ''}
932
+ </label>
933
+ ${inputArea}
934
+ </div>`;
935
+ }).join('');
936
+
937
+ return `
938
+ <div class="form-section">
939
+ <h3>${esc(section.section)}</h3>
940
+ ${fields}
941
+ </div>`;
942
+ }).join('');
943
+
944
+ return layoutHTML('Settings', `
945
+ <h1>Settings</h1>
946
+ ${alert}
947
+ <form method="POST" action="/settings">
948
+ ${sections}
949
+ <button type="submit">Save Settings</button>
950
+ </form>
951
+ `);
952
+ }
953
+
954
+ /**
955
+ * Log viewer page with level filtering.
956
+ */
957
+ function logsHTML(logs, activeLevel) {
958
+ const levels = ['', 'debug', 'info', 'warn', 'error'];
959
+ const filters = levels.map((lvl) => {
960
+ const label = lvl ? lvl.charAt(0).toUpperCase() + lvl.slice(1) : 'All';
961
+ const href = lvl ? `/logs?level=${lvl}` : '/logs';
962
+ const cls = activeLevel === lvl ? ' active' : '';
963
+ return `<a href="${href}" class="${cls}">${label}</a>`;
964
+ }).join('');
965
+
966
+ let body;
967
+ if (logs.length > 0) {
968
+ body = logs.map((log) => `
969
+ <div class="log-entry">
970
+ <span class="log-time">${fmtTime(log.created_at)}</span>
971
+ <span class="level-${esc(log.level)}">${esc(log.level)}</span>
972
+ <span class="log-source">${esc(log.source || '')}</span>
973
+ <span class="log-content">${esc(log.content)}</span>
974
+ </div>`).join('');
975
+ } else {
976
+ body = '<p class="empty">No log entries found.</p>';
977
+ }
978
+
979
+ return layoutHTML('Logs', `
980
+ <h1>Logs</h1>
981
+ <div class="log-filters">${filters}</div>
982
+ <div class="card">${body}</div>
983
+ `);
984
+ }
985
+
986
+ /**
987
+ * Database admin page showing migration status and table metadata.
988
+ */
989
+ function databaseHTML(data) {
990
+ const alert = data.message
991
+ ? `<div class="alert alert-${esc(data.message.type)}">${esc(data.message.text)}</div>`
992
+ : '';
993
+
994
+ const dbWarn = data.dbAvailable
995
+ ? ''
996
+ : '<div class="db-warn">Database is unavailable. Cannot retrieve database information.</div>';
997
+
998
+ const stats = data.dbAvailable
999
+ ? `
1000
+ <div class="grid">
1001
+ <div class="card">
1002
+ <div class="stat-label">Total Migrations</div>
1003
+ <div class="stat-value">${data.migrations.total}</div>
1004
+ </div>
1005
+ <div class="card">
1006
+ <div class="stat-label">Applied</div>
1007
+ <div class="stat-value" style="color:#3fb950;">${data.migrations.applied.length}</div>
1008
+ </div>
1009
+ <div class="card">
1010
+ <div class="stat-label">Pending</div>
1011
+ <div class="stat-value"${data.migrations.pending.length > 0 ? ' style="color:#d29922;"' : ''}>${data.migrations.pending.length}</div>
1012
+ </div>
1013
+ <div class="card">
1014
+ <div class="stat-label">Database Size</div>
1015
+ <div class="stat-value">${esc(data.dbSize)}</div>
1016
+ </div>
1017
+ </div>`
1018
+ : '';
1019
+
1020
+ const versionSection = data.dbVersion
1021
+ ? `<p class="muted" style="margin-bottom:1rem;font-size:0.82rem;">${esc(data.dbVersion)}</p>`
1022
+ : '';
1023
+
1024
+ let pendingSection = '';
1025
+ if (data.dbAvailable && data.migrations.pending.length > 0) {
1026
+ const pendingRows = data.migrations.pending
1027
+ .map(
1028
+ (name) => `
1029
+ <tr>
1030
+ <td><span class="badge badge-warn">pending</span></td>
1031
+ <td style="font-family:monospace;">${esc(name)}</td>
1032
+ <td class="muted">--</td>
1033
+ </tr>`,
1034
+ )
1035
+ .join('');
1036
+
1037
+ pendingSection = `
1038
+ <h2>Pending Migrations</h2>
1039
+ <div class="card" style="overflow-x:auto;">
1040
+ <table>
1041
+ <thead><tr><th>Status</th><th>Migration</th><th>Applied At</th></tr></thead>
1042
+ <tbody>${pendingRows}</tbody>
1043
+ </table>
1044
+ <form method="POST" action="/database/migrate"
1045
+ onsubmit="return confirm('Run ${data.migrations.pending.length} pending migration(s)?\\n\\nThis will modify the database schema.');"
1046
+ style="margin-top:1rem;">
1047
+ <button type="submit" style="background:#d29922;">Run ${data.migrations.pending.length} Pending Migration${data.migrations.pending.length === 1 ? '' : 's'}</button>
1048
+ </form>
1049
+ </div>`;
1050
+ }
1051
+
1052
+ let appliedSection = '';
1053
+ if (data.dbAvailable && data.migrations.applied.length > 0) {
1054
+ const appliedRows = data.migrations.applied
1055
+ .map(
1056
+ (m) => `
1057
+ <tr>
1058
+ <td><span class="badge badge-ok">applied</span></td>
1059
+ <td style="font-family:monospace;">${esc(m.name)}</td>
1060
+ <td>${fmtTime(m.applied_at)}</td>
1061
+ </tr>`,
1062
+ )
1063
+ .join('');
1064
+
1065
+ appliedSection = `
1066
+ <h2>Applied Migrations</h2>
1067
+ <div class="card" style="overflow-x:auto;">
1068
+ <table>
1069
+ <thead><tr><th>Status</th><th>Migration</th><th>Applied At</th></tr></thead>
1070
+ <tbody>${appliedRows}</tbody>
1071
+ </table>
1072
+ </div>`;
1073
+ } else if (data.dbAvailable && data.migrations.applied.length === 0) {
1074
+ appliedSection = `
1075
+ <h2>Applied Migrations</h2>
1076
+ <div class="card"><p class="empty">No migrations have been applied yet.</p></div>`;
1077
+ }
1078
+
1079
+ let tablesSection = '';
1080
+ if (data.dbAvailable && data.tables.length > 0) {
1081
+ const tableRows = data.tables
1082
+ .map(
1083
+ (t) => `
1084
+ <tr>
1085
+ <td style="font-family:monospace;">${esc(t.name)}</td>
1086
+ <td style="text-align:right;">${t.row_count ?? '--'}</td>
1087
+ <td style="text-align:right;">${esc(t.size)}</td>
1088
+ </tr>`,
1089
+ )
1090
+ .join('');
1091
+
1092
+ tablesSection = `
1093
+ <h2>Tables</h2>
1094
+ <div class="card" style="overflow-x:auto;">
1095
+ <table>
1096
+ <thead><tr><th>Table</th><th style="text-align:right;">Rows (est.)</th><th style="text-align:right;">Size</th></tr></thead>
1097
+ <tbody>${tableRows}</tbody>
1098
+ </table>
1099
+ </div>`;
1100
+ }
1101
+
1102
+ return layoutHTML('Database', `
1103
+ <h1>Database</h1>
1104
+ ${alert}
1105
+ ${dbWarn}
1106
+ ${versionSection}
1107
+ ${stats}
1108
+ ${pendingSection}
1109
+ ${appliedSection}
1110
+ ${tablesSection}
1111
+ `);
1112
+ }
1113
+
1114
+ // ---------------------------------------------------------------------------
1115
+ // Export
1116
+ // ---------------------------------------------------------------------------
1117
+
1118
+ export { WebServer };
1119
+ export default WebServer;