Showing 1 changed files with 207 additions and 1 deletions
+207 -1
scripts/host_manager.pl
@@ -151,6 +151,12 @@ sub handle_client {
151 151
     if ($method eq 'GET' && $path eq '/api/work-orders') {
152 152
         return send_json($client, 200, work_orders_payload(load_work_orders()));
153 153
     }
154
+    if ($method eq 'GET' && $path eq '/api/debug/database/tables') {
155
+        return send_json($client, 200, debug_database_tables_payload());
156
+    }
157
+    if ($method eq 'GET' && $path eq '/api/debug/database/table') {
158
+        return send_json($client, 200, debug_database_table_payload($query{name} || $query{table} || '', $query{limit} || 100));
159
+    }
154 160
     if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
155 161
         my $registry = load_registry();
156 162
         return send_download($client, 200, render_hosts_yaml($registry), 'application/x-yaml; charset=utf-8', 'hosts.yaml');
@@ -208,7 +214,7 @@ sub handle_client {
208 214
 
209 215
 sub app_page_path {
210 216
     my ($path) = @_;
211
-    return $path =~ m{\A/(?:|overview|hosts|dns|work-orders|ca)\z};
217
+    return $path =~ m{\A/(?:|overview|hosts|dns|work-orders|ca|debug)\z};
212 218
 }
213 219
 
214 220
 sub load_registry {
@@ -555,6 +561,91 @@ sub render_monitoring {
555 561
     };
556 562
 }
557 563
 
564
+sub debug_database_tables_payload {
565
+    my $dbh = dbh();
566
+    my @tables;
567
+    my $sth = $dbh->prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name");
568
+    $sth->execute;
569
+    while (my ($name) = $sth->fetchrow_array) {
570
+        my $quoted = $dbh->quote_identifier($name);
571
+        my ($count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
572
+        push @tables, {
573
+            name => $name,
574
+            rows => int($count || 0),
575
+        };
576
+    }
577
+    return {
578
+        database => $opt{db},
579
+        generated_at => iso_now(),
580
+        tables => \@tables,
581
+        counts => {
582
+            tables => scalar @tables,
583
+            rows => sum(map { $_->{rows} } @tables),
584
+        },
585
+    };
586
+}
587
+
588
+sub debug_database_table_payload {
589
+    my ($table, $limit) = @_;
590
+    my $dbh = dbh();
591
+    $table = clean_scalar($table);
592
+    return { error => 'missing_table' } unless length $table;
593
+    return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
594
+    $limit = int($limit || 100);
595
+    $limit = 1 if $limit < 1;
596
+    $limit = 500 if $limit > 500;
597
+
598
+    my $quoted = $dbh->quote_identifier($table);
599
+    my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
600
+    my $indexes = $dbh->selectall_arrayref("PRAGMA index_list($quoted)", { Slice => {} }) || [];
601
+    my @index_details;
602
+    for my $index (@$indexes) {
603
+        my $index_name = $index->{name} || '';
604
+        next unless length $index_name;
605
+        my $quoted_index = $dbh->quote_identifier($index_name);
606
+        my $index_columns = $dbh->selectall_arrayref("PRAGMA index_info($quoted_index)", { Slice => {} }) || [];
607
+        push @index_details, {
608
+            name => $index_name,
609
+            unique => int($index->{unique} || 0),
610
+            origin => $index->{origin} || '',
611
+            partial => int($index->{partial} || 0),
612
+            columns => [ map { $_->{name} || '' } @$index_columns ],
613
+        };
614
+    }
615
+    my $foreign_keys = $dbh->selectall_arrayref("PRAGMA foreign_key_list($quoted)", { Slice => {} }) || [];
616
+    my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
617
+    my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted LIMIT ?", { Slice => {} }, $limit) || [];
618
+
619
+    return {
620
+        database => $opt{db},
621
+        table => $table,
622
+        generated_at => iso_now(),
623
+        limit => $limit,
624
+        row_count => int($row_count || 0),
625
+        columns => $columns,
626
+        indexes => \@index_details,
627
+        foreign_keys => $foreign_keys,
628
+        rows => $rows,
629
+    };
630
+}
631
+
632
+sub debug_table_exists {
633
+    my ($dbh, $table) = @_;
634
+    return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/;
635
+    my ($exists) = $dbh->selectrow_array(
636
+        "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ? AND name NOT LIKE 'sqlite_%'",
637
+        undef,
638
+        $table,
639
+    );
640
+    return $exists ? 1 : 0;
641
+}
642
+
643
+sub sum {
644
+    my $total = 0;
645
+    $total += $_ || 0 for @_;
646
+    return $total;
647
+}
648
+
558 649
 sub ca_script_path {
559 650
     return "$project_dir/scripts/ca_manager.sh";
560 651
 }
@@ -2372,6 +2463,10 @@ sub app_html {
2372 2463
     .work-order-checkitem { display: flex; align-items: flex-start; gap: 8px; min-width: 0; color: var(--ink); font-size: 13px; font-weight: 400; }
2373 2464
     .work-order-checkitem input[type="checkbox"] { width: auto; flex: 0 0 auto; margin: 2px 0 0; }
2374 2465
     .work-order-checkitem span { min-width: 0; overflow-wrap: anywhere; }
2466
+    .debug-controls { display: grid; grid-template-columns: minmax(220px, 320px) auto 1fr; gap: 8px; align-items: center; width: 100%; }
2467
+    .debug-controls select { min-width: 0; }
2468
+    .debug-meta { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2469
+    .debug-section { display: grid; gap: 16px; }
2375 2470
     .host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
2376 2471
     .host-tools input { max-width: 240px; }
2377 2472
     .modal-backdrop {
@@ -2419,6 +2514,7 @@ sub app_html {
2419 2514
       .panel-head { align-items: stretch; flex-direction: column; }
2420 2515
       .host-tools { justify-content: flex-start; flex-wrap: wrap; }
2421 2516
       .host-tools input { max-width: none; }
2517
+      .debug-controls { grid-template-columns: 1fr; }
2422 2518
       .modal-backdrop { padding-top: 16px; }
2423 2519
       .modal { max-height: calc(100dvh - 32px); }
2424 2520
       .grid { grid-template-columns: 1fr; }
@@ -2474,6 +2570,7 @@ sub app_html {
2474 2570
         <a href="/dns" data-page-link="dns">DNS</a>
2475 2571
         <a href="/work-orders" data-page-link="work-orders">Work Orders</a>
2476 2572
         <a href="/ca" data-page-link="ca">Local CA</a>
2573
+        <a href="/debug" data-page-link="debug">Debug</a>
2477 2574
       </nav>
2478 2575
       <div class="header-right">
2479 2576
         <span class="muted" id="app-updated"></span>
@@ -2570,6 +2667,49 @@ sub app_html {
2570 2667
           </div>
2571 2668
         </section>
2572 2669
       </section>
2670
+
2671
+      <section class="page" id="page-debug" data-page="debug" hidden>
2672
+        <section class="panel">
2673
+          <div class="panel-head">
2674
+            <h2>Database</h2>
2675
+            <div class="stats" id="debug-db-stats"></div>
2676
+          </div>
2677
+          <div class="toolbar">
2678
+            <div class="debug-controls">
2679
+              <select id="debug-db-table" aria-label="Database table"></select>
2680
+              <button type="button" id="debug-db-refresh">Refresh</button>
2681
+              <div class="debug-meta muted mono" id="debug-db-meta"></div>
2682
+            </div>
2683
+          </div>
2684
+        </section>
2685
+        <section class="debug-section">
2686
+          <section class="panel">
2687
+            <div class="panel-head">
2688
+              <h2>Rows</h2>
2689
+              <div class="stats" id="debug-table-stats"></div>
2690
+            </div>
2691
+            <div class="table-wrap" id="debug-table-rows"></div>
2692
+          </section>
2693
+          <section class="panel">
2694
+            <div class="panel-head">
2695
+              <h2>Columns</h2>
2696
+            </div>
2697
+            <div class="table-wrap" id="debug-table-columns"></div>
2698
+          </section>
2699
+          <section class="panel">
2700
+            <div class="panel-head">
2701
+              <h2>Indexes</h2>
2702
+            </div>
2703
+            <div class="table-wrap" id="debug-table-indexes"></div>
2704
+          </section>
2705
+          <section class="panel">
2706
+            <div class="panel-head">
2707
+              <h2>Foreign Keys</h2>
2708
+            </div>
2709
+            <div class="table-wrap" id="debug-table-foreign-keys"></div>
2710
+          </section>
2711
+        </section>
2712
+      </section>
2573 2713
     </main>
2574 2714
 
2575 2715
     <div id="host-modal" class="modal-backdrop" hidden>
@@ -2616,6 +2756,7 @@ sub app_html {
2616 2756
       '/dns': 'dns',
2617 2757
       '/work-orders': 'work-orders',
2618 2758
       '/ca': 'ca',
2759
+      '/debug': 'debug',
2619 2760
     };
2620 2761
 
2621 2762
     async function api(path, options = {}) {
@@ -2642,6 +2783,9 @@ sub app_html {
2642 2783
         const href = target === 'overview' ? '/overview' : '/' + target;
2643 2784
         history.pushState({ page: target }, '', href);
2644 2785
       }
2786
+      if (state.authenticated && target === 'debug') {
2787
+        renderDebugDatabase().catch(e => msg(e.message));
2788
+      }
2645 2789
     }
2646 2790
 
2647 2791
     function showLogin(errorText) {
@@ -2672,6 +2816,7 @@ sub app_html {
2672 2816
       render(data);
2673 2817
       await renderCa();
2674 2818
       await renderWorkOrders();
2819
+      if (currentPage() === 'debug') await renderDebugDatabase();
2675 2820
     }
2676 2821
 
2677 2822
     function render(data) {
@@ -2820,6 +2965,65 @@ sub app_html {
2820 2965
       }
2821 2966
     }
2822 2967
 
2968
+    async function renderDebugDatabase() {
2969
+      if (!state.authenticated) return;
2970
+      const data = await api('/api/debug/database/tables');
2971
+      const tableSelect = $('debug-db-table');
2972
+      const current = tableSelect.value;
2973
+      const tables = data.tables || [];
2974
+      tableSelect.innerHTML = tables.map(table => `<option value="${escapeHtml(table.name)}">${escapeHtml(table.name)} (${escapeHtml(String(table.rows))})</option>`).join('');
2975
+      const selected = tables.some(table => table.name === current) ? current : (tables[0] ? tables[0].name : '');
2976
+      tableSelect.value = selected;
2977
+      $('debug-db-stats').innerHTML = [
2978
+        ['tables', data.counts ? data.counts.tables : tables.length],
2979
+        ['rows', data.counts ? data.counts.rows : tables.reduce((total, table) => total + Number(table.rows || 0), 0)],
2980
+      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
2981
+      $('debug-db-meta').textContent = data.database || '';
2982
+      if (selected) await renderDebugTable(selected);
2983
+    }
2984
+
2985
+    async function renderDebugTable(tableName) {
2986
+      const data = await api(`/api/debug/database/table?name=${encodeURIComponent(tableName)}&limit=200`);
2987
+      if (data.error) throw new Error(data.error);
2988
+      $('debug-table-stats').innerHTML = [
2989
+        ['table', data.table || tableName],
2990
+        ['rows', data.row_count || 0],
2991
+        ['shown', (data.rows || []).length],
2992
+      ].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
2993
+      renderDebugRows(data);
2994
+      $('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
2995
+      $('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
2996
+      $('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
2997
+    }
2998
+
2999
+    function renderDebugRows(data) {
3000
+      const rows = data.rows || [];
3001
+      const columnNames = (data.columns || []).map(column => column.name).filter(Boolean);
3002
+      $('debug-table-rows').innerHTML = renderDebugObjectTable(rows, columnNames);
3003
+    }
3004
+
3005
+    function renderDebugObjectTable(rows, preferredKeys) {
3006
+      const keys = preferredKeys && preferredKeys.length
3007
+        ? preferredKeys
3008
+        : Array.from(rows.reduce((set, row) => {
3009
+            Object.keys(row || {}).forEach(key => set.add(key));
3010
+            return set;
3011
+          }, new Set()));
3012
+      if (!keys.length) return '<div class="ca-empty muted">No columns.</div>';
3013
+      const header = keys.map(key => `<th>${escapeHtml(key)}</th>`).join('');
3014
+      const body = rows.length
3015
+        ? rows.map(row => `<tr>${keys.map(key => `<td class="mono">${escapeHtml(debugCell(row ? row[key] : ''))}</td>`).join('')}</tr>`).join('')
3016
+        : `<tr><td colspan="${keys.length}" class="muted">No rows.</td></tr>`;
3017
+      return `<table><thead><tr>${header}</tr></thead><tbody>${body}</tbody></table>`;
3018
+    }
3019
+
3020
+    function debugCell(value) {
3021
+      if (value === null || value === undefined) return 'NULL';
3022
+      if (Array.isArray(value)) return value.join(', ');
3023
+      if (typeof value === 'object') return JSON.stringify(value);
3024
+      return String(value);
3025
+    }
3026
+
2823 3027
     async function updateWorkOrderChecklist(id, itemId, checked) {
2824 3028
       try {
2825 3029
         await api('/api/work-orders/checklist', {
@@ -3123,6 +3327,8 @@ sub app_html {
3123 3327
     $('refresh').addEventListener('click', () => refresh().catch(e => msg(e.message)));
3124 3328
     $('filter').addEventListener('input', renderHosts);
3125 3329
     $('new-host').addEventListener('click', newHost);
3330
+    $('debug-db-refresh').addEventListener('click', () => renderDebugDatabase().catch(e => msg(e.message)));
3331
+    $('debug-db-table').addEventListener('change', () => renderDebugTable($('debug-db-table').value).catch(e => msg(e.message)));
3126 3332
     $('close-host-modal').addEventListener('click', requestCloseHostModal);
3127 3333
     $('host-modal').addEventListener('click', (event) => {
3128 3334
       if (event.target === $('host-modal') && !$('save-host').disabled) closeHostModal();