dbviewer 0.3.3 → 0.3.5
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 +4 -4
- data/README.md +56 -19
- data/app/controllers/concerns/dbviewer/database_operations.rb +11 -1
- data/app/controllers/dbviewer/application_controller.rb +8 -4
- data/app/controllers/dbviewer/tables_controller.rb +9 -1
- data/app/views/dbviewer/tables/show.html.erb +127 -26
- data/app/views/layouts/dbviewer/application.html.erb +2 -1
- data/lib/dbviewer/configuration.rb +2 -1
- data/lib/dbviewer/database_manager.rb +65 -1
- data/lib/dbviewer/version.rb +1 -1
- metadata +1 -1
    
        checksums.yaml
    CHANGED
    
    | @@ -1,7 +1,7 @@ | |
| 1 1 | 
             
            ---
         | 
| 2 2 | 
             
            SHA256:
         | 
| 3 | 
            -
              metadata.gz:  | 
| 4 | 
            -
              data.tar.gz:  | 
| 3 | 
            +
              metadata.gz: 648be97f775ec9c5d18dabf9ebe90e164d760fde2b1e3c9c9ff2442eb6dba9fe
         | 
| 4 | 
            +
              data.tar.gz: f0510fd50eabd5ba176d99e9dd3b66d074d1ccaf6cf15aa43a82beec0e45e923
         | 
| 5 5 | 
             
            SHA512:
         | 
| 6 | 
            -
              metadata.gz:  | 
| 7 | 
            -
              data.tar.gz:  | 
| 6 | 
            +
              metadata.gz: 84bdca42e27ed6dbcf2dfdcdb2756558796b0e2ecc727fa46ffe7fd734bf66d21d5fd76c47d9ae632a003292048c78151a412fcc98c6b33954d2f23cd7b71c19
         | 
| 7 | 
            +
              data.tar.gz: 9f1204704614413b211b1814c96660656e03579712015152b3323f9dff0d64caaff5b96017cbbe203ed9a77ce15bc23e0e1d9473143edca14824f1380b925a25
         | 
    
        data/README.md
    CHANGED
    
    | @@ -87,13 +87,24 @@ Rails.application.routes.draw do | |
| 87 87 | 
             
              # Your application routes...
         | 
| 88 88 |  | 
| 89 89 | 
             
              # Mount the DBViewer engine
         | 
| 90 | 
            -
               | 
| 91 | 
            -
             | 
| 92 | 
            -
             | 
| 90 | 
            +
              mount Dbviewer::Engine, at: "/dbviewer"
         | 
| 91 | 
            +
              # The engine can be mounted in any environment when using Basic Authentication
         | 
| 92 | 
            +
            end
         | 
| 93 | 
            +
            ```
         | 
| 94 | 
            +
             | 
| 95 | 
            +
            Configure Basic Authentication in an initializer to secure access (strongly recommended):
         | 
| 96 | 
            +
             | 
| 97 | 
            +
            ```ruby
         | 
| 98 | 
            +
            # config/initializers/dbviewer.rb
         | 
| 99 | 
            +
            Dbviewer.configure do |config|
         | 
| 100 | 
            +
              config.admin_credentials = {
         | 
| 101 | 
            +
                username: "your_username",
         | 
| 102 | 
            +
                password: "your_secure_password"
         | 
| 103 | 
            +
              }
         | 
| 93 104 | 
             
            end
         | 
| 94 105 | 
             
            ```
         | 
| 95 106 |  | 
| 96 | 
            -
            Then, visit `/dbviewer` in your browser to access the database viewer.
         | 
| 107 | 
            +
            Then, visit `/dbviewer` in your browser to access the database viewer. You'll be prompted for your username and password.
         | 
| 97 108 |  | 
| 98 109 | 
             
            ### Rails API-only Applications
         | 
| 99 110 |  | 
| @@ -171,6 +182,9 @@ Dbviewer.configure do |config| | |
| 171 182 | 
             
              config.query_logging_mode = :memory                # Storage mode for SQL queries (:memory or :file)
         | 
| 172 183 | 
             
              config.query_log_path = "log/dbviewer.log"         # Path for query log file when in :file mode
         | 
| 173 184 | 
             
              config.max_memory_queries = 1000                   # Maximum number of queries to store in memory
         | 
| 185 | 
            +
             | 
| 186 | 
            +
              # Authentication options
         | 
| 187 | 
            +
              config.admin_credentials = { username: "admin", password: "your_secure_password" } # Basic HTTP auth credentials
         | 
| 174 188 | 
             
            end
         | 
| 175 189 | 
             
            ```
         | 
