2ndbrain 2026.1.31 → 2026.1.33

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.
@@ -10,7 +10,11 @@
10
10
  "WebFetch(domain:api.x.ai)",
11
11
  "WebFetch(domain:latenode.com)",
12
12
  "Bash(claude:*)",
13
- "Bash(printf:*)"
13
+ "Bash(printf:*)",
14
+ "Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\\\\migrations\"\" 2>/dev/null || echo \"No migrations directory \")",
15
+ "Bash(node:*)",
16
+ "Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\src\\\\web\"\" 2>/dev/null || echo \"web directory not found \")",
17
+ "Bash(ls -la \"c:\\\\dev\\\\fingerskier\\\\agent\\\\2ndbrain\\\\db\"\" 2>/dev/null || echo \"Directory not accessible \")"
14
18
  ]
15
19
  }
16
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "2ndbrain",
3
- "version": "2026.1.31",
3
+ "version": "2026.1.33",
4
4
  "description": "Always-on Node.js service bridging Telegram messaging to Claude AI with knowledge graph, journal, project management, and semantic search.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -60,6 +60,19 @@ const config = {
60
60
  EMBEDDING_BASE_URL: env.EMBEDDING_BASE_URL || '',
61
61
  };
62
62
 
63
+ // Ensure DATABASE_URL includes a database name (default: 2ndbrain)
64
+ if (config.DATABASE_URL) {
65
+ try {
66
+ const url = new URL(config.DATABASE_URL);
67
+ if (!url.pathname || url.pathname === '/') {
68
+ url.pathname = '/2ndbrain';
69
+ config.DATABASE_URL = url.toString();
70
+ }
71
+ } catch {
72
+ // leave as-is if URL parsing fails
73
+ }
74
+ }
75
+
63
76
  const REQUIRED_VARS = ['TELEGRAM_BOT_TOKEN', 'TELEGRAM_ALLOWED_USERS', 'DATABASE_URL'];
64
77
 
