tina4ruby 3.11.13 → 3.11.15

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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -80
  3. data/LICENSE.txt +21 -21
  4. data/README.md +137 -137
  5. data/exe/tina4ruby +5 -5
  6. data/lib/tina4/ai.rb +696 -696
  7. data/lib/tina4/api.rb +189 -189
  8. data/lib/tina4/auth.rb +305 -305
  9. data/lib/tina4/auto_crud.rb +244 -244
  10. data/lib/tina4/cache.rb +154 -154
  11. data/lib/tina4/cli.rb +1449 -1449
  12. data/lib/tina4/constants.rb +46 -46
  13. data/lib/tina4/container.rb +74 -74
  14. data/lib/tina4/cors.rb +74 -74
  15. data/lib/tina4/crud.rb +692 -692
  16. data/lib/tina4/database/sqlite3_adapter.rb +165 -165
  17. data/lib/tina4/database.rb +625 -625
  18. data/lib/tina4/database_result.rb +208 -208
  19. data/lib/tina4/debug.rb +8 -8
  20. data/lib/tina4/dev.rb +14 -14
  21. data/lib/tina4/dev_admin.rb +935 -935
  22. data/lib/tina4/dev_mailbox.rb +191 -191
  23. data/lib/tina4/drivers/firebird_driver.rb +124 -110
  24. data/lib/tina4/drivers/mongodb_driver.rb +561 -561
  25. data/lib/tina4/drivers/mssql_driver.rb +112 -112
  26. data/lib/tina4/drivers/mysql_driver.rb +90 -90
  27. data/lib/tina4/drivers/odbc_driver.rb +191 -191
  28. data/lib/tina4/drivers/postgres_driver.rb +116 -106
  29. data/lib/tina4/drivers/sqlite_driver.rb +122 -122
  30. data/lib/tina4/env.rb +95 -95
  31. data/lib/tina4/error_overlay.rb +252 -252
  32. data/lib/tina4/events.rb +109 -109
  33. data/lib/tina4/field_types.rb +154 -154
  34. data/lib/tina4/frond.rb +2025 -2025
  35. data/lib/tina4/gallery/auth/meta.json +1 -1
  36. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
  37. data/lib/tina4/gallery/database/meta.json +1 -1
  38. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
  39. data/lib/tina4/gallery/error-overlay/meta.json +1 -1
  40. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
  41. data/lib/tina4/gallery/orm/meta.json +1 -1
  42. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
  43. data/lib/tina4/gallery/queue/meta.json +1 -1
  44. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
  45. data/lib/tina4/gallery/rest-api/meta.json +1 -1
  46. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
  47. data/lib/tina4/gallery/templates/meta.json +1 -1
  48. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
  49. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
  50. data/lib/tina4/graphql.rb +966 -966
  51. data/lib/tina4/health.rb +39 -39
  52. data/lib/tina4/html_element.rb +170 -170
  53. data/lib/tina4/job.rb +80 -80
  54. data/lib/tina4/localization.rb +168 -168
  55. data/lib/tina4/log.rb +203 -203
  56. data/lib/tina4/mcp.rb +696 -696
  57. data/lib/tina4/messenger.rb +587 -587
  58. data/lib/tina4/metrics.rb +793 -793
  59. data/lib/tina4/middleware.rb +445 -445
  60. data/lib/tina4/migration.rb +451 -451
  61. data/lib/tina4/orm.rb +790 -790
  62. data/lib/tina4/public/css/tina4.css +2463 -2463
  63. data/lib/tina4/public/css/tina4.min.css +1 -1
  64. data/lib/tina4/public/images/logo.svg +5 -5
  65. data/lib/tina4/public/js/frond.min.js +2 -2
  66. data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
  67. data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
  68. data/lib/tina4/public/js/tina4.min.js +92 -92
  69. data/lib/tina4/public/js/tina4js.min.js +48 -48
  70. data/lib/tina4/public/swagger/index.html +90 -90
  71. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
  72. data/lib/tina4/query_builder.rb +380 -380
  73. data/lib/tina4/queue.rb +366 -366
  74. data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
  75. data/lib/tina4/queue_backends/lite_backend.rb +298 -298
  76. data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
  77. data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
  78. data/lib/tina4/rack_app.rb +817 -817
  79. data/lib/tina4/rate_limiter.rb +130 -130
  80. data/lib/tina4/request.rb +268 -255
  81. data/lib/tina4/response.rb +346 -346
  82. data/lib/tina4/response_cache.rb +551 -551
  83. data/lib/tina4/router.rb +406 -406
  84. data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
  85. data/lib/tina4/scss/tina4css/_badges.scss +22 -22
  86. data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
  87. data/lib/tina4/scss/tina4css/_cards.scss +49 -49
  88. data/lib/tina4/scss/tina4css/_forms.scss +156 -156
  89. data/lib/tina4/scss/tina4css/_grid.scss +81 -81
  90. data/lib/tina4/scss/tina4css/_modals.scss +84 -84
  91. data/lib/tina4/scss/tina4css/_nav.scss +149 -149
  92. data/lib/tina4/scss/tina4css/_reset.scss +94 -94
  93. data/lib/tina4/scss/tina4css/_tables.scss +54 -54
  94. data/lib/tina4/scss/tina4css/_typography.scss +55 -55
  95. data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
  96. data/lib/tina4/scss/tina4css/_variables.scss +117 -117
  97. data/lib/tina4/scss/tina4css/base.scss +1 -1
  98. data/lib/tina4/scss/tina4css/colors.scss +48 -48
  99. data/lib/tina4/scss/tina4css/tina4.scss +17 -17
  100. data/lib/tina4/scss_compiler.rb +178 -178
  101. data/lib/tina4/seeder.rb +567 -567
  102. data/lib/tina4/service_runner.rb +303 -303
  103. data/lib/tina4/session.rb +297 -297
  104. data/lib/tina4/session_handlers/database_handler.rb +72 -72
  105. data/lib/tina4/session_handlers/file_handler.rb +67 -67
  106. data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
  107. data/lib/tina4/session_handlers/redis_handler.rb +43 -43
  108. data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
  109. data/lib/tina4/shutdown.rb +84 -84
  110. data/lib/tina4/sql_translation.rb +158 -158
  111. data/lib/tina4/swagger.rb +124 -124
  112. data/lib/tina4/template.rb +894 -894
  113. data/lib/tina4/templates/base.twig +26 -26
  114. data/lib/tina4/templates/errors/302.twig +14 -14
  115. data/lib/tina4/templates/errors/401.twig +9 -9
  116. data/lib/tina4/templates/errors/403.twig +29 -29
  117. data/lib/tina4/templates/errors/404.twig +29 -29
  118. data/lib/tina4/templates/errors/500.twig +38 -38
  119. data/lib/tina4/templates/errors/502.twig +9 -9
  120. data/lib/tina4/templates/errors/503.twig +12 -12
  121. data/lib/tina4/templates/errors/base.twig +37 -37
  122. data/lib/tina4/test_client.rb +159 -159
  123. data/lib/tina4/testing.rb +340 -340
  124. data/lib/tina4/validator.rb +174 -174
  125. data/lib/tina4/version.rb +1 -1
  126. data/lib/tina4/webserver.rb +312 -312
  127. data/lib/tina4/websocket.rb +343 -343
  128. data/lib/tina4/websocket_backplane.rb +190 -190
  129. data/lib/tina4/wsdl.rb +564 -564
  130. data/lib/tina4.rb +458 -458
  131. data/lib/tina4ruby.rb +4 -4
  132. metadata +3 -3