| 176 190 |  | 
| @@ -209,37 +223,60 @@ DBViewer includes several security features to protect your database: | |
| 209 223 | 
             
            - **Query Limits**: Automatic LIMIT clause added to prevent excessive data retrieval
         | 
| 210 224 | 
             
            - **Pattern Detection**: Detection of SQL injection patterns and suspicious constructs
         | 
| 211 225 | 
             
            - **Error Handling**: Informative error messages without exposing sensitive information
         | 
| 226 | 
            +
            - **HTTP Basic Authentication**: Protect access with username and password authentication
         | 
| 212 227 |  | 
| 213 | 
            -
             | 
| 228 | 
            +
            ### Basic Authentication
         | 
| 214 229 |  | 
| 215 | 
            -
             | 
| 230 | 
            +
            You can enable HTTP Basic Authentication to secure access to DBViewer:
         | 
| 216 231 |  | 
| 217 | 
            -
             | 
| 232 | 
            +
            ```ruby
         | 
| 233 | 
            +
            Dbviewer.configure do |config|
         | 
| 234 | 
            +
              config.admin_credentials = {
         | 
| 235 | 
            +
                username: "your_username",
         | 
| 236 | 
            +
                password: "your_secure_password"
         | 
| 237 | 
            +
              }
         | 
| 238 | 
            +
            end
         | 
| 239 | 
            +
            ```
         | 
| 218 240 |  | 
| 219 | 
            -
             | 
| 220 | 
            -
             | 
| 221 | 
            -
               ```
         | 
| 241 | 
            +
            When credentials are provided, all DBViewer routes will be protected by HTTP Basic Authentication.
         | 
| 242 | 
            +
            Without valid credentials, users will be prompted for a username and password before they can access any DBViewer page.
         | 
| 222 243 |  | 
| 223 | 
            -
             | 
| 244 | 
            +
            ## 🌱 Production Access
         | 
| 245 | 
            +
             | 
| 246 | 
            +
            With the addition of Basic Authentication, DBViewer can now be used in any environment including production. We recommend the following for production deployments:
         | 
| 247 | 
            +
             | 
| 248 | 
            +
            1. **Always** enable HTTP Basic Authentication with strong credentials:
         | 
| 224 249 |  | 
| 225 250 | 
             
               ```ruby
         | 
| 226 | 
            -
                | 
| 227 | 
            -
                  | 
| 228 | 
            -
                    | 
| 229 | 
            -
             | 
| 230 | 
            -
             | 
| 231 | 
            -
                 mount Dbviewer::Engine, at: "/dbviewer"
         | 
| 251 | 
            +
               Dbviewer.configure do |config|
         | 
| 252 | 
            +
                 config.admin_credentials = {
         | 
| 253 | 
            +
                   username: "unique_username",
         | 
| 254 | 
            +
                   password: SecureRandom.hex(16)  # Generate a strong random password
         | 
| 255 | 
            +
                 }
         | 
| 232 256 | 
             
               end
         | 
| 233 257 | 
             
               ```
         | 
| 234 258 |  | 
| 235 | 
            -
             | 
| 259 | 
            +
            2. Mount the engine in your routes file:
         | 
| 260 | 
            +
             | 
| 261 | 
            +
               ```ruby
         | 
| 262 | 
            +
               # In any environment, with Basic Auth protection
         | 
| 263 | 
            +
               mount Dbviewer::Engine, at: "/dbviewer"
         | 
| 264 | 
            +
               ```
         | 
| 265 | 
            +
             | 
| 266 | 
            +
            3. Access the tool through your regular application URL:
         | 
| 236 267 | 
             
               ```
         | 
| 237 268 | 
             
               https://yourdomain.com/dbviewer?override_env_check=your_secure_random_key
         | 
