dbviewer 0.3.6 → 0.3.16

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 151a189372c94a737108969cd768ab9dbc4e16a2abc608f07026d1a6b7ee65a5
4
- data.tar.gz: a5fa3a4f4aa8e1acd606f60c2264412a261beec6b6889675ef941d05d1e593cb
3
+ metadata.gz: 72e7257a74294ca4a6ad8b53fc497ca9eb0727772e798b2c4d7fa2c164b35531
4
+ data.tar.gz: d01a7c934e3e859f06f94a746671974dd93813e832c6f5f54df49b3071e95178
5
5
  SHA512:
6
- metadata.gz: e8345197920cf8c6a006d3faecd9a0b9f2a6ef4c972226707203ba43b121e05c3a02195eec4d38a699231ae488741802a9dff5c7317ddc576f4e27381ad897e7
7
- data.tar.gz: b9a8ff1804963de25e85f9a288c198ee4aaa23cf25253ff99f0ce84f3e470e2e35a47c304ff24b2cbd962d9c913fb69b08f11d6bbd2d213af52113ce4d048e18
6
+ metadata.gz: 79511e9ac29ca71e4834b958c54b36ee05f83e831f1426745282139ea6b46d5e0e1bcd2a2b31df956987218bfc44239fa624dba86f74ec4858cc35aea4af8536
7
+ data.tar.gz: e63f51557b5f2431f63c920989a1b13f7ed5c1cbe5cd708091fc8f728e649caae11a6c09a754d6522b2dbbb3408cb380a2ded2f438770e8b9c2d49b782bdcd7f
data/README.md CHANGED
@@ -44,7 +44,8 @@ It's designed for development, debugging, and database analysis, offering a clea
44
44
  <details>
45
45
  <summary>Click to see more screenshots</summary>
46
46
 
47
- #### Dashboard Overview
47
+ #### Dashboard Overview
48
+
48
49
  <img width="1470" alt="image" src="https://github.com/user-attachments/assets/4e803d51-9a5b-4c80-bb4c-a761dba15a40" />
49
50
 
50
51
  #### Table Details
@@ -76,7 +77,7 @@ gem "dbviewer", group: :development
76
77
  And then execute:
77
78
 
78
79
  ```bash
79
- $ bundle
80
+ bundle
80
81
  ```
81
82
 
82
83
  ## 🔧 Usage
@@ -274,6 +275,7 @@ With the addition of Basic Authentication, DBViewer can now be used in any envir
274
275
  ```
275
276
 
276
277
  3. Access the tool through your regular application URL:
278
+
277
279
  ```
278
280
  https://yourdomain.com/dbviewer?override_env_check=your_secure_random_key
