dbwatcher 0.1.5 → 1.1.0

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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +81 -210
  3. data/app/assets/config/dbwatcher_manifest.js +15 -0
  4. data/app/assets/javascripts/dbwatcher/alpine_registrations.js +39 -0
  5. data/app/assets/javascripts/dbwatcher/auto_init.js +23 -0
  6. data/app/assets/javascripts/dbwatcher/components/base.js +141 -0
  7. data/app/assets/javascripts/dbwatcher/components/changes_table_hybrid.js +1008 -0
  8. data/app/assets/javascripts/dbwatcher/components/diagrams.js +449 -0
  9. data/app/assets/javascripts/dbwatcher/components/summary.js +234 -0
  10. data/app/assets/javascripts/dbwatcher/core/alpine_store.js +138 -0
  11. data/app/assets/javascripts/dbwatcher/core/api_client.js +162 -0
  12. data/app/assets/javascripts/dbwatcher/core/component_loader.js +70 -0
  13. data/app/assets/javascripts/dbwatcher/core/component_registry.js +94 -0
  14. data/app/assets/javascripts/dbwatcher/dbwatcher.js +120 -0
  15. data/app/assets/javascripts/dbwatcher/services/mermaid.js +315 -0
  16. data/app/assets/javascripts/dbwatcher/services/mermaid_service.js +199 -0
  17. data/app/assets/javascripts/dbwatcher/vendor/date-fns-browser.js +99 -0
  18. data/app/assets/javascripts/dbwatcher/vendor/lodash.min.js +140 -0
  19. data/app/assets/javascripts/dbwatcher/vendor/tabulator.min.js +3 -0
  20. data/app/assets/stylesheets/dbwatcher/application.css +423 -0
  21. data/app/assets/stylesheets/dbwatcher/application.scss +15 -0
  22. data/app/assets/stylesheets/dbwatcher/components/_badges.scss +38 -0
  23. data/app/assets/stylesheets/dbwatcher/components/_compact_table.scss +162 -0
  24. data/app/assets/stylesheets/dbwatcher/components/_diagrams.scss +51 -0
  25. data/app/assets/stylesheets/dbwatcher/components/_forms.scss +27 -0
  26. data/app/assets/stylesheets/dbwatcher/components/_navigation.scss +55 -0
  27. data/app/assets/stylesheets/dbwatcher/core/_base.scss +34 -0
  28. data/app/assets/stylesheets/dbwatcher/core/_variables.scss +47 -0
  29. data/app/assets/stylesheets/dbwatcher/vendor/tabulator.min.css +2 -0
  30. data/app/controllers/dbwatcher/api/v1/sessions_controller.rb +64 -0
  31. data/app/controllers/dbwatcher/base_controller.rb +101 -0
  32. data/app/controllers/dbwatcher/dashboard_controller.rb +20 -0
  33. data/app/controllers/dbwatcher/queries_controller.rb +24 -0
  34. data/app/controllers/dbwatcher/sessions_controller.rb +30 -20
  35. data/app/controllers/dbwatcher/tables_controller.rb +38 -0
  36. data/app/helpers/dbwatcher/application_helper.rb +103 -0
  37. data/app/helpers/dbwatcher/component_helper.rb +29 -0
  38. data/app/helpers/dbwatcher/diagram_helper.rb +110 -0
  39. data/app/helpers/dbwatcher/formatting_helper.rb +108 -0
  40. data/app/helpers/dbwatcher/session_helper.rb +28 -0
  41. data/app/views/dbwatcher/dashboard/index.html.erb +177 -0
  42. data/app/views/dbwatcher/queries/index.html.erb +240 -0
  43. data/app/views/dbwatcher/sessions/_changes_tab.html.erb +265 -0
  44. data/app/views/dbwatcher/sessions/_diagrams_tab.html.erb +166 -0
  45. data/app/views/dbwatcher/sessions/_session_header.html.erb +11 -0
  46. data/app/views/dbwatcher/sessions/_summary_tab.html.erb +88 -0
  47. data/app/views/dbwatcher/sessions/_tab_navigation.html.erb +12 -0
  48. data/app/views/dbwatcher/sessions/changes.html.erb +21 -0
  49. data/app/views/dbwatcher/sessions/components/changes/_filters.html.erb +44 -0
  50. data/app/views/dbwatcher/sessions/components/changes/_table_list.html.erb +96 -0
  51. data/app/views/dbwatcher/sessions/diagrams.html.erb +21 -0
  52. data/app/views/dbwatcher/sessions/index.html.erb +124 -27
  53. data/app/views/dbwatcher/sessions/shared/_layout.html.erb +8 -0
  54. data/app/views/dbwatcher/sessions/shared/_navigation.html.erb +35 -0
  55. data/app/views/dbwatcher/sessions/shared/_session_header.html.erb +25 -0
  56. data/app/views/dbwatcher/sessions/show.html.erb +3 -149
  57. data/app/views/dbwatcher/sessions/summary.html.erb +21 -0
  58. data/app/views/dbwatcher/shared/_badge.html.erb +4 -0
  59. data/app/views/dbwatcher/shared/_data_table.html.erb +20 -0
  60. data/app/views/dbwatcher/shared/_header.html.erb +7 -0
  61. data/app/views/dbwatcher/shared/_page_layout.html.erb +20 -0
  62. data/app/views/dbwatcher/shared/_section_panel.html.erb +9 -0
  63. data/app/views/dbwatcher/shared/_stats_card.html.erb +11 -0
  64. data/app/views/dbwatcher/shared/_tab_bar.html.erb +6 -0
  65. data/app/views/dbwatcher/tables/changes.html.erb +225 -0
  66. data/app/views/dbwatcher/tables/index.html.erb +123 -0
  67. data/app/views/dbwatcher/tables/show.html.erb +86 -0
  68. data/app/views/layouts/dbwatcher/application.html.erb +252 -25
  69. data/bin/compile_scss +49 -0
  70. data/config/routes.rb +43 -3
  71. data/lib/dbwatcher/configuration.rb +103 -1
  72. data/lib/dbwatcher/engine.rb +28 -13
  73. data/lib/dbwatcher/logging.rb +72 -0
  74. data/lib/dbwatcher/services/analyzers/session_data_processor.rb +98 -0
  75. data/lib/dbwatcher/services/analyzers/table_summary_builder.rb +202 -0
  76. data/lib/dbwatcher/services/api/base_api_service.rb +100 -0
  77. data/lib/dbwatcher/services/api/changes_data_service.rb +112 -0
  78. data/lib/dbwatcher/services/api/diagram_data_service.rb +145 -0
  79. data/lib/dbwatcher/services/api/summary_data_service.rb +158 -0
  80. data/lib/dbwatcher/services/base_service.rb +64 -0
  81. data/lib/dbwatcher/services/dashboard_data_aggregator.rb +121 -0
  82. data/lib/dbwatcher/services/diagram_analyzers/base_analyzer.rb +162 -0
  83. data/lib/dbwatcher/services/diagram_analyzers/foreign_key_analyzer.rb +354 -0
  84. data/lib/dbwatcher/services/diagram_analyzers/inferred_relationship_analyzer.rb +502 -0
  85. data/lib/dbwatcher/services/diagram_analyzers/model_association_analyzer.rb +564 -0
  86. data/lib/dbwatcher/services/diagram_data/attribute.rb +154 -0
  87. data/lib/dbwatcher/services/diagram_data/dataset.rb +278 -0
  88. data/lib/dbwatcher/services/diagram_data/entity.rb +180 -0
  89. data/lib/dbwatcher/services/diagram_data/relationship.rb +188 -0
  90. data/lib/dbwatcher/services/diagram_data/relationship_params.rb +55 -0
  91. data/lib/dbwatcher/services/diagram_data.rb +65 -0
  92. data/lib/dbwatcher/services/diagram_error_handler.rb +239 -0
  93. data/lib/dbwatcher/services/diagram_generator.rb +154 -0
  94. data/lib/dbwatcher/services/diagram_strategies/base_diagram_strategy.rb +149 -0
  95. data/lib/dbwatcher/services/diagram_strategies/class_diagram_strategy.rb +49 -0
  96. data/lib/dbwatcher/services/diagram_strategies/erd_diagram_strategy.rb +52 -0
  97. data/lib/dbwatcher/services/diagram_strategies/flowchart_diagram_strategy.rb +52 -0
  98. data/lib/dbwatcher/services/diagram_system.rb +69 -0
  99. data/lib/dbwatcher/services/diagram_type_registry.rb +164 -0
  100. data/lib/dbwatcher/services/mermaid_syntax/base_builder.rb +127 -0
  101. data/lib/dbwatcher/services/mermaid_syntax/cardinality_mapper.rb +90 -0
  102. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_builder.rb +136 -0
  103. data/lib/dbwatcher/services/mermaid_syntax/class_diagram_helper.rb +46 -0
  104. data/lib/dbwatcher/services/mermaid_syntax/erd_builder.rb +116 -0
  105. data/lib/dbwatcher/services/mermaid_syntax/flowchart_builder.rb +109 -0
  106. data/lib/dbwatcher/services/mermaid_syntax/sanitizer.rb +102 -0
  107. data/lib/dbwatcher/services/mermaid_syntax_builder.rb +155 -0
  108. data/lib/dbwatcher/services/query_filter_processor.rb +114 -0
  109. data/lib/dbwatcher/services/table_statistics_collector.rb +119 -0
  110. data/lib/dbwatcher/sql_logger.rb +107 -0
  111. data/lib/dbwatcher/storage/api/base_api.rb +134 -0
  112. data/lib/dbwatcher/storage/api/concerns/table_analyzer.rb +59 -0
  113. data/lib/dbwatcher/storage/api/query_api.rb +95 -0
  114. data/lib/dbwatcher/storage/api/session_api.rb +181 -0
  115. data/lib/dbwatcher/storage/api/table_api.rb +86 -0
  116. data/lib/dbwatcher/storage/base_storage.rb +120 -0
  117. data/lib/dbwatcher/storage/change_processor.rb +65 -0
  118. data/lib/dbwatcher/storage/concerns/data_normalizer.rb +134 -0
  119. data/lib/dbwatcher/storage/concerns/error_handler.rb +75 -0
  120. data/lib/dbwatcher/storage/concerns/timestampable.rb +74 -0
  121. data/lib/dbwatcher/storage/concerns/validatable.rb +117 -0
  122. data/lib/dbwatcher/storage/date_helper.rb +21 -0
  123. data/lib/dbwatcher/storage/errors.rb +86 -0
  124. data/lib/dbwatcher/storage/file_manager.rb +122 -0
  125. data/lib/dbwatcher/storage/null_session.rb +39 -0
  126. data/lib/dbwatcher/storage/query_storage.rb +338 -0
  127. data/lib/dbwatcher/storage/query_validator.rb +24 -0
  128. data/lib/dbwatcher/storage/session.rb +58 -0
  129. data/lib/dbwatcher/storage/session_operations.rb +37 -0
  130. data/lib/dbwatcher/storage/session_query.rb +71 -0
  131. data/lib/dbwatcher/storage/session_storage.rb +322 -0
  132. data/lib/dbwatcher/storage/table_storage.rb +237 -0
  133. data/lib/dbwatcher/storage.rb +112 -85
  134. data/lib/dbwatcher/tracker.rb +4 -55
  135. data/lib/dbwatcher/version.rb +1 -1
  136. data/lib/dbwatcher.rb +70 -3
  137. metadata +140 -2
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module DiagramHelper
5
+ # Generate diagram configuration for Alpine.js
6
+ def diagram_config(session, active_tab)
7
+ {
8
+ auto_generate: active_tab == "diagrams",
9
+ default_type: "database_tables",
10
+ endpoint: diagram_data_api_v1_session_path(session),
11
+ container_id: "diagram-container"
12
+ }.to_json
13
+ end
14
+
15
+ # Generate diagram type options dynamically from registry
16
+ def diagram_type_options
17
+ registry = Dbwatcher::Services::DiagramTypeRegistry.new
18
+ options = registry.available_types_with_metadata.map do |type, metadata|
19
+ [metadata[:display_name], type]
20
+ end
21
+
22
+ options_for_select(options)
23
+ end
24
+
25
+ # Generate CSS variables for diagram container height calculation
26
+ def diagram_container_css_variables
27
+ {
28
+ "--header-height": "64px",
29
+ "--tab-bar-height": "40px",
30
+ "--toolbar-height": "72px",
31
+ "--footer-height": "0px",
32
+ "--diagram-height": "calc(100vh - var(--header-height) - var(--tab-bar-height) - " \
33
+ "var(--toolbar-height) - var(--footer-height) - 2rem)",
34
+ "--diagram-min-height": "500px"
35
+ }.map { |key, value| "#{key}: #{value}" }.join("; ")
36
+ end
37
+
38
+ # Generate button classes for diagram controls
39
+ def diagram_button_classes(type = :default)
40
+ base_classes = "compact-button text-xs rounded"
41
+
42
+ button_styles = {
43
+ primary: "bg-blue-medium text-white px-3 py-1 hover:bg-navy-dark",
44
+ secondary: "bg-navy-dark text-white px-2 py-1 hover:bg-blue-medium",
45
+ toggle: "bg-blue-medium text-white px-2 py-1 hover:bg-navy-dark flex items-center gap-1",
46
+ icon: "bg-white border border-gray-300 hover:bg-gray-50 p-1",
47
+ danger: "bg-red-500 text-white px-2 py-1 hover:bg-red-600",
48
+ success: "bg-green-500 text-white px-2 py-1 hover:bg-green-600"
49
+ }
50
+
51
+ style = button_styles[type] || button_styles[:primary]
52
+ "#{base_classes} #{style}"
53
+ end
54
+
55
+ # Generate a code view with syntax highlighting for Mermaid code
56
+ def diagram_code_view(content)
57
+ content_tag(:div, class: "diagram-code-view") do
58
+ content_tag(:pre,
59
+ class: "text-xs font-mono p-4 bg-gray-50 rounded border border-gray-200 " \
60
+ "overflow-x-auto whitespace-pre-wrap",
61
+ style: "max-height: calc(100vh - 220px); overflow-y: auto;") do
62
+ content_tag(:code, content)
63
+ end
64
+ end
65
+ end
66
+
67
+ # Generate copy to clipboard button
68
+ def copy_to_clipboard_button(target_id)
69
+ button_tag(
70
+ type: "button",
71
+ class: diagram_button_classes(:icon),
72
+ "x-on:click": "copyToClipboard('#{target_id}')",
73
+ title: "Copy to clipboard"
74
+ ) do
75
+ copy_icon_svg
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ # Generate copy icon SVG
82
+ def copy_icon_svg
83
+ content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
84
+ tag.path(
85
+ stroke_linecap: "round",
86
+ stroke_linejoin: "round",
87
+ stroke_width: "2",
88
+ d: "M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2" \
89
+ "M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
90
+ )
91
+ end
92
+ end
93
+
94
+ # Generate toggle view button
95
+ def toggle_view_button
96
+ button_tag(
97
+ type: "button",
98
+ class: diagram_button_classes(:toggle),
99
+ "x-on:click": "toggleViewMode()",
100
+ title: "Toggle between code and preview"
101
+ ) do
102
+ concat(content_tag(:svg, class: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
103
+ tag.path(stroke_linecap: "round", stroke_linejoin: "round", stroke_width: "2",
104
+ d: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4")
105
+ end)
106
+ concat(content_tag(:span, "x-text": "viewMode === 'preview' ? 'View Code' : 'View Preview'"))
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module FormattingHelper
5
+ # Truncate cell values for display in compact view
6
+ def truncate_cell_value(value, max_length = 50)
7
+ return "" if value.nil?
8
+
9
+ formatted_value = format_cell_value_simple(value)
10
+
11
+ if formatted_value.length > max_length
12
+ "#{formatted_value[0...max_length]}..."
13
+ else
14
+ formatted_value
15
+ end
16
+ end
17
+
18
+ # Format cell values for display
19
+ def format_cell_value(value)
20
+ return "" if value.nil?
21
+
22
+ case value
23
+ when String
24
+ format_string_value(value)
25
+ when Hash, Array
26
+ JSON.pretty_generate(value)
27
+ when Time, DateTime
28
+ format_datetime_value(value)
29
+ when Date
30
+ value.strftime("%Y-%m-%d")
31
+ else
32
+ value.to_s
33
+ end
34
+ end
35
+
36
+ # Simple formatting for truncated display
37
+ def format_cell_value_simple(value)
38
+ return "" if value.nil?
39
+
40
+ case value
41
+ when String
42
+ format_string_value_simple(value)
43
+ when Hash
44
+ format_hash_simple(value)
45
+ when Array
46
+ format_array_simple(value)
47
+ when Time, DateTime
48
+ format_datetime_value(value)
49
+ when Date
50
+ value.strftime("%Y-%m-%d")
51
+ else
52
+ value.to_s
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ # Format string values with JSON detection
59
+ def format_string_value(value)
60
+ return value unless json?(value)
61
+
62
+ begin
63
+ JSON.pretty_generate(JSON.parse(value))
64
+ rescue JSON::ParserError
65
+ value
66
+ end
67
+ end
68
+
69
+ def format_string_value_simple(value)
70
+ return value unless json?(value)
71
+
72
+ begin
73
+ parsed = JSON.parse(value)
74
+ format_parsed_json_simple(parsed)
75
+ rescue JSON::ParserError
76
+ value
77
+ end
78
+ end
79
+
80
+ def format_parsed_json_simple(parsed)
81
+ if parsed.is_a?(Array)
82
+ format_array_simple(parsed)
83
+ elsif parsed.is_a?(Hash)
84
+ format_hash_simple(parsed)
85
+ else
86
+ parsed.to_s
87
+ end
88
+ end
89
+
90
+ def format_array_simple(array)
91
+ "[#{array.length} items]"
92
+ end
93
+
94
+ def format_hash_simple(hash)
95
+ "{#{hash.keys.length} keys}"
96
+ end
97
+
98
+ def format_datetime_value(value)
99
+ value.strftime("%Y-%m-%d %H:%M:%S")
100
+ end
101
+
102
+ def json?(string)
103
+ return false unless string.is_a?(String)
104
+
105
+ string.strip.start_with?("{", "[")
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dbwatcher
4
+ module SessionHelper
5
+ # Get session change count with fallback
6
+ def session_change_count(session)
7
+ safe_value(session, :change_count, 0).to_i
8
+ end
9
+
10
+ # Determine if session is active
11
+ def session_active?(session)
12
+ safe_value(session, :ended_at).blank?
13
+ end
14
+
15
+ # Format session name for display
16
+ def display_session_name(name)
17
+ name.to_s.gsub(/^HTTP \w+ /, "")
18
+ end
19
+
20
+ # Generate session ID display (wider format)
21
+ def display_session_id(id)
22
+ return "N/A" unless id
23
+
24
+ # Show more characters of the session ID for better readability
25
+ "#{id[0..15]}..."
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,177 @@
1
+ <%# Dashboard Overview Page %>
2
+ <div class="h-full flex flex-col">
3
+ <!-- Compact Header -->
4
+ <div class="h-10 bg-navy-dark text-white flex items-center px-4">
5
+ <h1 class="text-sm font-medium">Dashboard</h1>
6
+ <span class="ml-auto text-xs text-blue-light">
7
+ <%= Time.current.strftime("%Y-%m-%d %H:%M:%S") %>
8
+ </span>
9
+ </div>
10
+
11
+ <!-- Tab Bar -->
12
+ <div class="tab-bar">
13
+ <div class="tab-item active">Overview</div>
14
+ <div class="tab-item">Statistics</div>
15
+ <div class="tab-item">Recent Activity</div>
16
+ </div>
17
+
18
+ <!-- Content Area -->
19
+ <div class="flex-1 overflow-auto p-4">
20
+ <!-- Compact Stats Grid -->
21
+ <div class="grid grid-cols-4 gap-3 mb-4">
22
+ <!-- Sessions Card -->
23
+ <%= render 'dbwatcher/shared/stats_card',
24
+ label: 'Active Sessions',
25
+ value: @recent_sessions&.count || 0,
26
+ description: 'Last 24 hours',
27
+ icon_html: stats_icon(:sessions) %>
28
+
29
+ <!-- Tables Card -->
30
+ <%= render 'dbwatcher/shared/stats_card',
31
+ label: 'Modified Tables',
32
+ value: @active_tables&.count || 0,
33
+ description: 'With changes',
34
+ icon_html: stats_icon(:tables) %>
35
+
36
+ <!-- Queries Card -->
37
+ <%= render 'dbwatcher/shared/stats_card',
38
+ label: 'SQL Queries',
39
+ value: @query_stats&.dig(:total) || 0,
40
+ description: 'Today',
41
+ icon_html: stats_icon(:queries) %>
42
+
43
+ <!-- Performance Card -->
44
+ <% slow_queries = @query_stats&.dig(:slow_queries) || 0 %>
45
+ <%= render 'dbwatcher/shared/stats_card',
46
+ label: 'Slow Queries',
47
+ value: slow_queries,
48
+ value_class: (slow_queries > 0 ? 'text-red-600' : 'text-navy-dark'),
49
+ description: '> 100ms',
50
+ icon_html: stats_icon(:performance) %>
51
+ </div>
52
+
53
+ <!-- Two Column Layout -->
54
+ <div class="grid grid-cols-2 gap-4">
55
+ <!-- Recent Sessions -->
56
+ <div class="border border-gray-300 rounded">
57
+ <div class="bg-gray-100 px-3 py-2 border-b border-gray-300">
58
+ <h3 class="text-xs font-medium text-gray-700">Recent Sessions</h3>
59
+ </div>
60
+ <div class="max-h-64 overflow-auto">
61
+ <% if @recent_sessions&.any? %>
62
+ <table class="compact-table w-full">
63
+ <thead>
64
+ <tr>
65
+ <th class="text-left">Session</th>
66
+ <th class="text-center">Changes</th>
67
+ <th class="text-right">Time</th>
68
+ </tr>
69
+ </thead>
70
+ <tbody>
71
+ <% @recent_sessions.each do |session| %>
72
+ <tr class="hover:bg-blue-50">
73
+ <td class="truncate max-w-xs" title="<%= session[:name] || session['name'] %>">
74
+ <% session_id = session[:id] || session['id'] %>
75
+ <% session_name = (session[:name] || session['name']).to_s.gsub(/^HTTP \w+ /, '') %>
76
+ <%= link_to session_name, session_path(session_id), class: "text-navy-dark hover:text-blue-medium" %>
77
+ </td>
78
+ <td class="text-center">
79
+ <span class="badge bg-gray-600 text-white">
80
+ <%= session[:change_count] || session['change_count'] || 0 %>
81
+ </span>
82
+ </td>
83
+ <td class="text-right text-gray-500">
84
+ <% started_at = session[:started_at] || session['started_at'] %>
85
+ <%= Time.parse(started_at).strftime("%H:%M:%S") rescue 'N/A' %>
86
+ </td>
87
+ </tr>
88
+ <% end %>
89
+ </tbody>
90
+ </table>
91
+ <% else %>
92
+ <div class="p-4 text-center text-gray-500 text-xs">No recent sessions</div>
93
+ <% end %>
94
+ </div>
95
+ </div>
96
+
97
+ <!-- Active Tables -->
98
+ <div class="border border-gray-300 rounded">
99
+ <div class="bg-gray-100 px-3 py-2 border-b border-gray-300">
100
+ <h3 class="text-xs font-medium text-gray-700">Most Active Tables</h3>
101
+ </div>
102
+ <div class="max-h-64 overflow-auto">
103
+ <% if @active_tables&.any? %>
104
+ <table class="compact-table w-full">
105
+ <thead>
106
+ <tr>
107
+ <th class="text-left">Table</th>
108
+ <th class="text-center">Operations</th>
109
+ <th class="text-right">Changes</th>
110
+ </tr>
111
+ </thead>
112
+ <tbody>
113
+ <% @active_tables.first(10).each do |table_name, count| %>
114
+ <tr class="hover:bg-blue-50">
115
+ <td class="font-medium text-navy-dark">
116
+ <%= link_to table_name, table_path(table_name), class: "text-navy-dark hover:text-blue-medium" %>
117
+ </td>
118
+ <td class="text-center">
119
+ <div class="flex gap-1 justify-center">
120
+ <span class="badge badge-insert" title="Inserts">I</span>
121
+ <span class="badge badge-update" title="Updates">U</span>
122
+ <span class="badge badge-delete" title="Deletes">D</span>
123
+ </div>
124
+ </td>
125
+ <td class="text-right">
126
+ <span class="text-sm font-medium"><%= count %></span>
127
+ </td>
128
+ </tr>
129
+ <% end %>
130
+ </tbody>
131
+ </table>
132
+ <% else %>
133
+ <div class="p-4 text-center text-gray-500 text-xs">No active tables</div>
134
+ <% end %>
135
+ </div>
136
+ </div>
137
+ </div>
138
+
139
+ <!-- Query Activity Section -->
140
+ <div class="mt-4 border border-gray-300 rounded">
141
+ <div class="bg-gray-100 px-3 py-2 border-b border-gray-300">
142
+ <h3 class="text-xs font-medium text-gray-700">Query Activity</h3>
143
+ </div>
144
+ <div class="p-4">
145
+ <% if @query_stats&.dig(:by_operation)&.any? %>
146
+ <div class="grid grid-cols-4 gap-4 text-center">
147
+ <% @query_stats[:by_operation].each do |operation, count| %>
148
+ <div class="border border-gray-200 rounded p-2">
149
+ <div class="text-lg font-bold text-navy-dark"><%= count %></div>
150
+ <div class="text-xs text-gray-500 uppercase"><%= operation %></div>
151
+ </div>
152
+ <% end %>
153
+ </div>
154
+ <% else %>
155
+ <div class="text-center text-gray-500 text-xs">No query activity</div>
156
+ <% end %>
157
+ </div>
158
+ </div>
159
+
160
+ <!-- Quick Actions -->
161
+ <div class="mt-4 border border-gray-300 rounded">
162
+ <div class="bg-gray-100 px-3 py-2 border-b border-gray-300">
163
+ <h3 class="text-xs font-medium text-gray-700">Quick Actions</h3>
164
+ </div>
165
+ <div class="p-3">
166
+ <div class="flex gap-2">
167
+ <%= link_to "View All Sessions", sessions_path,
168
+ class: "px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700" %>
169
+ <%= link_to "Browse Tables", tables_path,
170
+ class: "px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700" %>
171
+ <%= link_to "SQL Logs", queries_path,
172
+ class: "px-3 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700" %>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ </div>
@@ -0,0 +1,240 @@
1
+ <div class="h-full flex flex-col" x-data="queryLogs()">
2
+ <!-- Header with embedded stats -->
3
+ <div class="h-10 bg-navy-dark text-white flex items-center px-4">
4
+ <svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
5
+ <path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0l3 3a1 1 0 010 1.414l-3 3a1 1 0 01-1.414-1.414L7.586 10 5.293 7.707a1 1 0 010-1.414zM11 12a1 1 0 100 2h3a1 1 0 100-2h-3z" clip-rule="evenodd"/>
6
+ </svg>
7
+ <h1 class="text-sm font-medium">SQL Query Logs</h1>
8
+
9
+ <!-- Quick Stats -->
10
+ <div class="ml-auto flex items-center gap-4 text-xs">
11
+ <span x-text="`${filteredQueries.length} queries`"></span>
12
+ <span class="text-gold-light" x-text="`${slowQueries.length} slow`"></span>
13
+ </div>
14
+ </div>
15
+
16
+ <%= render 'dbwatcher/shared/tab_bar', tabs: [
17
+ { name: 'All Queries', active: true },
18
+ { name: 'Slow Queries', active: false },
19
+ { name: 'Recent', active: false }
20
+ ] %>
21
+
22
+ <!-- Compact Toolbar -->
23
+ <div class="bg-gray-100 border-b border-gray-300 px-3 py-1 flex items-center gap-3">
24
+ <!-- Filter Controls -->
25
+ <select x-model="filters.operation"
26
+ @change="applyFilters()"
27
+ class="compact-select">
28
+ <option value="">All Operations</option>
29
+ <option value="SELECT">SELECT</option>
30
+ <option value="INSERT">INSERT</option>
31
+ <option value="UPDATE">UPDATE</option>
32
+ <option value="DELETE">DELETE</option>
33
+ </select>
34
+
35
+ <input type="text"
36
+ x-model="filters.table"
37
+ @input="applyFilters()"
38
+ placeholder="Filter by table..."
39
+ class="compact-input flex-1 max-w-xs">
40
+
41
+ <input type="number"
42
+ x-model="filters.minDuration"
43
+ @input="applyFilters()"
44
+ placeholder="Min ms"
45
+ class="compact-input w-20">
46
+
47
+ <input type="date"
48
+ value="<%= @date %>"
49
+ @change="changeDate($event.target.value)"
50
+ class="compact-input">
51
+
52
+ <!-- Time Range Filters -->
53
+ <input type="time"
54
+ x-model="filters.startTime"
55
+ @input="applyFilters()"
56
+ placeholder="Start time"
57
+ title="Start time"
58
+ class="compact-input w-24">
59
+
60
+ <input type="time"
61
+ x-model="filters.endTime"
62
+ @input="applyFilters()"
63
+ placeholder="End time"
64
+ title="End time"
65
+ class="compact-input w-24">
66
+
67
+ <button @click="clearFilters()"
68
+ title="Clear all filters"
69
+ class="compact-button bg-gray-500 text-white hover:bg-gray-600">
70
+ Clear
71
+ </button>
72
+
73
+ <div class="ml-auto flex items-center gap-2">
74
+ <button @click="exportQueries()"
75
+ class="compact-button bg-white border border-gray-300 hover:bg-gray-50">
76
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
77
+ <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
78
+ </svg>
79
+ Export
80
+ </button>
81
+
82
+ <button @click="refreshData()"
83
+ class="compact-button bg-blue-medium text-white hover:bg-blue-700">
84
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
85
+ <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
86
+ </svg>
87
+ Refresh
88
+ </button>
89
+
90
+ <%= button_to clear_queries_path,
91
+ method: :delete,
92
+ class: "compact-button bg-red-600 text-white hover:bg-red-700",
93
+ data: {
94
+ confirm: "Are you sure you want to clear all SQL query logs? This action cannot be undone."
95
+ } do %>
96
+ <svg class="w-3 h-3 inline mr-1" fill="currentColor" viewBox="0 0 20 20">
97
+ <path fill-rule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"/>
98
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V7a1 1 0 00-1-1z"/>
99
+ </svg>
100
+ Clear Logs
101
+ <% end %>
102
+ </div>
103
+ </div>
104
+
105
+ <!-- Content Area -->
106
+ <div class="flex-1 overflow-auto">
107
+ <div class="h-full">
108
+ <table class="compact-table w-full">
109
+ <thead>
110
+ <tr>
111
+ <th class="text-left w-20">Time</th>
112
+ <th class="text-center w-16">Op</th>
113
+ <th class="text-left w-32">Tables</th>
114
+ <th class="text-right w-16">Duration</th>
115
+ <th class="text-left">SQL</th>
116
+ </tr>
117
+ </thead>
118
+ <tbody>
119
+ <template x-for="query in filteredQueries" :key="query.id">
120
+ <tr class="cursor-pointer hover:bg-blue-50"
121
+ @click="selectQuery(query)"
122
+ :class="{ 'selected': selectedQuery?.id === query.id }">
123
+ <td class="text-xs text-gray-600" x-text="formatTime(query.timestamp)"></td>
124
+ <td class="text-center">
125
+ <span class="badge"
126
+ :class="`badge-${query.operation.toLowerCase()}`"
127
+ x-text="query.operation.charAt(0)"></span>
128
+ </td>
129
+ <td class="font-medium text-navy-dark truncate"
130
+ :title="query.tables.join(', ')"
131
+ x-text="query.tables.join(', ')"></td>
132
+ <td class="text-right text-xs"
133
+ :class="query.duration > 100 ? 'text-red-600 font-medium' : 'text-gray-600'"
134
+ x-text="`${query.duration}ms`"></td>
135
+ <td class="font-mono text-xs truncate max-w-md"
136
+ :title="query.sql"
137
+ x-text="query.sql"></td>
138
+ </tr>
139
+ </template>
140
+ </tbody>
141
+ </table>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ <script>
146
+ function queryLogs() {
147
+ return {
148
+ filters: {
149
+ operation: '',
150
+ table: '',
151
+ minDuration: '',
152
+ startTime: '',
153
+ endTime: ''
154
+ },
155
+ selectedQuery: null,
156
+ queries: <%= @queries.to_json.html_safe %>,
157
+
158
+ get filteredQueries() {
159
+ return this.queries.filter(query => {
160
+ if (this.filters.operation && query.operation !== this.filters.operation) return false;
161
+ if (this.filters.table && !query.tables.some(t => t.toLowerCase().includes(this.filters.table.toLowerCase()))) return false;
162
+ if (this.filters.minDuration && query.duration < parseFloat(this.filters.minDuration)) return false;
163
+
164
+ // Time filtering
165
+ if (this.filters.startTime || this.filters.endTime) {
166
+ const queryTime = new Date(query.timestamp);
167
+ const queryTimeStr = queryTime.toTimeString().substr(0, 5); // HH:MM format
168
+
169
+ if (this.filters.startTime && queryTimeStr < this.filters.startTime) return false;
170
+ if (this.filters.endTime && queryTimeStr > this.filters.endTime) return false;
171
+ }
172
+
173
+ return true;
174
+ });
175
+ },
176
+
177
+ get slowQueries() {
178
+ return this.queries.filter(q => q.duration > 100);
179
+ },
180
+
181
+ selectQuery(query) {
182
+ this.selectedQuery = this.selectedQuery?.id === query.id ? null : query;
183
+ },
184
+
185
+ formatTime(timestamp) {
186
+ return new Date(timestamp).toLocaleTimeString('en-US', {
187
+ hour12: false,
188
+ hour: '2-digit',
189
+ minute: '2-digit',
190
+ second: '2-digit'
191
+ });
192
+ },
193
+
194
+ applyFilters() {
195
+ // Filters are applied via computed property
196
+ },
197
+
198
+ exportQueries() {
199
+ const data = this.filteredQueries.map(q => ({
200
+ time: this.formatTime(q.timestamp),
201
+ operation: q.operation,
202
+ tables: q.tables.join(', '),
203
+ duration: q.duration,
204
+ sql: q.sql
205
+ }));
206
+
207
+ const csv = [
208
+ ['Time', 'Operation', 'Tables', 'Duration (ms)', 'SQL'],
209
+ ...data.map(row => [row.time, row.operation, row.tables, row.duration, row.sql])
210
+ ].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
211
+
212
+ const blob = new Blob([csv], { type: 'text/csv' });
213
+ const url = URL.createObjectURL(blob);
214
+ const a = document.createElement('a');
215
+ a.href = url;
216
+ a.download = `queries-${new Date().toISOString().split('T')[0]}.csv`;
217
+ a.click();
218
+ URL.revokeObjectURL(url);
219
+ },
220
+
221
+ refreshData() {
222
+ window.location.reload();
223
+ },
224
+
225
+ changeDate(date) {
226
+ const params = new URLSearchParams(window.location.search);
227
+ params.set('date', date);
228
+ window.location.search = params.toString();
229
+ },
230
+
231
+ clearFilters() {
232
+ this.filters.operation = '';
233
+ this.filters.table = '';
234
+ this.filters.minDuration = '';
235
+ this.filters.startTime = '';
236
+ this.filters.endTime = '';
237
+ }
238
+ }
239
+ }
240
+ </script>