Showing 1 changed files with 84 additions and 1 deletions
+84 -1
scripts/host_manager.pl
@@ -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);