279
281
  ```
@@ -288,6 +290,36 @@ When used in production, ensure:
288
290
  - You access DBViewer over HTTPS connections only
289
291
  - Access is limited to trusted administrators only
290
292
 
293
+ ## 🔄 Updating DBViewer
294
+
295
+ To keep DBViewer up to date with the latest features, security patches, and bug fixes, follow these steps:
296
+
297
+ ### Using Bundler
298
+
299
+ The simplest way to update is using Bundler:
300
+
301
+ - Update your Gemfile with the desired version:
302
+
303
+ ```ruby
304
+ # For the latest version
305
+ gem "dbviewer", group: :development
306
+
307
+ # Or specify a version
308
+ gem "dbviewer", "~> 0.3.2", group: :development
309
+ ```
310
+
311
+ - Run bundle update:
312
+
313
+ ```ruby
314
+ bundle update dbviewer
315
+ ```
316
+
317
+ - Restart your Rails server to apply the changes:
318
+
319
+ ```ruby
320
+ rails server
321
+ ```
322
+
291
323
  ## 🤌🏻 Contributing
292
324
 
293
325
  Bug reports and pull requests are welcome.
@@ -11,6 +11,12 @@ module Dbviewer
11
11
  @database_manager ||= ::Dbviewer::DatabaseManager.new
12
12
  end
13
13
 
14
+ # Initialize the table query operations manager
15
+ # This gives direct access to table query operations when needed
16
+ def table_query_operations
17
+ @table_query_operations ||= database_manager.table_query_operations
18
+ end
19
+
14
20
  # Get the name of the current database
15
21
  def get_database_name
16
22
  adapter = database_manager.connection.adapter_name.downcase
@@ -65,6 +71,19 @@ module Dbviewer
65
71
  empty_tables: tables.select { |t| t[:record_count] == 0 }
66
72
  }
67
73
 
74
+ # Calculate total foreign key relationships
75
+ begin
76
+ total_relationships = 0
77
+ tables.each do |table|
78
+ metadata = fetch_table_metadata(table[:name])
79
+ total_relationships += metadata[:foreign_keys].size if metadata && metadata[:foreign_keys]
80
+ end
81
+ analytics[:total_relationships] = total_relationships
82
+ rescue => e
83
+ Rails.logger.error("Error calculating relationship count: #{e.message}")
84
+ analytics[:total_relationships] = 0
85
+ end
86
+
68
87
  # Calculate schema size if possible
69
88
  begin
70
89
  analytics[:schema_size] = calculate_schema_size
@@ -138,19 +157,8 @@ module Dbviewer
138
157
  end
139
158
 
140
159
  # Fetch records for a table with pagination and sorting
141
- def fetch_table_records(table_name)
142
- column_filters = params[:column_filters] || {}
143
- # Clean up blank filters
144
- column_filters.reject! { |_, v| v.blank? }
145
-
146
- database_manager.table_records(
147
- table_name,
148
- @current_page,
149
- @order_by,
150
- @order_direction,
151
- @per_page,
152
- column_filters || {}
153
- )
160
+ def fetch_table_records(table_name, query_params)
161
+ database_manager.table_records(table_name, query_params)
154
162
  end
155
163
 
156
164
  # Get filtered record count for a table
@@ -207,6 +215,130 @@ module Dbviewer
207
215
  relationships
208
216
  end
209
217
 
218
+ # Get mini ERD data for a specific table and its relationships
219
+ def fetch_mini_erd_for_table(table_name)
220
+ related_tables = []
221
+ relationships = []
222
+
223
+ # Validate the table exists
224
+ unless database_manager.tables.include?(table_name)
225
+ Rails.logger.error("[DBViewer] Table not found for mini ERD: #{table_name}")
226
+ return {
227
+ tables: [],
228
+ relationships: [],
229
+ error: "Table '#{table_name}' not found in the database"
230
+ }
231
+ end
232
+
233
+ # Add current table
234
+ related_tables << { name: table_name }
235
+
236
+ Rails.logger.info("[DBViewer] Generating mini ERD for table: #{table_name}")
237
+
238
+ # Get foreign keys from this table to others (outgoing relationships)
239
+ begin
240
+ metadata = fetch_table_metadata(table_name)
241
+ Rails.logger.debug("[DBViewer] Table metadata: #{metadata.inspect}")
242
+
243
+ if metadata && metadata[:foreign_keys].present?
244
+ metadata[:foreign_keys].each do |fk|
245
+ # Ensure all required fields are present
246
+ next unless fk[:to_table].present? && fk[:column].present?
247
+
248
+ # Sanitize table and column names for display
249
+ from_table = table_name.to_s
250
+ to_table = fk[:to_table].to_s
251
+ from_column = fk[:column].to_s
252
+ to_column = fk[:primary_key].to_s.presence || "id"
253
+ relationship_name = fk[:name].to_s.presence || "#{from_table}_to_#{to_table}"
254
+
255
+ relationship = {
256
+ from_table: from_table,
257
+ to_table: to_table,
258
+ from_column: from_column,
259
+ to_column: to_column,
260
+ name: relationship_name,
261
+ direction: "outgoing"
262
+ }
263
+
264
+ relationships << relationship
265
+ Rails.logger.debug("[DBViewer] Added outgoing relationship: #{relationship.inspect}")
266
+
267
+ # Add the related table if not already included
268
+ unless related_tables.any? { |t| t[:name] == to_table }
269
+ related_tables << { name: to_table }
270
+ end
271
+ end
272
+ end
273
+ rescue => e
274
+ Rails.logger.error("[DBViewer] Error fetching outgoing relationships for #{table_name}: #{e.message}")
275
+ Rails.logger.error(e.backtrace.join("\n"))
276
+ end
277
+
278
+ # Get foreign keys from other tables to this one (incoming relationships)
279
+ begin
280
+ database_manager.tables.each do |other_table_name|
281
+ next if other_table_name == table_name # Skip self
282
+
283
+ begin
284
+ other_metadata = fetch_table_metadata(other_table_name)
285
+ if other_metadata && other_metadata[:foreign_keys].present?
286
+ other_metadata[:foreign_keys].each do |fk|
287
+ if fk[:to_table] == table_name
288
+ # Ensure all required fields are present
289
+ next unless fk[:column].present?
290
+
291
+ # Sanitize table and column names for display
292
+ from_table = other_table_name.to_s
293
+ to_table = table_name.to_s
294
+ from_column = fk[:column].to_s
295
+ to_column = fk[:primary_key].to_s.presence || "id"
296
+ relationship_name = fk[:name].to_s.presence || "#{from_table}_to_#{to_table}"
297
+
298
+ relationship = {
299
+ from_table: from_table,
300
+ to_table: to_table,
301
+ from_column: from_column,
302
+ to_column: to_column,
303
+ name: relationship_name,
304
+ direction: "incoming"
305
+ }
306
+
307
+ relationships << relationship
308
+ Rails.logger.debug("[DBViewer] Added incoming relationship: #{relationship.inspect}")
309
+
310
+ # Add the related table if not already included
311
+ unless related_tables.any? { |t| t[:name] == from_table }
312
+ related_tables << { name: from_table }
313
+ end
314
+ end
315
+ end
316
+ end
317
+ rescue => e
318
+ Rails.logger.error("[DBViewer] Error processing relationships for table #{other_table_name}: #{e.message}")
319
+ # Continue to the next table
320
+ end
321
+ end
322
+ rescue => e
323
+ Rails.logger.error("[DBViewer] Error fetching incoming relationships for #{table_name}: #{e.message}")
324
+ Rails.logger.error(e.backtrace.join("\n"))
325
+ end
326
+
327
+ # If no relationships were found, make sure to still include at least the current table
328
+ if relationships.empty?
329
+ Rails.logger.info("[DBViewer] No relationships found for table: #{table_name}")
330
+ end
331
+
332
+ result = {
333
+ tables: related_tables,
334
+ relationships: relationships,
335
+ timestamp: Time.now.to_i
336
+ }
337
+
338
+ Rails.logger.info("[DBViewer] Mini ERD data generated: #{related_tables.length} tables, #{relationships.length} relationships")
339
+ result
340
+ end
341
+
210
342
  # Prepare the SQL query - either from params or default
211
343
  def prepare_query
212
344
  quoted_table = safe_quote_table_name(@table_name)
@@ -240,17 +372,24 @@ module Dbviewer
240
372
  end
241
373
 
242
374
  # Export table data to CSV
243
- def export_table_to_csv(table_name, limit = 10000, include_headers = true)
375
+ def export_table_to_csv(table_name, query_params = nil, include_headers = true)
244
376
  require "csv"
245
377
 
246
378
  begin
247
- records = database_manager.table_records(
248
- table_name,
249
- 1, # First page
250
- nil, # Default sorting
251
- "asc",
252
- limit # Limit number of records
253
- )
379
+ if query_params.is_a?(Dbviewer::TableQueryParams)
380
+ # Use the query params object directly
381
+ records = database_manager.query_operations.table_records(table_name, query_params)
382
+ else
383
+ # Legacy support for the old method signature
384
+ limit = query_params.is_a?(Numeric) ? query_params : 10000
385
+ records = database_manager.table_records(
386
+ table_name,
387
+ 1, # First page
388
+ nil, # Default sorting
389
+ "asc",
390
+ limit # Limit number of records
391
+ )
392
+ end
254
393
 
255
394
  csv_data = CSV.generate do |csv|
256
395
  # Add headers if requested
@@ -35,6 +35,14 @@ module Dbviewer
35
35
  @order_direction = "ASC" unless self.class::VALID_SORT_DIRECTIONS.include?(@order_direction)
36
36
  end
37
37
 
38
+ def fetch_total_count(table_name, query_params)
39
+ if query_params.column_filters.present? && query_params.column_filters.values.any?(&:present?)
40
+ fetch_filtered_record_count(table_name, query_params.column_filters)
41
+ else
42
+ fetch_table_record_count(table_name)
43
+ end
44
+ end
45
+
38
46
  # Calculate the total number of pages
39
47
  def calculate_total_pages(total_count, per_page)
40
48
  (total_count.to_f / per_page).ceil
@@ -2,30 +2,40 @@ module Dbviewer
2
2
  class TablesController < ApplicationController
3
3
  include Dbviewer::PaginationConcern
4
4
 
5
+ before_action :set_table_name, except: [ :index ]
6
+
5
7
  def index
6
8
  @tables = fetch_tables_with_stats(include_record_counts: true)
7
9
  end
8
10
 
9
11
  def show
10
- @table_name = params[:id]
11
- @columns = fetch_table_columns(@table_name)
12
- @metadata = fetch_table_metadata(@table_name)
13
- @tables = fetch_tables_with_stats # Fetch tables for sidebar
14
-
15
12
  set_pagination_params
16
13
  set_sorting_params
17
14
 
18
- # Extract column filters from params
15
+ # Get column filters from params first
19
16
  @column_filters = params[:column_filters].presence ? params[:column_filters].to_enum.to_h : {}
20
17
 
21
- if @column_filters.present? && @column_filters.values.any?(&:present?)
22
- @total_count = fetch_filtered_record_count(@table_name, @column_filters)
23
- else
24
- @total_count = fetch_table_record_count(@table_name)
25
- end
26
-
18
+ # Then apply global creation filters (this will modify @column_filters)
19
+ set_global_filters
20
+
21
+ # Now create the query params with the combined filters
22
+ query_params = Dbviewer::TableQueryParams.new(
23
+ page: @current_page,
24
+ per_page: @per_page,
25
+ order_by: @order_by,
26
+ direction: @order_direction,
27
+ column_filters: @column_filters.reject { |_, v| v.blank? }
28
+ )
29
+ @total_count = fetch_total_count(@table_name, query_params)
30
+ @records = fetch_table_records(@table_name, query_params)
27
31
  @total_pages = calculate_total_pages(@total_count, @per_page)
28
- @records = fetch_table_records(@table_name)
32
+ @columns = fetch_table_columns(@table_name)
33
+ @metadata = fetch_table_metadata(@table_name)
34
+
35
+ if @records.nil?
36
+ column_names = @columns.map { |c| c[:name] }
37
+ @records = ActiveRecord::Result.new(column_names, [])
38
+ end
29
39
 
30
40
  # Fetch timestamp visualization data if the table has a created_at column
31
41
  if has_timestamp_column?(@table_name)
@@ -46,8 +56,33 @@ module Dbviewer
46
56
  end
47
57
  end
48
58
 
59
+ def mini_erd
60
+ begin
61
+ @erd_data = fetch_mini_erd_for_table(@table_name)
62
+
63
+ if @erd_data[:error].present?
64
+ Rails.logger.error("Mini ERD error: #{@erd_data[:error]}")
65
+ end
66
+
67
+ respond_to do |format|
68
+ format.json { render json: @erd_data }
69
+ format.html { render layout: false }
70
+ end
71
+ rescue => e
72
+ Rails.logger.error("Error generating Mini ERD: #{e.message}")
73
+ Rails.logger.error(e.backtrace.join("\n"))
74
+
75
+ @error_message = e.message
76
+ @erd_data = { tables: [], relationships: [], error: @error_message }
77
+
78
+ respond_to do |format|
79
+ format.json { render json: { error: @error_message }, status: :internal_server_error }
80
+ format.html { render layout: false }
81
+ end
82
+ end
83
+ end
84
+
49
85
  def query
50
- @table_name = params[:id]
51
86
  @read_only_mode = true # Flag to indicate we're in read-only mode
52
87
  @columns = fetch_table_columns(@table_name)
53
88
  @tables = fetch_tables_with_stats # Fetch tables for sidebar
@@ -68,20 +103,98 @@ module Dbviewer
68
103
  return
69
104
  end
70
105
 
71
- table_name = params[:id]
72
106
  limit = (params[:limit] || 10000).to_i
73
107
  include_headers = params[:include_headers] != "0"
74
108
 
75
- csv_data = export_table_to_csv(table_name, limit, include_headers)
109
+ # Apply global creation filters
110
+ set_global_filters
111
+
112
+ # Create query parameters for export
113
+ query_params = Dbviewer::TableQueryParams.new(
114
+ page: 1,
115
+ per_page: limit,
116
+ order_by: nil,
117
+ direction: "asc",
118
+ column_filters: @column_filters.reject { |_, v| v.blank? }
119
+ )
120
+
121
+ # Get filtered data for export
122
+ csv_data = export_table_to_csv(@table_name, query_params, include_headers)
76
123
 
77
124
  # Set filename with timestamp for uniqueness
78
125
  timestamp = Time.now.strftime("%Y%m%d%H%M%S")
79
- filename = "#{table_name}_export_#{timestamp}.csv"
126
+
127
+ # Add filter info to filename if filters are applied
128
+ filename_suffix = ""
129
+ if @creation_filter_start.present? || @creation_filter_end.present?
130
+ filename_suffix = "_filtered"
131
+ end
132
+
133
+ filename = "#{@table_name}#{filename_suffix}_#{timestamp}.csv"
80
134
 
81
135
  # Send data as file
82
136
  send_data csv_data,
83
137
  type: "text/csv; charset=utf-8; header=present",
84
138
  disposition: "attachment; filename=#{filename}"
85
139
  end
140
+
141
+ private
142
+
143
+ def set_table_name
144
+ @table_name = params[:id]
145
+ end # Handle global creation datetime filters across tables
146
+ def set_global_filters
147
+ # Store creation filter datetimes in session to persist between table navigation
148
+ if params[:creation_filter_start].present?
149
+ session[:creation_filter_start] = params[:creation_filter_start]
150
+ end
151
+
152
+ if params[:creation_filter_end].present?
153
+ session[:creation_filter_end] = params[:creation_filter_end]
154
+ end
155
+
156
+ # Clear filters if explicitly requested
157
+ if params[:clear_creation_filter] == "true"
158
+ session.delete(:creation_filter_start)
159
+ session.delete(:creation_filter_end)
160
+ end
161
+
162
+ # Set instance variables for view access
163
+ @creation_filter_start = session[:creation_filter_start]
164
+ @creation_filter_end = session[:creation_filter_end]
165
+
166
+ # Initialize column_filters if not present
167
+ @column_filters ||= {}
168
+
169
+ # Apply creation filters to column_filters if the table has a created_at column
170
+ if has_timestamp_column?(@table_name) && (@creation_filter_start.present? || @creation_filter_end.present?)
171
+ # Clear any existing created_at filters to avoid conflicts
172
+ @column_filters.delete("created_at")
173
+ @column_filters.delete("created_at_operator")
174
+ @column_filters.delete("created_at_end")
175
+
176
+ if @creation_filter_start.present? && @creation_filter_end.present?
177
+ # If both start and end are present, set up a range filter
178
+ @column_filters["created_at"] = @creation_filter_start
179
+ @column_filters["created_at_end"] = @creation_filter_end
180
+ # No need for operator when using start and end together
181
+ Rails.logger.info("[DBViewer] Setting creation filter range: #{@creation_filter_start} to #{@creation_filter_end}")
182
+ elsif @creation_filter_start.present?
183
+ # Only start date is present
184
+ @column_filters["created_at"] = @creation_filter_start
185
+ @column_filters["created_at_operator"] = "gte" # Greater than or equal
186
+ Rails.logger.info("[DBViewer] Setting creation filter start: #{@creation_filter_start}")
187
+ elsif @creation_filter_end.present?
188
+ # Only end date is present
189
+ @column_filters["created_at"] = @creation_filter_end
190
+ @column_filters["created_at_operator"] = "lte" # Less than or equal
191
+ Rails.logger.info("[DBViewer] Setting creation filter end: #{@creation_filter_end}")
192
+ end
193
+ else
194
+ if @creation_filter_start.present? || @creation_filter_end.present?
195
+ Rails.logger.info("[DBViewer] Creation filter present but table #{@table_name} has no created_at column")
196
+ end
197
+ end
198
+ end
86
199
  end
87
200
  end
@@ -1,5 +1,19 @@
1
1
  module Dbviewer
2
2
  module ApplicationHelper
3
+ # Check if a table has a created_at column
4
+ def has_timestamp_column?(table_name)
5
+ return false unless table_name.present?
6
+
7
+ # Get the columns for the table directly using DatabaseManager
8
+ columns = get_database_manager.table_columns(table_name)
9
+ columns.any? { |col| col[:name] == "created_at" && [ :datetime, :timestamp ].include?(col[:type]) }
10
+ end
11
+
12
+ # Helper to access the database manager
13
+ def get_database_manager
14
+ @database_manager ||= ::Dbviewer::DatabaseManager.new
15
+ end
16
+
3
17
  def format_cell_value(value)
4
18
  return "NULL" if value.nil?
5
19
  return value.to_s.truncate(100) unless value.is_a?(String)
@@ -114,5 +128,64 @@ module Dbviewer
114
128
  def logs_nav_class
115
129
  active_nav_class("logs")
116
130
  end
131
+
132
+ # Returns a sort icon based on the current sort direction
133
+ def sort_icon(column_name, current_order_by, current_direction)
134
+ if column_name == current_order_by
135
+ direction = current_direction == "ASC" ? "up" : "down"
136
+ "<i class='bi bi-sort-#{direction}'></i>".html_safe
137
+ else
138
+ "<i class='bi bi-filter invisible sort-icon'></i>".html_safe
139
+ end
140
+ end
141
+
142
+ # Determine the next sort direction based on the current one
143
+ def next_sort_direction(column_name, current_order_by, current_direction)
144
+ if column_name == current_order_by && current_direction == "ASC"
145
+ "DESC"
146
+ else
147
+ "ASC"
148
+ end
149
+ end
150
+
151
+ # Generate a sortable column header link
152
+ def sortable_column_header(column_name, current_order_by, current_direction, table_name, current_page, per_page, column_filters)
153
+ is_sorted = column_name == current_order_by
154
+ sort_direction = next_sort_direction(column_name, current_order_by, current_direction)
155
+
156
+ aria_sort = if is_sorted
157
+ current_direction.downcase == "asc" ? "ascending" : "descending"
158
+ else
159
+ "none"
160
+ end
161
+
162
+ # Build parameters for the sort link
163
+ sort_params = {
164
+ order_by: column_name,
165
+ order_direction: sort_direction,
166
+ page: current_page,
167
+ per_page: per_page,
168
+ column_filters: column_filters
169
+ }
170
+
171
+ # Add creation filter parameters if they're in the controller
172
+ if defined?(@creation_filter_start) && @creation_filter_start.present?
173
+ sort_params[:creation_filter_start] = @creation_filter_start
174
+ end
175
+
176
+ if defined?(@creation_filter_end) && @creation_filter_end.present?
177
+ sort_params[:creation_filter_end] = @creation_filter_end
178
+ end
179
+
180
+ link_to table_path(table_name, sort_params),
181
+ class: "d-flex align-items-center text-decoration-none text-reset column-sort-link",
182
+ title: "Sort by #{column_name} (#{sort_direction.downcase})",
183
+ "aria-sort": aria_sort,
184
+ role: "button",
185
+ tabindex: "0" do
186
+ content_tag(:span, column_name, class: "column-name") +
187
+ content_tag(:span, sort_icon(column_name, current_order_by, current_direction), class: "sort-icon-container")
188
+ end
189
+ end
117
190
  end
118
191
  end
@@ -15,37 +15,58 @@
15
15
 
16
16
  <div class="row g-3 mb-4">
17
17
  <div class="col-md-3">
18
- <div class="card h-100 text-center border-0 <%= stat_card_bg_class %>">
19
- <div class="card-body">
20
- <h5 class="mb-1">Tables</h5>
21
- <h2 class="mb-0"><%= @analytics[:total_tables] %></h2>
18
+ <div class="card h-100 border-0 shadow-sm <%= stat_card_bg_class %>">
19
+ <div class="card-body d-flex align-items-center">
20
+ <div class="metric-icon me-3">
21
+ <i class="bi bi-table fs-4"></i>
22
+ </div>
23
+ <div class="text-start">
24
+ <h5 class="mb-1">Tables</h5>
25
+ <h2 class="mb-0"><%= @analytics[:total_tables] %></h2>
26
+ </div>
22
27
  </div>
23
28
  </div>
24
29
  </div>
25
30
 
26
31
  <div class="col-md-3">
27
- <div class="card h-100 text-center border-0 <%= stat_card_bg_class %>">
28
- <div class="card-body">
29
- <h5 class="mb-1">Records</h5>
30
- <h2 class="mb-0"><%= number_with_delimiter(@analytics[:total_records]) %></h2>
32
+ <div class="card h-100 border-0 shadow-sm <%= stat_card_bg_class %>">
33
+ <div class="card-body d-flex align-items-center">
34
+ <div class="metric-icon me-3">
35
+ <i class="bi bi-database fs-4"></i>
36
+ </div>
37
+ <div class="text-start">
38
+ <h5 class="mb-1">Records</h5>
39
+ <h2 class="mb-0"><%= number_with_delimiter(@analytics[:total_records]) %></h2>
40
+ </div>
31
41
  </div>
32
42
  </div>
33
43
  </div>
34
44
 
35
45
  <div class="col-md-3">
36
- <div class="card h-100 text-center border-0 <%= stat_card_bg_class %>">
37
- <div class="card-body">
38
- <h5 class="mb-1">Columns</h5>
39
- <h2 class="mb-0"><%= @analytics[:total_columns] %></h2>
46
+ <div class="card h-100 border-0 shadow-sm <%= stat_card_bg_class %>">
47
+ <div class="card-body d-flex align-items-center">
48
+ <div class="metric-icon me-3">
49
+ <i class="bi bi-link-45deg fs-4"></i>
50
+ </div>
51
+ <div class="text-start">
52
+ <h5 class="mb-1">Relationships</h5>
53
+ <h2 class="mb-0"><%= @analytics[:total_relationships] %></h2>
54
+ <small class="text-muted d-block">Foreign Key Connections</small>
55
+ </div>
40
56
  </div>
41
57
  </div>
42
58
  </div>
43
59
 
44
60
  <div class="col-md-3">
45
- <div class="card h-100 text-center border-0 <%= stat_card_bg_class %>">
46
- <div class="card-body">
47
- <h5 class="mb-1">Database Size</h5>
48
- <h2 class="mb-0"><%= number_to_human_size(@analytics[:schema_size]) %></h2>
61
+ <div class="card h-100 border-0 shadow-sm <%= stat_card_bg_class %>">
62
+ <div class="card-body d-flex align-items-center">
63
+ <div class="metric-icon me-3">
64
+ <i class="bi bi-hdd fs-4"></i>
65
+ </div>
66
+ <div class="text-start">
67
+ <h5 class="mb-1">Database Size</h5>
68
+ <h2 class="mb-0"><%= number_to_human_size(@analytics[:schema_size]) %></h2>
69
+ </div>
49
70
  </div>
50
71
  </div>
51
72
  </div>