@@ -157,6 +157,16 @@ sub handle_client {
|
||
| 157 | 157 |
if ($method eq 'GET' && $path eq '/api/debug/database/table') {
|
| 158 | 158 |
return send_json($client, 200, debug_database_table_payload($query{name} || $query{table} || '', $query{limit} || 100));
|
| 159 | 159 |
} |
| 160 |
+ if ($method eq 'GET' && $path eq '/download/debug/database/table.json') {
|
|
| 161 |
+ my $export = debug_database_table_export_payload($query{name} || $query{table} || '');
|
|
| 162 |
+ return send_json($client, 400, { error => $export->{error} }) if $export->{error};
|
|
| 163 |
+ return send_download($client, 200, json_encode($export), 'application/json; charset=utf-8', debug_table_export_filename($export->{table}, 'json'));
|
|
| 164 |
+ } |
|
| 165 |
+ if ($method eq 'GET' && $path eq '/download/debug/database/table.csv') {
|
|
| 166 |
+ my $export = debug_database_table_export_payload($query{name} || $query{table} || '');
|
|
| 167 |
+ return send_json($client, 400, { error => $export->{error} }) if $export->{error};
|
|
| 168 |
+ return send_download($client, 200, render_debug_table_csv($export), 'text/csv; charset=utf-8', debug_table_export_filename($export->{table}, 'csv'));
|
|
| 169 |
+ } |
|
| 160 | 170 |
if ($method eq 'GET' && $path eq '/download/hosts.yaml') {
|
| 161 | 171 |
my $registry = load_registry(); |
| 162 | 172 |
return send_download($client, 200, render_hosts_yaml($registry), 'application/x-yaml; charset=utf-8', 'hosts.yaml'); |
@@ -629,6 +639,56 @@ sub debug_database_table_payload {
|
||
| 629 | 639 |
}; |
| 630 | 640 |
} |
| 631 | 641 |
|
| 642 |
+sub debug_database_table_export_payload {
|
|
| 643 |
+ my ($table) = @_; |
|
| 644 |
+ my $dbh = dbh(); |
|
| 645 |
+ $table = clean_scalar($table); |
|
| 646 |
+ return { error => 'missing_table' } unless length $table;
|
|
| 647 |
+ return { error => 'invalid_table' } unless debug_table_exists($dbh, $table);
|
|
| 648 |
+ |
|
| 649 |
+ my $quoted = $dbh->quote_identifier($table); |
|
| 650 |
+ my $columns = $dbh->selectall_arrayref("PRAGMA table_info($quoted)", { Slice => {} }) || [];
|
|
| 651 |
+ my @column_names = map { $_->{name} || '' } @$columns;
|
|
| 652 |
+ my ($row_count) = $dbh->selectrow_array("SELECT COUNT(*) FROM $quoted");
|
|
| 653 |
+ my $rows = $dbh->selectall_arrayref("SELECT * FROM $quoted", { Slice => {} }) || [];
|
|
| 654 |
+ |
|
| 655 |
+ return {
|
|
| 656 |
+ database => $opt{db},
|
|
| 657 |
+ table => $table, |
|
| 658 |
+ generated_at => iso_now(), |
|
| 659 |
+ row_count => int($row_count || 0), |
|
| 660 |
+ columns => \@column_names, |
|
| 661 |
+ rows => $rows, |
|
| 662 |
+ }; |
|
| 663 |
+} |
|
| 664 |
+ |
|
| 665 |
+sub render_debug_table_csv {
|
|
| 666 |
+ my ($export) = @_; |
|
| 667 |
+ my @columns = @{ $export->{columns} || [] };
|
|
| 668 |
+ my @lines = (join(',', map { csv_cell($_) } @columns));
|
|
| 669 |
+ for my $row (@{ $export->{rows} || [] }) {
|
|
| 670 |
+ push @lines, join(',', map { csv_cell($row->{$_}) } @columns);
|
|
| 671 |
+ } |
|
| 672 |
+ return join("\n", @lines) . "\n";
|
|
| 673 |
+} |
|
| 674 |
+ |
|
| 675 |
+sub csv_cell {
|
|
| 676 |
+ my ($value) = @_; |
|
| 677 |
+ $value = '' unless defined $value; |
|
| 678 |
+ $value = "$value"; |
|
| 679 |
+ $value =~ s/"/""/g; |
|
| 680 |
+ return qq("$value") if $value =~ /[",\r\n]/;
|
|
| 681 |
+ return $value; |
|
| 682 |
+} |
|
| 683 |
+ |
|
| 684 |
+sub debug_table_export_filename {
|
|
| 685 |
+ my ($table, $extension) = @_; |
|
| 686 |
+ $table = clean_scalar($table || 'table'); |
|
| 687 |
+ $table =~ s/[^A-Za-z0-9_.-]+/-/g; |
|
| 688 |
+ $table = 'table' unless length $table; |
|
| 689 |
+ return "debug-$table.$extension"; |
|
| 690 |
+} |
|
| 691 |
+ |
|
| 632 | 692 |
sub debug_table_exists {
|
| 633 | 693 |
my ($dbh, $table) = @_; |
| 634 | 694 |
return 0 unless $table =~ /\A[A-Za-z_][A-Za-z0-9_]*\z/; |
@@ -2474,6 +2534,8 @@ sub app_html {
|
||
| 2474 | 2534 |
.debug-table-copy::before, .debug-table-copy::after { content: ""; position: absolute; width: 12px; height: 14px; border: 1.6px solid currentColor; border-radius: 2px; box-sizing: border-box; }
|
| 2475 | 2535 |
.debug-table-copy::before { transform: translate(2px, -2px); opacity: .62; }
|
| 2476 | 2536 |
.debug-table-copy::after { transform: translate(-2px, 2px); background: #fff; }
|
| 2537 |
+ .debug-table-head-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
|
| 2538 |
+ .debug-table-exports { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
| 2477 | 2539 |
.debug-section { display: grid; gap: 16px; }
|
| 2478 | 2540 |
.host-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-width: 0; }
|
| 2479 | 2541 |
.host-tools input { max-width: 240px; }
|
@@ -2694,7 +2756,13 @@ sub app_html {
|
||
| 2694 | 2756 |
<section class="panel"> |
| 2695 | 2757 |
<div class="panel-head"> |
| 2696 | 2758 |
<h2>Rows</h2> |
| 2697 |
- <div class="stats" id="debug-table-stats"></div> |
|
| 2759 |
+ <div class="debug-table-head-actions"> |
|
| 2760 |
+ <div class="stats" id="debug-table-stats"></div> |
|
| 2761 |
+ <div class="debug-table-exports"> |
|
| 2762 |
+ <a class="linkbtn" id="debug-export-json" href="#" aria-disabled="true">JSON</a> |
|
| 2763 |
+ <a class="linkbtn" id="debug-export-csv" href="#" aria-disabled="true">CSV</a> |
|
| 2764 |
+ </div> |
|
| 2765 |
+ </div> |
|
| 2698 | 2766 |
</div> |
| 2699 | 2767 |
<div class="table-wrap" id="debug-table-rows"></div> |
| 2700 | 2768 |
</section> |
@@ -3074,6 +3142,7 @@ sub app_html {
|
||
| 3074 | 3142 |
|
| 3075 | 3143 |
function clearDebugTable() {
|
| 3076 | 3144 |
$('debug-table-stats').innerHTML = '';
|
| 3145 |
+ updateDebugExportLinks('');
|
|
| 3077 | 3146 |
$('debug-table-rows').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
|
| 3078 | 3147 |
$('debug-table-columns').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
|
| 3079 | 3148 |
$('debug-table-indexes').innerHTML = '<div class="ca-empty muted">No table selected.</div>';
|
@@ -3088,12 +3157,26 @@ sub app_html {
|
||
| 3088 | 3157 |
['rows', data.row_count || 0], |
| 3089 | 3158 |
['shown', (data.rows || []).length], |
| 3090 | 3159 |
].map(([k, v]) => `<span class="stat">${escapeHtml(k)}: ${escapeHtml(String(v))}</span>`).join('');
|
| 3160 |
+ updateDebugExportLinks(data.table || tableName); |
|
| 3091 | 3161 |
renderDebugRows(data); |
| 3092 | 3162 |
$('debug-table-columns').innerHTML = renderDebugObjectTable(data.columns || [], ['cid', 'name', 'type', 'notnull', 'dflt_value', 'pk']);
|
| 3093 | 3163 |
$('debug-table-indexes').innerHTML = renderDebugObjectTable(data.indexes || [], ['name', 'unique', 'origin', 'partial', 'columns']);
|
| 3094 | 3164 |
$('debug-table-foreign-keys').innerHTML = renderDebugObjectTable(data.foreign_keys || [], ['id', 'seq', 'table', 'from', 'to', 'on_update', 'on_delete', 'match']);
|
| 3095 | 3165 |
} |
| 3096 | 3166 |
|
| 3167 |
+ function updateDebugExportLinks(tableName) {
|
|
| 3168 |
+ const encoded = encodeURIComponent(tableName || ''); |
|
| 3169 |
+ [ |
|
| 3170 |
+ ['debug-export-json', `/download/debug/database/table.json?name=${encoded}`],
|
|
| 3171 |
+ ['debug-export-csv', `/download/debug/database/table.csv?name=${encoded}`],
|
|
| 3172 |
+ ].forEach(([id, href]) => {
|
|
| 3173 |
+ const link = $(id); |
|
| 3174 |
+ const enabled = !!tableName; |
|
| 3175 |
+ link.href = enabled ? href : '#'; |
|
| 3176 |
+ link.setAttribute('aria-disabled', enabled ? 'false' : 'true');
|
|
| 3177 |
+ }); |
|
| 3178 |
+ } |
|
| 3179 |
+ |
|
| 3097 | 3180 |
function renderDebugRows(data) {
|
| 3098 | 3181 |
const rows = data.rows || []; |
| 3099 | 3182 |
const columnNames = (data.columns || []).map(column => column.name).filter(Boolean); |