65
78
  /**
package/src/db/migrate.js CHANGED
@@ -90,5 +90,5 @@ async function migrate() {
90
90
  return newlyApplied;
91
91
  }
92
92
 
93
- export { migrate };
93
+ export { migrate, getAppliedMigrations, getMigrationFiles, ensureMigrationsTable };
94
94
  export default migrate;
package/src/db/pool.js CHANGED
@@ -12,6 +12,40 @@ pool.on('error', (err) => {
12
12
  console.error(`[${new Date().toISOString()}] [error] [db/pool] Unexpected idle client error:`, err.message);
13
13
  });
14
14
 
15
+ /**
16
+ * Ensure the target database exists, creating it if necessary.
17
+ * Connects to the 'postgres' maintenance database to check/create.
18
+ */
19
+ async function ensureDatabase() {
20
+ try {
21
+ const url = new URL(config.DATABASE_URL);
22
+ const dbName = url.pathname.slice(1); // strip leading '/'
23
+ if (!dbName) return;
24
+
25
+ // Build a maintenance URL pointing at the 'postgres' system database
26
+ const maintenanceUrl = new URL(config.DATABASE_URL);
27
+ maintenanceUrl.pathname = '/postgres';
28
+
29
+ const client = new pg.Client({ connectionString: maintenanceUrl.toString() });
30
+ await client.connect();
31
+
32
+ const result = await client.query(
33
+ 'SELECT 1 FROM pg_database WHERE datname = $1',
34
+ [dbName],
35
+ );
36
+
37
+ if (result.rows.length === 0) {
38
+ // Use double-quoted identifier to handle special characters in name
39
+ await client.query(`CREATE DATABASE "${dbName}"`);
40
+ console.log(`[${new Date().toISOString()}] [info] [db/pool] Created database "${dbName}".`);
41
+ }
42
+
43
+ await client.end();
44
+ } catch (err) {
45
+ console.error(`[${new Date().toISOString()}] [error] [db/pool] ensureDatabase failed: ${err.message}`);
46
+ }
47
+ }
48
+
15
49
  /**
16
50
  * Execute a parameterized SQL query against the pool.
17
51
  * @param {string} text - SQL query string
@@ -29,5 +63,5 @@ async function close() {
29
63
  await pool.end();
30
64
  }
31
65
 
32
- export { pool, query, close };
66
+ export { pool, query, close, ensureDatabase };
33
67
  export default pool;
package/src/index.js CHANGED
@@ -20,7 +20,7 @@ import path from 'node:path';
20
20
  import fs from 'node:fs';
21
21
 
22
22
  import { config, validateConfig, isFirstRun, PROJECT_ROOT } from './config.js';
23
- import { pool, query, close as closeDb } from './db/pool.js';
23
+ import { pool, query, close as closeDb, ensureDatabase } from './db/pool.js';
24
24
  import { migrate } from './db/migrate.js';
25
25
  import logger from './logging.js';
26
26
  import { createRateLimiters } from './rate-limiter.js';
@@ -329,6 +329,7 @@ async function main() {
329
329
  let dbReady = false;
330
330
  if (config.DATABASE_URL) {
331
331
  try {
332
+ await ensureDatabase();
332
333
  await pool.query('SELECT 1');
333
334
  logger.info('startup', 'Database connection established.');
334
335
  dbReady = true;
package/src/web/server.js CHANGED
@@ -3,6 +3,7 @@ import { createServer } from 'node:http';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { migrate, getMigrationFiles, ensureMigrationsTable } from '../db/migrate.js';
6
7
 
7
8
  // Resolve project root (two directories up from src/web/)
8
9
  const __filename = fileURLToPath(import.meta.url);
@@ -31,7 +32,7 @@ const SETTINGS_FIELDS = [
31
32
  section: 'Claude',
32
33
  fields: [
33
34
  { key: 'CLAUDE_MODEL', label: 'Model', hint: 'Default: claude-sonnet-4-20250514' },
34
- { key: 'CLAUDE_THINKING', label: 'Thinking', hint: 'Enable extended thinking (true/false)' },
35
+ { key: 'CLAUDE_THINKING', label: 'Thinking', type: 'boolean', hint: 'Enable extended thinking' },
35
36
  { key: 'CLAUDE_TIMEOUT', label: 'Timeout (ms)', hint: 'Default: 120000' },
36
37
  { key: 'CLAUDE_MAX_BUDGET', label: 'Max Budget (USD)', hint: 'Max cost per invocation (e.g. 0.50)' },
37
38
  ],
@@ -74,7 +75,7 @@ const SETTINGS_FIELDS = [
74
75
  fields: [
75
76
  { key: 'WEB_PORT', label: 'Port', hint: 'Default: 3000' },
76
77
  { key: 'WEB_BIND', label: 'Bind Address', hint: 'Default: 127.0.0.1' },
77
- { key: 'AUTO_OPEN_BROWSER', label: 'Auto Open Browser', hint: 'true/false (default: true)' },
78
+ { key: 'AUTO_OPEN_BROWSER', label: 'Auto Open Browser', type: 'boolean', hint: 'Default: true' },
78
79
  ],
79
80
  },
80
81
  {
@@ -127,6 +128,8 @@ class WebServer {
127
128
  app.post('/settings', (req, res) => this._handleSaveSettings(req, res));
128
129
  app.get('/logs', (req, res) => this._handleLogs(req, res));
129
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));
130
133
 
131
134
  // Start listening
132
135
  const server = createServer(app);
@@ -184,8 +187,10 @@ class WebServer {
184
187
  'SELECT COUNT(*)::int AS count FROM conversation_messages',
185
188
  );
186
189
  data.messageCount = countRes.rows[0]?.count ?? 0;
187
- } catch {
190
+ } catch (err) {
188
191
  data.dbAvailable = false;
192
+ data.dbError = diagnosePgError(err);
193
+ this._logger.error('web', `Dashboard DB error: ${err.message} (code=${err.code || 'none'})`);
189
194
  }
190
195
 
191
196
  if (data.dbAvailable) {
@@ -296,20 +301,107 @@ class WebServer {
296
301
 
297
302
  try {
298
303
  await this._db.query('SELECT 1');
299
- } catch {
300
- health.components.database = 'error';
304
+ } catch (err) {
305
+ health.components.database = {
306
+ status: 'error',
307
+ error: err.message || 'Unknown error',
308
+ code: err.code || undefined,
309
+ };
301
310
  }
302
311
 
303
312
  // Derive overall status from component states
304
313
  const states = Object.values(health.components);
305
- if (states.includes('error')) {
306
- health.status = states.every((s) => s === 'error') ? 'error' : 'degraded';
314
+ const isErr = (s) => s === 'error' || (typeof s === 'object' && s.status === 'error');
315
+ if (states.some(isErr)) {
316
+ health.status = states.every(isErr) ? 'error' : 'degraded';
307
317
  }
308
318
 
309
319
  const httpStatus = health.status === 'error' ? 503 : 200;
310
320
  res.status(httpStatus).json(health);
311
321
  }
312
322
 
323
+ // -----------------------------------------------------------------------
324
+ // Database page
325
+ // -----------------------------------------------------------------------
326
+
327
+ async _handleDatabase(req, res) {
328
+ const data = {
329
+ dbAvailable: true,
330
+ migrations: { applied: [], pending: [], total: 0 },
331
+ tables: [],
332
+ dbVersion: '',
333
+ dbSize: '',
334
+ message: null,
335
+ };
336
+
337
+ if (req.query.migrated) {
338
+ const count = parseInt(req.query.migrated, 10) || 0;
339
+ data.message = { type: 'success', text: `Successfully applied ${count} migration(s).` };
340
+ } else if (req.query.error) {
341
+ data.message = { type: 'error', text: req.query.error };
342
+ } else if (req.query.noop === '1') {
343
+ data.message = { type: 'success', text: 'No pending migrations to apply.' };
344
+ }
345
+
346
+ try {
347
+ await ensureMigrationsTable();
348
+
349
+ const appliedResult = await this._db.query(
350
+ 'SELECT name, applied_at FROM schema_migrations ORDER BY name',
351
+ );
352
+ const appliedSet = new Set(appliedResult.rows.map((r) => r.name));
353
+ const allFiles = getMigrationFiles();
354
+
355
+ data.migrations.applied = appliedResult.rows;
356
+ data.migrations.pending = allFiles.filter((f) => !appliedSet.has(f));
357
+ data.migrations.total = allFiles.length;
358
+
359
+ const tablesResult = await this._db.query(`
360
+ SELECT
361
+ c.relname AS name,
362
+ n_live_tup AS row_count,
363
+ pg_size_pretty(pg_total_relation_size(c.oid)) AS size
364
+ FROM pg_class c
365
+ JOIN pg_namespace n ON n.oid = c.relnamespace
366
+ LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid
367
+ WHERE n.nspname = 'public'
368
+ AND c.relkind = 'r'
369
+ ORDER BY pg_total_relation_size(c.oid) DESC
370
+ `);
371
+ data.tables = tablesResult.rows;
372
+
373
+ const versionResult = await this._db.query('SELECT version()');
374
+ data.dbVersion = versionResult.rows[0]?.version || '';
375
+
376
+ const sizeResult = await this._db.query(
377
+ 'SELECT pg_size_pretty(pg_database_size(current_database())) AS size',
378
+ );
379
+ data.dbSize = sizeResult.rows[0]?.size || '';
380
+ } catch (err) {
381
+ data.dbAvailable = false;
382
+ data.dbError = diagnosePgError(err);
383
+ data.dbUrl = maskDatabaseUrl(this._config.DATABASE_URL);
384
+ this._logger.error('web', `Database page error: ${err.message} (code=${err.code || 'none'})`);
385
+ }
386
+
387
+ res.send(databaseHTML(data));
388
+ }
389
+
390
+ async _handleRunMigrations(_req, res) {
391
+ try {
392
+ const applied = await migrate();
393
+ if (applied.length === 0) {
394
+ res.redirect('/database?noop=1');
395
+ } else {
396
+ this._logger.info('web', `Applied ${applied.length} migration(s) via web admin.`);
397
+ res.redirect(`/database?migrated=${applied.length}`);
398
+ }
399
+ } catch (err) {
400
+ this._logger.error('web', `Migration failed via web admin: ${err.message}`);
401
+ res.redirect(`/database?error=${encodeURIComponent(err.message)}`);
402
+ }
403
+ }
404
+
313
405
  // -----------------------------------------------------------------------
314
406
  // .env file helpers
315
407
  // -----------------------------------------------------------------------
@@ -416,6 +508,83 @@ function maskValue(value) {
416
508
  return s.slice(0, 4) + '*'.repeat(Math.min(s.length - 8, 20)) + s.slice(-4);
417
509
  }
418
510
 
511
+ /** Mask the password in a PostgreSQL connection URL for safe display. */
512
+ function maskDatabaseUrl(url) {
513
+ if (!url) return '(not set)';
514
+ const schemeEnd = url.indexOf('://');
515
+ const lastAt = url.lastIndexOf('@');
516
+ if (schemeEnd >= 0 && lastAt > schemeEnd) {
517
+ const userInfo = url.slice(schemeEnd + 3, lastAt);
518
+ const colonPos = userInfo.indexOf(':');
519
+ if (colonPos >= 0) {
520
+ return url.slice(0, schemeEnd + 3 + colonPos + 1) + '****' + url.slice(lastAt);
521
+ }
522
+ }
523
+ return maskValue(url);
524
+ }
525
+
526
+ /**
527
+ * Extract diagnostic information from a pg / Node.js connection error.
528
+ * Returns { message, code, detail, hint, diagnosis }.
529
+ */
530
+ function diagnosePgError(err) {
531
+ const info = {
532
+ message: err.message || 'Unknown error',
533
+ code: err.code || '',
534
+ detail: err.detail || '',
535
+ hint: err.hint || '',
536
+ diagnosis: '',
537
+ };
538
+
539
+ switch (err.code) {
540
+ case 'ECONNREFUSED':
541
+ info.diagnosis = 'Cannot connect to the database server. Verify the host and port are correct and that PostgreSQL is running.';
542
+ break;
543
+ case 'ENOTFOUND':
544
+ info.diagnosis = 'DNS lookup failed. The database hostname could not be resolved. Check the host in your DATABASE_URL.';
545
+ break;
546
+ case 'ETIMEDOUT':
547
+ info.diagnosis = 'Connection timed out. The database server may be unreachable or behind a firewall.';
548
+ break;
549
+ case 'ECONNRESET':
550
+ info.diagnosis = 'Connection was reset by the server. This may indicate a network issue or server restart.';
551
+ break;
552
+ default:
553
+ break;
554
+ }
555
+
556
+ if (!info.diagnosis && typeof err.code === 'string' && err.code.length === 5) {
557
+ const cls = err.code.substring(0, 2);
558
+ switch (cls) {
559
+ case '08':
560
+ info.diagnosis = 'Connection exception. The database server rejected or dropped the connection.';
561
+ break;
562
+ case '28':
563
+ info.diagnosis = 'Authentication failed. Check the username and password in your DATABASE_URL.';
564
+ break;
565
+ case '3D':
566
+ info.diagnosis = 'The specified database does not exist. Verify the database name in your DATABASE_URL.';
567
+ break;
568
+ case '53':
569
+ info.diagnosis = 'The database server has insufficient resources (too many connections, out of memory, or disk full).';
570
+ break;
571
+ case '57':
572
+ info.diagnosis = 'The database server is shutting down or not accepting connections.';
573
+ break;
574
+ default:
575
+ break;
576
+ }
577
+ }
578
+
579
+ if (!info.diagnosis && err.message) {
580
+ if (/ssl/i.test(err.message) || /certificate/i.test(err.message)) {
581
+ info.diagnosis = 'SSL/TLS error. Check your SSL configuration or try adding ?sslmode=require or ?sslmode=no-verify to your DATABASE_URL.';
582
+ }
583
+ }
584
+
585
+ return info;
586
+ }
587
+
419
588
  // ---------------------------------------------------------------------------
