tina4-nodejs 3.10.41 → 3.10.44

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.
@@ -434,19 +434,26 @@ const handleDashboard: RouteHandler = (_req, res) => {
434
434
  };
435
435
 
436
436
  function handleStatus(router: Router): RouteHandler {
437
- return (_req, res) => {
437
+ return async (_req, res) => {
438
438
  const mem = process.memoryUsage();
439
439
  const reqStats = RequestInspector.stats();
440
440
  const msgCounts = MessageLog.count();
441
441
  const errors = ErrorTracker.get();
442
442
  const unresolved = errors.filter((e) => !e.resolved).length;
443
443
  const mailboxCounts = DevMailboxStore.count();
444
+ let dbTableCount = 0;
445
+ try {
446
+ const { getAdapter } = await import("@tina4/orm");
447
+ const db = getAdapter();
448
+ dbTableCount = db.tables().length;
449
+ } catch { /* no database connected */ }
444
450
  res.json({
445
451
  nodeVersion: process.version,
446
452
  framework: `tina4-nodejs v${TINA4_VERSION}`,
447
453
  debug: process.env.TINA4_DEBUG ?? "false",
448
454
  logLevel: process.env.TINA4_LOG_LEVEL ?? "ERROR",
449
455
  routes: router.getRoutes().length,
456
+ db_tables: dbTableCount,
450
457
  messages: msgCounts,
451
458
  message_counts: msgCounts,
452
459
  requests: reqStats,
@@ -528,10 +535,18 @@ const handleRequestsClear: RouteHandler = (_req, res) => {
528
535
  res.json({ cleared: true });
529
536
  };
530
537
 
531
- const handleSystem: RouteHandler = (_req, res) => {
538
+ const handleSystem: RouteHandler = async (_req, res) => {
532
539
  const mem = process.memoryUsage();
533
540
  const heapUsedMb = Math.round(mem.heapUsed / 1048576);
534
541
  const rssMb = Math.round(mem.rss / 1048576);
542
+ let dbTableCount: number | undefined;
543
+ let dbConnected = false;
544
+ try {
545
+ const { getAdapter } = await import("@tina4/orm");
546
+ const db = getAdapter();
547
+ dbTableCount = db.tables().length;
548
+ dbConnected = true;
549
+ } catch { /* no database connected */ }
535
550
  // Respond in both the shared-JS format and the Node-specific format
536
551
  res.json({
537
552
  // Shared JS fields
@@ -541,6 +556,8 @@ const handleSystem: RouteHandler = (_req, res) => {
541
556
  os: `${process.platform} ${process.arch}`,
542
557
  pid: process.pid,
543
558
  memory_mb: heapUsedMb,
559
+ db_tables: dbTableCount !== undefined ? dbTableCount : "N/A",
560
+ db_connected: dbConnected,
544
561
  memory: {
545
562
  current_mb: heapUsedMb,
546
563
  peak_mb: rssMb,
@@ -694,30 +711,129 @@ const handleTable: RouteHandler = (req, res) => {
694
711
  res.json({ table: name, columns: [], rows: [], message: "Database not connected or table not found" });
695
712
  };
696
713
 
697
- const handleTables: RouteHandler = (_req, res) => {
698
- // Stub response — actual implementation will use ORM adapter
699
- res.json({ tables: [], message: "Database not connected" });
714
+ const handleTables: RouteHandler = async (_req, res) => {
715
+ try {
716
+ const { getAdapter } = await import("@tina4/orm");
717
+ const db = getAdapter();
718
+ const tables = db.tables();
719
+ res.json({ tables });
720
+ } catch {
721
+ res.json({ tables: [], message: "Database not connected" });
722
+ }
700
723
  };
701
724
 
702
- const handleSeed: RouteHandler = (req, res) => {
725
+ const handleSeed: RouteHandler = async (req, res) => {
703
726
  const url = new URL(req.url ?? "/", "http://localhost");
704
727
  const table = url.searchParams.get("table") ?? (req as any).body?.table ?? "";
728
+ const count = parseInt(String(url.searchParams.get("count") ?? (req as any).body?.count ?? "10"), 10) || 10;
705
729
  if (!table) {
706
730
  res.json({ error: "Missing table parameter" });
707
731
  return;
708
732
  }
709
- // Stub response — actual implementation will use ORM adapter
710
- res.json({ seeded: false, table, message: "Database not connected" });
733
+ try {
734
+ const orm = await import("@tina4/orm");
735
+ const db = orm.getAdapter();
736
+ const { seedTable } = orm;
737
+ const fake = new orm.FakeData();
738
+ const columns = db.columns(table);
739
+ if (!columns.length) {
740
+ res.json({ error: `Table '${table}' not found or has no columns` });
741
+ return;
742
+ }
743
+ // Build a field map based on column info
744
+ const fieldMap: Record<string, () => unknown> = {};
745
+ for (const col of columns) {
746
+ const name = col.name.toLowerCase();
747
+ const type = col.type.toLowerCase();
748
+ if (name === "id") continue; // skip primary key
749
+ if (name.includes("email")) { fieldMap[col.name] = () => fake.email(); }
750
+ else if (name.includes("name")) { fieldMap[col.name] = () => fake.name(); }
751
+ else if (name.includes("phone")) { fieldMap[col.name] = () => fake.phone(); }
752
+ else if (name.includes("address") || name.includes("city") || name.includes("country")) { fieldMap[col.name] = () => fake.address(); }
753
+ else if (name.includes("url") || name.includes("website")) { fieldMap[col.name] = () => fake.url(); }
754
+ else if (type.includes("int")) { fieldMap[col.name] = () => fake.integer(1, 1000); }
755
+ else if (type.includes("real") || type.includes("float") || type.includes("double") || type.includes("numeric") || type.includes("decimal")) { fieldMap[col.name] = () => fake.decimal(0, 1000, 2); }
756
+ else if (type.includes("bool")) { fieldMap[col.name] = () => fake.boolean(); }
757
+ else if (type.includes("date") || type.includes("time")) { fieldMap[col.name] = () => fake.date(); }
758
+ else { fieldMap[col.name] = () => fake.sentence(3); }
759
+ }
760
+ let inserted = 0;
761
+ for (let i = 0; i < count; i++) {
762
+ const row: Record<string, unknown> = {};
763
+ for (const [col, gen] of Object.entries(fieldMap)) {
764
+ row[col] = gen();
765
+ }
766
+ try {
767
+ db.insert(table, row);
768
+ inserted++;
769
+ } catch { /* skip failed rows */ }
770
+ }
771
+ res.json({ inserted, table });
772
+ } catch {
773
+ res.json({ error: "Database not connected" });
774
+ }
711
775
  };
712
776
 
713
- const handleQuery: RouteHandler = (req, res) => {
714
- const query = (req as any).body?.query ?? "";
777
+ const handleQuery: RouteHandler = async (req, res) => {
778
+ const query = ((req as any).body?.query ?? "").trim();
779
+ const queryType = (req as any).body?.type ?? "sql";
715
780
  if (!query) {
716
781
  res.json({ error: "Missing query parameter" });
717
782
  return;
718
783
  }
719
- // Stub response — actual implementation will use ORM adapter
720
- res.json({ query, rows: [], message: "Database not connected" });
784
+
785
+ if (queryType === "graphql") {
786
+ // GraphQL stub — can be extended when GraphQL module is available
787
+ res.json({ error: "GraphQL not yet supported in dev admin" });
788
+ return;
789
+ }
790
+
791
+ try {
792
+ const { getAdapter } = await import("@tina4/orm");
793
+ const db = getAdapter();
794
+
795
+ // Split multiple statements on semicolons
796
+ const statements = query.split(";").map((s: string) => s.trim()).filter((s: string) => s.length > 0);
797
+
798
+ if (statements.length === 1) {
799
+ const upper = statements[0].toUpperCase().trimStart();
800
+ const isRead = upper.startsWith("SELECT") || upper.startsWith("PRAGMA") || upper.startsWith("SHOW") || upper.startsWith("DESCRIBE");
801
+
802
+ if (isRead) {
803
+ const rows = db.fetch(statements[0]);
804
+ MessageLog.log("query", "info", `SQL: ${statements[0].substring(0, 80)}`, { rows: rows.length });
805
+ res.json({ rows, count: rows.length });
806
+ return;
807
+ }
808
+ }
809
+
810
+ // Execute all statements (single write or multi-statement batch)
811
+ let totalAffected = 0;
812
+ db.startTransaction();
813
+ try {
814
+ for (const stmt of statements) {
815
+ const result = db.execute(stmt);
816
+ // execute may return an object with affected count or a number
817
+ if (typeof result === "number") {
818
+ totalAffected += result;
819
+ } else if (result && typeof result === "object" && "changes" in (result as Record<string, unknown>)) {
820
+ totalAffected += Number((result as Record<string, unknown>).changes) || 0;
821
+ }
822
+ }
823
+ db.commit();
824
+ } catch (e: unknown) {
825
+ db.rollback();
826
+ const msg = e instanceof Error ? e.message : String(e);
827
+ res.json({ error: msg });
828
+ return;
829
+ }
830
+
831
+ MessageLog.log("query", "warn", `SQL batch: ${statements.length} statement(s)`, { affected: totalAffected });
832
+ res.json({ affected: totalAffected, success: true });
833
+ } catch (e: unknown) {
834
+ const msg = e instanceof Error ? e.message : String(e);
835
+ res.json({ error: msg });
836
+ }
721
837
  };
722
838
 
723
839
  // -- Broken (errors) handlers --
@@ -1085,8 +1201,7 @@ const handleDevAdminJs: RouteHandler = (_req, res) => {
1085
1201
  // Shared Dev Admin JS — cross-language, vanilla JS, zero dependencies
1086
1202
  // ---------------------------------------------------------------------------
1087
1203
 
1088
- function renderDevAdminJs(): string {
1089
- // Use single-quoted strings and concatenation to avoid template literal escaping issues
1204
+ function renderAppShell(): string[] {
1090
1205
  return [
1091
1206
  "let currentTab = 'routes';",
1092
1207
  "let queueFilter = '';",
@@ -1107,7 +1222,11 @@ function renderDevAdminJs(): string {
1107
1222
  " if (body) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }",
1108
1223
  " return fetch(path, opts).then(function(r) { return r.json(); });",
1109
1224
  "}",
1110
- "",
1225
+ ];
1226
+ }
1227
+
1228
+ function renderRoutesTab(): string[] {
1229
+ return [
1111
1230
  "// -- Routes --",
1112
1231
  "function loadRoutes() {",
1113
1232
  " api('/__dev/api/routes').then(function(d) {",
@@ -1122,7 +1241,11 @@ function renderDevAdminJs(): string {
1122
1241
  " }).join('');",
1123
1242
  " });",
1124
1243
  "}",
1125
- "",
1244
+ ];
1245
+ }
1246
+
1247
+ function renderQueueTab(): string[] {
1248
+ return [
1126
1249
  "// -- Queue --",
1127
1250
  "function loadQueue() {",
1128
1251
  " var qs = queueFilter ? '?status=' + queueFilter : '';",
@@ -1158,7 +1281,11 @@ function renderDevAdminJs(): string {
1158
1281
  "function retryQueue() { api('/__dev/api/queue/retry', 'POST', {}).then(function() { loadQueue(); }); }",
1159
1282
  "function purgeQueue() { api('/__dev/api/queue/purge', 'POST', {}).then(function() { loadQueue(); }); }",
1160
1283
  "function replayJob(id, topic) { api('/__dev/api/queue/replay', 'POST', {job_id: id, topic: topic}).then(function() { loadQueue(); }); }",
1161
- "",
1284
+ ];
1285
+ }
1286
+
1287
+ function renderMailboxTab(): string[] {
1288
+ return [
1162
1289
  "// -- Mailbox --",
1163
1290
  "function loadMailbox() {",
1164
1291
  " var qs = mailboxFolder ? '?folder=' + mailboxFolder : '';",
@@ -1194,7 +1321,11 @@ function renderDevAdminJs(): string {
1194
1321
  "}",
1195
1322
  "function seedMailbox() { api('/__dev/api/mailbox/seed', 'POST', {count:5}).then(function() { loadMailbox(); }); }",
1196
1323
  "function clearMailbox() { api('/__dev/api/mailbox/clear', 'POST', {}).then(function() { loadMailbox(); }); }",
1197
- "",
1324
+ ];
1325
+ }
1326
+
1327
+ function renderMessagesTab(): string[] {
1328
+ return [
1198
1329
  "// -- Messages --",
1199
1330
  "function loadMessages() {",
1200
1331
  " api('/__dev/api/messages').then(function(d) {",
@@ -1223,20 +1354,168 @@ function renderDevAdminJs(): string {
1223
1354
  " }).join('');",
1224
1355
  "}",
1225
1356
  "function clearMessages() { api('/__dev/api/messages/clear', 'POST', {}).then(function() { loadMessages(); }); }",
1226
- "",
1357
+ ];
1358
+ }
1359
+
1360
+ function renderDatabaseTab(): string[] {
1361
+ return [
1227
1362
  "// -- Database --",
1363
+ "var _currentTable = '';",
1364
+ "",
1365
+ "function _clipCopy(text, btn) {",
1366
+ " var orig = btn.textContent;",
1367
+ " var ta = document.createElement('textarea');",
1368
+ " ta.value = text;",
1369
+ " ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';",
1370
+ " document.body.appendChild(ta);",
1371
+ " ta.focus();",
1372
+ " ta.select();",
1373
+ " try { document.execCommand('copy'); btn.textContent = 'Copied!'; btn.style.color = 'var(--success)'; }",
1374
+ " catch(e) { btn.textContent = 'Failed'; btn.style.color = 'var(--danger)'; }",
1375
+ " document.body.removeChild(ta);",
1376
+ " setTimeout(function() { btn.textContent = orig; btn.style.color = ''; }, 1500);",
1377
+ "}",
1378
+ "",
1379
+ "function copyResults(fmt, btn) {",
1380
+ " var el = document.getElementById('query-results');",
1381
+ " var tbl = el ? el.querySelector('table') : null;",
1382
+ " if (!tbl) { btn.textContent = 'No data'; btn.style.color = 'var(--danger)'; setTimeout(function() { btn.textContent = fmt === 'json' ? 'Copy JSON' : 'Copy CSV'; btn.style.color = ''; }, 1500); return; }",
1383
+ " var headerCells = tbl.querySelectorAll('thead th');",
1384
+ " var headers = []; headerCells.forEach(function(h) { headers.push(h.textContent); });",
1385
+ " var bodyRows = tbl.querySelectorAll('tbody tr');",
1386
+ " var data = [];",
1387
+ " bodyRows.forEach(function(row) {",
1388
+ " var cells = row.querySelectorAll('td');",
1389
+ " var obj = {};",
1390
+ " cells.forEach(function(c, i) { obj[headers[i] || ('col' + i)] = c.textContent === 'null' ? null : c.textContent; });",
1391
+ " data.push(obj);",
1392
+ " });",
1393
+ " var text = '';",
1394
+ " if (fmt === 'json') {",
1395
+ " text = JSON.stringify(data, null, 2);",
1396
+ " } else {",
1397
+ " var lines = [headers.join(',')];",
1398
+ " data.forEach(function(r) {",
1399
+ " lines.push(headers.map(function(h) { var v = (r[h] !== null ? r[h] : ''); return v.indexOf(',') >= 0 || v.indexOf('\"') >= 0 ? '\"' + v.replace(/\"/g, '\"\"') + '\"' : v; }).join(','));",
1400
+ " });",
1401
+ " text = lines.join(String.fromCharCode(10));",
1402
+ " }",
1403
+ " _clipCopy(text, btn);",
1404
+ "}",
1405
+ "",
1406
+ "function pasteData() {",
1407
+ " var NL = String.fromCharCode(10);",
1408
+ " var TAB = String.fromCharCode(9);",
1409
+ " var overlay = document.createElement('div');",
1410
+ " overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:99999;display:flex;align-items:center;justify-content:center';",
1411
+ " var box = document.createElement('div');",
1412
+ " box.style.cssText = 'background:var(--bg,#1e293b);border:1px solid var(--border,#334155);border-radius:0.5rem;padding:1rem;width:500px;max-width:90vw';",
1413
+ " box.innerHTML = '<div style=\"font-weight:600;margin-bottom:0.5rem;color:var(--text,#e2e8f0)\">Paste Data</div><div style=\"font-size:0.75rem;color:var(--muted,#94a3b8);margin-bottom:0.5rem\">Paste JSON array or CSV/tab-separated data below</div>';",
1414
+ " var ta = document.createElement('textarea');",
1415
+ " ta.style.cssText = 'width:100%;height:150px;font-family:monospace;font-size:0.75rem;background:var(--bg-alt,#0f172a);color:var(--text,#e2e8f0);border:1px solid var(--border,#334155);border-radius:0.25rem;padding:0.5rem;resize:vertical';",
1416
+ " ta.placeholder = 'Paste here (Ctrl+V)...';",
1417
+ " var btns = document.createElement('div');",
1418
+ " btns.style.cssText = 'display:flex;gap:0.5rem;margin-top:0.5rem;justify-content:flex-end';",
1419
+ " var cancelBtn = document.createElement('button');",
1420
+ " cancelBtn.className = 'btn btn-sm'; cancelBtn.textContent = 'Cancel';",
1421
+ " cancelBtn.onclick = function() { document.body.removeChild(overlay); };",
1422
+ " var goBtn = document.createElement('button');",
1423
+ " goBtn.className = 'btn btn-sm btn-primary'; goBtn.textContent = 'Generate SQL';",
1424
+ " goBtn.onclick = function() {",
1425
+ " var text = ta.value.trim();",
1426
+ " if (!text) { alert('Paste some data first'); return; }",
1427
+ " var upper = text.substring(0, 50).toUpperCase();",
1428
+ " if (upper.indexOf('INSERT ') >= 0 || upper.indexOf('CREATE ') >= 0 || upper.indexOf('SELECT ') >= 0) {",
1429
+ " document.getElementById('query-input').value = text;",
1430
+ " document.body.removeChild(overlay);",
1431
+ " return;",
1432
+ " }",
1433
+ " var rows = [];",
1434
+ " var parsed = null;",
1435
+ " try { parsed = JSON.parse(text); } catch(e) {}",
1436
+ " if (parsed && Array.isArray(parsed) && parsed.length > 0) {",
1437
+ " rows = parsed;",
1438
+ " } else {",
1439
+ " var lines = text.split(NL);",
1440
+ " if (lines.length < 2) { alert('Need a header row + at least one data row'); return; }",
1441
+ " var sep = lines[0].indexOf(TAB) >= 0 ? TAB : ',';",
1442
+ " var hdrs = lines[0].split(sep);",
1443
+ " for (var i = 1; i < lines.length; i++) {",
1444
+ " var vals = lines[i].split(sep);",
1445
+ " if (!vals.length || vals.join('').trim() === '') continue;",
1446
+ " var row = {};",
1447
+ " hdrs.forEach(function(h, idx) { row[h.trim()] = vals[idx] !== undefined ? vals[idx].trim() : ''; });",
1448
+ " rows.push(row);",
1449
+ " }",
1450
+ " }",
1451
+ " if (!rows.length) { alert('No data rows found'); return; }",
1452
+ " var allCols = Object.keys(rows[0]);",
1453
+ " var table = _currentTable || '';",
1454
+ " var isNew = false;",
1455
+ " if (!table) {",
1456
+ " table = prompt('No table selected. Enter table name (creates if new):');",
1457
+ " if (!table) { return; }",
1458
+ " isNew = true;",
1459
+ " }",
1460
+ " var sql = '';",
1461
+ " if (isNew) {",
1462
+ " var hasId = allCols.some(function(c) { return c.toLowerCase() === 'id'; });",
1463
+ " var colDefs = allCols.map(function(h) {",
1464
+ " if (h.toLowerCase() === 'id') return h + ' INTEGER PRIMARY KEY AUTOINCREMENT';",
1465
+ " return h + ' TEXT';",
1466
+ " }).join(', ');",
1467
+ " if (!hasId) colDefs = 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + colDefs;",
1468
+ " sql = 'CREATE TABLE IF NOT EXISTS ' + table + ' (' + colDefs + ');' + NL;",
1469
+ " }",
1470
+ " sql += rows.map(function(r) {",
1471
+ " var keys = Object.keys(r);",
1472
+ " if (isNew) { keys = keys.filter(function(k) { return k.toLowerCase() !== 'id'; }); }",
1473
+ " var cols = keys.join(', ');",
1474
+ " var vs = keys.map(function(k) { var v = r[k]; return v === null ? 'NULL' : \"'\" + String(v).replace(/'/g, \"''\") + \"'\"; }).join(', ');",
1475
+ " return 'INSERT INTO ' + table + ' (' + cols + ') VALUES (' + vs + ')';",
1476
+ " }).join(';' + NL);",
1477
+ " document.getElementById('query-input').value = sql;",
1478
+ " document.body.removeChild(overlay);",
1479
+ " };",
1480
+ " btns.appendChild(cancelBtn); btns.appendChild(goBtn);",
1481
+ " box.appendChild(ta); box.appendChild(btns);",
1482
+ " overlay.appendChild(box); document.body.appendChild(overlay);",
1483
+ " ta.focus();",
1484
+ "}",
1485
+ "",
1228
1486
  "function loadTables() {",
1229
1487
  " api('/__dev/api/tables').then(function(d) {",
1488
+ " if (d.error) { document.getElementById('table-list').innerHTML = '<div style=\"color:var(--danger)\">' + d.error + '</div>'; return; }",
1230
1489
  " var tables = d.tables || [];",
1231
1490
  " document.getElementById('db-count').textContent = tables.length;",
1232
- " document.getElementById('table-list').innerHTML = tables.map(function(t) {",
1233
- " return '<div style=\"padding:0.2rem 0.4rem;cursor:pointer;border-radius:0.25rem\" onclick=\"browseTable(\\'' + t + '\\')\" onmouseover=\"this.style.background=\\'rgba(46,125,50,0.1)\\'\" onmouseout=\"this.style.background=\\'\\'\">' + t + '</div>';",
1234
- " }).join('');",
1491
+ " var list = document.getElementById('table-list');",
1492
+ " if (!tables.length) { list.innerHTML = '<div class=\"text-muted\">No tables</div>'; return; }",
1493
+ " list.innerHTML = '';",
1494
+ " tables.forEach(function(t) {",
1495
+ " var div = document.createElement('div');",
1496
+ " div.style.cssText = 'cursor:pointer;padding:4px 6px;color:var(--primary);border-radius:4px;margin-bottom:1px';",
1497
+ " div.textContent = t;",
1498
+ " div.addEventListener('mouseenter', function() { if (t !== _currentTable) div.style.background = 'var(--bg-alt,rgba(255,255,255,0.05))'; });",
1499
+ " div.addEventListener('mouseleave', function() { if (t !== _currentTable) div.style.background = ''; });",
1500
+ " div.addEventListener('click', function() {",
1501
+ " list.querySelectorAll('div').forEach(function(d) { d.style.background = ''; d.style.fontWeight = ''; });",
1502
+ " div.style.background = 'var(--primary-bg,rgba(53,114,165,0.2))'; div.style.fontWeight = '600';",
1503
+ " browseTable(t);",
1504
+ " });",
1505
+ " list.appendChild(div);",
1506
+ " });",
1235
1507
  " var sel = document.getElementById('seed-table');",
1236
1508
  " sel.innerHTML = '<option value=\"\">Pick table...</option>' + tables.map(function(t) { return '<option value=\"' + t + '\">' + t + '</option>'; }).join('');",
1237
- " });",
1509
+ " }).catch(function(e) { document.getElementById('table-list').innerHTML = '<div style=\"color:var(--danger)\">' + e.message + '</div>'; });",
1510
+ "}",
1511
+ "",
1512
+ "function browseTable(name) {",
1513
+ " _currentTable = name;",
1514
+ " var lim = document.getElementById('query-limit').value;",
1515
+ " document.getElementById('query-input').value = 'SELECT * FROM ' + name + (lim !== '0' ? ' LIMIT ' + lim : '');",
1516
+ " runQuery();",
1238
1517
  "}",
1239
- "function browseTable(name) { document.getElementById('query-input').value = 'SELECT * FROM ' + name + ' LIMIT 20'; runQuery(); }",
1518
+ "",
1240
1519
  "function seedTable() {",
1241
1520
  " var table = document.getElementById('seed-table').value;",
1242
1521
  " var count = parseInt(document.getElementById('seed-count').value) || 10;",
@@ -1246,30 +1525,38 @@ function renderDevAdminJs(): string {
1246
1525
  " browseTable(table);",
1247
1526
  " });",
1248
1527
  "}",
1528
+ "",
1249
1529
  "function runQuery() {",
1250
1530
  " var query = document.getElementById('query-input').value.trim();",
1251
1531
  " var type = document.getElementById('query-type').value;",
1252
1532
  " var errorEl = document.getElementById('query-error');",
1533
+ " var resultEl = document.getElementById('query-results');",
1534
+ " if (!query) { errorEl.textContent = 'Enter a query'; errorEl.classList.remove('hidden'); return; }",
1253
1535
  " errorEl.classList.add('hidden');",
1254
- " if (!query) return;",
1536
+ " resultEl.innerHTML = '<p class=\"text-muted\">Running...</p>';",
1255
1537
  " api('/__dev/api/query', 'POST', {query:query, type:type}).then(function(d) {",
1256
- " if (d.error) { errorEl.textContent = d.error; errorEl.classList.remove('hidden'); return; }",
1257
- " var results = document.getElementById('query-results');",
1258
- " if (d.rows && d.rows.length) {",
1538
+ " if (d.error) { errorEl.textContent = d.error; errorEl.classList.remove('hidden'); resultEl.innerHTML = ''; return; }",
1539
+ " if (d.rows) {",
1540
+ " if (!d.rows.length) { resultEl.innerHTML = '<p class=\"text-muted\">No results</p>'; return; }",
1259
1541
  " var cols = d.columns || Object.keys(d.rows[0]);",
1260
- " results.innerHTML = '<div class=\"text-sm text-muted p-sm\">' + (d.count||d.rows.length) + ' rows</div>' +",
1261
- " '<table><thead><tr>' + cols.map(function(c){return '<th>'+c+'</th>';}).join('') + '</tr></thead>' +",
1262
- " '<tbody>' + d.rows.map(function(r){ return '<tr>' + cols.map(function(c){ return '<td class=\"text-mono text-sm\">' + (r[c]===null?'<span class=\"text-muted\">NULL</span>':esc(String(r[c]))) + '</td>'; }).join('') + '</tr>'; }).join('') + '</tbody></table>';",
1542
+ " var html = '<div class=\"text-muted\" style=\"margin-bottom:0.25rem\">' + d.rows.length + ' row(s)</div><table class=\"table\"><thead><tr>' + cols.map(function(c) { return '<th>' + c + '</th>'; }).join('') + '</tr></thead><tbody>';",
1543
+ " d.rows.forEach(function(row) { html += '<tr>' + cols.map(function(c) { return '<td>' + (row[c] !== null ? row[c] : '<em>null</em>') + '</td>'; }).join('') + '</tr>'; });",
1544
+ " html += '</tbody></table>'; resultEl.innerHTML = html;",
1263
1545
  " } else if (d.data) {",
1264
- " results.innerHTML = '<pre class=\"p-md text-mono text-sm\">' + JSON.stringify(d.data, null, 2) + '</pre>';",
1546
+ " resultEl.innerHTML = '<pre class=\"p-md text-mono text-sm\">' + JSON.stringify(d.data, null, 2) + '</pre>';",
1265
1547
  " } else if (d.success) {",
1266
- " results.innerHTML = '<div class=\"empty\">Query executed. ' + (d.affected||0) + ' rows affected.</div>';",
1548
+ " resultEl.innerHTML = '<p style=\"color:var(--success)\">' + (d.affected || 0) + ' row(s) affected</p>';",
1549
+ " loadTables();",
1267
1550
  " } else {",
1268
- " results.innerHTML = '<div class=\"empty\">No results</div>';",
1551
+ " resultEl.innerHTML = '<div class=\"empty\">No results</div>';",
1269
1552
  " }",
1270
1553
  " }).catch(function(e) { errorEl.textContent = e.message; errorEl.classList.remove('hidden'); });",
1271
1554
  "}",
1272
- "",
1555
+ ];
1556
+ }
1557
+
1558
+ function renderRequestsTab(): string[] {
1559
+ return [
1273
1560
  "// -- Requests --",
1274
1561
  "function loadRequests() {",
1275
1562
  " api('/__dev/api/requests').then(function(d) {",
@@ -1294,7 +1581,11 @@ function renderDevAdminJs(): string {
1294
1581
  " });",
1295
1582
  "}",
1296
1583
  "function clearRequests() { api('/__dev/api/requests/clear', 'POST', {}).then(function() { loadRequests(); }); }",
1297
- "",
1584
+ ];
1585
+ }
1586
+
1587
+ function renderErrorsTab(): string[] {
1588
+ return [
1298
1589
  "// -- Errors --",
1299
1590
  "function loadErrors() {",
1300
1591
  " api('/__dev/api/broken').then(function(d) {",
@@ -1320,7 +1611,11 @@ function renderDevAdminJs(): string {
1320
1611
  "}",
1321
1612
  "function resolveError(id) { api('/__dev/api/broken/resolve', 'POST', {id:id}).then(function() { loadErrors(); }); }",
1322
1613
  "function clearResolvedErrors() { api('/__dev/api/broken/clear', 'POST', {}).then(function() { loadErrors(); }); }",
1323
- "",
1614
+ ];
1615
+ }
1616
+
1617
+ function renderWebSocketTab(): string[] {
1618
+ return [
1324
1619
  "// -- WebSockets --",
1325
1620
  "function loadWebSockets() {",
1326
1621
  " api('/__dev/api/websockets').then(function(d) {",
@@ -1342,7 +1637,11 @@ function renderDevAdminJs(): string {
1342
1637
  " });",
1343
1638
  "}",
1344
1639
  "function wsDisconnect(id) { api('/__dev/api/websockets/disconnect', 'POST', {id:id}).then(function() { loadWebSockets(); }); }",
1345
- "",
1640
+ ];
1641
+ }
1642
+
1643
+ function renderSystemTab(): string[] {
1644
+ return [
1346
1645
  "// -- System --",
1347
1646
  "function loadSystem() {",
1348
1647
  " api('/__dev/api/system').then(function(d) {",
@@ -1367,6 +1666,9 @@ function renderDevAdminJs(): string {
1367
1666
  " if (fwVersion) html += '<div class=\"sys-card\"><div class=\"label\">Version</div><div class=\"value text-sm\">' + fwVersion + '</div></div>';",
1368
1667
  " if (routeCount !== '') html += '<div class=\"sys-card\"><div class=\"label\">Routes</div><div class=\"value\">' + routeCount + '</div></div>';",
1369
1668
  "",
1669
+ " html += '<div class=\"sys-card\"><div class=\"label\">DB Tables</div><div class=\"value\">' + (d.db_tables !== undefined ? d.db_tables : 'N/A') + '</div></div>';",
1670
+ " html += '<div class=\"sys-card\"><div class=\"label\">DB Connected</div><div class=\"value\">' + (d.db_connected ? '<span style=\"color:var(--success)\">Yes</span>' : '<span style=\"color:var(--danger)\">No</span>') + '</div></div>';",
1671
+ "",
1370
1672
  " if (d.node && d.node.v8) html += '<div class=\"sys-card\"><div class=\"label\">V8 Engine</div><div class=\"value text-sm\">' + d.node.v8 + '</div></div>';",
1371
1673
  " if (d.pid) html += '<div class=\"sys-card\"><div class=\"label\">PID</div><div class=\"value text-sm\">' + d.pid + '</div></div>';",
1372
1674
  " if (d.cpus) html += '<div class=\"sys-card\"><div class=\"label\">CPU Cores</div><div class=\"value\">' + d.cpus + '</div></div>';",
@@ -1379,7 +1681,11 @@ function renderDevAdminJs(): string {
1379
1681
  " document.getElementById('sys-cards').innerHTML = html;",
1380
1682
  " });",
1381
1683
  "}",
1382
- "",
1684
+ ];
1685
+ }
1686
+
1687
+ function renderChatTab(): string[] {
1688
+ return [
1383
1689
  "// -- Chat (Tina4) --",
1384
1690
  "var _aiKey = '';",
1385
1691
  "var _aiProvider = 'anthropic';",
@@ -1428,7 +1734,11 @@ function renderDevAdminJs(): string {
1428
1734
  " document.getElementById('chat-input').value = msg;",
1429
1735
  " sendChat();",
1430
1736
  "}",
1431
- "",
1737
+ ];
1738
+ }
1739
+
1740
+ function renderToolsTab(): string[] {
1741
+ return [
1432
1742
  "// -- Tools --",
1433
1743
  "function runTool(tool) {",
1434
1744
  " var titles = {carbon:'Carbon Benchmark',test:'Test Suite',routes:'Routes',migrate:'Migrations',seed:'Seeders',ai:'AI Detection'};",
@@ -1441,7 +1751,11 @@ function renderDevAdminJs(): string {
1441
1751
  " document.getElementById('tool-result').textContent = 'Error: ' + e.message;",
1442
1752
  " });",
1443
1753
  "}",
1444
- "",
1754
+ ];
1755
+ }
1756
+
1757
+ function renderUtilitiesAndInit(): string[] {
1758
+ return [
1445
1759
  "// -- Exit Dev Admin --",
1446
1760
  "function exitDevAdmin() {",
1447
1761
  " if (document.referrer && !document.referrer.includes('/__dev')) { window.location.href = document.referrer; }",
@@ -1469,7 +1783,38 @@ function renderDevAdminJs(): string {
1469
1783
  " if (d.health) document.getElementById('err-count').textContent = d.health.unresolved || 0;",
1470
1784
  " if (d.requests) document.getElementById('req-count').textContent = d.requests.total || 0;",
1471
1785
  " if (d.request_stats) document.getElementById('req-count').textContent = d.request_stats.total || 0;",
1786
+ " if (d.db_tables !== undefined) document.getElementById('db-count').textContent = d.db_tables;",
1472
1787
  "});",
1788
+ ];
1789
+ }
1790
+
1791
+ function renderDevAdminJs(): string {
1792
+ return [
1793
+ ...renderAppShell(),
1794
+ "",
1795
+ ...renderRoutesTab(),
1796
+ "",
1797
+ ...renderQueueTab(),
1798
+ "",
1799
+ ...renderMailboxTab(),
1800
+ "",
1801
+ ...renderMessagesTab(),
1802
+ "",
1803
+ ...renderDatabaseTab(),
1804
+ "",
1805
+ ...renderRequestsTab(),
1806
+ "",
1807
+ ...renderErrorsTab(),
1808
+ "",
1809
+ ...renderWebSocketTab(),
1810
+ "",
1811
+ ...renderSystemTab(),
1812
+ "",
1813
+ ...renderChatTab(),
1814
+ "",
1815
+ ...renderToolsTab(),
1816
+ "",
1817
+ ...renderUtilitiesAndInit(),
1473
1818
  ].join("\n");
1474
1819
  }
1475
1820
 
@@ -1477,7 +1822,7 @@ function renderDevAdminJs(): string {
1477
1822
  // Dashboard HTML — Single-page app
1478
1823
  // ---------------------------------------------------------------------------
1479
1824
 
1480
- function renderDashboard(): string {
1825
+ export function renderDashboard(): string {
1481
1826
  return `<!DOCTYPE html>
1482
1827
  <html lang="en">
1483
1828
  <head>
@@ -1730,33 +2075,46 @@ code, .mono { font-family: var(--mono); font-size: 0.82rem; }
1730
2075
  <h2>Database</h2>
1731
2076
  <button class="btn btn-sm" onclick="loadTables()">Refresh</button>
1732
2077
  </div>
1733
- <div class="flex gap-md p-md">
1734
- <div class="flex-1">
1735
- <div class="flex gap-sm items-center mb-sm">
1736
- <select id="query-type" class="input">
2078
+ <div style="display:flex;height:calc(100vh - 140px);overflow:hidden">
2079
+ <!-- Left: Tables navigation -->
2080
+ <div style="width:200px;min-width:200px;border-right:1px solid var(--border);padding:0.5rem;overflow-y:auto;display:flex;flex-direction:column;gap:0.5rem">
2081
+ <div class="text-sm text-muted" style="font-weight:600">Tables</div>
2082
+ <div id="table-list" class="text-sm"></div>
2083
+ <div style="border-top:1px solid var(--border);padding-top:0.5rem;margin-top:auto">
2084
+ <div class="text-sm text-muted" style="font-weight:600;margin-bottom:0.25rem">Seed Data</div>
2085
+ <select id="seed-table" class="input" style="width:100%;margin-bottom:0.25rem;font-size:0.75rem"><option value="">Pick table...</option></select>
2086
+ <div class="flex gap-sm items-center">
2087
+ <input type="number" id="seed-count" class="input" value="10" min="1" max="1000" style="width:60px;font-size:0.75rem">
2088
+ <button class="btn btn-sm btn-success" onclick="seedTable()">Seed</button>
2089
+ </div>
2090
+ </div>
2091
+ </div>
2092
+ <!-- Right: Query + Results -->
2093
+ <div style="flex:1;display:flex;flex-direction:column;overflow:hidden;padding:0.5rem">
2094
+ <div style="display:flex;gap:0.5rem;align-items:center;margin-bottom:0.25rem">
2095
+ <select id="query-type" class="input" style="width:auto;font-size:0.75rem">
1737
2096
  <option value="sql">SQL</option>
1738
2097
  <option value="graphql">GraphQL</option>
1739
2098
  </select>
2099
+ <span class="text-sm text-muted">Limit</span>
2100
+ <select id="query-limit" class="input" style="width:70px;font-size:0.75rem">
2101
+ <option value="20">20</option>
2102
+ <option value="50">50</option>
2103
+ <option value="100">100</option>
2104
+ <option value="500">500</option>
2105
+ <option value="0">All</option>
2106
+ </select>
1740
2107
  <button class="btn btn-sm btn-primary" onclick="runQuery()">Run</button>
2108
+ <button class="btn btn-sm" id="btn-csv" onclick="copyResults('csv',this)" title="Copy results as CSV">Copy CSV</button>
2109
+ <button class="btn btn-sm" id="btn-json" onclick="copyResults('json',this)" title="Copy results as JSON">Copy JSON</button>
2110
+ <button class="btn btn-sm" onclick="pasteData()" title="Paste tab-separated data as INSERTs">Paste</button>
1741
2111
  <span class="text-sm text-muted">Ctrl+Enter</span>
1742
2112
  </div>
1743
- <textarea id="query-input" rows="4" placeholder="SELECT * FROM users LIMIT 20" class="input input-mono" style="width:100%"></textarea>
2113
+ <textarea id="query-input" rows="3" placeholder="SELECT * FROM users LIMIT 20" class="input input-mono" style="width:100%;font-size:0.75rem;resize:vertical"></textarea>
1744
2114
  <div id="query-error" class="hidden" style="color:var(--danger);font-size:0.75rem;margin-top:0.25rem"></div>
1745
- </div>
1746
- <div style="width:180px">
1747
- <div class="text-sm text-muted" style="font-weight:600;margin-bottom:0.5rem">Tables</div>
1748
- <div id="table-list" class="text-sm"></div>
1749
- <div style="margin-top:0.75rem;border-top:1px solid var(--border);padding-top:0.75rem">
1750
- <div class="text-sm text-muted" style="font-weight:600;margin-bottom:0.5rem">Seed Data</div>
1751
- <select id="seed-table" class="input" style="width:100%;margin-bottom:0.25rem"><option value="">Pick table...</option></select>
1752
- <div class="flex gap-sm items-center">
1753
- <input type="number" id="seed-count" class="input" value="10" min="1" max="1000" style="width:60px">
1754
- <button class="btn btn-sm btn-success" onclick="seedTable()">Seed</button>
1755
- </div>
1756
- </div>
2115
+ <div id="query-results" style="flex:1;overflow:auto;margin-top:0.25rem;font-size:0.75rem"></div>
1757
2116
  </div>
1758
2117
  </div>
1759
- <div id="query-results" style="overflow-x:auto"></div>
1760
2118
  </div>
1761
2119
 
1762
2120
  <!-- Requests Panel -->
@@ -2047,20 +2405,41 @@ function miColor(mi){
2047
2405
  if(mi>=30) return 'rgb('+(Math.round(220+((60-mi)/30)*19))+','+(Math.round(180-((60-mi)/30)*112))+',0)';
2048
2406
  return 'rgb(239,'+(Math.round(68-mi*2))+',0)';
2049
2407
  }
2050
- function renderBubbleChart(files){
2408
+ function renderBubbleChart(files,depGraph,scanMode){
2051
2409
  var container=document.getElementById('metrics-bubble');
2052
2410
  if(!files||!files.length){container.innerHTML='<p style="color:var(--muted);padding:1rem">No files to analyze</p>';return;}
2411
+ depGraph=depGraph||{};
2412
+ scanMode=scanMode||'project';
2053
2413
  var W=container.offsetWidth||900,H=Math.max(450,Math.min(650,W*0.45));
2054
2414
  var maxLoc=Math.max.apply(null,files.map(function(f){return f.loc}))||1;
2415
+ var maxDeps=Math.max.apply(null,files.map(function(f){return f.dep_count||0}))||1;
2416
+ var maxCC=Math.max.apply(null,files.map(function(f){return f.complexity||0}))||1;
2055
2417
  var minR=14,maxR=Math.min(70,W/10);
2056
- var sorted=files.slice().sort(function(a,b){return a.loc-b.loc});
2418
+ // Composite health colour: complexity + tests + dependencies
2419
+ function healthColor(f){
2420
+ var cc=Math.min((f.avg_complexity||0)/10,1);
2421
+ var untested=f.has_tests?0:1;
2422
+ var deps=Math.min((f.dep_count||0)/5,1);
2423
+ var score=cc*0.4+untested*0.4+deps*0.2;
2424
+ score=Math.max(0,Math.min(1,score));
2425
+ var hue=Math.round(120*(1-score));
2426
+ var sat=Math.round(70+score*30);
2427
+ var lit=Math.round(42+18*(1-score));
2428
+ return 'hsl('+hue+','+sat+'%,'+lit+'%)';
2429
+ }
2430
+ // Build path->index lookup
2431
+ var pathIdx={};
2432
+ files.forEach(function(f,i){pathIdx[f.path]=i;});
2433
+ // Spiral placement
2434
+ function sizeScore(f){return (f.loc/maxLoc)*0.4+((f.avg_complexity||0)/10)*0.4+((f.dep_count||0)/maxDeps)*0.2;}
2435
+ var sorted=files.slice().sort(function(a,b){return sizeScore(a)-sizeScore(b)});
2057
2436
  var cx=W/2,cy=H/2;
2058
2437
  var bubbles=[];
2059
2438
  var angle=0,spiralR=0;
2060
2439
  for(var i=0;i<sorted.length;i++){
2061
2440
  var f=sorted[i];
2062
- var r=minR+Math.sqrt(f.loc/maxLoc)*(maxR-minR);
2063
- var color=miColor(f.maintainability||0);
2441
+ var r=minR+Math.sqrt(sizeScore(f))*(maxR-minR);
2442
+ var color=healthColor(f);
2064
2443
  var placed=false;
2065
2444
  for(var attempt=0;attempt<800;attempt++){
2066
2445
  var px=cx+spiralR*Math.cos(angle);
@@ -2071,79 +2450,236 @@ function renderBubbleChart(files){
2071
2450
  if(Math.sqrt(dx*dx+dy*dy)<r+bubbles[j].r+2){collides=true;break;}
2072
2451
  }
2073
2452
  if(!collides&&px>r+2&&px<W-r-2&&py>r+25&&py<H-r-2){
2074
- bubbles.push({x:px,y:py,r:r,color:color,f:f,angle:Math.random()*Math.PI*2,speed:0.3+Math.random()*0.5,drift:2+Math.random()*3});
2453
+ bubbles.push({x:px,y:py,vx:0,vy:0,r:r,color:color,f:f});
2075
2454
  placed=true;break;
2076
2455
  }
2077
2456
  angle+=0.2;spiralR+=0.04;
2078
2457
  }
2079
- if(!placed){bubbles.push({x:cx+(Math.random()-0.5)*W*0.3,y:cy+(Math.random()-0.5)*H*0.3,r:r,color:color,f:f,angle:Math.random()*Math.PI*2,speed:0.3+Math.random()*0.5,drift:2+Math.random()*3});}
2458
+ if(!placed){bubbles.push({x:cx+(Math.random()-0.5)*W*0.3,y:cy+(Math.random()-0.5)*H*0.3,vx:0,vy:0,r:r,color:color,f:f});}
2080
2459
  }
2460
+ // Build edge list from dependency graph — match by filename not full path
2461
+ var edges=[];
2462
+ function basename(p){var n=p.split('/').pop();var d=n.lastIndexOf('.');return(d>0?n.substring(0,d):n).toLowerCase();}
2463
+ var nameIdx={};
2464
+ bubbles.forEach(function(b,i){nameIdx[basename(b.f.path)]=i;});
2465
+ Object.keys(depGraph).forEach(function(src){
2466
+ var srcIdx=null;
2467
+ bubbles.forEach(function(b,i){if(b.f.path===src)srcIdx=i;});
2468
+ if(srcIdx===null)return;
2469
+ (depGraph[src]||[]).forEach(function(tgt){
2470
+ var tgtName=tgt.split('.').pop().toLowerCase();
2471
+ var tgtIdx=nameIdx[tgtName];
2472
+ if(tgtIdx!==undefined&&srcIdx!==tgtIdx)edges.push([srcIdx,tgtIdx]);
2473
+ });
2474
+ });
2475
+ // Canvas
2081
2476
  var canvas=document.createElement('canvas');
2082
2477
  canvas.width=W;canvas.height=H;
2083
2478
  canvas.style.cssText='display:block;border:1px solid var(--border);border-radius:8px;cursor:pointer;background:#0f172a';
2084
- container.innerHTML='<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem"><h3 style="margin:0;color:var(--primary)">Code Landscape</h3><span style="font-size:0.7rem;color:var(--muted)">Click a bubble to drill down | Size=LOC | <span style="color:#22c55e">Green</span>=maintainable <span style="color:#eab308">Yellow</span>=moderate <span style="color:#ef4444">Red</span>=needs work</span></div>';
2479
+ var modeLabel=scanMode==='framework'?'<span style="color:#cba6f7;font-weight:600"> (Framework)</span> Add code to src/ to see your project':'';
2480
+ container.innerHTML='<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem"><h3 style="margin:0;color:var(--primary)">Code Landscape'+modeLabel+'</h3><span style="font-size:0.7rem;color:var(--muted)">Drag bubbles | Click to drill down | Size=LOC | Colour=health | T=tested | D=deps</span></div>';
2085
2481
  container.appendChild(canvas);
2086
2482
  var ctx=canvas.getContext('2d');
2087
- var hoveredIdx=-1;
2088
- var t=0;
2483
+ var hoveredIdx=-1,dragIdx=-1,dragOX=0,dragOY=0;
2484
+ // Physics
2485
+ function simulate(){
2486
+ var damping=0.65,springK=0.002,repulse=40,gravity=0.008;
2487
+ var cx=W/2,cy=H/2;
2488
+ // Gravity: pull all bubbles toward center, bigger = stronger pull
2489
+ bubbles.forEach(function(b,idx){
2490
+ if(idx===dragIdx)return;
2491
+ var dx=cx-b.x,dy=cy-b.y;
2492
+ var sizeFactor=0.3+(b.r/maxR)*0.7;
2493
+ var pull=gravity*sizeFactor*sizeFactor;
2494
+ b.vx+=dx*pull;b.vy+=dy*pull;
2495
+ });
2496
+ // Spring forces along edges
2497
+ edges.forEach(function(e){
2498
+ var a=bubbles[e[0]],b=bubbles[e[1]];
2499
+ var dx=b.x-a.x,dy=b.y-a.y;
2500
+ var dist=Math.sqrt(dx*dx+dy*dy)||1;
2501
+ var rest=a.r+b.r+20;
2502
+ var force=(dist-rest)*springK;
2503
+ var fx=dx/dist*force,fy=dy/dist*force;
2504
+ if(e[0]!==dragIdx){a.vx+=fx;a.vy+=fy;}
2505
+ if(e[1]!==dragIdx){b.vx-=fx;b.vy-=fy;}
2506
+ });
2507
+ // Soft repulsion
2508
+ for(var i=0;i<bubbles.length;i++){
2509
+ for(var j=i+1;j<bubbles.length;j++){
2510
+ var a=bubbles[i],b=bubbles[j];
2511
+ var dx=b.x-a.x,dy=b.y-a.y;
2512
+ var dist=Math.sqrt(dx*dx+dy*dy)||1;
2513
+ var minDist=a.r+b.r+20;
2514
+ if(dist<minDist){
2515
+ var force=repulse*(minDist-dist)/minDist;
2516
+ var fx=dx/dist*force,fy=dy/dist*force;
2517
+ if(i!==dragIdx){a.vx-=fx;a.vy-=fy;}
2518
+ if(j!==dragIdx){b.vx+=fx;b.vy+=fy;}
2519
+ }
2520
+ }
2521
+ }
2522
+ // Apply velocity + damping + boundary
2523
+ bubbles.forEach(function(b,idx){
2524
+ if(idx===dragIdx)return;
2525
+ b.vx*=damping;b.vy*=damping;
2526
+ // Cap velocity
2527
+ var maxV=2;
2528
+ if(b.vx>maxV)b.vx=maxV;if(b.vx<-maxV)b.vx=-maxV;
2529
+ if(b.vy>maxV)b.vy=maxV;if(b.vy<-maxV)b.vy=-maxV;
2530
+ b.x+=b.vx;b.y+=b.vy;
2531
+ b.x=Math.max(b.r+2,Math.min(W-b.r-2,b.x));
2532
+ b.y=Math.max(b.r+25,Math.min(H-b.r-2,b.y));
2533
+ });
2534
+ }
2535
+ // Draw
2089
2536
  function draw(){
2090
- t+=0.016;
2537
+ simulate();
2091
2538
  ctx.clearRect(0,0,W,H);
2092
- ctx.strokeStyle='rgba(255,255,255,0.03)';ctx.lineWidth=1;
2093
- for(var gx=0;gx<W;gx+=50){ctx.beginPath();ctx.moveTo(gx,0);ctx.lineTo(gx,H);ctx.stroke();}
2094
- for(var gy=0;gy<H;gy+=50){ctx.beginPath();ctx.moveTo(0,gy);ctx.lineTo(W,gy);ctx.stroke();}
2539
+ ctx.save();ctx.translate(panX,panY);ctx.scale(zoom,zoom);
2540
+ // Grid
2541
+ ctx.strokeStyle='rgba(255,255,255,0.03)';ctx.lineWidth=1/zoom;
2542
+ for(var gx=0;gx<W/zoom;gx+=50){ctx.beginPath();ctx.moveTo(gx,0);ctx.lineTo(gx,H/zoom);ctx.stroke();}
2543
+ for(var gy=0;gy<H/zoom;gy+=50){ctx.beginPath();ctx.moveTo(0,gy);ctx.lineTo(W/zoom,gy);ctx.stroke();}
2544
+ // Dependency arrows
2545
+ edges.forEach(function(e){
2546
+ var a=bubbles[e[0]],b=bubbles[e[1]];
2547
+ var dx=b.x-a.x,dy=b.y-a.y;
2548
+ var dist=Math.sqrt(dx*dx+dy*dy)||1;
2549
+ var highlighted=(hoveredIdx===e[0]||hoveredIdx===e[1]);
2550
+ ctx.beginPath();
2551
+ ctx.moveTo(a.x+dx/dist*a.r,a.y+dy/dist*a.r);
2552
+ var ex=b.x-dx/dist*b.r,ey=b.y-dy/dist*b.r;
2553
+ ctx.lineTo(ex,ey);
2554
+ ctx.strokeStyle=highlighted?'rgba(139,180,250,0.9)':'rgba(255,255,255,0.3)';
2555
+ ctx.lineWidth=highlighted?3:1.5;ctx.stroke();
2556
+ // Arrowhead
2557
+ var aLen=highlighted?14:8;
2558
+ var aAngle=Math.atan2(dy,dx);
2559
+ ctx.beginPath();
2560
+ ctx.moveTo(ex,ey);
2561
+ ctx.lineTo(ex-aLen*Math.cos(aAngle-0.4),ey-aLen*Math.sin(aAngle-0.4));
2562
+ ctx.lineTo(ex-aLen*Math.cos(aAngle+0.4),ey-aLen*Math.sin(aAngle+0.4));
2563
+ ctx.closePath();ctx.fillStyle=ctx.strokeStyle;ctx.fill();
2564
+ });
2565
+ // Bubbles
2095
2566
  bubbles.forEach(function(b,idx){
2096
- var ox=Math.sin(t*b.speed+b.angle)*b.drift;
2097
- var oy=Math.cos(t*b.speed*0.7+b.angle+1)*b.drift*0.6;
2098
- var bx=b.x+ox,by=b.y+oy;
2099
2567
  var isHovered=(idx===hoveredIdx);
2100
2568
  var drawR=isHovered?b.r+4:b.r;
2101
- if(isHovered){
2102
- ctx.beginPath();ctx.arc(bx,by,drawR+8,0,Math.PI*2);
2103
- ctx.fillStyle='rgba(255,255,255,0.08)';ctx.fill();
2104
- }
2105
- ctx.beginPath();ctx.arc(bx,by,drawR,0,Math.PI*2);
2106
- ctx.fillStyle=b.color;ctx.globalAlpha=isHovered?0.95:0.7;ctx.fill();
2107
- ctx.globalAlpha=1;ctx.strokeStyle=b.color;ctx.lineWidth=isHovered?2.5:1.5;ctx.stroke();
2569
+ if(isHovered){ctx.beginPath();ctx.arc(b.x,b.y,drawR+8,0,Math.PI*2);ctx.fillStyle='rgba(255,255,255,0.08)';ctx.fill();}
2570
+ // Matte flat bubble
2571
+ ctx.beginPath();ctx.arc(b.x,b.y,drawR,0,Math.PI*2);
2572
+ ctx.fillStyle=b.color;ctx.globalAlpha=isHovered?1.0:0.85;ctx.fill();
2573
+ ctx.globalAlpha=1;ctx.strokeStyle=isHovered?'rgba(255,255,255,0.6)':'rgba(255,255,255,0.25)';ctx.lineWidth=isHovered?2.5:1.5;ctx.stroke();
2574
+ // Label
2108
2575
  var name=b.f.path.split('/').pop().replace('.ts','').replace('.js','');
2109
2576
  if(drawR>16){
2110
2577
  var fs=Math.max(8,Math.min(13,drawR*0.38));
2111
2578
  ctx.fillStyle='#fff';ctx.font='600 '+fs+'px monospace';ctx.textAlign='center';
2112
- ctx.fillText(name,bx,by-2);
2579
+ ctx.fillText(name,b.x,b.y-2);
2113
2580
  ctx.fillStyle='rgba(255,255,255,0.65)';ctx.font=(fs-1)+'px monospace';
2114
- ctx.fillText(b.f.loc+' LOC',bx,by+fs);
2581
+ ctx.fillText(b.f.loc+' LOC',b.x,b.y+fs);
2115
2582
  if(isHovered&&drawR>25){
2116
2583
  ctx.fillStyle='rgba(255,255,255,0.5)';ctx.font=(fs-2)+'px monospace';
2117
- ctx.fillText('CC:'+b.f.complexity+' MI:'+b.f.maintainability,bx,by+fs*2);
2584
+ ctx.fillText('CC:'+b.f.complexity+' MI:'+b.f.maintainability,b.x,b.y+fs*2);
2118
2585
  }
2119
2586
  }
2120
- b._drawX=bx;b._drawY=by;b._drawR=drawR;
2587
+ // Markers: T (tested) and D (dependencies) — inverted badges
2588
+ var mfs=Math.max(9,drawR*0.3);
2589
+ var mrad=mfs*0.7;
2590
+ var mpad=mrad*2.4;
2591
+ var my=b.y-drawR+mrad+3;
2592
+ if(drawR>14&&b.f.has_tests){
2593
+ var mx=b.x-(b.f.dep_count>0?mpad*0.5:0);
2594
+ ctx.beginPath();ctx.arc(mx,my,mrad,0,Math.PI*2);
2595
+ ctx.fillStyle='#16a34a';ctx.fill();
2596
+ ctx.fillStyle='#fff';ctx.font='bold '+mfs+'px sans-serif';ctx.textAlign='center';
2597
+ ctx.fillText('T',mx,my+mfs*0.35);
2598
+ }
2599
+ if(drawR>14&&b.f.dep_count>0){
2600
+ var mx2=b.x+(b.f.has_tests?mpad*0.5:0);
2601
+ ctx.beginPath();ctx.arc(mx2,my,mrad,0,Math.PI*2);
2602
+ ctx.fillStyle='#ea580c';ctx.fill();
2603
+ ctx.fillStyle='#fff';ctx.font='bold '+mfs+'px sans-serif';ctx.textAlign='center';
2604
+ ctx.fillText('D',mx2,my+mfs*0.35);
2605
+ }
2606
+ b._drawX=b.x;b._drawY=b.y;b._drawR=drawR;
2121
2607
  });
2122
- var totalLoc=0,totalFiles=bubbles.length;
2123
- bubbles.forEach(function(b){totalLoc+=b.f.loc});
2608
+ // Summary
2609
+ var totalLoc=0,totalFiles=bubbles.length,testedCount=0;
2610
+ bubbles.forEach(function(b){totalLoc+=b.f.loc;if(b.f.has_tests)testedCount++;});
2124
2611
  var avgMI=bubbles.reduce(function(s,b){return s+b.f.maintainability},0)/totalFiles;
2125
2612
  ctx.fillStyle='rgba(255,255,255,0.35)';ctx.font='11px monospace';ctx.textAlign='right';
2126
- ctx.fillText(totalFiles+' files | '+totalLoc.toLocaleString()+' LOC | Avg MI: '+avgMI.toFixed(1),W-12,H-10);
2613
+ ctx.restore();
2614
+ ctx.fillStyle='rgba(255,255,255,0.35)';ctx.font='11px monospace';ctx.textAlign='right';
2615
+ ctx.fillText(totalFiles+' files | '+totalLoc.toLocaleString()+' LOC | MI:'+avgMI.toFixed(1)+' | Tested:'+testedCount+'/'+totalFiles,W-12,H-10);
2127
2616
  window._metricsAnimFrame=requestAnimationFrame(draw);
2128
2617
  }
2129
2618
  draw();
2619
+ // Mouse events — hover + drag bubbles + right-click pan
2620
+ var panning=false,panStartX=0,panStartY=0;
2621
+ canvas.addEventListener('contextmenu',function(e){e.preventDefault();});
2130
2622
  canvas.addEventListener('mousemove',function(e){
2131
2623
  var rect=canvas.getBoundingClientRect();
2132
2624
  var mx=e.clientX-rect.left,my=e.clientY-rect.top;
2625
+ if(panning){
2626
+ panX+=(mx-panStartX);panY+=(my-panStartY);
2627
+ panStartX=mx;panStartY=my;return;
2628
+ }
2629
+ if(dragIdx>=0){
2630
+ var wmx=(mx-panX)/zoom,wmy=(my-panY)/zoom;
2631
+ bubbles[dragIdx].x=wmx-dragOX;bubbles[dragIdx].y=wmy-dragOY;
2632
+ bubbles[dragIdx].vx=0;bubbles[dragIdx].vy=0;return;
2633
+ }
2634
+ var wmx2=(mx-panX)/zoom,wmy2=(my-panY)/zoom;
2133
2635
  hoveredIdx=-1;
2134
2636
  for(var i=bubbles.length-1;i>=0;i--){
2135
2637
  var b=bubbles[i];
2136
- var dx=mx-b._drawX,dy=my-b._drawY;
2137
- if(Math.sqrt(dx*dx+dy*dy)<=b._drawR){hoveredIdx=i;break;}
2638
+ var dx=wmx2-b.x,dy=wmy2-b.y;
2639
+ if(Math.sqrt(dx*dx+dy*dy)<=b.r){hoveredIdx=i;break;}
2640
+ }
2641
+ canvas.style.cursor=panning?'move':hoveredIdx>=0?'grab':'default';
2642
+ });
2643
+ canvas.addEventListener('mousedown',function(e){
2644
+ var rect=canvas.getBoundingClientRect();
2645
+ var mx=e.clientX-rect.left,my=e.clientY-rect.top;
2646
+ if(e.button===2){
2647
+ panning=true;panStartX=mx;panStartY=my;
2648
+ canvas.style.cursor='move';return;
2138
2649
  }
2139
- canvas.style.cursor=hoveredIdx>=0?'pointer':'default';
2650
+ if(hoveredIdx>=0){
2651
+ dragIdx=hoveredIdx;
2652
+ var wmx=(mx-panX)/zoom;
2653
+ var wmy=(my-panY)/zoom;
2654
+ dragOX=wmx-bubbles[dragIdx].x;
2655
+ dragOY=wmy-bubbles[dragIdx].y;
2656
+ canvas.style.cursor='grabbing';
2657
+ }
2658
+ });
2659
+ canvas.addEventListener('mouseup',function(){
2660
+ if(panning){panning=false;canvas.style.cursor='default';}
2661
+ if(dragIdx>=0){canvas.style.cursor='grab';dragIdx=-1;}
2140
2662
  });
2141
- canvas.addEventListener('mouseleave',function(){hoveredIdx=-1;});
2142
- canvas.addEventListener('click',function(e){
2663
+ canvas.addEventListener('mouseleave',function(){hoveredIdx=-1;dragIdx=-1;panning=false;});
2664
+ canvas.addEventListener('dblclick',function(e){
2143
2665
  if(hoveredIdx<0)return;
2144
- var f=bubbles[hoveredIdx].f;
2145
- drillDownFile(f.path);
2666
+ drillDownFile(bubbles[hoveredIdx].f.path);
2146
2667
  });
2668
+ // Zoom with mouse wheel
2669
+ var zoom=1.0,panX=0,panY=0;
2670
+ canvas.addEventListener('wheel',function(e){
2671
+ e.preventDefault();
2672
+ var rect=canvas.getBoundingClientRect();
2673
+ var mx=(e.clientX-rect.left-panX)/zoom;
2674
+ var my=(e.clientY-rect.top-panY)/zoom;
2675
+ var oldZoom=zoom;
2676
+ zoom*=e.deltaY<0?1.08:0.93;
2677
+ zoom=Math.max(0.5,Math.min(2.5,zoom));
2678
+ panX+=(mx*oldZoom-mx*zoom);
2679
+ panY+=(my*oldZoom-my*zoom);
2680
+ bubbles.forEach(function(b){});
2681
+ },{passive:false});
2682
+ bubbles.forEach(function(b){b._baseR=b.r;});
2147
2683
  }
2148
2684
  function drillDownFile(path){
2149
2685
  var dd=document.getElementById('metrics-drilldown');
@@ -2185,6 +2721,15 @@ function drillDownFile(path){
2185
2721
  });
2186
2722
  html+='</div>';
2187
2723
  }
2724
+ if(d.warnings&&d.warnings.length){
2725
+ html+='<h3 style="margin:0.75rem 0 0.25rem;color:#eab308;font-size:0.85rem">&#9888; Warnings</h3>';
2726
+ html+='<div style="display:flex;flex-direction:column;gap:4px">';
2727
+ d.warnings.forEach(function(w){
2728
+ html+='<div style="padding:4px 8px;background:rgba(234,179,8,0.08);border-left:3px solid #eab308;border-radius:0 4px 4px 0;font-size:0.75rem;font-family:var(--mono);color:var(--text)">';
2729
+ html+='<span style="color:#eab308;margin-right:6px">L'+w.line+'</span>'+w.message+'</div>';
2730
+ });
2731
+ html+='</div>';
2732
+ }
2188
2733
  dd.querySelector('.p-md').innerHTML=html;
2189
2734
  }).catch(function(e){
2190
2735
  dd.querySelector('.p-md').innerHTML='<p style="color:var(--danger)">Error: '+e.message+'</p>';
@@ -2213,7 +2758,7 @@ function loadAllMetrics(){
2213
2758
  fetch('/__dev/api/metrics/full').then(function(r){return r.json()}).then(function(d){
2214
2759
  _metricsFullData=d;
2215
2760
  if(d.error){document.getElementById('metrics-bubble').innerHTML='<p style="color:var(--danger);padding:1rem">'+d.error+'</p>';return;}
2216
- renderBubbleChart(d.file_metrics);
2761
+ renderBubbleChart(d.file_metrics,d.dependency_graph,d.scan_mode);
2217
2762
  var hm=document.getElementById('metrics-heatmap');
2218
2763
  var rows=d.file_metrics.map(function(f){
2219
2764
  var color=miColor(f.maintainability);