@@ -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();
|