| 238 269 | 
             
               ```
         | 
| 239 270 |  | 
| 240 271 | 
             
            ## 📝 Security Note
         | 
| 241 272 |  | 
| 242 | 
            -
            ⚠️ **Warning**: This engine  | 
| 273 | 
            +
            ⚠️ **Warning**: This engine provides direct access to your database contents, which contains sensitive information. Always protect it with HTTP Basic Authentication by configuring strong credentials as shown above.
         | 
| 274 | 
            +
             | 
| 275 | 
            +
            When used in production, ensure:
         | 
| 276 | 
            +
             | 
| 277 | 
            +
            - You use long, randomly generated passwords (e.g., with `SecureRandom.hex(16)`)
         | 
| 278 | 
            +
            - You access DBViewer over HTTPS connections only
         | 
| 279 | 
            +
            - Access is limited to trusted administrators only
         | 
| 243 280 |  | 
| 244 281 | 
             
            ## 🤌🏻 Contributing
         | 
| 245 282 |  | 
| @@ -139,15 +139,25 @@ module Dbviewer | |
| 139 139 |  | 
| 140 140 | 
             
                # Fetch records for a table with pagination and sorting
         | 
| 141 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 | 
            +
             | 
| 142 146 | 
             
                  database_manager.table_records(
         | 
| 143 147 | 
             
                    table_name,
         | 
| 144 148 | 
             
                    @current_page,
         | 
| 145 149 | 
             
                    @order_by,
         | 
| 146 150 | 
             
                    @order_direction,
         | 
| 147 | 
            -
                    @per_page
         | 
| 151 | 
            +
                    @per_page,
         | 
| 152 | 
            +
                    column_filters || {}
         | 
| 148 153 | 
             
                  )
         | 
| 149 154 | 
             
                end
         | 
| 150 155 |  | 
| 156 | 
            +
                # Get filtered record count for a table
         | 
| 157 | 
            +
                def fetch_filtered_record_count(table_name, column_filters)
         | 
| 158 | 
            +
                  database_manager.filtered_record_count(table_name, column_filters)
         | 
| 159 | 
            +
                end
         | 
| 160 | 
            +
             | 
| 151 161 | 
             
                # Safely quote a table name, with fallback
         | 
| 152 162 | 
             
                def safe_quote_table_name(table_name)
         | 
| 153 163 | 
             
                  database_manager.connection.quote_table_name(table_name)
         | 
| @@ -3,14 +3,18 @@ module Dbviewer | |
| 3 3 | 
             
                include Dbviewer::DatabaseOperations
         | 
| 4 4 | 
             
                include Dbviewer::ErrorHandling
         | 
| 5 5 |  | 
| 6 | 
            -
                before_action : | 
| 6 | 
            +
                before_action :authenticate_with_basic_auth
         | 
| 7 7 | 
             
                before_action :set_tables
         | 
| 8 8 |  | 
| 9 9 | 
             
                private
         | 
| 10 10 |  | 
| 11 | 
            -
                def  | 
| 12 | 
            -
                  unless  | 
| 13 | 
            -
             | 
| 11 | 
            +
                def authenticate_with_basic_auth
         | 
| 12 | 
            +
                  return unless Dbviewer.configuration.admin_credentials.present?
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                  credentials = Dbviewer.configuration.admin_credentials
         | 
| 15 | 
            +
                  authenticate_or_request_with_http_basic("DBViewer Authentication") do |username, password|
         | 
| 16 | 
            +
                    ActiveSupport::SecurityUtils.secure_compare(username, credentials[:username]) &
         | 
| 17 | 
            +
                    ActiveSupport::SecurityUtils.secure_compare(password, credentials[:password])
         | 
| 14 18 | 
             
                  end
         | 
| 15 19 | 
             
                end
         | 
| 16 20 |  | 
| @@ -15,7 +15,15 @@ module Dbviewer | |
| 15 15 | 
             
                  set_pagination_params
         | 
| 16 16 | 
             
                  set_sorting_params
         | 
| 17 17 |  | 
| 18 | 
            -
                   | 
| 18 | 
            +
                  # Extract column filters from params
         | 
| 19 | 
            +
                  @column_filters = params[:column_filters].presence ? params[:column_filters].to_enum.to_h : {}
         | 
| 20 | 
            +
             | 
| 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 | 
            +
             | 
| 19 27 | 
             
                  @total_pages = calculate_total_pages(@total_count, @per_page)
         | 
| 20 28 | 
             
                  @records = fetch_table_records(@table_name)
         | 
| 21 29 |  | 
| @@ -66,51 +66,73 @@ | |
| 66 66 | 
             
                <div class="d-flex align-items-center">
         | 
| 67 67 | 
             
                  <div class="me-3">
         | 
| 68 68 | 
             
                    <label for="per-page-select" class="me-2">Per page:</label>
         | 
| 69 | 
            -
                    <select id="per-page-select" class="form-select form-select-sm" onchange="window.location.href='<%= table_path(@table_name) %>?per_page=' + this.value + '&page=1&order_by=<%= @order_by %>&order_direction=<%= @order_direction %>'">
         | 
| 69 | 
            +
                    <select id="per-page-select" class="form-select form-select-sm" onchange="window.location.href='<%= table_path(@table_name) %>?per_page=' + this.value + '&page=1&order_by=<%= @order_by %>&order_direction=<%= @order_direction %><%= @column_filters.reject { |_, v| v.blank? }.any? ? "&" + @column_filters.reject { |_, v| v.blank? }.map { |k, v| "column_filters[#{k}]=#{CGI.escape(v.to_s)}" }.join("&") : "" %>'">
         | 
| 70 70 | 
             
                      <% Dbviewer::TablesController.per_page_options.each do |option| %>
         | 
| 71 71 | 
             
                        <option value="<%= option %>" <%= 'selected' if @per_page == option %>><%= option %></option>
         | 
| 72 72 | 
             
                      <% end %>
         | 
| 73 73 | 
             
                    </select>
         | 
| 74 74 | 
             
                  </div>
         | 
| 75 75 | 
             
                  <span class="badge bg-secondary">Total: <%= @total_count %> records</span>
         | 
| 76 | 
            +
                  <% active_filters = @column_filters.reject { |_, v| v.blank? }.size %>
         | 
| 77 | 
            +
                  <% if active_filters > 0 %>
         | 
| 78 | 
            +
                    <span class="badge bg-info ms-2" title="Active filters"><i class="bi bi-funnel-fill me-1"></i><%= active_filters %></span>
         | 
| 79 | 
            +
                  <% end %>
         | 
| 76 80 | 
             
                </div>
         | 
| 77 81 | 
             
              </div>
         | 
| 78 82 | 
             
                <div class="card-body p-0">
         | 
| 79 83 | 
             
                  <div class="table-responsive dbviewer-scrollable">
         | 
| 80 | 
            -
                     | 
| 81 | 
            -
                      <%  | 
| 82 | 
            -
             | 
| 83 | 
            -
             | 
| 84 | 
            -
             | 
| 85 | 
            -
             | 
| 86 | 
            -
             | 
| 87 | 
            -
             | 
| 88 | 
            -
             | 
| 89 | 
            -
                          </tr>
         | 
| 90 | 
            -
                        </thead>
         | 
| 91 | 
            -
                        <tbody>
         | 
| 92 | 
            -
                          <% @records.rows.each do |row| %>
         | 
| 84 | 
            +
                    <%= form_with(url: table_path(@table_name), method: :get, local: true, id: "column-filters-form", class: "mb-0") do |form| %>
         | 
| 85 | 
            +
                      <% # Hidden fields to preserve current parameters %>
         | 
| 86 | 
            +
                      <%= form.hidden_field :per_page, value: @per_page %>
         | 
| 87 | 
            +
                      <%= form.hidden_field :order_by, value: @order_by %>
         | 
| 88 | 
            +
                      <%= form.hidden_field :order_direction, value: @order_direction %>
         | 
| 89 | 
            +
                      <%= form.hidden_field :page, value: 1 %> <!-- Reset to first page on filter -->
         | 
| 90 | 
            +
                      
         | 
| 91 | 
            +
                      <table class="table table-bordered table-striped rounded-none">
         | 
| 92 | 
            +
                          <thead class="dbviewer-table-header">
         | 
| 93 93 | 
             
                            <tr>
         | 
| 94 | 
            -
                              <%  | 
| 95 | 
            -
                                 | 
| 96 | 
            -
             | 
| 94 | 
            +
                              <% @records.columns.each do |column_name| %>
         | 
| 95 | 
            +
                                <th class="pe-4">
         | 
| 96 | 
            +
                                  <%= column_name %>
         | 
| 97 | 
            +
                                </th>
         | 
| 97 98 | 
             
                              <% end %>
         | 
| 98 99 | 
             
                            </tr>
         | 
| 99 | 
            -
             | 
| 100 | 
            +
                            <tr class="column-filters">
         | 
| 101 | 
            +
                              <% @records.columns.each do |column_name| %>
         | 
| 102 | 
            +
                                <th class="p-0">
         | 
| 103 | 
            +
                                  <%= form.text_field "column_filters[#{column_name}]", 
         | 
| 104 | 
            +
                                      value: @column_filters[column_name], 
         | 
| 105 | 
            +
                                      placeholder: "",
         | 
| 106 | 
            +
                                      class: "form-control form-control-sm column-filter rounded-0",
         | 
| 107 | 
            +
                                      data: { column: column_name } %>
         | 
| 108 | 
            +
                                </th>
         | 
| 109 | 
            +
                              <% end %>
         | 
| 110 | 
            +
                            </tr>
         | 
| 111 | 
            +
                          </thead>
         | 
| 112 | 
            +
                          <tbody>
         | 
| 113 | 
            +
                            <% if @records.empty? %>
         | 
| 114 | 
            +
                              <tr>
         | 
| 115 | 
            +
                                <td colspan="100%" class="text-center">No records found or table is empty.</td>
         | 
| 116 | 
            +
                              </tr>
         | 
| 117 | 
            +
                            <% end %>
         | 
| 118 | 
            +
                            <% @records.rows.each do |row| %>
         | 
| 119 | 
            +
                              <tr>
         | 
| 120 | 
            +
                                  <% row.each do |cell| %>
         | 
| 121 | 
            +
                                    <% cell_value = format_cell_value(cell) %>
         | 
| 122 | 
            +
                                    <td title="<%= cell_value %>"><%= cell_value %></td>
         | 
| 123 | 
            +
                                <% end %>
         | 
| 124 | 
            +
                              </tr>
         | 
| 125 | 
            +
                            <% end %>
         | 
| 100 126 | 
             
                        </tbody>
         | 
| 101 | 
            -
                      <% else %>
         | 
| 102 | 
            -
                        <tr>
         | 
| 103 | 
            -
                          <td colspan="100%">No records found or table is empty.</td>
         | 
| 104 | 
            -
                        </tr>
         | 
| 105 | 
            -
                      <% end %>
         | 
| 106 127 | 
             
                    </table>
         | 
| 128 | 
            +
                    <% end %> <!-- End of form_with -->
         | 
| 107 129 | 
             
                  </div>
         | 
| 108 130 |  | 
| 109 131 | 
             
                  <% if @total_pages > 1 %>
         | 
| 110 132 | 
             
                    <nav aria-label="Page navigation">
         | 
| 111 133 | 
             
                      <ul class="pagination justify-content-center">
         | 
| 112 134 | 
             
                        <li class="page-item <%= 'disabled' if @current_page == 1 %>">
         | 
| 113 | 
            -
                          <%= link_to '«', table_path(@table_name, page: [@current_page - 1, 1].max, order_by: @order_by, order_direction: @order_direction, per_page: @per_page), class: 'page-link' %>
         | 
| 135 | 
            +
                          <%= link_to '«', table_path(@table_name, page: [@current_page - 1, 1].max, order_by: @order_by, order_direction: @order_direction, per_page: @per_page, column_filters: @column_filters), class: 'page-link' %>
         | 
| 114 136 | 
             
                        </li>
         | 
| 115 137 |  | 
| 116 138 | 
             
                        <% start_page = [1, @current_page - 2].max %>
         | 
| @@ -119,12 +141,12 @@ | |
| 119 141 |  | 
| 120 142 | 
             
                        <% (start_page..end_page).each do |page_num| %>
         | 
| 121 143 | 
             
                          <li class="page-item <%= 'active' if page_num == @current_page %>">
         | 
| 122 | 
            -
                            <%= link_to page_num, table_path(@table_name, page: page_num, order_by: @order_by, order_direction: @order_direction, per_page: @per_page), class: 'page-link' %>
         | 
| 144 | 
            +
                            <%= link_to page_num, table_path(@table_name, page: page_num, order_by: @order_by, order_direction: @order_direction, per_page: @per_page, column_filters: @column_filters), class: 'page-link' %>
         | 
| 123 145 | 
             
                          </li>
         | 
| 124 146 | 
             
                        <% end %>
         | 
| 125 147 |  | 
| 126 148 | 
             
                        <li class="page-item <%= 'disabled' if @current_page == @total_pages %>">
         | 
| 127 | 
            -
                          <%= link_to '»', table_path(@table_name, page: [@current_page + 1, @total_pages].min, order_by: @order_by, order_direction: @order_direction, per_page: @per_page), class: 'page-link' %>
         | 
| 149 | 
            +
                          <%= link_to '»', table_path(@table_name, page: [@current_page + 1, @total_pages].min, order_by: @order_by, order_direction: @order_direction, per_page: @per_page, column_filters: @column_filters), class: 'page-link' %>
         | 
| 128 150 | 
             
                        </li>
         | 
| 129 151 | 
             
                      </ul>
         | 
| 130 152 | 
             
                    </nav>
         | 
| @@ -190,6 +212,85 @@ | |
| 190 212 | 
             
              </div>
         | 
| 191 213 | 
             
            </div>
         | 
| 192 214 |  | 
| 215 | 
            +
            <script>
         | 
| 216 | 
            +
              document.addEventListener('DOMContentLoaded', function() {
         | 
| 217 | 
            +
                // Column filter functionality
         | 
| 218 | 
            +
                const columnFilters = document.querySelectorAll('.column-filter');
         | 
| 219 | 
            +
                const filterForm = document.getElementById('column-filters-form');
         | 
| 220 | 
            +
                
         | 
| 221 | 
            +
                // Add debounce function to reduce form submissions
         | 
| 222 | 
            +
                function debounce(func, wait) {
         | 
| 223 | 
            +
                  let timeout;
         | 
| 224 | 
            +
                  return function() {
         | 
| 225 | 
            +
                    const context = this;
         | 
| 226 | 
            +
                    const args = arguments;
         | 
| 227 | 
            +
                    clearTimeout(timeout);
         | 
| 228 | 
            +
                    timeout = setTimeout(function() {
         | 
| 229 | 
            +
                      func.apply(context, args);
         | 
| 230 | 
            +
                    }, wait);
         | 
| 231 | 
            +
                  };
         | 
| 232 | 
            +
                }
         | 
| 233 | 
            +
                
         | 
| 234 | 
            +
                // Function to submit the form
         | 
| 235 | 
            +
                const submitForm = debounce(function() {
         | 
| 236 | 
            +
                  filterForm.submit();
         | 
| 237 | 
            +
                }, 500);
         | 
| 238 | 
            +
                
         | 
| 239 | 
            +
                // Add event listeners to all filter inputs
         | 
| 240 | 
            +
                columnFilters.forEach(function(filter) {
         | 
| 241 | 
            +
                  filter.addEventListener('input', submitForm);
         | 
| 242 | 
            +
                });
         | 
| 243 | 
            +
                
         | 
| 244 | 
            +
                // Add clear button functionality if there are any filters applied
         | 
| 245 | 
            +
                const hasActiveFilters = Array.from(columnFilters).some(input => input.value);
         | 
| 246 | 
            +
                
         | 
| 247 | 
            +
                if (hasActiveFilters) {
         | 
| 248 | 
            +
                  // Add a clear filters button
         | 
| 249 | 
            +
                  const paginationContainer = document.querySelector('nav[aria-label="Page navigation"]') || 
         | 
| 250 | 
            +
                                               document.querySelector('.table-responsive');
         | 
| 251 | 
            +
                  
         | 
| 252 | 
            +
                  if (paginationContainer) {
         | 
| 253 | 
            +
                    const clearButton = document.createElement('div');
         | 
| 254 | 
            +
                    clearButton.className = 'text-center mt-3';
         | 
| 255 | 
            +
                    clearButton.innerHTML = '<button type="button" class="btn btn-sm btn-outline-secondary" id="clear-filters">' +
         | 
| 256 | 
            +
                                           '<i class="bi bi-x-circle me-1"></i>Clear All Filters</button>';
         | 
| 257 | 
            +
                    
         | 
| 258 | 
            +
                    paginationContainer.insertAdjacentHTML('afterend', clearButton.outerHTML);
         | 
| 259 | 
            +
                    
         | 
| 260 | 
            +
                    document.getElementById('clear-filters').addEventListener('click', function() {
         | 
| 261 | 
            +
                      columnFilters.forEach(filter => filter.value = '');
         | 
| 262 | 
            +
                      submitForm();
         | 
| 263 | 
            +
                    });
         | 
| 264 | 
            +
                  }
         | 
| 265 | 
            +
                }
         | 
| 266 | 
            +
              });
         | 
| 267 | 
            +
            </script>
         | 
| 268 | 
            +
             | 
| 269 | 
            +
            <style>
         | 
| 270 | 
            +
              /* Column filter styling */
         | 
| 271 | 
            +
              .column-filters td {
         | 
| 272 | 
            +
                padding: 0.5rem;
         | 
| 273 | 
            +
                background-color: var(--bs-tertiary-bg, #f8f9fa);
         | 
| 274 | 
            +
              }
         | 
| 275 | 
            +
              
         | 
| 276 | 
            +
              .column-filter {
         | 
| 277 | 
            +
                width: 100%;
         | 
| 278 | 
            +
                border: 1px solid rgba(0,0,0,0.1);
         | 
| 279 | 
            +
                padding: 0.3rem 0.5rem;
         | 
| 280 | 
            +
                font-size: 0.85rem;
         | 
| 281 | 
            +
              }
         | 
| 282 | 
            +
              
         | 
| 283 | 
            +
              [data-bs-theme="dark"] .column-filters td {
         | 
| 284 | 
            +
                background-color: rgba(255,255,255,0.05);
         | 
| 285 | 
            +
              }
         | 
| 286 | 
            +
              
         | 
| 287 | 
            +
              [data-bs-theme="dark"] .column-filter {
         | 
| 288 | 
            +
                background-color: rgba(255,255,255,0.1);
         | 
| 289 | 
            +
                color: rgba(255,255,255,0.9);
         | 
| 290 | 
            +
                border-color: rgba(255,255,255,0.15);
         | 
| 291 | 
            +
              }
         | 
| 292 | 
            +
            </style>
         | 
| 293 | 
            +
             | 
| 193 294 | 
             
            <% if @timestamp_data.present? %>
         | 
| 194 295 | 
             
            <script>
         | 
| 195 296 | 
             
              document.addEventListener('DOMContentLoaded', function() {
         | 
| @@ -211,7 +211,8 @@ | |
| 211 211 | 
             
                  border: 1px solid #495057;
         | 
| 212 212 | 
             
                }
         | 
| 213 213 |  | 
| 214 | 
            -
                .dbviewer-scrollable { max-height:  | 
| 214 | 
            +
                .dbviewer-scrollable { max-height: 700px; overflow-y: auto; }
         | 
| 215 | 
            +
                .dbviewer-scrollable thead { position: sticky; top: 0; z-index: 1; }
         | 
| 215 216 |  | 
| 216 217 | 
             
                /* Badge styling for dark mode */
         | 
| 217 218 | 
             
                [data-bs-theme="dark"] .bg-secondary-subtle {
         | 
| @@ -31,7 +31,8 @@ module Dbviewer | |
| 31 31 | 
             
                # Maximum number of queries to keep in memory
         | 
| 32 32 | 
             
                attr_accessor :max_memory_queries
         | 
| 33 33 |  | 
| 34 | 
            -
                # Admin access credentials  | 
| 34 | 
            +
                # Admin access credentials hash with :username and :password keys
         | 
| 35 | 
            +
                # @example { username: 'admin', password: 'secret' }
         | 
| 35 36 | 
             
                attr_accessor :admin_credentials
         | 
| 36 37 |  | 
| 37 38 | 
             
                def initialize
         | 
| @@ -75,9 +75,10 @@ module Dbviewer | |
| 75 75 | 
             
                # @param direction [String] Sort direction ('ASC' or 'DESC')
         | 
| 76 76 | 
             
                # @param per_page [Integer] Number of records per page
         | 
| 77 77 | 
             
                # @return [ActiveRecord::Result] Result set with columns and rows
         | 
| 78 | 
            -
                def table_records(table_name, page = 1, order_by = nil, direction = "ASC", per_page = nil)
         | 
| 78 | 
            +
                def table_records(table_name, page = 1, order_by = nil, direction = "ASC", per_page = nil, column_filters = nil)
         | 
| 79 79 | 
             
                  page = [ 1, page.to_i ].max
         | 
| 80 80 | 
             
                  default_per_page = self.class.default_per_page
         | 
| 81 | 
            +
                  column_filters ||= {}
         | 
| 81 82 | 
             
                  max_records = self.class.max_records
         | 
| 82 83 | 
             
                  per_page = (per_page || default_per_page).to_i
         | 
| 83 84 |  | 
| @@ -87,6 +88,32 @@ module Dbviewer | |
| 87 88 | 
             
                  model = get_model_for(table_name)
         | 
| 88 89 | 
             
                  query = model.all
         | 
| 89 90 |  | 
| 91 | 
            +
                  # Apply column filters if provided
         | 
| 92 | 
            +
                  if column_filters.present?
         | 
| 93 | 
            +
                    column_filters.each do |column, value|
         | 
| 94 | 
            +
                      next if value.blank?
         | 
| 95 | 
            +
                      next unless column_exists?(table_name, column)
         | 
| 96 | 
            +
             | 
| 97 | 
            +
                      # Use LIKE for string-based searches, = for exact matches on other types
         | 
| 98 | 
            +
                      column_info = table_columns(table_name).find { |c| c[:name] == column }
         | 
| 99 | 
            +
                      if column_info
         | 
| 100 | 
            +
                        column_type = column_info[:type].to_s
         | 
| 101 | 
            +
             | 
| 102 | 
            +
                        if column_type =~ /char|text|string|uuid|enum/i
         | 
| 103 | 
            +
                          query = query.where("#{connection.quote_column_name(column)} LIKE ?", "%#{value}%")
         | 
| 104 | 
            +
                        else
         | 
| 105 | 
            +
                          # For numeric types, try exact match if value looks like a number
         | 
| 106 | 
            +
                          if value =~ /\A[+-]?\d+(\.\d+)?\z/
         | 
| 107 | 
            +
                            query = query.where(column => value)
         | 
| 108 | 
            +
                          else
         | 
| 109 | 
            +
                            # Otherwise, try string comparison for non-string fields
         | 
| 110 | 
            +
                            query = query.where("CAST(#{connection.quote_column_name(column)} AS CHAR) LIKE ?", "%#{value}%")
         | 
| 111 | 
            +
                          end
         | 
| 112 | 
            +
                        end
         | 
| 113 | 
            +
                      end
         | 
| 114 | 
            +
                    end
         | 
| 115 | 
            +
                  end
         | 
| 116 | 
            +
             | 
| 90 117 | 
             
                  # Apply sorting if provided
         | 
| 91 118 | 
             
                  if order_by.present? && column_exists?(table_name, order_by)
         | 
| 92 119 | 
             
                    direction = %w[ASC DESC].include?(direction.to_s.upcase) ? direction.to_s.upcase : "ASC"
         | 
| @@ -110,6 +137,43 @@ module Dbviewer | |
| 110 137 | 
             
                  table_count(table_name)
         | 
| 111 138 | 
             
                end
         | 
| 112 139 |  | 
| 140 | 
            +
                # Get the number of records in a table with filters applied
         | 
| 141 | 
            +
                # @param table_name [String] Name of the table
         | 
| 142 | 
            +
                # @param column_filters [Hash] Hash of column_name => filter_value for filtering
         | 
| 143 | 
            +
                # @return [Integer] Number of filtered records
         | 
| 144 | 
            +
                def filtered_record_count(table_name, column_filters = {})
         | 
| 145 | 
            +
                  model = get_model_for(table_name)
         | 
| 146 | 
            +
                  query = model.all
         | 
| 147 | 
            +
             | 
| 148 | 
            +
                  # Apply column filters if provided
         | 
| 149 | 
            +
                  if column_filters.present?
         | 
| 150 | 
            +
                    column_filters.each do |column, value|
         | 
| 151 | 
            +
                      next if value.blank?
         | 
| 152 | 
            +
                      next unless column_exists?(table_name, column)
         | 
| 153 | 
            +
             | 
| 154 | 
            +
                      # Use LIKE for string-based searches, = for exact matches on other types
         | 
| 155 | 
            +
                      column_info = table_columns(table_name).find { |c| c[:name] == column }
         | 
| 156 | 
            +
                      if column_info
         | 
| 157 | 
            +
                        column_type = column_info[:type].to_s
         | 
| 158 | 
            +
             | 
| 159 | 
            +
                        if column_type =~ /char|text|string|uuid|enum/i
         | 
| 160 | 
            +
                          query = query.where("#{connection.quote_column_name(column)} LIKE ?", "%#{value}%")
         | 
| 161 | 
            +
                        else
         | 
| 162 | 
            +
                          # For numeric types, try exact match if value looks like a number
         | 
| 163 | 
            +
                          if value =~ /\A[+-]?\d+(\.\d+)?\z/
         | 
| 164 | 
            +
                            query = query.where(column => value)
         | 
| 165 | 
            +
                          else
         | 
| 166 | 
            +
                            # Otherwise, try string comparison for non-string fields
         | 
| 167 | 
            +
                            query = query.where("CAST(#{connection.quote_column_name(column)} AS CHAR) LIKE ?", "%#{value}%")
         | 
| 168 | 
            +
                          end
         | 
| 169 | 
            +
                        end
         | 
| 170 | 
            +
                      end
         | 
| 171 | 
            +
                    end
         | 
| 172 | 
            +
                  end
         | 
| 173 | 
            +
             | 
| 174 | 
            +
                  query.count
         | 
| 175 | 
            +
                end
         | 
| 176 | 
            +
             | 
| 113 177 | 
             
                # Get the number of columns in a table
         | 
| 114 178 | 
             
                # @param table_name [String] Name of the table
         | 
| 115 179 | 
             
                # @return [Integer] Number of columns
         | 
    
        data/lib/dbviewer/version.rb
    CHANGED