420
589
  // HTML template functions
421
590
  // ---------------------------------------------------------------------------
@@ -587,6 +756,46 @@ function layoutHTML(title, content) {
587
756
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
588
757
  margin-bottom: 0.25rem;
589
758
  }
759
+ .secret-input-wrapper {
760
+ position: relative;
761
+ }
762
+ .secret-input-wrapper input {
763
+ padding-right: 2.5rem;
764
+ }
765
+ .secret-toggle {
766
+ position: absolute;
767
+ right: 0.4rem;
768
+ top: 50%;
769
+ transform: translateY(-50%);
770
+ background: none;
771
+ border: none;
772
+ color: #8b949e;
773
+ cursor: pointer;
774
+ padding: 0.2rem;
775
+ font-size: 0.85rem;
776
+ line-height: 1;
777
+ }
778
+ .secret-toggle:hover {
779
+ color: #c9d1d9;
780
+ }
781
+ .checkbox-group {
782
+ display: flex;
783
+ align-items: center;
784
+ gap: 0.5rem;
785
+ padding-top: 0.35rem;
786
+ }
787
+ .checkbox-group input[type="checkbox"] {
788
+ width: 1.1rem;
789
+ height: 1.1rem;
790
+ accent-color: #238636;
791
+ cursor: pointer;
792
+ }
793
+ .checkbox-label {
794
+ font-size: 0.85rem;
795
+ color: #8b949e;
796
+ padding-top: 0;
797
+ cursor: pointer;
798
+ }
590
799
  button[type="submit"] {
591
800
  background: #238636;
592
801
  color: #fff;
@@ -680,6 +889,7 @@ function layoutHTML(title, content) {
680
889
  <a href="/">Dashboard</a>
681
890
  <a href="/settings">Settings</a>
682
891
  <a href="/logs">Logs</a>
892
+ <a href="/database">Database</a>
683
893
  </nav>
684
894
  <div class="container">
685
895
  ${content}
@@ -726,9 +936,17 @@ function dashboardHTML(data) {
726
936
  </div>`;
727
937
 
728
938
  // -- DB warning ----------------------------------------------------------
729
- const dbWarn = data.dbAvailable
730
- ? ''
731
- : '<div class="db-warn">Database is unavailable. Dashboard data may be incomplete.</div>';
939
+ let dbWarn = '';
940
+ if (!data.dbAvailable) {
941
+ const e = data.dbError || {};
942
+ dbWarn = '<div class="db-warn">Database is unavailable. Dashboard data may be incomplete.';
943
+ if (e.diagnosis) {
944
+ dbWarn += `<br><span style="font-size:0.82rem;">${esc(e.diagnosis)}</span>`;
945
+ } else if (e.message) {
946
+ dbWarn += `<br><span style="font-size:0.82rem;">${esc(e.message)}</span>`;
947
+ }
948
+ dbWarn += '</div>';
949
+ }
732
950
 
733
951
  // -- Recent messages -----------------------------------------------------
734
952
  let messagesSection;
@@ -798,15 +1016,32 @@ function settingsHTML(config, message) {
798
1016
  let inputArea;
799
1017
 
800
1018
  if (field.secret) {
1019
+ const maskedDisplay = field.key === 'DATABASE_URL'
1020
+ ? maskDatabaseUrl(String(value))
1021
+ : maskValue(String(value));
801
1022
  const status = value
802
- ? `Current: ${esc(maskValue(String(value)))}`
1023
+ ? `Current: ${esc(maskedDisplay)}`
803
1024
  : '(not set)';
804
1025
  inputArea = `
805
1026
  <div>
806
1027
  <div class="secret-current">${status}</div>
807
- <input type="password" name="${esc(field.key)}"
808
- value="" placeholder="Enter new value to change"
809
- autocomplete="off" />
1028
+ <div class="secret-input-wrapper">
1029
+ <input type="password" id="input-${esc(field.key)}" name="${esc(field.key)}"
1030
+ value="" placeholder="Enter new value to change"
1031
+ autocomplete="off" />
1032
+ <button type="button" class="secret-toggle"
1033
+ onclick="const inp=document.getElementById('input-${esc(field.key)}');const show=inp.type==='password';inp.type=show?'text':'password';this.textContent=show?'&#x25C9;':'&#x25CE;';"
1034
+ title="Toggle visibility">&#x25CE;</button>
1035
+ </div>
1036
+ </div>`;
1037
+ } else if (field.type === 'boolean') {
1038
+ const checked = String(value).toLowerCase() === 'true' ? ' checked' : '';
1039
+ inputArea = `
1040
+ <div class="checkbox-group">
1041
+ <input type="hidden" name="${esc(field.key)}" value="false" />
1042
+ <input type="checkbox" id="${esc(field.key)}" name="${esc(field.key)}"
1043
+ value="true"${checked} />
1044
+ <label for="${esc(field.key)}" class="checkbox-label">Enabled</label>
810
1045
  </div>`;
811
1046
  } else {
812
1047
  inputArea = `<input type="text" name="${esc(field.key)}"
@@ -872,6 +1107,154 @@ function logsHTML(logs, activeLevel) {
872
1107
  `);
873
1108
  }
874
1109
 
1110
+ /**
1111
+ * Database admin page showing migration status and table metadata.
1112
+ */
1113
+ function databaseHTML(data) {
1114
+ const alert = data.message
1115
+ ? `<div class="alert alert-${esc(data.message.type)}">${esc(data.message.text)}</div>`
1116
+ : '';
1117
+
1118
+ let dbWarn = '';
1119
+ if (!data.dbAvailable) {
1120
+ const e = data.dbError || {};
1121
+ dbWarn = '<div class="db-warn">';
1122
+ dbWarn += '<strong>Database is unavailable.</strong> Cannot retrieve database information.';
1123
+ if (e.diagnosis) {
1124
+ dbWarn += `<br><br><strong>Diagnosis:</strong> ${esc(e.diagnosis)}`;
1125
+ }
1126
+ dbWarn += `<br><br><strong>Error:</strong> ${esc(e.message || 'Unknown')}`;
1127
+ if (e.code) {
1128
+ dbWarn += ` <span class="muted">(code: ${esc(e.code)})</span>`;
1129
+ }
1130
+ if (e.detail) {
1131
+ dbWarn += `<br><strong>Detail:</strong> ${esc(e.detail)}`;
1132
+ }
1133
+ if (e.hint) {
1134
+ dbWarn += `<br><strong>Hint:</strong> ${esc(e.hint)}`;
1135
+ }
1136
+ if (data.dbUrl) {
1137
+ dbWarn += `<br><br><strong>Connection URL:</strong> <code style="font-size:0.82rem;">${esc(data.dbUrl)}</code>`;
1138
+ }
1139
+ dbWarn += '</div>';
1140
+ }
1141
+
1142
+ const stats = data.dbAvailable
1143
+ ? `
1144
+ <div class="grid">
1145
+ <div class="card">
1146
+ <div class="stat-label">Total Migrations</div>
1147
+ <div class="stat-value">${data.migrations.total}</div>
1148
+ </div>
1149
+ <div class="card">
1150
+ <div class="stat-label">Applied</div>
1151
+ <div class="stat-value" style="color:#3fb950;">${data.migrations.applied.length}</div>
1152
+ </div>
1153
+ <div class="card">
1154
+ <div class="stat-label">Pending</div>
1155
+ <div class="stat-value"${data.migrations.pending.length > 0 ? ' style="color:#d29922;"' : ''}>${data.migrations.pending.length}</div>
1156
+ </div>
1157
+ <div class="card">
1158
+ <div class="stat-label">Database Size</div>
1159
+ <div class="stat-value">${esc(data.dbSize)}</div>
1160
+ </div>
1161
+ </div>`
1162
+ : '';
1163
+
1164
+ const versionSection = data.dbVersion
1165
+ ? `<p class="muted" style="margin-bottom:1rem;font-size:0.82rem;">${esc(data.dbVersion)}</p>`
1166
+ : '';
1167
+
1168
+ let pendingSection = '';
1169
+ if (data.dbAvailable && data.migrations.pending.length > 0) {
1170
+ const pendingRows = data.migrations.pending
1171
+ .map(
1172
+ (name) => `
1173
+ <tr>
1174
+ <td><span class="badge badge-warn">pending</span></td>
1175
+ <td style="font-family:monospace;">${esc(name)}</td>
1176
+ <td class="muted">--</td>
1177
+ </tr>`,
1178
+ )
1179
+ .join('');
1180
+
1181
+ pendingSection = `
1182
+ <h2>Pending Migrations</h2>
1183
+ <div class="card" style="overflow-x:auto;">
1184
+ <table>
1185
+ <thead><tr><th>Status</th><th>Migration</th><th>Applied At</th></tr></thead>
1186
+ <tbody>${pendingRows}</tbody>
1187
+ </table>
1188
+ <form method="POST" action="/database/migrate"
1189
+ onsubmit="return confirm('Run ${data.migrations.pending.length} pending migration(s)?\\n\\nThis will modify the database schema.');"
1190
+ style="margin-top:1rem;">
1191
+ <button type="submit" style="background:#d29922;">Run ${data.migrations.pending.length} Pending Migration${data.migrations.pending.length === 1 ? '' : 's'}</button>
1192
+ </form>
1193
+ </div>`;
1194
+ }
1195
+
1196
+ let appliedSection = '';
1197
+ if (data.dbAvailable && data.migrations.applied.length > 0) {
1198
+ const appliedRows = data.migrations.applied
1199
+ .map(
1200
+ (m) => `
1201
+ <tr>
1202
+ <td><span class="badge badge-ok">applied</span></td>
1203
+ <td style="font-family:monospace;">${esc(m.name)}</td>
1204
+ <td>${fmtTime(m.applied_at)}</td>
1205
+ </tr>`,
1206
+ )
1207
+ .join('');
1208
+
1209
+ appliedSection = `
1210
+ <h2>Applied Migrations</h2>
1211
+ <div class="card" style="overflow-x:auto;">
1212
+ <table>
1213
+ <thead><tr><th>Status</th><th>Migration</th><th>Applied At</th></tr></thead>
1214
+ <tbody>${appliedRows}</tbody>
1215
+ </table>
1216
+ </div>`;
1217
+ } else if (data.dbAvailable && data.migrations.applied.length === 0) {
1218
+ appliedSection = `
1219
+ <h2>Applied Migrations</h2>
1220
+ <div class="card"><p class="empty">No migrations have been applied yet.</p></div>`;
1221
+ }
1222
+
1223
+ let tablesSection = '';
1224
+ if (data.dbAvailable && data.tables.length > 0) {
1225
+ const tableRows = data.tables
1226
+ .map(
1227
+ (t) => `
1228
+ <tr>
1229
+ <td style="font-family:monospace;">${esc(t.name)}</td>
1230
+ <td style="text-align:right;">${t.row_count ?? '--'}</td>
1231
+ <td style="text-align:right;">${esc(t.size)}</td>
1232
+ </tr>`,
1233
+ )
1234
+ .join('');
1235
+
1236
+ tablesSection = `
1237
+ <h2>Tables</h2>
1238
+ <div class="card" style="overflow-x:auto;">
1239
+ <table>
1240
+ <thead><tr><th>Table</th><th style="text-align:right;">Rows (est.)</th><th style="text-align:right;">Size</th></tr></thead>
1241
+ <tbody>${tableRows}</tbody>
1242
+ </table>
1243
+ </div>`;
1244
+ }
1245
+
1246
+ return layoutHTML('Database', `
1247
+ <h1>Database</h1>
1248
+ ${alert}
1249
+ ${dbWarn}
1250
+ ${versionSection}
1251
+ ${stats}
1252
+ ${pendingSection}
1253
+ ${appliedSection}
1254
+ ${tablesSection}
1255
+ `);
1256
+ }
1257
+
875
1258
  // ---------------------------------------------------------------------------
876
1259
  // Export
877
1260
  // ---------------------------------------------------------------------------