@@ -1,935 +1,935 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "digest"
5
- require "tmpdir"
6
- require "net/http"
7
- require "uri"
8
- require_relative "metrics"
9
-
10
- module Tina4
11
- # Thread-safe in-memory message log for dev dashboard
12
- class MessageLog
13
- Entry = Struct.new(:timestamp, :category, :level, :message, keyword_init: true)
14
-
15
- def initialize
16
- @entries = []
17
- @mutex = Mutex.new
18
- end
19
-
20
- def log(category, level, message)
21
- @mutex.synchronize do
22
- @entries << Entry.new(
23
- timestamp: Time.now.utc.iso8601(3),
24
- category: category.to_s,
25
- level: level.to_s.upcase,
26
- message: message.to_s
27
- )
28
- # Keep last 500 entries
29
- @entries.shift if @entries.size > 500
30
- end
31
- end
32
-
33
- def get(category: nil)
34
- @mutex.synchronize do
35
- list = category ? @entries.select { |e| e.category == category.to_s } : @entries.dup
36
- list.reverse.map { |e| { timestamp: e.timestamp, category: e.category, level: e.level, message: e.message } }
37
- end
38
- end
39
-
40
- def clear(category: nil)
41
- @mutex.synchronize do
42
- if category
43
- @entries.reject! { |e| e.category == category.to_s }
44
- else
45
- @entries.clear
46
- end
47
- end
48
- end
49
-
50
- def count
51
- @mutex.synchronize do
52
- counts = Hash.new(0)
53
- @entries.each { |e| counts[e.category] += 1 }
54
- counts["total"] = @entries.size
55
- counts
56
- end
57
- end
58
- end
59
-
60
- # Thread-safe request capture for dev dashboard
61
- class RequestInspector
62
- CapturedRequest = Struct.new(:timestamp, :method, :path, :status, :duration, keyword_init: true)
63
-
64
- def initialize
65
- @requests = []
66
- @mutex = Mutex.new
67
- end
68
-
69
- def capture(method:, path:, status:, duration:)
70
- @mutex.synchronize do
71
- @requests << CapturedRequest.new(
72
- timestamp: Time.now.utc.iso8601(3),
73
- method: method.to_s,
74
- path: path.to_s,
75
- status: status.to_i,
76
- duration: duration.to_f.round(3)
77
- )
78
- # Keep last 200 entries
79
- @requests.shift if @requests.size > 200
80
- end
81
- end
82
-
83
- def get(limit: 50)
84
- @mutex.synchronize do
85
- @requests.last([limit, @requests.size].min).reverse.map do |r|
86
- { timestamp: r.timestamp, method: r.method, path: r.path, status: r.status, duration_ms: r.duration }
87
- end
88
- end
89
- end
90
-
91
- def stats
92
- @mutex.synchronize do
93
- return { total: 0, avg_ms: 0.0, errors: 0, slowest_ms: 0.0 } if @requests.empty?
94
-
95
- durations = @requests.map(&:duration)
96
- error_count = @requests.count { |r| r.status >= 400 }
97
-
98
- {
99
- total: @requests.size,
100
- avg_ms: (durations.sum / durations.size).round(2),
101
- errors: error_count,
102
- slowest_ms: durations.max.round(2)
103
- }
104
- end
105
- end
106
-
107
- def clear
108
- @mutex.synchronize { @requests.clear }
109
- end
110
- end
111
-
112
- # Thread-safe, file-persisted error tracker for the dev dashboard Error Tracker panel.
113
- #
114
- # Errors are stored in a JSON file in the system temp directory keyed by
115
- # project path, so they survive across requests and server restarts.
116
- # Duplicate errors (same type + message + file + line) are de-duplicated —
117
- # the count increments and the entry is re-opened if it was resolved.
118
- class ErrorTracker
119
- MAX_ERRORS = 200
120
- private_constant :MAX_ERRORS
121
-
122
- def initialize
123
- @mutex = Mutex.new
124
- @errors = nil # lazy-loaded
125
- @registered = false
126
- @store_path = File.join(
127
- Dir.tmpdir,
128
- "tina4_dev_errors_#{Digest::MD5.hexdigest(Dir.pwd)}.json"
129
- )
130
- end
131
-
132
- # Capture a Ruby error / exception into the tracker.
133
- # @param error_type [String] e.g. "RuntimeError" or "NoMethodError"
134
- # @param message [String] exception message
135
- # @param traceback [String] formatted backtrace (optional)
136
- # @param file [String] source file (optional)
137
- # @param line [Integer] source line (optional)
138
- def capture(error_type:, message:, traceback: "", file: "", line: 0)
139
- @mutex.synchronize do
140
- load_unlocked
141
- fingerprint = Digest::MD5.hexdigest("#{error_type}|#{message}|#{file}|#{line}")
142
- now = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
143
-
144
- if @errors.key?(fingerprint)
145
- @errors[fingerprint][:count] += 1
146
- @errors[fingerprint][:last_seen] = now
147
- @errors[fingerprint][:resolved] = false # re-open resolved duplicates
148
- else
149
- @errors[fingerprint] = {
150
- id: fingerprint,
151
- error_type: error_type,
152
- message: message,
153
- traceback: traceback,
154
- file: file,
155
- line: line,
156
- first_seen: now,
157
- last_seen: now,
158
- count: 1,
159
- resolved: false
160
- }
161
- end
162
- save_unlocked
163
- end
164
- end
165
-
166
- # Capture a Ruby exception object directly.
167
- def capture_exception(exc)
168
- capture(
169
- error_type: exc.class.name,
170
- message: exc.message,
171
- traceback: (exc.backtrace || []).first(20).join("\n"),
172
- file: (exc.backtrace_locations&.first&.path || ""),
173
- line: (exc.backtrace_locations&.first&.lineno || 0)
174
- )
175
- end
176
-
177
- # Return all errors (newest first).
178
- # @param include_resolved [Boolean]
179
- def get(include_resolved: true)
180
- @mutex.synchronize do
181
- load_unlocked
182
- entries = @errors.values
183
- entries = entries.reject { |e| e[:resolved] } unless include_resolved
184
- entries.sort_by { |e| e[:last_seen] }.reverse
185
- end
186
- end
187
-
188
- # Count of unresolved errors.
189
- def unresolved_count
190
- @mutex.synchronize do
191
- load_unlocked
192
- @errors.count { |_, e| !e[:resolved] }
193
- end
194
- end
195
-
196
- # Health summary (matches Python BrokenTracker interface).
197
- def health
198
- @mutex.synchronize do
199
- load_unlocked
200
- total = @errors.size
201
- resolved = @errors.count { |_, e| e[:resolved] }
202
- unresolved = total - resolved
203
- { total: total, unresolved: unresolved, resolved: resolved, healthy: unresolved.zero? }
204
- end
205
- end
206
-
207
- # Mark a single error as resolved.
208
- def resolve(id)
209
- @mutex.synchronize do
210
- load_unlocked
211
- return false unless @errors.key?(id)
212
-
213
- @errors[id][:resolved] = true
214
- save_unlocked
215
- true
216
- end
217
- end
218
-
219
- # Remove all resolved errors.
220
- def clear_resolved
221
- @mutex.synchronize do
222
- load_unlocked
223
- @errors.reject! { |_, e| e[:resolved] }
224
- save_unlocked
225
- end
226
- end
227
-
228
- # Remove ALL errors.
229
- def clear_all
230
- @mutex.synchronize do
231
- @errors = {}
232
- save_unlocked
233
- end
234
- end
235
-
236
- # Register Ruby error handlers to feed the tracker.
237
- # Installs an at_exit hook that captures unhandled exceptions.
238
- # Safe to call multiple times — only registers once.
239
- def register
240
- return if @registered
241
-
242
- @registered = true
243
- tracker = self
244
- at_exit do
245
- if (exc = $!) && !exc.is_a?(SystemExit)
246
- tracker.capture_exception(exc)
247
- end
248
- end
249
- end
250
-
251
- # Reset (for testing).
252
- def reset!
253
- @mutex.synchronize do
254
- @errors = {}
255
- @registered = false
256
- File.delete(@store_path) if File.exist?(@store_path)
257
- end
258
- end
259
-
260
- private
261
-
262
- def load_unlocked
263
- return if @errors
264
-
265
- if File.exist?(@store_path)
266
- raw = File.read(@store_path) rescue nil
267
- data = raw ? (JSON.parse(raw, symbolize_names: true) rescue nil) : nil
268
- if data.is_a?(Array)
269
- # Re-key by id
270
- @errors = {}
271
- data.each { |e| @errors[e[:id]] = e if e[:id] }
272
- else
273
- @errors = {}
274
- end
275
- else
276
- @errors = {}
277
- end
278
- end
279
-
280
- def save_unlocked
281
- # Trim to max, keeping newest last_seen
282
- if @errors.size > MAX_ERRORS
283
- sorted = @errors.values.sort_by { |e| e[:last_seen] }.last(MAX_ERRORS)
284
- @errors = {}
285
- sorted.each { |e| @errors[e[:id]] = e }
286
- end
287
-
288
- File.write(@store_path, JSON.generate(@errors.values))
289
- rescue StandardError
290
- # Best-effort persistence — never raise in a tracker
291
- end
292
- end
293
-
294
- # Developer dashboard module - only active in debug mode
295
- module DevAdmin
296
- class << self
297
- def message_log
298
- @message_log ||= MessageLog.new
299
- end
300
-
301
- def request_inspector
302
- @request_inspector ||= RequestInspector.new
303
- end
304
-
305
- def mailbox
306
- @mailbox ||= DevMailbox.new
307
- end
308
-
309
- def error_tracker
310
- @error_tracker ||= ErrorTracker.new
311
- end
312
-
313
- def enabled?
314
- Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
315
- end
316
-
317
- # Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
318
- def handle_request(env)
319
- return nil unless enabled?
320
-
321
- path = env["PATH_INFO"] || "/"
322
- method = env["REQUEST_METHOD"]
323
-
324
- case [method, path]
325
- when ["GET", "/__dev"], ["GET", "/__dev/"]
326
- serve_dashboard
327
- when ["GET", "/__dev/js/tina4-dev-admin.min.js"]
328
- serve_dev_js
329
- when ["GET", "/__dev/api/mtime"]
330
- json_response({ mtime: @reload_mtime || 0, file: @reload_file || "" })
331
- when ["POST", "/__dev/api/reload"]
332
- body = read_json_body(env) || {}
333
- @reload_mtime = Time.now.to_i
334
- @reload_file = body["file"] || ""
335
- reload_type = body["type"] || "reload"
336
- Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
337
- json_response({ ok: true, type: reload_type })
338
- when ["GET", "/__dev/api/status"]
339
- json_response(status_payload)
340
- when ["GET", "/__dev/api/routes"]
341
- json_response(routes_payload)
342
- when ["GET", "/__dev/api/messages"]
343
- category = query_param(env, "category")
344
- messages = message_log.get(category: category)
345
- counts = message_log.count
346
- json_response({ messages: messages, counts: counts })
347
- when ["POST", "/__dev/api/messages/clear"]
348
- body = read_json_body(env)
349
- category = body["category"] if body
350
- message_log.clear(category: category)
351
- json_response({ cleared: true })
352
- when ["GET", "/__dev/api/requests"]
353
- limit = (query_param(env, "limit") || 50).to_i
354
- json_response({ requests: request_inspector.get(limit: limit), stats: request_inspector.stats })
355
- when ["POST", "/__dev/api/requests/clear"]
356
- request_inspector.clear
357
- json_response({ cleared: true })
358
- when ["GET", "/__dev/api/system"]
359
- json_response(system_payload)
360
- when ["GET", "/__dev/api/queue/topics"]
361
- queue_dir = File.join(Dir.pwd, "data", "queue")
362
- topics = Dir.exist?(queue_dir) ? Dir.children(queue_dir).select { |d| File.directory?(File.join(queue_dir, d)) }.sort : []
363
- topics = ["default"] if topics.empty?
364
- json_response({ topics: topics })
365
- when ["GET", "/__dev/api/queue/dead-letters"]
366
- topic = query_param(env, "topic") || "default"
367
- jobs = []
368
- begin
369
- queue = Tina4::Queue.new(backend: :file, topic: topic) if defined?(Tina4::Queue)
370
- jobs = queue.respond_to?(:dead_letters) ? queue.dead_letters.map { |j| j.merge(status: "dead_letter") } : []
371
- rescue StandardError => e
372
- jobs = []
373
- end
374
- json_response({ jobs: jobs, count: jobs.size, topic: topic })
375
- when ["GET", "/__dev/api/queue"]
376
- topic = query_param(env, "topic") || "default"
377
- stats = { pending: 0, completed: 0, failed: 0, reserved: 0 }
378
- jobs = []
379
- begin
380
- if defined?(Tina4::Queue)
381
- queue = Tina4::Queue.new(backend: :file, topic: topic)
382
- stats = {
383
- pending: queue.respond_to?(:size) ? queue.size("pending") : 0,
384
- completed: queue.respond_to?(:size) ? queue.size("completed") : 0,
385
- failed: queue.respond_to?(:size) ? queue.size("failed") : 0,
386
- reserved: queue.respond_to?(:size) ? queue.size("reserved") : 0,
387
- }
388
- jobs.concat(queue.failed.map { |j| j.merge(status: "failed") }) if queue.respond_to?(:failed)
389
- jobs.concat(queue.dead_letters.map { |j| j.merge(status: "dead_letter") }) if queue.respond_to?(:dead_letters)
390
- end
391
- rescue StandardError => e
392
- # fall through to empty stats
393
- end
394
- json_response({ jobs: jobs, stats: stats })
395
- when ["GET", "/__dev/api/mailbox"]
396
- messages = mailbox.inbox
397
- json_response({ messages: messages, count: messages.size, unread: mailbox.unread_count })
398
- when ["GET", "/__dev/api/broken"]
399
- errors = error_tracker.get(include_resolved: true)
400
- h = error_tracker.health
401
- json_response({ errors: errors, count: errors.size, health: h })
402
- when ["POST", "/__dev/api/broken/resolve"]
403
- body = read_json_body(env)
404
- id = body && body["id"]
405
- resolved = id ? error_tracker.resolve(id) : false
406
- json_response({ resolved: resolved, id: id })
407
- when ["POST", "/__dev/api/broken/clear"]
408
- error_tracker.clear_resolved
409
- json_response({ cleared: true })
410
- when ["GET", "/__dev/api/websockets"]
411
- json_response({ connections: [], count: 0 })
412
- when ["POST", "/__dev/api/websockets/disconnect"]
413
- body = read_json_body(env)
414
- # TODO: disconnect WS connection by id from body["id"]
415
- json_response({ disconnected: true })
416
- when ["GET", "/__dev/api/mailbox/read"]
417
- message_id = query_param(env, "id")
418
- message = mailbox.read(message_id)
419
- if message
420
- json_response(message)
421
- else
422
- body = JSON.generate({ error: "Message not found", id: message_id })
423
- [404, { "content-type" => "application/json; charset=utf-8" }, [body]]
424
- end
425
- when ["POST", "/__dev/api/mailbox/seed"]
426
- body = read_json_body(env)
427
- count = ((body && body["count"]) || 5).to_i
428
- mailbox.seed(count: count)
429
- json_response({ seeded: count })
430
- when ["POST", "/__dev/api/mailbox/clear"]
431
- mailbox.clear
432
- json_response({ cleared: true })
433
- when ["GET", "/__dev/api/messages/search"]
434
- keyword = query_param(env, "q") || query_param(env, "keyword") || ""
435
- all_messages = message_log.get
436
- filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
437
- json_response({ messages: filtered, count: filtered.size, keyword: keyword })
438
- when ["POST", "/__dev/api/queue/retry"]
439
- body = read_json_body(env)
440
- # TODO: retry failed jobs by id from body["id"]
441
- json_response({ retried: true })
442
- when ["POST", "/__dev/api/queue/purge"]
443
- # TODO: purge completed jobs
444
- json_response({ purged: true })
445
- when ["POST", "/__dev/api/queue/replay"]
446
- body = read_json_body(env)
447
- # TODO: replay a specific job by id from body["id"]
448
- json_response({ replayed: true })
449
- when ["GET", "/__dev/api/table"]
450
- table_name = query_param(env, "name")
451
- json_response(table_detail_payload(table_name))
452
- when ["POST", "/__dev/api/seed"]
453
- body = read_json_body(env)
454
- table_name = (body && body["table"]) || ""
455
- count = (body && body["count"]) || 10
456
- json_response(seed_table_data(table_name, count.to_i))
457
- when ["POST", "/__dev/api/tool"]
458
- body = read_json_body(env)
459
- tool = (body && body["tool"]) || ""
460
- json_response(run_tool(tool))
461
- when ["POST", "/__dev/api/chat"]
462
- body = read_json_body(env)
463
- message = (body && body["message"]) || ""
464
- json_response({
465
- reply: "Chat is not yet connected to an AI backend. You said: \"#{message}\"",
466
- timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
467
- })
468
- when ["GET", "/__dev/api/connections"]
469
- handle_connections_get
470
- when ["POST", "/__dev/api/connections/test"]
471
- body = read_json_body(env)
472
- handle_connections_test(body)
473
- when ["POST", "/__dev/api/connections/save"]
474
- body = read_json_body(env)
475
- handle_connections_save(body)
476
- when ["POST", "/__dev/api/query"]
477
- body = read_json_body(env)
478
- sql = (body && (body["query"] || body["sql"])) || ""
479
- json_response(run_query(sql))
480
- when ["GET", "/__dev/api/tables"]
481
- json_response(tables_payload)
482
- when ["GET", "/__dev/api/gallery"]
483
- json_response(gallery_list)
484
- when ["POST", "/__dev/api/gallery/deploy"]
485
- body = read_json_body(env)
486
- name = (body && body["name"]) || ""
487
- json_response(gallery_deploy(name))
488
- when ["GET", "/__dev/api/version-check"]
489
- json_response(version_check_payload)
490
- when ["GET", "/__dev/api/metrics"]
491
- json_response(Tina4::Metrics.quick_metrics)
492
- when ["GET", "/__dev/api/metrics/full"]
493
- json_response(Tina4::Metrics.full_analysis)
494
- when ["GET", "/__dev/api/metrics/file"]
495
- file_path = (query_param(env, "path") || "").to_s
496
- json_response(Tina4::Metrics.file_detail(file_path))
497
- when ["GET", "/__dev/api/graphql/schema"]
498
- begin
499
- gql = Tina4::GraphQL.new
500
- # Auto-discover and register all ORM subclasses
501
- ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM }.each do |model_class|
502
- gql.from_orm(model_class.new)
503
- end
504
- json_response({ schema: gql.introspect, sdl: gql.schema_sdl })
505
- rescue => e
506
- json_response({ error: e.message }, 400)
507
- end
508
- else
509
- nil
510
- end
511
- end
512
-
513
-
514
- private
515
-
516
- def query_param(env, key)
517
- qs = env["QUERY_STRING"] || ""
518
- params = URI.decode_www_form(qs).to_h rescue {}
519
- params[key]
520
- end
521
-
522
- def read_json_body(env)
523
- input = env["rack.input"]
524
- return nil unless input
525
- input.rewind if input.respond_to?(:rewind)
526
- raw = input.read
527
- return nil if raw.nil? || raw.empty?
528
- JSON.parse(raw) rescue nil
529
- end
530
-
531
- def json_response(data)
532
- body = JSON.generate(data)
533
- [200, { "content-type" => "application/json; charset=utf-8" }, [body]]
534
- end
535
-
536
- def version_check_payload
537
- current = Tina4::VERSION
538
- latest = current
539
- begin
540
- uri = URI.parse("https://rubygems.org/api/v1/versions/tina4ruby/latest.json")
541
- http = Net::HTTP.new(uri.host, uri.port)
542
- http.use_ssl = true
543
- http.open_timeout = 5
544
- http.read_timeout = 5
545
- req = Net::HTTP::Get.new(uri)
546
- resp = http.request(req)
547
- if resp.is_a?(Net::HTTPSuccess)
548
- data = JSON.parse(resp.body)
549
- latest = data["version"] || current
550
- end
551
- rescue StandardError
552
- # Offline or timeout — return current as latest
553
- end
554
- { current: current, latest: latest }
555
- end
556
-
557
- def serve_dashboard
558
- spa = '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev Admin</title></head><body><div id="app" data-framework="ruby" data-color="#ef4444"></div><script src="/__dev/js/tina4-dev-admin.min.js"></script></body></html>'
559
- [200, { "content-type" => "text/html; charset=utf-8" }, [spa]]
560
- end
561
-
562
- def serve_dev_js
563
- js_path = File.join(File.dirname(__FILE__), "public", "js", "tina4-dev-admin.min.js")
564
- if File.file?(js_path)
565
- [200, { "content-type" => "application/javascript; charset=utf-8" }, [File.read(js_path)]]
566
- else
567
- [404, { "content-type" => "text/plain" }, ["tina4-dev-admin.min.js not found"]]
568
- end
569
- end
570
-
571
- def status_payload
572
- db_table_count = 0
573
- begin
574
- db = Tina4.database
575
- db_table_count = db.tables.size if db
576
- rescue
577
- # ignore
578
- end
579
-
580
- {
581
- framework: "tina4-ruby",
582
- version: Tina4::VERSION,
583
- ruby_version: RUBY_VERSION,
584
- platform: RUBY_PLATFORM,
585
- debug: ENV["TINA4_DEBUG"] || "false",
586
- log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
587
- database: ENV["DATABASE_URL"] || "not configured",
588
- db_tables: db_table_count,
589
- uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
590
- route_count: Tina4::Router.routes.size,
591
- request_stats: request_inspector.stats,
592
- message_counts: message_log.count,
593
- health: error_tracker.health
594
- }
595
- end
596
-
597
- def routes_payload
598
- internal_prefixes = ["/__dev", "/health", "/swagger"]
599
- routes = Tina4::Router.routes
600
- .reject { |route| internal_prefixes.any? { |prefix| route.path.start_with?(prefix) } }
601
- .map do |route|
602
- handler_name = ""
603
- mod = ""
604
- if route.handler.is_a?(Proc)
605
- source = route.handler.source_location
606
- if source
607
- handler_name = "#{File.basename(source[0])}:#{source[1]}"
608
- mod = File.dirname(source[0])
609
- end
610
- end
611
- {
612
- method: route.method,
613
- pattern: route.path,
614
- path: route.path,
615
- middleware: route.respond_to?(:middleware_count) ? route.middleware_count : 0,
616
- cache: route.respond_to?(:cached?) ? route.cached? : false,
617
- secure: !route.auth_handler.nil?,
618
- auth_required: !route.auth_handler.nil?,
619
- handler: handler_name,
620
- module: mod
621
- }
622
- end
623
- { routes: routes, count: routes.size }
624
- end
625
-
626
- def system_payload
627
- gc = GC.stat
628
- mem = begin
629
- if RUBY_PLATFORM.include?("darwin")
630
- `ps -o rss= -p #{Process.pid}`.strip.to_i # KB
631
- elsif RUBY_PLATFORM.include?("linux")
632
- (File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i rescue 0)
633
- else
634
- 0
635
- end
636
- end
637
-
638
- os_release = (`uname -r`.strip rescue "unknown")
639
- host_name = (`hostname`.strip rescue "unknown")
640
-
641
- {
642
- ruby_version: RUBY_VERSION,
643
- ruby_engine: RUBY_ENGINE,
644
- os: "#{RUBY_PLATFORM} #{os_release}",
645
- architecture: RUBY_PLATFORM,
646
- memory: {
647
- current_mb: (mem / 1024.0).round(1),
648
- peak_mb: "N/A",
649
- limit: "N/A"
650
- },
651
- server: {
652
- software: "Ruby/WEBrick",
653
- hostname: host_name,
654
- document_root: Tina4.root_dir || Dir.pwd
655
- },
656
- framework: {
657
- name: "tina4-ruby",
658
- version: Tina4::VERSION,
659
- route_count: Tina4::Router.routes.size
660
- },
661
- extensions: $LOADED_FEATURES.map { |f| File.basename(f, ".rb") }.uniq.sort.first(50),
662
- gc: {
663
- count: gc[:count],
664
- heap_allocated_pages: gc[:heap_allocated_pages],
665
- heap_live_slots: gc[:heap_live_slots],
666
- total_allocated_objects: gc[:total_allocated_objects],
667
- total_freed_objects: gc[:total_freed_objects]
668
- },
669
- pid: Process.pid,
670
- thread_count: Thread.list.size,
671
- env: ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development",
672
- db_tables: (begin; db = Tina4.database; db ? db.tables.size : 0; rescue; 0; end),
673
- db_connected: (begin; db = Tina4.database; !db.nil?; rescue; false; end)
674
- }
675
- end
676
-
677
- def run_tool(tool)
678
- output = case tool
679
- when "routes"
680
- routes = Tina4::Router.routes.map { |r| { method: r.method, path: r.path } }
681
- JSON.pretty_generate(routes)
682
- when "test"
683
- "Test runner not yet configured. Run: bundle exec rspec"
684
- when "migrate"
685
- "Migration runner not yet configured. Run: tina4ruby migrate"
686
- when "seed"
687
- "Seeder not yet configured. Run: tina4ruby seed"
688
- else
689
- "Unknown tool: #{tool}"
690
- end
691
- { tool: tool, output: output }
692
- end
693
-
694
- def run_query(sql)
695
- sql = sql.to_s.strip
696
- return { error: "No SQL provided" } if sql.empty?
697
-
698
- db = Tina4.database
699
- return { error: "No database configured" } unless db
700
-
701
- # Split multiple statements on semicolons
702
- statements = sql.split(";").map(&:strip).reject(&:empty?)
703
-
704
- begin
705
- if statements.size == 1
706
- first_word = statements[0].split(/[\s\t\n\r]+/, 2).first.to_s.upcase
707
- if %w[SELECT PRAGMA EXPLAIN SHOW DESCRIBE].include?(first_word)
708
- result = db.fetch(statements[0])
709
- rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
710
- columns = rows.first.is_a?(Hash) ? rows.first.keys.map(&:to_s) : []
711
- return { columns: columns, rows: rows, count: rows.size }
712
- end
713
- end
714
-
715
- # Execute all statements (single write or multi-statement batch)
716
- total_affected = 0
717
- statements.each do |stmt|
718
- result = db.execute(stmt)
719
- if result == false
720
- return { error: db.get_error || "Statement failed: #{stmt}" }
721
- end
722
- total_affected += (result.respond_to?(:affected_rows) ? result.affected_rows : 0)
723
- end
724
-
725
- { affected: total_affected, success: true }
726
- rescue => e
727
- { error: e.message }
728
- end
729
- end
730
-
731
- def tables_payload
732
- db = Tina4.database
733
- return { error: "No database configured", tables: [] } unless db
734
-
735
- begin
736
- table_list = db.tables
737
- { tables: table_list }
738
- rescue => e
739
- { error: e.message, tables: [] }
740
- end
741
- end
742
-
743
- def table_detail_payload(table_name)
744
- return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
745
-
746
- db = Tina4.database
747
- return { error: "No database configured" } unless db
748
-
749
- begin
750
- columns = db.columns(table_name)
751
- result = db.fetch("SELECT * FROM #{table_name} LIMIT 20")
752
- rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
753
- { table: table_name, columns: columns, rows: rows, count: rows.size }
754
- rescue => e
755
- { error: e.message }
756
- end
757
- end
758
-
759
- def seed_table_data(table_name, count)
760
- return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
761
-
762
- db = Tina4.database
763
- return { error: "No database configured" } unless db
764
-
765
- begin
766
- columns = db.columns(table_name)
767
- seeded = Tina4.seed_table(table_name, columns, count: count)
768
- { table: table_name, seeded: seeded }
769
- rescue => e
770
- { error: e.message }
771
- end
772
- end
773
-
774
- def handle_connections_get
775
- env_path = File.join(Dir.pwd, ".env")
776
- url = ""
777
- username = ""
778
- password = ""
779
- if File.file?(env_path)
780
- File.readlines(env_path).each do |line|
781
- line = line.strip
782
- next if line.empty? || line.start_with?("#") || !line.include?("=")
783
- key, val = line.split("=", 2)
784
- key = key.strip
785
- val = (val || "").strip.gsub(/\A["']|["']\z/, "")
786
- case key
787
- when "DATABASE_URL" then url = val
788
- when "DATABASE_USERNAME" then username = val
789
- when "DATABASE_PASSWORD" then password = val.empty? ? "" : "***"
790
- end
791
- end
792
- end
793
- json_response({ url: url, username: username, password: password })
794
- end
795
-
796
- def handle_connections_test(body)
797
- url = (body && body["url"]) || ""
798
- username = (body && body["username"]) || ""
799
- password = (body && body["password"]) || ""
800
- return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
801
- begin
802
- db = Tina4::Database.new(url, username: username, password: password)
803
- version = "Connected"
804
- table_count = 0
805
- begin
806
- tables = db.tables
807
- table_count = tables.is_a?(Array) ? tables.size : 0
808
- rescue => e
809
- table_count = 0
810
- end
811
- begin
812
- url_lower = url.downcase
813
- if url_lower.include?("sqlite")
814
- row = db.fetch_one("SELECT sqlite_version() as v")
815
- version = "SQLite #{row && row[:v] || row && row['v']}" if row
816
- elsif url_lower.include?("postgres")
817
- row = db.fetch_one("SELECT version() as v")
818
- version = (row && (row[:v] || row["v"]) || "PostgreSQL").to_s.split(",").first if row
819
- elsif url_lower.include?("mysql")
820
- row = db.fetch_one("SELECT version() as v")
821
- version = "MySQL #{row && row[:v] || row && row['v']}" if row
822
- elsif url_lower.include?("mssql") || url_lower.include?("sqlserver")
823
- row = db.fetch_one("SELECT @@VERSION as v")
824
- version = (row && (row[:v] || row["v"]) || "MSSQL").to_s.split("\n").first if row
825
- elsif url_lower.include?("firebird")
826
- row = db.fetch_one("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database")
827
- version = "Firebird #{row && row[:v] || row && row['v']}" if row
828
- end
829
- rescue => e
830
- # Keep version as "Connected"
831
- end
832
- db.close if db.respond_to?(:close)
833
- json_response({ success: true, version: version, tables: table_count })
834
- rescue => e
835
- json_response({ success: false, error: e.message })
836
- end
837
- end
838
-
839
- def handle_connections_save(body)
840
- url = (body && body["url"]) || ""
841
- username = (body && body["username"]) || ""
842
- password = (body && body["password"]) || ""
843
- return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
844
- begin
845
- env_path = File.join(Dir.pwd, ".env")
846
- lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
847
- keys_found = { "DATABASE_URL" => false, "DATABASE_USERNAME" => false, "DATABASE_PASSWORD" => false }
848
- new_lines = []
849
- lines.each do |line|
850
- stripped = line.strip
851
- if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=")
852
- new_lines << line
853
- next
854
- end
855
- key = stripped.split("=", 2).first.strip
856
- case key
857
- when "DATABASE_URL"
858
- new_lines << "DATABASE_URL=#{url}"
859
- keys_found["DATABASE_URL"] = true
860
- when "DATABASE_USERNAME"
861
- new_lines << "DATABASE_USERNAME=#{username}"
862
- keys_found["DATABASE_USERNAME"] = true
863
- when "DATABASE_PASSWORD"
864
- new_lines << "DATABASE_PASSWORD=#{password}"
865
- keys_found["DATABASE_PASSWORD"] = true
866
- else
867
- new_lines << line
868
- end
869
- end
870
- values = { "DATABASE_URL" => url, "DATABASE_USERNAME" => username, "DATABASE_PASSWORD" => password }
871
- keys_found.each do |key, found|
872
- new_lines << "#{key}=#{values[key]}" unless found
873
- end
874
- File.write(env_path, new_lines.join("\n") + "\n")
875
- json_response({ success: true })
876
- rescue => e
877
- json_response({ success: false, error: e.message })
878
- end
879
- end
880
-
881
- def gallery_list
882
- gallery_dir = File.join(File.dirname(__FILE__), "gallery")
883
- items = []
884
- if Dir.exist?(gallery_dir)
885
- Dir.children(gallery_dir).sort.each do |entry|
886
- entry_path = File.join(gallery_dir, entry)
887
- meta_file = File.join(entry_path, "meta.json")
888
- next unless File.directory?(entry_path) && File.file?(meta_file)
889
-
890
- meta = JSON.parse(File.read(meta_file)) rescue next
891
- meta["id"] = entry
892
- src_dir = File.join(entry_path, "src")
893
- if Dir.exist?(src_dir)
894
- meta["files"] = Dir.glob(File.join(src_dir, "**", "*"))
895
- .select { |f| File.file?(f) }
896
- .map { |f| f.sub("#{src_dir}/", "") }
897
- end
898
- items << meta
899
- end
900
- end
901
- { gallery: items, count: items.size }
902
- end
903
-
904
- def gallery_deploy(name)
905
- return { error: "No gallery item specified" } if name.to_s.empty?
906
-
907
- gallery_src = File.join(File.dirname(__FILE__), "gallery", name, "src")
908
- return { error: "Gallery item '#{name}' not found" } unless Dir.exist?(gallery_src)
909
-
910
- require "fileutils"
911
- project_src = File.join(Tina4.root_dir || Dir.pwd, "src")
912
- copied = []
913
- Dir.glob(File.join(gallery_src, "**", "*")).each do |src_file|
914
- next unless File.file?(src_file)
915
-
916
- rel = src_file.sub("#{gallery_src}/", "")
917
- dest = File.join(project_src, rel)
918
- FileUtils.mkdir_p(File.dirname(dest))
919
- FileUtils.cp(src_file, dest)
920
- copied << rel
921
- end
922
-
923
- # Re-discover routes so new files are immediately available
924
- begin
925
- routes_dir = File.join(Tina4.root_dir || Dir.pwd, "src", "routes")
926
- Tina4::Router.load_routes(routes_dir) if Dir.exist?(routes_dir)
927
- rescue => e
928
- Tina4::Log.warning("Gallery route reload: #{e.message}")
929
- end
930
-
931
- { deployed: name, files: copied }
932
- end
933
- end
934
- end
935
- end
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "tmpdir"
6
+ require "net/http"
7
+ require "uri"
8
+ require_relative "metrics"
9
+
10
+ module Tina4
11
+ # Thread-safe in-memory message log for dev dashboard
12
+ class MessageLog
13
+ Entry = Struct.new(:timestamp, :category, :level, :message, keyword_init: true)
14
+
15
+ def initialize
16
+ @entries = []
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ def log(category, level, message)
21
+ @mutex.synchronize do
22
+ @entries << Entry.new(
23
+ timestamp: Time.now.utc.iso8601(3),
24
+ category: category.to_s,
25
+ level: level.to_s.upcase,
26
+ message: message.to_s
27
+ )
28
+ # Keep last 500 entries
29
+ @entries.shift if @entries.size > 500
30
+ end
31
+ end
32
+
33
+ def get(category: nil)
34
+ @mutex.synchronize do
35
+ list = category ? @entries.select { |e| e.category == category.to_s } : @entries.dup
36
+ list.reverse.map { |e| { timestamp: e.timestamp, category: e.category, level: e.level, message: e.message } }
37
+ end
38
+ end
39
+
40
+ def clear(category: nil)
41
+ @mutex.synchronize do
42
+ if category
43
+ @entries.reject! { |e| e.category == category.to_s }
44
+ else
45
+ @entries.clear
46
+ end
47
+ end
48
+ end
49
+
50
+ def count
51
+ @mutex.synchronize do
52
+ counts = Hash.new(0)
53
+ @entries.each { |e| counts[e.category] += 1 }
54
+ counts["total"] = @entries.size
55
+ counts
56
+ end
57
+ end
58
+ end
59
+
60
+ # Thread-safe request capture for dev dashboard
61
+ class RequestInspector
62
+ CapturedRequest = Struct.new(:timestamp, :method, :path, :status, :duration, keyword_init: true)
63
+
64
+ def initialize
65
+ @requests = []
66
+ @mutex = Mutex.new
67
+ end
68
+
69
+ def capture(method:, path:, status:, duration:)
70
+ @mutex.synchronize do
71
+ @requests << CapturedRequest.new(
72
+ timestamp: Time.now.utc.iso8601(3),
73
+ method: method.to_s,
74
+ path: path.to_s,
75
+ status: status.to_i,
76
+ duration: duration.to_f.round(3)
77
+ )
78
+ # Keep last 200 entries
79
+ @requests.shift if @requests.size > 200
80
+ end
81
+ end
82
+
83
+ def get(limit: 50)
84
+ @mutex.synchronize do
85
+ @requests.last([limit, @requests.size].min).reverse.map do |r|
86
+ { timestamp: r.timestamp, method: r.method, path: r.path, status: r.status, duration_ms: r.duration }
87
+ end
88
+ end
89
+ end
90
+
91
+ def stats
92
+ @mutex.synchronize do
93
+ return { total: 0, avg_ms: 0.0, errors: 0, slowest_ms: 0.0 } if @requests.empty?
94
+
95
+ durations = @requests.map(&:duration)
96
+ error_count = @requests.count { |r| r.status >= 400 }
97
+
98
+ {
99
+ total: @requests.size,
100
+ avg_ms: (durations.sum / durations.size).round(2),
101
+ errors: error_count,
102
+ slowest_ms: durations.max.round(2)
103
+ }
104
+ end
105
+ end
106
+
107
+ def clear
108
+ @mutex.synchronize { @requests.clear }
109
+ end
110
+ end
111
+
112
+ # Thread-safe, file-persisted error tracker for the dev dashboard Error Tracker panel.
113
+ #
114
+ # Errors are stored in a JSON file in the system temp directory keyed by
115
+ # project path, so they survive across requests and server restarts.
116
+ # Duplicate errors (same type + message + file + line) are de-duplicated —
117
+ # the count increments and the entry is re-opened if it was resolved.
118
+ class ErrorTracker
119
+ MAX_ERRORS = 200
120
+ private_constant :MAX_ERRORS
121
+
122
+ def initialize
123
+ @mutex = Mutex.new
124
+ @errors = nil # lazy-loaded
125
+ @registered = false
126
+ @store_path = File.join(
127
+ Dir.tmpdir,
128
+ "tina4_dev_errors_#{Digest::MD5.hexdigest(Dir.pwd)}.json"
129
+ )
130
+ end
131
+
132
+ # Capture a Ruby error / exception into the tracker.
133
+ # @param error_type [String] e.g. "RuntimeError" or "NoMethodError"
134
+ # @param message [String] exception message
135
+ # @param traceback [String] formatted backtrace (optional)
136
+ # @param file [String] source file (optional)
137
+ # @param line [Integer] source line (optional)
138
+ def capture(error_type:, message:, traceback: "", file: "", line: 0)
139
+ @mutex.synchronize do
140
+ load_unlocked
141
+ fingerprint = Digest::MD5.hexdigest("#{error_type}|#{message}|#{file}|#{line}")
142
+ now = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
143
+
144
+ if @errors.key?(fingerprint)
145
+ @errors[fingerprint][:count] += 1
146
+ @errors[fingerprint][:last_seen] = now
147
+ @errors[fingerprint][:resolved] = false # re-open resolved duplicates
148
+ else
149
+ @errors[fingerprint] = {
150
+ id: fingerprint,
151
+ error_type: error_type,
152
+ message: message,
153
+ traceback: traceback,
154
+ file: file,
155
+ line: line,
156
+ first_seen: now,
157
+ last_seen: now,
158
+ count: 1,
159
+ resolved: false
160
+ }
161
+ end
162
+ save_unlocked
163
+ end
164
+ end
165
+
166
+ # Capture a Ruby exception object directly.
167
+ def capture_exception(exc)
168
+ capture(
169
+ error_type: exc.class.name,
170
+ message: exc.message,
171
+ traceback: (exc.backtrace || []).first(20).join("\n"),
172
+ file: (exc.backtrace_locations&.first&.path || ""),
173
+ line: (exc.backtrace_locations&.first&.lineno || 0)
174
+ )
175
+ end
176
+
177
+ # Return all errors (newest first).
178
+ # @param include_resolved [Boolean]
179
+ def get(include_resolved: true)
180
+ @mutex.synchronize do
181
+ load_unlocked
182
+ entries = @errors.values
183
+ entries = entries.reject { |e| e[:resolved] } unless include_resolved
184
+ entries.sort_by { |e| e[:last_seen] }.reverse
185
+ end
186
+ end
187
+
188
+ # Count of unresolved errors.
189
+ def unresolved_count
190
+ @mutex.synchronize do
191
+ load_unlocked
192
+ @errors.count { |_, e| !e[:resolved] }
193
+ end
194
+ end
195
+
196
+ # Health summary (matches Python BrokenTracker interface).
197
+ def health
198
+ @mutex.synchronize do
199
+ load_unlocked
200
+ total = @errors.size
201
+ resolved = @errors.count { |_, e| e[:resolved] }
202
+ unresolved = total - resolved
203
+ { total: total, unresolved: unresolved, resolved: resolved, healthy: unresolved.zero? }
204
+ end
205
+ end
206
+
207
+ # Mark a single error as resolved.
208
+ def resolve(id)
209
+ @mutex.synchronize do
210
+ load_unlocked
211
+ return false unless @errors.key?(id)
212
+
213
+ @errors[id][:resolved] = true
214
+ save_unlocked
215
+ true
216
+ end
217
+ end
218
+
219
+ # Remove all resolved errors.
220
+ def clear_resolved
221
+ @mutex.synchronize do
222
+ load_unlocked
223
+ @errors.reject! { |_, e| e[:resolved] }
224
+ save_unlocked
225
+ end
226
+ end
227
+
228
+ # Remove ALL errors.
229
+ def clear_all
230
+ @mutex.synchronize do
231
+ @errors = {}
232
+ save_unlocked
233
+ end
234
+ end
235
+
236
+ # Register Ruby error handlers to feed the tracker.
237
+ # Installs an at_exit hook that captures unhandled exceptions.
238
+ # Safe to call multiple times — only registers once.
239
+ def register
240
+ return if @registered
241
+
242
+ @registered = true
243
+ tracker = self
244
+ at_exit do
245
+ if (exc = $!) && !exc.is_a?(SystemExit)
246
+ tracker.capture_exception(exc)
247
+ end
248
+ end
249
+ end
250
+
251
+ # Reset (for testing).
252
+ def reset!
253
+ @mutex.synchronize do
254
+ @errors = {}
255
+ @registered = false
256
+ File.delete(@store_path) if File.exist?(@store_path)
257
+ end
258
+ end
259
+
260
+ private
261
+
262
+ def load_unlocked
263
+ return if @errors
264
+
265
+ if File.exist?(@store_path)
266
+ raw = File.read(@store_path) rescue nil
267
+ data = raw ? (JSON.parse(raw, symbolize_names: true) rescue nil) : nil
268
+ if data.is_a?(Array)
269
+ # Re-key by id
270
+ @errors = {}
271
+ data.each { |e| @errors[e[:id]] = e if e[:id] }
272
+ else
273
+ @errors = {}
274
+ end
275
+ else
276
+ @errors = {}
277
+ end
278
+ end
279
+
280
+ def save_unlocked
281
+ # Trim to max, keeping newest last_seen
282
+ if @errors.size > MAX_ERRORS
283
+ sorted = @errors.values.sort_by { |e| e[:last_seen] }.last(MAX_ERRORS)
284
+ @errors = {}
285
+ sorted.each { |e| @errors[e[:id]] = e }
286
+ end
287
+
288
+ File.write(@store_path, JSON.generate(@errors.values))
289
+ rescue StandardError
290
+ # Best-effort persistence — never raise in a tracker
291
+ end
292
+ end
293
+
294
+ # Developer dashboard module - only active in debug mode
295
+ module DevAdmin
296
+ class << self
297
+ def message_log
298
+ @message_log ||= MessageLog.new
299
+ end
300
+
301
+ def request_inspector
302
+ @request_inspector ||= RequestInspector.new
303
+ end
304
+
305
+ def mailbox
306
+ @mailbox ||= DevMailbox.new
307
+ end
308
+
309
+ def error_tracker
310
+ @error_tracker ||= ErrorTracker.new
311
+ end
312
+
313
+ def enabled?
314
+ Tina4::Env.is_truthy(ENV["TINA4_DEBUG"])
315
+ end
316
+
317
+ # Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
318
+ def handle_request(env)
319
+ return nil unless enabled?
320
+
321
+ path = env["PATH_INFO"] || "/"
322
+ method = env["REQUEST_METHOD"]
323
+
324
+ case [method, path]
325
+ when ["GET", "/__dev"], ["GET", "/__dev/"]
326
+ serve_dashboard
327
+ when ["GET", "/__dev/js/tina4-dev-admin.min.js"]
328
+ serve_dev_js
329
+ when ["GET", "/__dev/api/mtime"]
330
+ json_response({ mtime: @reload_mtime || 0, file: @reload_file || "" })
331
+ when ["POST", "/__dev/api/reload"]
332
+ body = read_json_body(env) || {}
333
+ @reload_mtime = Time.now.to_i
334
+ @reload_file = body["file"] || ""
335
+ reload_type = body["type"] || "reload"
336
+ Tina4::Log.info("External reload trigger: #{reload_type}#{@reload_file.empty? ? '' : " (#{@reload_file})"}")
337
+ json_response({ ok: true, type: reload_type })
338
+ when ["GET", "/__dev/api/status"]
339
+ json_response(status_payload)
340
+ when ["GET", "/__dev/api/routes"]
341
+ json_response(routes_payload)
342
+ when ["GET", "/__dev/api/messages"]
343
+ category = query_param(env, "category")
344
+ messages = message_log.get(category: category)
345
+ counts = message_log.count
346
+ json_response({ messages: messages, counts: counts })
347
+ when ["POST", "/__dev/api/messages/clear"]
348
+ body = read_json_body(env)
349
+ category = body["category"] if body
350
+ message_log.clear(category: category)
351
+ json_response({ cleared: true })
352
+ when ["GET", "/__dev/api/requests"]
353
+ limit = (query_param(env, "limit") || 50).to_i
354
+ json_response({ requests: request_inspector.get(limit: limit), stats: request_inspector.stats })
355
+ when ["POST", "/__dev/api/requests/clear"]
356
+ request_inspector.clear
357
+ json_response({ cleared: true })
358
+ when ["GET", "/__dev/api/system"]
359
+ json_response(system_payload)
360
+ when ["GET", "/__dev/api/queue/topics"]
361
+ queue_dir = File.join(Dir.pwd, "data", "queue")
362
+ topics = Dir.exist?(queue_dir) ? Dir.children(queue_dir).select { |d| File.directory?(File.join(queue_dir, d)) }.sort : []
363
+ topics = ["default"] if topics.empty?
364
+ json_response({ topics: topics })
365
+ when ["GET", "/__dev/api/queue/dead-letters"]
366
+ topic = query_param(env, "topic") || "default"
367
+ jobs = []
368
+ begin
369
+ queue = Tina4::Queue.new(backend: :file, topic: topic) if defined?(Tina4::Queue)
370
+ jobs = queue.respond_to?(:dead_letters) ? queue.dead_letters.map { |j| j.merge(status: "dead_letter") } : []
371
+ rescue StandardError => e
372
+ jobs = []
373
+ end
374
+ json_response({ jobs: jobs, count: jobs.size, topic: topic })
375
+ when ["GET", "/__dev/api/queue"]
376
+ topic = query_param(env, "topic") || "default"
377
+ stats = { pending: 0, completed: 0, failed: 0, reserved: 0 }
378
+ jobs = []
379
+ begin
380
+ if defined?(Tina4::Queue)
381
+ queue = Tina4::Queue.new(backend: :file, topic: topic)
382
+ stats = {
383
+ pending: queue.respond_to?(:size) ? queue.size("pending") : 0,
384
+ completed: queue.respond_to?(:size) ? queue.size("completed") : 0,
385
+ failed: queue.respond_to?(:size) ? queue.size("failed") : 0,
386
+ reserved: queue.respond_to?(:size) ? queue.size("reserved") : 0,
387
+ }
388
+ jobs.concat(queue.failed.map { |j| j.merge(status: "failed") }) if queue.respond_to?(:failed)
389
+ jobs.concat(queue.dead_letters.map { |j| j.merge(status: "dead_letter") }) if queue.respond_to?(:dead_letters)
390
+ end
391
+ rescue StandardError => e
392
+ # fall through to empty stats
393
+ end
394
+ json_response({ jobs: jobs, stats: stats })
395
+ when ["GET", "/__dev/api/mailbox"]
396
+ messages = mailbox.inbox
397
+ json_response({ messages: messages, count: messages.size, unread: mailbox.unread_count })
398
+ when ["GET", "/__dev/api/broken"]
399
+ errors = error_tracker.get(include_resolved: true)
400
+ h = error_tracker.health
401
+ json_response({ errors: errors, count: errors.size, health: h })
402
+ when ["POST", "/__dev/api/broken/resolve"]
403
+ body = read_json_body(env)
404
+ id = body && body["id"]
405
+ resolved = id ? error_tracker.resolve(id) : false
406
+ json_response({ resolved: resolved, id: id })
407
+ when ["POST", "/__dev/api/broken/clear"]
408
+ error_tracker.clear_resolved
409
+ json_response({ cleared: true })
410
+ when ["GET", "/__dev/api/websockets"]
411
+ json_response({ connections: [], count: 0 })
412
+ when ["POST", "/__dev/api/websockets/disconnect"]
413
+ body = read_json_body(env)
414
+ # TODO: disconnect WS connection by id from body["id"]
415
+ json_response({ disconnected: true })
416
+ when ["GET", "/__dev/api/mailbox/read"]
417
+ message_id = query_param(env, "id")
418
+ message = mailbox.read(message_id)
419
+ if message
420
+ json_response(message)
421
+ else
422
+ body = JSON.generate({ error: "Message not found", id: message_id })
423
+ [404, { "content-type" => "application/json; charset=utf-8" }, [body]]
424
+ end
425
+ when ["POST", "/__dev/api/mailbox/seed"]
426
+ body = read_json_body(env)
427
+ count = ((body && body["count"]) || 5).to_i
428
+ mailbox.seed(count: count)
429
+ json_response({ seeded: count })
430
+ when ["POST", "/__dev/api/mailbox/clear"]
431
+ mailbox.clear
432
+ json_response({ cleared: true })
433
+ when ["GET", "/__dev/api/messages/search"]
434
+ keyword = query_param(env, "q") || query_param(env, "keyword") || ""
435
+ all_messages = message_log.get
436
+ filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
437
+ json_response({ messages: filtered, count: filtered.size, keyword: keyword })
438
+ when ["POST", "/__dev/api/queue/retry"]
439
+ body = read_json_body(env)
440
+ # TODO: retry failed jobs by id from body["id"]
441
+ json_response({ retried: true })
442
+ when ["POST", "/__dev/api/queue/purge"]
443
+ # TODO: purge completed jobs
444
+ json_response({ purged: true })
445
+ when ["POST", "/__dev/api/queue/replay"]
446
+ body = read_json_body(env)
447
+ # TODO: replay a specific job by id from body["id"]
448
+ json_response({ replayed: true })
449
+ when ["GET", "/__dev/api/table"]
450
+ table_name = query_param(env, "name")
451
+ json_response(table_detail_payload(table_name))
452
+ when ["POST", "/__dev/api/seed"]
453
+ body = read_json_body(env)
454
+ table_name = (body && body["table"]) || ""
455
+ count = (body && body["count"]) || 10
456
+ json_response(seed_table_data(table_name, count.to_i))
457
+ when ["POST", "/__dev/api/tool"]
458
+ body = read_json_body(env)
459
+ tool = (body && body["tool"]) || ""
460
+ json_response(run_tool(tool))
461
+ when ["POST", "/__dev/api/chat"]
462
+ body = read_json_body(env)
463
+ message = (body && body["message"]) || ""
464
+ json_response({
465
+ reply: "Chat is not yet connected to an AI backend. You said: \"#{message}\"",
466
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
467
+ })
468
+ when ["GET", "/__dev/api/connections"]
469
+ handle_connections_get
470
+ when ["POST", "/__dev/api/connections/test"]
471
+ body = read_json_body(env)
472
+ handle_connections_test(body)
473
+ when ["POST", "/__dev/api/connections/save"]
474
+ body = read_json_body(env)
475
+ handle_connections_save(body)
476
+ when ["POST", "/__dev/api/query"]
477
+ body = read_json_body(env)
478
+ sql = (body && (body["query"] || body["sql"])) || ""
479
+ json_response(run_query(sql))
480
+ when ["GET", "/__dev/api/tables"]
481
+ json_response(tables_payload)
482
+ when ["GET", "/__dev/api/gallery"]
483
+ json_response(gallery_list)
484
+ when ["POST", "/__dev/api/gallery/deploy"]
485
+ body = read_json_body(env)
486
+ name = (body && body["name"]) || ""
487
+ json_response(gallery_deploy(name))
488
+ when ["GET", "/__dev/api/version-check"]
489
+ json_response(version_check_payload)
490
+ when ["GET", "/__dev/api/metrics"]
491
+ json_response(Tina4::Metrics.quick_metrics)
492
+ when ["GET", "/__dev/api/metrics/full"]
493
+ json_response(Tina4::Metrics.full_analysis)
494
+ when ["GET", "/__dev/api/metrics/file"]
495
+ file_path = (query_param(env, "path") || "").to_s
496
+ json_response(Tina4::Metrics.file_detail(file_path))
497
+ when ["GET", "/__dev/api/graphql/schema"]
498
+ begin
499
+ gql = Tina4::GraphQL.new
500
+ # Auto-discover and register all ORM subclasses
501
+ ObjectSpace.each_object(Class).select { |c| c < Tina4::ORM }.each do |model_class|
502
+ gql.from_orm(model_class.new)
503
+ end
504
+ json_response({ schema: gql.introspect, sdl: gql.schema_sdl })
505
+ rescue => e
506
+ json_response({ error: e.message }, 400)
507
+ end
508
+ else
509
+ nil
510
+ end
511
+ end
512
+
513
+
514
+ private
515
+
516
+ def query_param(env, key)
517
+ qs = env["QUERY_STRING"] || ""
518
+ params = URI.decode_www_form(qs).to_h rescue {}
519
+ params[key]
520
+ end
521
+
522
+ def read_json_body(env)
523
+ input = env["rack.input"]
524
+ return nil unless input
525
+ input.rewind if input.respond_to?(:rewind)
526
+ raw = input.read
527
+ return nil if raw.nil? || raw.empty?
528
+ JSON.parse(raw) rescue nil
529
+ end
530
+
531
+ def json_response(data)
532
+ body = JSON.generate(data)
533
+ [200, { "content-type" => "application/json; charset=utf-8" }, [body]]
534
+ end
535
+
536
+ def version_check_payload
537
+ current = Tina4::VERSION
538
+ latest = current
539
+ begin
540
+ uri = URI.parse("https://rubygems.org/api/v1/versions/tina4ruby/latest.json")
541
+ http = Net::HTTP.new(uri.host, uri.port)
542
+ http.use_ssl = true
543
+ http.open_timeout = 5
544
+ http.read_timeout = 5
545
+ req = Net::HTTP::Get.new(uri)
546
+ resp = http.request(req)
547
+ if resp.is_a?(Net::HTTPSuccess)
548
+ data = JSON.parse(resp.body)
549
+ latest = data["version"] || current
550
+ end
551
+ rescue StandardError
552
+ # Offline or timeout — return current as latest
553
+ end
554
+ { current: current, latest: latest }
555
+ end
556
+
557
+ def serve_dashboard
558
+ spa = '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev Admin</title></head><body><div id="app" data-framework="ruby" data-color="#ef4444"></div><script src="/__dev/js/tina4-dev-admin.min.js"></script></body></html>'
559
+ [200, { "content-type" => "text/html; charset=utf-8" }, [spa]]
560
+ end
561
+
562
+ def serve_dev_js
563
+ js_path = File.join(File.dirname(__FILE__), "public", "js", "tina4-dev-admin.min.js")
564
+ if File.file?(js_path)
565
+ [200, { "content-type" => "application/javascript; charset=utf-8" }, [File.read(js_path)]]
566
+ else
567
+ [404, { "content-type" => "text/plain" }, ["tina4-dev-admin.min.js not found"]]
568
+ end
569
+ end
570
+
571
+ def status_payload
572
+ db_table_count = 0
573
+ begin
574
+ db = Tina4.database
575
+ db_table_count = db.tables.size if db
576
+ rescue
577
+ # ignore
578
+ end
579
+
580
+ {
581
+ framework: "tina4-ruby",
582
+ version: Tina4::VERSION,
583
+ ruby_version: RUBY_VERSION,
584
+ platform: RUBY_PLATFORM,
585
+ debug: ENV["TINA4_DEBUG"] || "false",
586
+ log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
587
+ database: ENV["DATABASE_URL"] || "not configured",
588
+ db_tables: db_table_count,
589
+ uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
590
+ route_count: Tina4::Router.routes.size,
591
+ request_stats: request_inspector.stats,
592
+ message_counts: message_log.count,
593
+ health: error_tracker.health
594
+ }
595
+ end
596
+
597
+ def routes_payload
598
+ internal_prefixes = ["/__dev", "/health", "/swagger"]
599
+ routes = Tina4::Router.routes
600
+ .reject { |route| internal_prefixes.any? { |prefix| route.path.start_with?(prefix) } }
601
+ .map do |route|
602
+ handler_name = ""
603
+ mod = ""
604
+ if route.handler.is_a?(Proc)
605
+ source = route.handler.source_location
606
+ if source
607
+ handler_name = "#{File.basename(source[0])}:#{source[1]}"
608
+ mod = File.dirname(source[0])
609
+ end
610
+ end
611
+ {
612
+ method: route.method,
613
+ pattern: route.path,
614
+ path: route.path,
615
+ middleware: route.respond_to?(:middleware_count) ? route.middleware_count : 0,
616
+ cache: route.respond_to?(:cached?) ? route.cached? : false,
617
+ secure: !route.auth_handler.nil?,
618
+ auth_required: !route.auth_handler.nil?,
619
+ handler: handler_name,
620
+ module: mod
621
+ }
622
+ end
623
+ { routes: routes, count: routes.size }
624
+ end
625
+
626
+ def system_payload
627
+ gc = GC.stat
628
+ mem = begin
629
+ if RUBY_PLATFORM.include?("darwin")
630
+ `ps -o rss= -p #{Process.pid}`.strip.to_i # KB
631
+ elsif RUBY_PLATFORM.include?("linux")
632
+ (File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i rescue 0)
633
+ else
634
+ 0
635
+ end
636
+ end
637
+
638
+ os_release = (`uname -r`.strip rescue "unknown")
639
+ host_name = (`hostname`.strip rescue "unknown")
640
+
641
+ {
642
+ ruby_version: RUBY_VERSION,
643
+ ruby_engine: RUBY_ENGINE,
644
+ os: "#{RUBY_PLATFORM} #{os_release}",
645
+ architecture: RUBY_PLATFORM,
646
+ memory: {
647
+ current_mb: (mem / 1024.0).round(1),
648
+ peak_mb: "N/A",
649
+ limit: "N/A"
650
+ },
651
+ server: {
652
+ software: "Ruby/WEBrick",
653
+ hostname: host_name,
654
+ document_root: Tina4.root_dir || Dir.pwd
655
+ },
656
+ framework: {
657
+ name: "tina4-ruby",
658
+ version: Tina4::VERSION,
659
+ route_count: Tina4::Router.routes.size
660
+ },
661
+ extensions: $LOADED_FEATURES.map { |f| File.basename(f, ".rb") }.uniq.sort.first(50),
662
+ gc: {
663
+ count: gc[:count],
664
+ heap_allocated_pages: gc[:heap_allocated_pages],
665
+ heap_live_slots: gc[:heap_live_slots],
666
+ total_allocated_objects: gc[:total_allocated_objects],
667
+ total_freed_objects: gc[:total_freed_objects]
668
+ },
669
+ pid: Process.pid,
670
+ thread_count: Thread.list.size,
671
+ env: ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development",
672
+ db_tables: (begin; db = Tina4.database; db ? db.tables.size : 0; rescue; 0; end),
673
+ db_connected: (begin; db = Tina4.database; !db.nil?; rescue; false; end)
674
+ }
675
+ end
676
+
677
+ def run_tool(tool)
678
+ output = case tool
679
+ when "routes"
680
+ routes = Tina4::Router.routes.map { |r| { method: r.method, path: r.path } }
681
+ JSON.pretty_generate(routes)
682
+ when "test"
683
+ "Test runner not yet configured. Run: bundle exec rspec"
684
+ when "migrate"
685
+ "Migration runner not yet configured. Run: tina4ruby migrate"
686
+ when "seed"
687
+ "Seeder not yet configured. Run: tina4ruby seed"
688
+ else
689
+ "Unknown tool: #{tool}"
690
+ end
691
+ { tool: tool, output: output }
692
+ end
693
+
694
+ def run_query(sql)
695
+ sql = sql.to_s.strip
696
+ return { error: "No SQL provided" } if sql.empty?
697
+
698
+ db = Tina4.database
699
+ return { error: "No database configured" } unless db
700
+
701
+ # Split multiple statements on semicolons
702
+ statements = sql.split(";").map(&:strip).reject(&:empty?)
703
+
704
+ begin
705
+ if statements.size == 1
706
+ first_word = statements[0].split(/[\s\t\n\r]+/, 2).first.to_s.upcase
707
+ if %w[SELECT PRAGMA EXPLAIN SHOW DESCRIBE].include?(first_word)
708
+ result = db.fetch(statements[0])
709
+ rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
710
+ columns = rows.first.is_a?(Hash) ? rows.first.keys.map(&:to_s) : []
711
+ return { columns: columns, rows: rows, count: rows.size }
712
+ end
713
+ end
714
+
715
+ # Execute all statements (single write or multi-statement batch)
716
+ total_affected = 0
717
+ statements.each do |stmt|
718
+ result = db.execute(stmt)
719
+ if result == false
720
+ return { error: db.get_error || "Statement failed: #{stmt}" }
721
+ end
722
+ total_affected += (result.respond_to?(:affected_rows) ? result.affected_rows : 0)
723
+ end
724
+
725
+ { affected: total_affected, success: true }
726
+ rescue => e
727
+ { error: e.message }
728
+ end
729
+ end
730
+
731
+ def tables_payload
732
+ db = Tina4.database
733
+ return { error: "No database configured", tables: [] } unless db
734
+
735
+ begin
736
+ table_list = db.tables
737
+ { tables: table_list }
738
+ rescue => e
739
+ { error: e.message, tables: [] }
740
+ end
741
+ end
742
+
743
+ def table_detail_payload(table_name)
744
+ return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
745
+
746
+ db = Tina4.database
747
+ return { error: "No database configured" } unless db
748
+
749
+ begin
750
+ columns = db.columns(table_name)
751
+ result = db.fetch("SELECT * FROM #{table_name} LIMIT 20")
752
+ rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
753
+ { table: table_name, columns: columns, rows: rows, count: rows.size }
754
+ rescue => e
755
+ { error: e.message }
756
+ end
757
+ end
758
+
759
+ def seed_table_data(table_name, count)
760
+ return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
761
+
762
+ db = Tina4.database
763
+ return { error: "No database configured" } unless db
764
+
765
+ begin
766
+ columns = db.columns(table_name)
767
+ seeded = Tina4.seed_table(table_name, columns, count: count)
768
+ { table: table_name, seeded: seeded }
769
+ rescue => e
770
+ { error: e.message }
771
+ end
772
+ end
773
+
774
+ def handle_connections_get
775
+ env_path = File.join(Dir.pwd, ".env")
776
+ url = ""
777
+ username = ""
778
+ password = ""
779
+ if File.file?(env_path)
780
+ File.readlines(env_path).each do |line|
781
+ line = line.strip
782
+ next if line.empty? || line.start_with?("#") || !line.include?("=")
783
+ key, val = line.split("=", 2)
784
+ key = key.strip
785
+ val = (val || "").strip.gsub(/\A["']|["']\z/, "")
786
+ case key
787
+ when "DATABASE_URL" then url = val
788
+ when "DATABASE_USERNAME" then username = val
789
+ when "DATABASE_PASSWORD" then password = val.empty? ? "" : "***"
790
+ end
791
+ end
792
+ end
793
+ json_response({ url: url, username: username, password: password })
794
+ end
795
+
796
+ def handle_connections_test(body)
797
+ url = (body && body["url"]) || ""
798
+ username = (body && body["username"]) || ""
799
+ password = (body && body["password"]) || ""
800
+ return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
801
+ begin
802
+ db = Tina4::Database.new(url, username: username, password: password)
803
+ version = "Connected"
804
+ table_count = 0
805
+ begin
806
+ tables = db.tables
807
+ table_count = tables.is_a?(Array) ? tables.size : 0
808
+ rescue => e
809
+ table_count = 0
810
+ end
811
+ begin
812
+ url_lower = url.downcase
813
+ if url_lower.include?("sqlite")
814
+ row = db.fetch_one("SELECT sqlite_version() as v")
815
+ version = "SQLite #{row && row[:v] || row && row['v']}" if row
816
+ elsif url_lower.include?("postgres")
817
+ row = db.fetch_one("SELECT version() as v")
818
+ version = (row && (row[:v] || row["v"]) || "PostgreSQL").to_s.split(",").first if row
819
+ elsif url_lower.include?("mysql")
820
+ row = db.fetch_one("SELECT version() as v")
821
+ version = "MySQL #{row && row[:v] || row && row['v']}" if row
822
+ elsif url_lower.include?("mssql") || url_lower.include?("sqlserver")
823
+ row = db.fetch_one("SELECT @@VERSION as v")
824
+ version = (row && (row[:v] || row["v"]) || "MSSQL").to_s.split("\n").first if row
825
+ elsif url_lower.include?("firebird")
826
+ row = db.fetch_one("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database")
827
+ version = "Firebird #{row && row[:v] || row && row['v']}" if row
828
+ end
829
+ rescue => e
830
+ # Keep version as "Connected"
831
+ end
832
+ db.close if db.respond_to?(:close)
833
+ json_response({ success: true, version: version, tables: table_count })
834
+ rescue => e
835
+ json_response({ success: false, error: e.message })
836
+ end
837
+ end
838
+
839
+ def handle_connections_save(body)
840
+ url = (body && body["url"]) || ""
841
+ username = (body && body["username"]) || ""
842
+ password = (body && body["password"]) || ""
843
+ return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
844
+ begin
845
+ env_path = File.join(Dir.pwd, ".env")
846
+ lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
847
+ keys_found = { "DATABASE_URL" => false, "DATABASE_USERNAME" => false, "DATABASE_PASSWORD" => false }
848
+ new_lines = []
849
+ lines.each do |line|
850
+ stripped = line.strip
851
+ if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=")
852
+ new_lines << line
853
+ next
854
+ end
855
+ key = stripped.split("=", 2).first.strip
856
+ case key
857
+ when "DATABASE_URL"
858
+ new_lines << "DATABASE_URL=#{url}"
859
+ keys_found["DATABASE_URL"] = true
860
+ when "DATABASE_USERNAME"
861
+ new_lines << "DATABASE_USERNAME=#{username}"
862
+ keys_found["DATABASE_USERNAME"] = true
863
+ when "DATABASE_PASSWORD"
864
+ new_lines << "DATABASE_PASSWORD=#{password}"
865
+ keys_found["DATABASE_PASSWORD"] = true
866
+ else
867
+ new_lines << line
868
+ end
869
+ end
870
+ values = { "DATABASE_URL" => url, "DATABASE_USERNAME" => username, "DATABASE_PASSWORD" => password }
871
+ keys_found.each do |key, found|
872
+ new_lines << "#{key}=#{values[key]}" unless found
873
+ end
874
+ File.write(env_path, new_lines.join("\n") + "\n")
875
+ json_response({ success: true })
876
+ rescue => e
877
+ json_response({ success: false, error: e.message })
878
+ end
879
+ end
880
+
881
+ def gallery_list
882
+ gallery_dir = File.join(File.dirname(__FILE__), "gallery")
883
+ items = []
884
+ if Dir.exist?(gallery_dir)
885
+ Dir.children(gallery_dir).sort.each do |entry|
886
+ entry_path = File.join(gallery_dir, entry)
887
+ meta_file = File.join(entry_path, "meta.json")
888
+ next unless File.directory?(entry_path) && File.file?(meta_file)
889
+
890
+ meta = JSON.parse(File.read(meta_file)) rescue next
891
+ meta["id"] = entry
892
+ src_dir = File.join(entry_path, "src")
893
+ if Dir.exist?(src_dir)
894
+ meta["files"] = Dir.glob(File.join(src_dir, "**", "*"))
895
+ .select { |f| File.file?(f) }
896
+ .map { |f| f.sub("#{src_dir}/", "") }
897
+ end
898
+ items << meta
899
+ end
900
+ end
901
+ { gallery: items, count: items.size }
902
+ end
903
+
904
+ def gallery_deploy(name)
905
+ return { error: "No gallery item specified" } if name.to_s.empty?
906
+
907
+ gallery_src = File.join(File.dirname(__FILE__), "gallery", name, "src")
908
+ return { error: "Gallery item '#{name}' not found" } unless Dir.exist?(gallery_src)
909
+
910
+ require "fileutils"
911
+ project_src = File.join(Tina4.root_dir || Dir.pwd, "src")
912
+ copied = []
913
+ Dir.glob(File.join(gallery_src, "**", "*")).each do |src_file|
914
+ next unless File.file?(src_file)
915
+
916
+ rel = src_file.sub("#{gallery_src}/", "")
917
+ dest = File.join(project_src, rel)
918
+ FileUtils.mkdir_p(File.dirname(dest))
919
+ FileUtils.cp(src_file, dest)
920
+ copied << rel
921
+ end
922
+
923
+ # Re-discover routes so new files are immediately available
924
+ begin
925
+ routes_dir = File.join(Tina4.root_dir || Dir.pwd, "src", "routes")
926
+ Tina4::Router.load_routes(routes_dir) if Dir.exist?(routes_dir)
927
+ rescue => e
928
+ Tina4::Log.warning("Gallery route reload: #{e.message}")
929
+ end
930
+
931
+ { deployed: name, files: copied }
932
+ end
933
+ end
934
+ end
935
+ end