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.
- package/.claude/settings.local.json +5 -1
- package/package.json +1 -1
- package/src/config.js +13 -0
- package/src/db/migrate.js +1 -1
- package/src/db/pool.js +35 -1
- package/src/index.js +2 -1
- package/src/web/server.js +397 -14
|
@@ -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.
|
|
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
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
|
|
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',
|
|
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 =
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
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(
|
|
1023
|
+
? `Current: ${esc(maskedDisplay)}`
|
|
803
1024
|
: '(not set)';
|
|
804
1025
|
inputArea = `
|
|
805
1026
|
<div>
|
|
806
1027
|
<div class="secret-current">${status}</div>
|
|
807
|
-
<
|
|
808
|
-
|
|
809
|
-
|
|
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?'◉':'◎';"
|
|
1034
|
+
title="Toggle visibility">◎</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
|
// ---------------------------------------------------------------------------
|