tina4ruby 0.5.2 → 3.0.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +360 -559
  4. data/exe/{tina4 → tina4ruby} +1 -0
  5. data/lib/tina4/ai.rb +312 -0
  6. data/lib/tina4/auth.rb +44 -3
  7. data/lib/tina4/auto_crud.rb +163 -0
  8. data/lib/tina4/cli.rb +242 -77
  9. data/lib/tina4/constants.rb +46 -0
  10. data/lib/tina4/cors.rb +74 -0
  11. data/lib/tina4/database/sqlite3_adapter.rb +139 -0
  12. data/lib/tina4/database.rb +43 -7
  13. data/lib/tina4/debug.rb +4 -79
  14. data/lib/tina4/dev_admin.rb +1162 -0
  15. data/lib/tina4/dev_mailbox.rb +191 -0
  16. data/lib/tina4/dev_reload.rb +9 -9
  17. data/lib/tina4/drivers/firebird_driver.rb +19 -3
  18. data/lib/tina4/drivers/mssql_driver.rb +3 -3
  19. data/lib/tina4/drivers/mysql_driver.rb +4 -4
  20. data/lib/tina4/drivers/postgres_driver.rb +9 -2
  21. data/lib/tina4/drivers/sqlite_driver.rb +1 -1
  22. data/lib/tina4/env.rb +42 -2
  23. data/lib/tina4/error_overlay.rb +252 -0
  24. data/lib/tina4/events.rb +90 -0
  25. data/lib/tina4/field_types.rb +4 -0
  26. data/lib/tina4/frond.rb +1336 -0
  27. data/lib/tina4/gallery/auth/meta.json +1 -0
  28. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
  29. data/lib/tina4/gallery/database/meta.json +1 -0
  30. data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
  31. data/lib/tina4/gallery/error-overlay/meta.json +1 -0
  32. data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
  33. data/lib/tina4/gallery/orm/meta.json +1 -0
  34. data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
  35. data/lib/tina4/gallery/queue/meta.json +1 -0
  36. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
  37. data/lib/tina4/gallery/rest-api/meta.json +1 -0
  38. data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
  39. data/lib/tina4/gallery/templates/meta.json +1 -0
  40. data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
  41. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
  42. data/lib/tina4/health.rb +39 -0
  43. data/lib/tina4/html_element.rb +148 -0
  44. data/lib/tina4/localization.rb +2 -2
  45. data/lib/tina4/log.rb +203 -0
  46. data/lib/tina4/messenger.rb +484 -0
  47. data/lib/tina4/migration.rb +132 -29
  48. data/lib/tina4/orm.rb +337 -31
  49. data/lib/tina4/public/css/tina4.css +178 -1
  50. data/lib/tina4/public/css/tina4.min.css +1 -2
  51. data/lib/tina4/public/favicon.ico +0 -0
  52. data/lib/tina4/public/images/logo.svg +5 -0
  53. data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
  54. data/lib/tina4/public/js/frond.min.js +420 -0
  55. data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
  56. data/lib/tina4/public/js/tina4.min.js +93 -0
  57. data/lib/tina4/public/swagger/index.html +90 -0
  58. data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
  59. data/lib/tina4/queue.rb +40 -4
  60. data/lib/tina4/queue_backends/lite_backend.rb +88 -0
  61. data/lib/tina4/rack_app.rb +314 -23
  62. data/lib/tina4/rate_limiter.rb +123 -0
  63. data/lib/tina4/request.rb +61 -15
  64. data/lib/tina4/response.rb +54 -24
  65. data/lib/tina4/response_cache.rb +134 -0
  66. data/lib/tina4/router.rb +90 -15
  67. data/lib/tina4/scss_compiler.rb +2 -2
  68. data/lib/tina4/seeder.rb +56 -61
  69. data/lib/tina4/service_runner.rb +303 -0
  70. data/lib/tina4/session.rb +85 -0
  71. data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
  72. data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
  73. data/lib/tina4/shutdown.rb +84 -0
  74. data/lib/tina4/sql_translation.rb +295 -0
  75. data/lib/tina4/template.rb +36 -6
  76. data/lib/tina4/templates/base.twig +2 -2
  77. data/lib/tina4/templates/errors/302.twig +14 -0
  78. data/lib/tina4/templates/errors/401.twig +9 -0
  79. data/lib/tina4/templates/errors/403.twig +22 -15
  80. data/lib/tina4/templates/errors/404.twig +22 -15
  81. data/lib/tina4/templates/errors/500.twig +31 -15
  82. data/lib/tina4/templates/errors/502.twig +9 -0
  83. data/lib/tina4/templates/errors/503.twig +12 -0
  84. data/lib/tina4/templates/errors/base.twig +37 -0
  85. data/lib/tina4/version.rb +1 -1
  86. data/lib/tina4/webserver.rb +28 -18
  87. data/lib/tina4.rb +57 -21
  88. metadata +51 -19
  89. data/lib/tina4/public/js/tina4.js +0 -134
  90. data/lib/tina4/public/js/tina4helper.js +0 -387
@@ -0,0 +1,1162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Tina4
6
+ # Thread-safe in-memory message log for dev dashboard
7
+ class MessageLog
8
+ Entry = Struct.new(:timestamp, :category, :level, :message, keyword_init: true)
9
+
10
+ def initialize
11
+ @entries = []
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ def log(category, level, message)
16
+ @mutex.synchronize do
17
+ @entries << Entry.new(
18
+ timestamp: Time.now.utc.iso8601(3),
19
+ category: category.to_s,
20
+ level: level.to_s.upcase,
21
+ message: message.to_s
22
+ )
23
+ # Keep last 500 entries
24
+ @entries.shift if @entries.size > 500
25
+ end
26
+ end
27
+
28
+ def get(category: nil)
29
+ @mutex.synchronize do
30
+ list = category ? @entries.select { |e| e.category == category.to_s } : @entries.dup
31
+ list.reverse.map { |e| { timestamp: e.timestamp, category: e.category, level: e.level, message: e.message } }
32
+ end
33
+ end
34
+
35
+ def clear(category: nil)
36
+ @mutex.synchronize do
37
+ if category
38
+ @entries.reject! { |e| e.category == category.to_s }
39
+ else
40
+ @entries.clear
41
+ end
42
+ end
43
+ end
44
+
45
+ def count
46
+ @mutex.synchronize do
47
+ counts = Hash.new(0)
48
+ @entries.each { |e| counts[e.category] += 1 }
49
+ counts["total"] = @entries.size
50
+ counts
51
+ end
52
+ end
53
+ end
54
+
55
+ # Thread-safe request capture for dev dashboard
56
+ class RequestInspector
57
+ CapturedRequest = Struct.new(:timestamp, :method, :path, :status, :duration, keyword_init: true)
58
+
59
+ def initialize
60
+ @requests = []
61
+ @mutex = Mutex.new
62
+ end
63
+
64
+ def capture(method:, path:, status:, duration:)
65
+ @mutex.synchronize do
66
+ @requests << CapturedRequest.new(
67
+ timestamp: Time.now.utc.iso8601(3),
68
+ method: method.to_s,
69
+ path: path.to_s,
70
+ status: status.to_i,
71
+ duration: duration.to_f.round(3)
72
+ )
73
+ # Keep last 200 entries
74
+ @requests.shift if @requests.size > 200
75
+ end
76
+ end
77
+
78
+ def get(limit: 50)
79
+ @mutex.synchronize do
80
+ @requests.last([limit, @requests.size].min).reverse.map do |r|
81
+ { timestamp: r.timestamp, method: r.method, path: r.path, status: r.status, duration_ms: r.duration }
82
+ end
83
+ end
84
+ end
85
+
86
+ def stats
87
+ @mutex.synchronize do
88
+ return { total: 0, avg_ms: 0.0, errors: 0, slowest_ms: 0.0 } if @requests.empty?
89
+
90
+ durations = @requests.map(&:duration)
91
+ error_count = @requests.count { |r| r.status >= 400 }
92
+
93
+ {
94
+ total: @requests.size,
95
+ avg_ms: (durations.sum / durations.size).round(2),
96
+ errors: error_count,
97
+ slowest_ms: durations.max.round(2)
98
+ }
99
+ end
100
+ end
101
+
102
+ def clear
103
+ @mutex.synchronize { @requests.clear }
104
+ end
105
+ end
106
+
107
+ # Developer dashboard module - only active in debug mode
108
+ module DevAdmin
109
+ class << self
110
+ def message_log
111
+ @message_log ||= MessageLog.new
112
+ end
113
+
114
+ def request_inspector
115
+ @request_inspector ||= RequestInspector.new
116
+ end
117
+
118
+ def mailbox
119
+ @mailbox ||= DevMailbox.new
120
+ end
121
+
122
+ def enabled?
123
+ Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
124
+ end
125
+
126
+ # Handle a /__dev request; returns [status, headers, body] or nil if not a dev path
127
+ def handle_request(env)
128
+ return nil unless enabled?
129
+
130
+ path = env["PATH_INFO"] || "/"
131
+ method = env["REQUEST_METHOD"]
132
+
133
+ case [method, path]
134
+ when ["GET", "/__dev"], ["GET", "/__dev/"]
135
+ serve_dashboard
136
+ when ["GET", "/__dev/js/tina4-dev-admin.min.js"]
137
+ serve_dev_js
138
+ when ["GET", "/__dev/api/status"]
139
+ json_response(status_payload)
140
+ when ["GET", "/__dev/api/routes"]
141
+ json_response(routes_payload)
142
+ when ["GET", "/__dev/api/messages"]
143
+ category = query_param(env, "category")
144
+ messages = message_log.get(category: category)
145
+ counts = message_log.count
146
+ json_response({ messages: messages, counts: counts })
147
+ when ["POST", "/__dev/api/messages/clear"]
148
+ body = read_json_body(env)
149
+ category = body["category"] if body
150
+ message_log.clear(category: category)
151
+ json_response({ cleared: true })
152
+ when ["GET", "/__dev/api/requests"]
153
+ limit = (query_param(env, "limit") || 50).to_i
154
+ json_response({ requests: request_inspector.get(limit: limit), stats: request_inspector.stats })
155
+ when ["POST", "/__dev/api/requests/clear"]
156
+ request_inspector.clear
157
+ json_response({ cleared: true })
158
+ when ["GET", "/__dev/api/system"]
159
+ json_response(system_payload)
160
+ when ["GET", "/__dev/api/queue"]
161
+ json_response({ jobs: [], stats: { pending: 0, completed: 0, failed: 0, reserved: 0 } })
162
+ when ["GET", "/__dev/api/mailbox"]
163
+ messages = mailbox.inbox
164
+ json_response({ messages: messages, count: messages.size, unread: mailbox.unread_count })
165
+ when ["GET", "/__dev/api/broken"]
166
+ json_response({ errors: [], health: { total: 0, unresolved: 0, resolved: 0, healthy: true } })
167
+ when ["POST", "/__dev/api/broken/resolve"]
168
+ body = read_json_body(env)
169
+ # TODO: resolve tracked error by id from body["id"]
170
+ json_response({ resolved: true })
171
+ when ["POST", "/__dev/api/broken/clear"]
172
+ # TODO: clear resolved errors
173
+ json_response({ cleared: true })
174
+ when ["GET", "/__dev/api/websockets"]
175
+ json_response({ connections: [], count: 0 })
176
+ when ["POST", "/__dev/api/websockets/disconnect"]
177
+ body = read_json_body(env)
178
+ # TODO: disconnect WS connection by id from body["id"]
179
+ json_response({ disconnected: true })
180
+ when ["GET", "/__dev/api/mailbox/read"]
181
+ message_id = query_param(env, "id")
182
+ message = mailbox.read(message_id)
183
+ if message
184
+ json_response(message)
185
+ else
186
+ body = JSON.generate({ error: "Message not found", id: message_id })
187
+ [404, { "content-type" => "application/json; charset=utf-8" }, [body]]
188
+ end
189
+ when ["POST", "/__dev/api/mailbox/seed"]
190
+ body = read_json_body(env)
191
+ count = ((body && body["count"]) || 5).to_i
192
+ mailbox.seed(count: count)
193
+ json_response({ seeded: count })
194
+ when ["POST", "/__dev/api/mailbox/clear"]
195
+ mailbox.clear
196
+ json_response({ cleared: true })
197
+ when ["GET", "/__dev/api/messages/search"]
198
+ keyword = query_param(env, "q") || query_param(env, "keyword") || ""
199
+ all_messages = message_log.get
200
+ filtered = keyword.empty? ? all_messages : all_messages.select { |m| m[:message].to_s.downcase.include?(keyword.downcase) }
201
+ json_response({ messages: filtered, count: filtered.size, keyword: keyword })
202
+ when ["POST", "/__dev/api/queue/retry"]
203
+ body = read_json_body(env)
204
+ # TODO: retry failed jobs by id from body["id"]
205
+ json_response({ retried: true })
206
+ when ["POST", "/__dev/api/queue/purge"]
207
+ # TODO: purge completed jobs
208
+ json_response({ purged: true })
209
+ when ["POST", "/__dev/api/queue/replay"]
210
+ body = read_json_body(env)
211
+ # TODO: replay a specific job by id from body["id"]
212
+ json_response({ replayed: true })
213
+ when ["GET", "/__dev/api/table"]
214
+ table_name = query_param(env, "name")
215
+ json_response(table_detail_payload(table_name))
216
+ when ["POST", "/__dev/api/seed"]
217
+ body = read_json_body(env)
218
+ table_name = (body && body["table"]) || ""
219
+ count = (body && body["count"]) || 10
220
+ json_response(seed_table_data(table_name, count.to_i))
221
+ when ["POST", "/__dev/api/tool"]
222
+ body = read_json_body(env)
223
+ tool = (body && body["tool"]) || ""
224
+ json_response(run_tool(tool))
225
+ when ["POST", "/__dev/api/chat"]
226
+ body = read_json_body(env)
227
+ message = (body && body["message"]) || ""
228
+ json_response({
229
+ reply: "Chat is not yet connected to an AI backend. You said: \"#{message}\"",
230
+ timestamp: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
231
+ })
232
+ when ["GET", "/__dev/api/connections"]
233
+ handle_connections_get
234
+ when ["POST", "/__dev/api/connections/test"]
235
+ body = read_json_body(env)
236
+ handle_connections_test(body)
237
+ when ["POST", "/__dev/api/connections/save"]
238
+ body = read_json_body(env)
239
+ handle_connections_save(body)
240
+ when ["POST", "/__dev/api/query"]
241
+ body = read_json_body(env)
242
+ sql = (body && (body["query"] || body["sql"])) || ""
243
+ json_response(run_query(sql))
244
+ when ["GET", "/__dev/api/tables"]
245
+ json_response(tables_payload)
246
+ when ["GET", "/__dev/api/gallery"]
247
+ json_response(gallery_list)
248
+ when ["POST", "/__dev/api/gallery/deploy"]
249
+ body = read_json_body(env)
250
+ name = (body && body["name"]) || ""
251
+ json_response(gallery_deploy(name))
252
+ else
253
+ nil
254
+ end
255
+ end
256
+
257
+
258
+ private
259
+
260
+ def query_param(env, key)
261
+ qs = env["QUERY_STRING"] || ""
262
+ params = URI.decode_www_form(qs).to_h rescue {}
263
+ params[key]
264
+ end
265
+
266
+ def read_json_body(env)
267
+ input = env["rack.input"]
268
+ return nil unless input
269
+ input.rewind if input.respond_to?(:rewind)
270
+ raw = input.read
271
+ return nil if raw.nil? || raw.empty?
272
+ JSON.parse(raw) rescue nil
273
+ end
274
+
275
+ def json_response(data)
276
+ body = JSON.generate(data)
277
+ [200, { "content-type" => "application/json; charset=utf-8" }, [body]]
278
+ end
279
+
280
+ def serve_dashboard
281
+ [200, { "content-type" => "text/html; charset=utf-8" }, [render_dashboard]]
282
+ end
283
+
284
+ def serve_dev_js
285
+ js_path = File.join(File.dirname(__FILE__), "public", "js", "tina4-dev-admin.min.js")
286
+ if File.file?(js_path)
287
+ [200, { "content-type" => "application/javascript; charset=utf-8" }, [File.read(js_path)]]
288
+ else
289
+ [404, { "content-type" => "text/plain" }, ["tina4-dev-admin.min.js not found"]]
290
+ end
291
+ end
292
+
293
+ def status_payload
294
+ {
295
+ framework: "tina4-ruby",
296
+ version: Tina4::VERSION,
297
+ ruby_version: RUBY_VERSION,
298
+ platform: RUBY_PLATFORM,
299
+ debug: ENV["TINA4_DEBUG"] || "false",
300
+ log_level: ENV["TINA4_LOG_LEVEL"] || "ERROR",
301
+ uptime: (Time.now - (defined?(@boot_time) && @boot_time ? @boot_time : (@boot_time = Time.now))).round(1),
302
+ route_count: Tina4::Router.routes.size,
303
+ request_stats: request_inspector.stats,
304
+ message_counts: message_log.count
305
+ }
306
+ end
307
+
308
+ def routes_payload
309
+ internal_prefixes = ["/__dev", "/health", "/swagger"]
310
+ routes = Tina4::Router.routes
311
+ .reject { |route| internal_prefixes.any? { |prefix| route.path.start_with?(prefix) } }
312
+ .map do |route|
313
+ {
314
+ method: route.method,
315
+ pattern: route.path,
316
+ middleware: route.respond_to?(:middleware_count) ? route.middleware_count : 0,
317
+ cache: route.respond_to?(:cached?) ? route.cached? : false,
318
+ secure: !route.auth_handler.nil?
319
+ }
320
+ end
321
+ { routes: routes, count: routes.size }
322
+ end
323
+
324
+ def system_payload
325
+ gc = GC.stat
326
+ mem = begin
327
+ if RUBY_PLATFORM.include?("darwin")
328
+ `ps -o rss= -p #{Process.pid}`.strip.to_i # KB
329
+ elsif RUBY_PLATFORM.include?("linux")
330
+ (File.read("/proc/self/status")[/VmRSS:\s+(\d+)/, 1].to_i rescue 0)
331
+ else
332
+ 0
333
+ end
334
+ end
335
+
336
+ os_release = (`uname -r`.strip rescue "unknown")
337
+ host_name = (`hostname`.strip rescue "unknown")
338
+
339
+ {
340
+ ruby_version: RUBY_VERSION,
341
+ ruby_engine: RUBY_ENGINE,
342
+ os: "#{RUBY_PLATFORM} #{os_release}",
343
+ architecture: RUBY_PLATFORM,
344
+ memory: {
345
+ current_mb: (mem / 1024.0).round(1),
346
+ peak_mb: "N/A",
347
+ limit: "N/A"
348
+ },
349
+ server: {
350
+ software: "Ruby/WEBrick",
351
+ hostname: host_name,
352
+ document_root: Tina4.root_dir || Dir.pwd
353
+ },
354
+ framework: {
355
+ name: "tina4-ruby",
356
+ version: Tina4::VERSION,
357
+ route_count: Tina4::Router.routes.size
358
+ },
359
+ extensions: $LOADED_FEATURES.map { |f| File.basename(f, ".rb") }.uniq.sort.first(50),
360
+ gc: {
361
+ count: gc[:count],
362
+ heap_allocated_pages: gc[:heap_allocated_pages],
363
+ heap_live_slots: gc[:heap_live_slots],
364
+ total_allocated_objects: gc[:total_allocated_objects],
365
+ total_freed_objects: gc[:total_freed_objects]
366
+ },
367
+ pid: Process.pid,
368
+ thread_count: Thread.list.size,
369
+ env: ENV["TINA4_ENV"] || ENV["RACK_ENV"] || ENV["RUBY_ENV"] || "development"
370
+ }
371
+ end
372
+
373
+ def run_tool(tool)
374
+ output = case tool
375
+ when "routes"
376
+ routes = Tina4::Router.routes.map { |r| { method: r.method, path: r.path } }
377
+ JSON.pretty_generate(routes)
378
+ when "test"
379
+ "Test runner not yet configured. Run: bundle exec rspec"
380
+ when "migrate"
381
+ "Migration runner not yet configured. Run: tina4ruby migrate"
382
+ when "seed"
383
+ "Seeder not yet configured. Run: tina4ruby seed"
384
+ else
385
+ "Unknown tool: #{tool}"
386
+ end
387
+ { tool: tool, output: output }
388
+ end
389
+
390
+ def run_query(sql)
391
+ sql = sql.to_s.strip
392
+ return { error: "No SQL provided" } if sql.empty?
393
+
394
+ first_word = sql.split(/[\s\t\n\r]+/, 2).first.to_s.upcase
395
+ unless %w[SELECT PRAGMA EXPLAIN SHOW DESCRIBE].include?(first_word)
396
+ return { error: "Only SELECT queries are allowed in the dev dashboard" }
397
+ end
398
+
399
+ db = Tina4.database
400
+ return { error: "No database configured" } unless db
401
+
402
+ begin
403
+ result = db.fetch(sql)
404
+ rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
405
+ columns = rows.first.is_a?(Hash) ? rows.first.keys.map(&:to_s) : []
406
+ { columns: columns, rows: rows, count: rows.size }
407
+ rescue => e
408
+ { error: e.message }
409
+ end
410
+ end
411
+
412
+ def tables_payload
413
+ db = Tina4.database
414
+ return { error: "No database configured", tables: [] } unless db
415
+
416
+ begin
417
+ table_list = db.tables
418
+ { tables: table_list }
419
+ rescue => e
420
+ { error: e.message, tables: [] }
421
+ end
422
+ end
423
+
424
+ def table_detail_payload(table_name)
425
+ return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
426
+
427
+ db = Tina4.database
428
+ return { error: "No database configured" } unless db
429
+
430
+ begin
431
+ columns = db.columns(table_name)
432
+ result = db.fetch("SELECT * FROM #{table_name} LIMIT 20")
433
+ rows = result.respond_to?(:to_a) ? result.to_a : (result.is_a?(Array) ? result : [])
434
+ { table: table_name, columns: columns, rows: rows, count: rows.size }
435
+ rescue => e
436
+ { error: e.message }
437
+ end
438
+ end
439
+
440
+ def seed_table_data(table_name, count)
441
+ return { error: "No table name provided" } if table_name.nil? || table_name.strip.empty?
442
+
443
+ db = Tina4.database
444
+ return { error: "No database configured" } unless db
445
+
446
+ begin
447
+ columns = db.columns(table_name)
448
+ seeded = Tina4.seed_table(table_name, columns, count: count)
449
+ { table: table_name, seeded: seeded }
450
+ rescue => e
451
+ { error: e.message }
452
+ end
453
+ end
454
+
455
+ def handle_connections_get
456
+ env_path = File.join(Dir.pwd, ".env")
457
+ url = ""
458
+ username = ""
459
+ password = ""
460
+ if File.file?(env_path)
461
+ File.readlines(env_path).each do |line|
462
+ line = line.strip
463
+ next if line.empty? || line.start_with?("#") || !line.include?("=")
464
+ key, val = line.split("=", 2)
465
+ key = key.strip
466
+ val = (val || "").strip.gsub(/\A["']|["']\z/, "")
467
+ case key
468
+ when "DATABASE_URL" then url = val
469
+ when "DATABASE_USERNAME" then username = val
470
+ when "DATABASE_PASSWORD" then password = val.empty? ? "" : "***"
471
+ end
472
+ end
473
+ end
474
+ json_response({ url: url, username: username, password: password })
475
+ end
476
+
477
+ def handle_connections_test(body)
478
+ url = (body && body["url"]) || ""
479
+ username = (body && body["username"]) || ""
480
+ password = (body && body["password"]) || ""
481
+ return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
482
+ begin
483
+ db = Tina4::Database.new(url, username: username, password: password)
484
+ version = "Connected"
485
+ table_count = 0
486
+ begin
487
+ tables = db.tables
488
+ table_count = tables.is_a?(Array) ? tables.size : 0
489
+ rescue => e
490
+ table_count = 0
491
+ end
492
+ begin
493
+ url_lower = url.downcase
494
+ if url_lower.include?("sqlite")
495
+ row = db.fetch_one("SELECT sqlite_version() as v")
496
+ version = "SQLite #{row && row[:v] || row && row['v']}" if row
497
+ elsif url_lower.include?("postgres")
498
+ row = db.fetch_one("SELECT version() as v")
499
+ version = (row && (row[:v] || row["v"]) || "PostgreSQL").to_s.split(",").first if row
500
+ elsif url_lower.include?("mysql")
501
+ row = db.fetch_one("SELECT version() as v")
502
+ version = "MySQL #{row && row[:v] || row && row['v']}" if row
503
+ elsif url_lower.include?("mssql") || url_lower.include?("sqlserver")
504
+ row = db.fetch_one("SELECT @@VERSION as v")
505
+ version = (row && (row[:v] || row["v"]) || "MSSQL").to_s.split("\n").first if row
506
+ elsif url_lower.include?("firebird")
507
+ row = db.fetch_one("SELECT rdb$get_context('SYSTEM', 'ENGINE_VERSION') as v FROM rdb$database")
508
+ version = "Firebird #{row && row[:v] || row && row['v']}" if row
509
+ end
510
+ rescue => e
511
+ # Keep version as "Connected"
512
+ end
513
+ db.close if db.respond_to?(:close)
514
+ json_response({ success: true, version: version, tables: table_count })
515
+ rescue => e
516
+ json_response({ success: false, error: e.message })
517
+ end
518
+ end
519
+
520
+ def handle_connections_save(body)
521
+ url = (body && body["url"]) || ""
522
+ username = (body && body["username"]) || ""
523
+ password = (body && body["password"]) || ""
524
+ return json_response({ success: false, error: "No connection URL provided" }) if url.empty?
525
+ begin
526
+ env_path = File.join(Dir.pwd, ".env")
527
+ lines = File.file?(env_path) ? File.readlines(env_path, chomp: true) : []
528
+ keys_found = { "DATABASE_URL" => false, "DATABASE_USERNAME" => false, "DATABASE_PASSWORD" => false }
529
+ new_lines = []
530
+ lines.each do |line|
531
+ stripped = line.strip
532
+ if stripped.empty? || stripped.start_with?("#") || !stripped.include?("=")
533
+ new_lines << line
534
+ next
535
+ end
536
+ key = stripped.split("=", 2).first.strip
537
+ case key
538
+ when "DATABASE_URL"
539
+ new_lines << "DATABASE_URL=#{url}"
540
+ keys_found["DATABASE_URL"] = true
541
+ when "DATABASE_USERNAME"
542
+ new_lines << "DATABASE_USERNAME=#{username}"
543
+ keys_found["DATABASE_USERNAME"] = true
544
+ when "DATABASE_PASSWORD"
545
+ new_lines << "DATABASE_PASSWORD=#{password}"
546
+ keys_found["DATABASE_PASSWORD"] = true
547
+ else
548
+ new_lines << line
549
+ end
550
+ end
551
+ values = { "DATABASE_URL" => url, "DATABASE_USERNAME" => username, "DATABASE_PASSWORD" => password }
552
+ keys_found.each do |key, found|
553
+ new_lines << "#{key}=#{values[key]}" unless found
554
+ end
555
+ File.write(env_path, new_lines.join("\n") + "\n")
556
+ json_response({ success: true })
557
+ rescue => e
558
+ json_response({ success: false, error: e.message })
559
+ end
560
+ end
561
+
562
+ def gallery_list
563
+ gallery_dir = File.join(File.dirname(__FILE__), "gallery")
564
+ items = []
565
+ if Dir.exist?(gallery_dir)
566
+ Dir.children(gallery_dir).sort.each do |entry|
567
+ entry_path = File.join(gallery_dir, entry)
568
+ meta_file = File.join(entry_path, "meta.json")
569
+ next unless File.directory?(entry_path) && File.file?(meta_file)
570
+
571
+ meta = JSON.parse(File.read(meta_file)) rescue next
572
+ meta["id"] = entry
573
+ src_dir = File.join(entry_path, "src")
574
+ if Dir.exist?(src_dir)
575
+ meta["files"] = Dir.glob(File.join(src_dir, "**", "*"))
576
+ .select { |f| File.file?(f) }
577
+ .map { |f| f.sub("#{src_dir}/", "") }
578
+ end
579
+ items << meta
580
+ end
581
+ end
582
+ { gallery: items, count: items.size }
583
+ end
584
+
585
+ def gallery_deploy(name)
586
+ return { error: "No gallery item specified" } if name.to_s.empty?
587
+
588
+ gallery_src = File.join(File.dirname(__FILE__), "gallery", name, "src")
589
+ return { error: "Gallery item '#{name}' not found" } unless Dir.exist?(gallery_src)
590
+
591
+ require "fileutils"
592
+ project_src = File.join(Tina4.root_dir || Dir.pwd, "src")
593
+ copied = []
594
+ Dir.glob(File.join(gallery_src, "**", "*")).each do |src_file|
595
+ next unless File.file?(src_file)
596
+
597
+ rel = src_file.sub("#{gallery_src}/", "")
598
+ dest = File.join(project_src, rel)
599
+ FileUtils.mkdir_p(File.dirname(dest))
600
+ FileUtils.cp(src_file, dest)
601
+ copied << rel
602
+ end
603
+
604
+ # Re-discover routes so new files are immediately available
605
+ begin
606
+ routes_dir = File.join(Tina4.root_dir || Dir.pwd, "src", "routes")
607
+ Tina4::Router.load_routes(routes_dir) if Dir.exist?(routes_dir)
608
+ rescue => e
609
+ Tina4::Log.warning("Gallery route reload: #{e.message}")
610
+ end
611
+
612
+ { deployed: name, files: copied }
613
+ end
614
+
615
+ def render_dashboard
616
+ <<~'HTML'
617
+ <!DOCTYPE html>
618
+ <html lang="en">
619
+ <head>
620
+ <meta charset="UTF-8">
621
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
622
+ <title>Tina4 Dev Admin</title>
623
+ <style>
624
+ :root {
625
+ --bg: #0f172a; --surface: #1e293b; --border: #334155;
626
+ --text: #e2e8f0; --muted: #94a3b8; --primary: #c62828;
627
+ --success: #22c55e; --danger: #ef4444; --warn: #f59e0b;
628
+ --info: #06b6d4; --radius: 0.5rem;
629
+ --mono: 'SF Mono', 'Fira Code', 'Consolas', monospace;
630
+ --font: system-ui, -apple-system, sans-serif;
631
+ }
632
+ * { box-sizing: border-box; margin: 0; padding: 0; }
633
+
634
+ body { font-family: var(--font); background: var(--bg); color: var(--text); font-size: 0.875rem; }
635
+ .dev-header {
636
+ background: var(--surface); border-bottom: 1px solid var(--border);
637
+ padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1rem;
638
+ }
639
+ .dev-header h1 { font-size: 1rem; font-weight: 600; }
640
+ .dev-header .badge {
641
+ background: var(--primary); color: #fff; padding: 0.15rem 0.5rem;
642
+ border-radius: 1rem; font-size: 0.7rem; font-weight: 600;
643
+ }
644
+ .dev-tabs {
645
+ display: flex; gap: 0; background: var(--surface);
646
+ border-bottom: 1px solid var(--border); overflow-x: auto;
647
+ }
648
+ .dev-tab {
649
+ padding: 0.6rem 1rem; cursor: pointer; font-size: 0.8rem;
650
+ border-bottom: 2px solid transparent; color: var(--muted);
651
+ transition: all 0.15s; background: none; border-top: none;
652
+ border-left: none; border-right: none; white-space: nowrap;
653
+ }
654
+ .dev-tab:hover { color: var(--text); }
655
+ .dev-tab.active { color: var(--primary); border-bottom-color: var(--primary); }
656
+ .dev-tab .count {
657
+ background: var(--border); color: var(--muted); padding: 0.1rem 0.4rem;
658
+ border-radius: 0.75rem; font-size: 0.65rem; margin-left: 0.25rem;
659
+ }
660
+ .dev-content { padding: 1rem; max-width: 1400px; }
661
+ .dev-panel {
662
+ background: var(--surface); border: 1px solid var(--border);
663
+ border-radius: var(--radius); overflow: hidden;
664
+ }
665
+ .dev-panel-header {
666
+ padding: 0.75rem 1rem; border-bottom: 1px solid var(--border);
667
+ display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;
668
+ }
669
+ .dev-panel-header h2 { font-size: 0.9rem; font-weight: 600; }
670
+ table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
671
+ th { text-align: left; padding: 0.5rem 0.75rem; color: var(--muted); font-weight: 500; border-bottom: 1px solid var(--border); }
672
+ td { padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); }
673
+ tr:hover { background: rgba(198, 40, 40, 0.05); }
674
+ .method { font-family: var(--mono); font-size: 0.7rem; font-weight: 700; }
675
+ .method-get { color: var(--success); }
676
+ .method-post { color: var(--primary); }
677
+ .method-put { color: var(--warn); }
678
+ .method-delete { color: var(--danger); }
679
+ .path { font-family: var(--mono); font-size: 0.75rem; }
680
+ .badge-pill {
681
+ display: inline-block; padding: 0.1rem 0.5rem; border-radius: 1rem;
682
+ font-size: 0.65rem; font-weight: 600; text-transform: uppercase;
683
+ }
684
+ .bg-pending { background: rgba(245,158,11,0.15); color: var(--warn); }
685
+ .bg-completed, .bg-success { background: rgba(34,197,94,0.15); color: var(--success); }
686
+ .bg-failed, .bg-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
687
+ .bg-reserved, .bg-primary { background: rgba(198,40,40,0.15); color: var(--primary); }
688
+ .bg-info { background: rgba(6,182,212,0.15); color: var(--info); }
689
+ .btn {
690
+ padding: 0.3rem 0.65rem; border: 1px solid var(--border); border-radius: var(--radius);
691
+ background: var(--surface); color: var(--text); cursor: pointer; font-size: 0.75rem;
692
+ transition: all 0.15s;
693
+ }
694
+ .btn:hover { border-color: var(--primary); color: var(--primary); }
695
+ .btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
696
+ .btn-primary:hover { background: #d32f2f; }
697
+ .btn-danger { border-color: var(--danger); color: var(--danger); }
698
+ .btn-danger:hover { background: rgba(239,68,68,0.1); }
699
+ .btn-success { border-color: var(--success); color: var(--success); }
700
+ .btn-sm { padding: 0.2rem 0.5rem; font-size: 0.7rem; }
701
+ .empty { padding: 2rem; text-align: center; color: var(--muted); }
702
+ .input {
703
+ background: var(--bg); color: var(--text); border: 1px solid var(--border);
704
+ border-radius: var(--radius); padding: 0.35rem 0.5rem; font-size: 0.8rem;
705
+ font-family: var(--font);
706
+ }
707
+ .input:focus { outline: none; border-color: var(--primary); }
708
+ .input-mono { font-family: var(--mono); }
709
+ select.input { padding: 0.3rem; }
710
+ textarea.input { resize: vertical; font-family: var(--mono); }
711
+ .flex { display: flex; }
712
+ .gap-sm { gap: 0.5rem; }
713
+ .gap-md { gap: 1rem; }
714
+ .items-center { align-items: center; }
715
+ .justify-between { justify-content: space-between; }
716
+ .flex-1 { flex: 1; }
717
+ .p-sm { padding: 0.5rem; }
718
+ .p-md { padding: 1rem; }
719
+ .mb-sm { margin-bottom: 0.5rem; }
720
+ .text-sm { font-size: 0.75rem; }
721
+ .text-muted { color: var(--muted); }
722
+ .text-mono { font-family: var(--mono); }
723
+ .mail-item { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); cursor: pointer; }
724
+ .mail-item:hover { background: rgba(198,40,40,0.05); }
725
+ .mail-item.unread { border-left: 3px solid var(--primary); }
726
+ .msg-entry { padding: 0.4rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.75rem; }
727
+ .msg-entry .cat {
728
+ font-family: var(--mono); font-size: 0.65rem; padding: 0.1rem 0.35rem;
729
+ border-radius: 0.25rem; background: rgba(198,40,40,0.15); color: var(--primary);
730
+ }
731
+ .msg-entry .time { color: var(--muted); font-size: 0.7rem; font-family: var(--mono); }
732
+ .level-error { color: var(--danger); }
733
+ .level-warn { color: var(--warn); }
734
+ .toolbar { display: flex; gap: 0.5rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); flex-wrap: wrap; align-items: center; }
735
+ .hidden { display: none; }
736
+ /* Chat panel */
737
+ .chat-container { display: flex; flex-direction: column; height: 500px; }
738
+ .chat-messages { flex: 1; overflow-y: auto; padding: 0.75rem; }
739
+ .chat-msg { margin-bottom: 0.75rem; padding: 0.5rem 0.75rem; border-radius: var(--radius); font-size: 0.8rem; max-width: 85%; }
740
+ .chat-user { background: var(--primary); color: #fff; margin-left: auto; }
741
+ .chat-bot { background: var(--bg); border: 1px solid var(--border); }
742
+ .chat-input-row { display: flex; gap: 0.5rem; padding: 0.75rem; border-top: 1px solid var(--border); }
743
+ .chat-input-row input { flex: 1; }
744
+ /* System cards */
745
+ .sys-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.75rem; padding: 1rem; }
746
+ .sys-card { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.75rem; }
747
+ .sys-card .label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
748
+ .sys-card .value { font-size: 1.25rem; font-weight: 600; margin-top: 0.25rem; }
749
+ /* Request table */
750
+ .status-ok { color: var(--success); }
751
+ .status-err { color: var(--danger); }
752
+ .status-warn { color: var(--warn); }
753
+ /* Filter buttons */
754
+ .filter-btn { cursor: pointer; }
755
+ .filter-btn.active { border-color: var(--primary); color: var(--primary); }
756
+ code, .mono { font-family: var(--mono); font-size: 0.82rem; }
757
+ </style>
758
+ </head>
759
+ <body>
760
+
761
+ <div class="dev-header">
762
+ <img src="/images/logo.svg" style="width:1.5rem;height:1.5rem;cursor:pointer;opacity:0.7;transition:opacity 0.15s" title="Back to app" onclick="exitDevAdmin()" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.7'" alt="Tina4">
763
+ <h1>Tina4 Dev Admin</h1>
764
+ <span class="badge">DEV</span>
765
+ <span style="margin-left:auto; font-size:0.75rem; color:var(--muted)" id="timestamp"></span>
766
+ </div>
767
+
768
+ <div class="dev-tabs">
769
+ <button class="dev-tab active" onclick="showTab('routes', event)">Routes <span class="count" id="routes-count">0</span></button>
770
+ <button class="dev-tab" onclick="showTab('queue', event)">Queue <span class="count" id="queue-count">0</span></button>
771
+ <button class="dev-tab" onclick="showTab('mailbox', event)">Mailbox <span class="count" id="mailbox-count">0</span></button>
772
+ <button class="dev-tab" onclick="showTab('messages', event)">Messages <span class="count" id="messages-count">0</span></button>
773
+ <button class="dev-tab" onclick="showTab('database', event)">Database <span class="count" id="db-count">0</span></button>
774
+ <button class="dev-tab" onclick="showTab('requests', event)">Requests <span class="count" id="req-count">0</span></button>
775
+ <button class="dev-tab" onclick="showTab('errors', event)">Errors <span class="count" id="err-count">0</span></button>
776
+ <button class="dev-tab" onclick="showTab('websockets', event)">WS <span class="count" id="ws-count">0</span></button>
777
+ <button class="dev-tab" onclick="showTab('system', event)">System</button>
778
+ <button class="dev-tab" onclick="showTab('tools', event)">Tools</button>
779
+ <button class="dev-tab" onclick="showTab('connections', event)">Connections</button>
780
+ <button class="dev-tab" onclick="showTab('chat', event)">Tina4</button>
781
+ </div>
782
+
783
+ <div class="dev-content">
784
+
785
+ <!-- Routes Panel -->
786
+ <div id="panel-routes" class="dev-panel">
787
+ <div class="dev-panel-header">
788
+ <h2>Registered Routes</h2>
789
+ <button class="btn btn-sm" onclick="loadRoutes()">Refresh</button>
790
+ </div>
791
+ <table>
792
+ <thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Handler</th></tr></thead>
793
+ <tbody id="routes-body"></tbody>
794
+ </table>
795
+ </div>
796
+
797
+ <!-- Queue Panel -->
798
+ <div id="panel-queue" class="dev-panel hidden">
799
+ <div class="dev-panel-header">
800
+ <h2>Queue Jobs</h2>
801
+ <div class="flex gap-sm">
802
+ <button class="btn btn-sm" onclick="loadQueue()">Refresh</button>
803
+ <button class="btn btn-sm" onclick="retryQueue()">Retry Failed</button>
804
+ <button class="btn btn-sm btn-danger" onclick="purgeQueue()">Purge Done</button>
805
+ </div>
806
+ </div>
807
+ <div class="toolbar">
808
+ <button class="btn btn-sm filter-btn active" onclick="filterQueue('', event)">All</button>
809
+ <button class="btn btn-sm filter-btn" onclick="filterQueue('pending', event)">Pending <span id="q-pending">0</span></button>
810
+ <button class="btn btn-sm filter-btn" onclick="filterQueue('completed', event)">Done <span id="q-completed">0</span></button>
811
+ <button class="btn btn-sm filter-btn" onclick="filterQueue('failed', event)">Failed <span id="q-failed">0</span></button>
812
+ <button class="btn btn-sm filter-btn" onclick="filterQueue('reserved', event)">Active <span id="q-reserved">0</span></button>
813
+ </div>
814
+ <table>
815
+ <thead><tr><th>ID</th><th>Topic</th><th>Status</th><th>Attempts</th><th>Created</th><th>Data</th><th></th></tr></thead>
816
+ <tbody id="queue-body"></tbody>
817
+ </table>
818
+ <div id="queue-empty" class="empty hidden">No queue jobs</div>
819
+ </div>
820
+
821
+ <!-- Mailbox Panel -->
822
+ <div id="panel-mailbox" class="dev-panel hidden">
823
+ <div class="dev-panel-header">
824
+ <h2>Dev Mailbox</h2>
825
+ <div class="flex gap-sm">
826
+ <button class="btn btn-sm" onclick="loadMailbox()">Refresh</button>
827
+ <button class="btn btn-sm btn-primary" onclick="seedMailbox()">Seed 5</button>
828
+ <button class="btn btn-sm btn-danger" onclick="clearMailbox()">Clear</button>
829
+ </div>
830
+ </div>
831
+ <div class="toolbar">
832
+ <button class="btn btn-sm filter-btn active" onclick="filterMailbox('', event)">All</button>
833
+ <button class="btn btn-sm filter-btn" onclick="filterMailbox('inbox', event)">Inbox</button>
834
+ <button class="btn btn-sm filter-btn" onclick="filterMailbox('outbox', event)">Outbox</button>
835
+ </div>
836
+ <div id="mailbox-list"></div>
837
+ <div id="mail-detail" class="hidden p-md"></div>
838
+ </div>
839
+
840
+ <!-- Messages Panel -->
841
+ <div id="panel-messages" class="dev-panel hidden">
842
+ <div class="dev-panel-header">
843
+ <h2>Message Log</h2>
844
+ <div class="flex gap-sm items-center">
845
+ <input type="text" id="msg-search" class="input" placeholder="Search messages..." onkeydown="if(event.key==='Enter')searchMessages()">
846
+ <button class="btn btn-sm" onclick="searchMessages()">Search</button>
847
+ <button class="btn btn-sm" onclick="loadMessages()">All</button>
848
+ <button class="btn btn-sm btn-danger" onclick="clearMessages()">Clear</button>
849
+ </div>
850
+ </div>
851
+ <div id="messages-list"></div>
852
+ <div id="messages-empty" class="empty">No messages logged</div>
853
+ </div>
854
+
855
+ <!-- Database Panel -->
856
+ <div id="panel-database" class="dev-panel hidden">
857
+ <div class="dev-panel-header">
858
+ <h2>Database</h2>
859
+ <button class="btn btn-sm" onclick="loadTables()">Refresh</button>
860
+ </div>
861
+ <div class="flex gap-md p-md">
862
+ <div class="flex-1">
863
+ <div class="flex gap-sm items-center mb-sm">
864
+ <select id="query-type" class="input">
865
+ <option value="sql">SQL</option>
866
+ </select>
867
+ <button class="btn btn-sm btn-primary" onclick="runQuery()">Run</button>
868
+ <span class="text-sm text-muted">Ctrl+Enter</span>
869
+ </div>
870
+ <textarea id="query-input" rows="4" placeholder="SELECT * FROM users LIMIT 20" class="input input-mono" style="width:100%"></textarea>
871
+ <div id="query-error" class="hidden" style="color:var(--danger);font-size:0.75rem;margin-top:0.25rem"></div>
872
+ </div>
873
+ <div style="width:180px">
874
+ <div class="text-sm text-muted" style="font-weight:600;margin-bottom:0.5rem">Tables</div>
875
+ <div id="table-list" class="text-sm"></div>
876
+ <div style="margin-top:0.75rem;border-top:1px solid var(--border);padding-top:0.75rem">
877
+ <div class="text-sm text-muted" style="font-weight:600;margin-bottom:0.5rem">Seed Data</div>
878
+ <select id="seed-table" class="input" style="width:100%;margin-bottom:0.25rem"><option value="">Pick table...</option></select>
879
+ <div class="flex gap-sm items-center">
880
+ <input type="number" id="seed-count" class="input" value="10" min="1" max="1000" style="width:60px">
881
+ <button class="btn btn-sm btn-success" onclick="seedTable()">Seed</button>
882
+ </div>
883
+ </div>
884
+ </div>
885
+ </div>
886
+ <div id="query-results" style="overflow-x:auto"></div>
887
+ </div>
888
+
889
+ <!-- Requests Panel -->
890
+ <div id="panel-requests" class="dev-panel hidden">
891
+ <div class="dev-panel-header">
892
+ <h2>Request Inspector</h2>
893
+ <div class="flex gap-sm">
894
+ <button class="btn btn-sm" onclick="loadRequests()">Refresh</button>
895
+ <button class="btn btn-sm btn-danger" onclick="clearRequests()">Clear</button>
896
+ </div>
897
+ </div>
898
+ <div id="req-stats" class="toolbar text-sm text-muted"></div>
899
+ <table>
900
+ <thead><tr><th>Time</th><th>Method</th><th>Path</th><th>Status</th><th>Duration</th><th>Size</th></tr></thead>
901
+ <tbody id="req-body"></tbody>
902
+ </table>
903
+ <div id="req-empty" class="empty hidden">No requests captured</div>
904
+ </div>
905
+
906
+ <!-- Errors Panel -->
907
+ <div id="panel-errors" class="dev-panel hidden">
908
+ <div class="dev-panel-header">
909
+ <h2>Error Tracker</h2>
910
+ <div class="flex gap-sm">
911
+ <button class="btn btn-sm" onclick="loadErrors()">Refresh</button>
912
+ <button class="btn btn-sm btn-danger" onclick="clearResolvedErrors()">Clear Resolved</button>
913
+ </div>
914
+ </div>
915
+ <div id="errors-list"></div>
916
+ <div id="errors-empty" class="empty">No errors tracked</div>
917
+ </div>
918
+
919
+ <!-- WebSocket Panel -->
920
+ <div id="panel-websockets" class="dev-panel hidden">
921
+ <div class="dev-panel-header">
922
+ <h2>WebSocket Connections</h2>
923
+ <button class="btn btn-sm" onclick="loadWebSockets()">Refresh</button>
924
+ </div>
925
+ <table>
926
+ <thead><tr><th>ID</th><th>Path</th><th>IP</th><th>Connected</th><th>Status</th><th></th></tr></thead>
927
+ <tbody id="ws-body"></tbody>
928
+ </table>
929
+ <div id="ws-empty" class="empty">No active connections</div>
930
+ </div>
931
+
932
+ <!-- System Panel -->
933
+ <div id="panel-system" class="dev-panel hidden">
934
+ <div class="dev-panel-header">
935
+ <h2>System Overview</h2>
936
+ <button class="btn btn-sm" onclick="loadSystem()">Refresh</button>
937
+ </div>
938
+ <div id="sys-cards" class="sys-grid"></div>
939
+ <div id="sys-extensions" class="hidden"></div>
940
+ </div>
941
+
942
+ <!-- Tools Panel -->
943
+ <div id="panel-tools" class="dev-panel hidden">
944
+ <div class="dev-panel-header">
945
+ <h2>Developer Tools</h2>
946
+ </div>
947
+ <div class="sys-grid">
948
+ <div class="sys-card" style="cursor:pointer" onclick="runTool('test')">
949
+ <div class="label">Run Tests</div>
950
+ <div style="font-size:0.8rem;margin-top:0.25rem">Execute the RSpec test suite</div>
951
+ </div>
952
+ <div class="sys-card" style="cursor:pointer" onclick="runTool('routes')">
953
+ <div class="label">List Routes</div>
954
+ <div style="font-size:0.8rem;margin-top:0.25rem">Show all registered routes with auth status</div>
955
+ </div>
956
+ <div class="sys-card" style="cursor:pointer" onclick="runTool('migrate')">
957
+ <div class="label">Run Migrations</div>
958
+ <div style="font-size:0.8rem;margin-top:0.25rem">Apply pending database migrations</div>
959
+ </div>
960
+ <div class="sys-card" style="cursor:pointer" onclick="runTool('seed')">
961
+ <div class="label">Run Seeders</div>
962
+ <div style="font-size:0.8rem;margin-top:0.25rem">Execute seed scripts</div>
963
+ </div>
964
+ </div>
965
+ <div id="tool-output" class="hidden" style="margin:1rem">
966
+ <div class="dev-panel-header">
967
+ <h2 id="tool-title">Output</h2>
968
+ <button class="btn btn-sm" onclick="document.getElementById('tool-output').classList.add('hidden')">Close</button>
969
+ </div>
970
+ <pre id="tool-result" style="padding:1rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);font-size:0.75rem;font-family:var(--mono);max-height:400px;overflow:auto;white-space:pre-wrap"></pre>
971
+ </div>
972
+ </div>
973
+
974
+ <!-- Connections Panel -->
975
+ <div id="panel-connections" class="dev-panel hidden">
976
+ <div class="dev-panel-header">
977
+ <h2>Connection Builder</h2>
978
+ </div>
979
+ <div class="p-md">
980
+ <div class="flex gap-md" style="flex-wrap:wrap">
981
+ <div style="flex:1;min-width:300px">
982
+ <div class="mb-sm">
983
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Driver</label>
984
+ <select id="conn-driver" class="input" style="width:100%" onchange="connDriverChanged()">
985
+ <option value="sqlite">SQLite</option>
986
+ <option value="postgresql">PostgreSQL</option>
987
+ <option value="mysql">MySQL</option>
988
+ <option value="mssql">MSSQL</option>
989
+ <option value="firebird">Firebird</option>
990
+ </select>
991
+ </div>
992
+ <div class="mb-sm conn-server-field">
993
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Host</label>
994
+ <input type="text" id="conn-host" class="input" style="width:100%" value="localhost" placeholder="localhost" oninput="updateConnectionUrl()">
995
+ </div>
996
+ <div class="mb-sm conn-server-field">
997
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Port</label>
998
+ <input type="number" id="conn-port" class="input" style="width:100%" placeholder="5432" oninput="updateConnectionUrl()">
999
+ </div>
1000
+ <div class="mb-sm">
1001
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Database</label>
1002
+ <input type="text" id="conn-database" class="input" style="width:100%" placeholder="mydb" oninput="updateConnectionUrl()">
1003
+ </div>
1004
+ <div class="mb-sm conn-server-field">
1005
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Username</label>
1006
+ <input type="text" id="conn-username" class="input" style="width:100%" placeholder="username">
1007
+ </div>
1008
+ <div class="mb-sm conn-server-field">
1009
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Password</label>
1010
+ <input type="password" id="conn-password" class="input" style="width:100%" placeholder="password">
1011
+ </div>
1012
+ <div class="mb-sm">
1013
+ <label class="text-sm text-muted" style="display:block;margin-bottom:0.25rem">Connection URL</label>
1014
+ <input type="text" id="conn-url" class="input input-mono" style="width:100%" readonly>
1015
+ </div>
1016
+ <div class="flex gap-sm">
1017
+ <button class="btn btn-primary" onclick="testConnection()">Test Connection</button>
1018
+ <button class="btn btn-success" onclick="saveConnection()">Save to .env</button>
1019
+ </div>
1020
+ </div>
1021
+ <div style="width:300px">
1022
+ <div class="dev-panel" style="margin-bottom:1rem">
1023
+ <div class="dev-panel-header"><h2>Test Result</h2></div>
1024
+ <div id="conn-test-result" class="p-md text-sm text-muted">No test run yet</div>
1025
+ </div>
1026
+ <div class="dev-panel">
1027
+ <div class="dev-panel-header"><h2>Current .env Values</h2></div>
1028
+ <div id="conn-env-values" class="p-md text-sm text-muted">Loading...</div>
1029
+ </div>
1030
+ </div>
1031
+ </div>
1032
+ </div>
1033
+ </div>
1034
+
1035
+ <script>
1036
+ function connDriverChanged() {
1037
+ var driver = document.getElementById('conn-driver').value;
1038
+ var ports = {postgresql: 5432, mysql: 3306, mssql: 1433, firebird: 3050};
1039
+ var isSqlite = (driver === 'sqlite');
1040
+ document.getElementById('conn-port').value = ports[driver] || '';
1041
+ var fields = document.querySelectorAll('.conn-server-field');
1042
+ for (var i = 0; i < fields.length; i++) {
1043
+ fields[i].style.display = isSqlite ? 'none' : '';
1044
+ }
1045
+ updateConnectionUrl();
1046
+ }
1047
+ function updateConnectionUrl() {
1048
+ var driver = document.getElementById('conn-driver').value;
1049
+ var host = document.getElementById('conn-host').value || 'localhost';
1050
+ var port = document.getElementById('conn-port').value;
1051
+ var database = document.getElementById('conn-database').value;
1052
+ if (driver === 'sqlite') {
1053
+ document.getElementById('conn-url').value = 'sqlite:///' + database;
1054
+ } else {
1055
+ document.getElementById('conn-url').value = driver + '://' + host + ':' + port + '/' + database;
1056
+ }
1057
+ }
1058
+ function testConnection() {
1059
+ var url = document.getElementById('conn-url').value;
1060
+ var username = document.getElementById('conn-username').value;
1061
+ var password = document.getElementById('conn-password').value;
1062
+ var el = document.getElementById('conn-test-result');
1063
+ el.innerHTML = '<span class="text-muted">Testing...</span>';
1064
+ fetch('/__dev/api/connections/test', {
1065
+ method: 'POST',
1066
+ headers: {'Content-Type': 'application/json'},
1067
+ body: JSON.stringify({url: url, username: username, password: password})
1068
+ }).then(function(r){return r.json()}).then(function(data) {
1069
+ if (data.success) {
1070
+ el.innerHTML = '<div style="color:var(--success);font-weight:600;margin-bottom:0.5rem">&#10004; Connected</div>' +
1071
+ '<div class="text-sm">Version: ' + (data.version || 'N/A') + '</div>' +
1072
+ '<div class="text-sm">Tables: ' + (data.tables !== undefined ? data.tables : 'N/A') + '</div>';
1073
+ } else {
1074
+ el.innerHTML = '<div style="color:var(--danger);font-weight:600;margin-bottom:0.5rem">&#10008; Failed</div>' +
1075
+ '<div class="text-sm" style="color:var(--danger)">' + (data.error || 'Unknown error') + '</div>';
1076
+ }
1077
+ }).catch(function(e) {
1078
+ el.innerHTML = '<div style="color:var(--danger)">Error: ' + e.message + '</div>';
1079
+ });
1080
+ }
1081
+ function saveConnection() {
1082
+ var url = document.getElementById('conn-url').value;
1083
+ var username = document.getElementById('conn-username').value;
1084
+ var password = document.getElementById('conn-password').value;
1085
+ if (!url) { alert('Please build a connection URL first'); return; }
1086
+ fetch('/__dev/api/connections/save', {
1087
+ method: 'POST',
1088
+ headers: {'Content-Type': 'application/json'},
1089
+ body: JSON.stringify({url: url, username: username, password: password})
1090
+ }).then(function(r){return r.json()}).then(function(data) {
1091
+ if (data.success) {
1092
+ alert('Connection saved to .env');
1093
+ loadConnectionEnv();
1094
+ } else {
1095
+ alert('Save failed: ' + (data.error || 'Unknown error'));
1096
+ }
1097
+ }).catch(function(e) { alert('Error: ' + e.message); });
1098
+ }
1099
+ function loadConnectionEnv() {
1100
+ fetch('/__dev/api/connections').then(function(r){return r.json()}).then(function(data) {
1101
+ var el = document.getElementById('conn-env-values');
1102
+ el.innerHTML = '<div class="mb-sm"><span class="text-muted">DATABASE_URL:</span> <code>' + (data.url || '<em>not set</em>') + '</code></div>' +
1103
+ '<div class="mb-sm"><span class="text-muted">DATABASE_USERNAME:</span> <code>' + (data.username || '<em>not set</em>') + '</code></div>' +
1104
+ '<div><span class="text-muted">DATABASE_PASSWORD:</span> <code>' + (data.password || '<em>not set</em>') + '</code></div>';
1105
+ }).catch(function() {
1106
+ document.getElementById('conn-env-values').innerHTML = '<span class="text-muted">Could not load .env values</span>';
1107
+ });
1108
+ }
1109
+ document.addEventListener('DOMContentLoaded', function() {
1110
+ var connTab = document.querySelector('[onclick*="connections"]');
1111
+ if (connTab) {
1112
+ connTab.addEventListener('click', function() { loadConnectionEnv(); }, {once: true});
1113
+ }
1114
+ });
1115
+ </script>
1116
+
1117
+ <!-- Chat Panel (Tina4) -->
1118
+ <div id="panel-chat" class="dev-panel hidden">
1119
+ <div class="dev-panel-header">
1120
+ <h2>Tina4</h2>
1121
+ <div class="flex gap-sm items-center">
1122
+ <select id="ai-provider" class="input" style="width:120px">
1123
+ <option value="anthropic">Claude</option>
1124
+ <option value="openai">OpenAI</option>
1125
+ </select>
1126
+ <input type="password" id="ai-key" class="input" placeholder="Paste API key..." style="width:250px">
1127
+ <button class="btn btn-sm btn-primary" onclick="setAiKey()">Set Key</button>
1128
+ <span class="text-sm text-muted" id="ai-status">No key set</span>
1129
+ </div>
1130
+ </div>
1131
+ <div class="chat-container">
1132
+ <div class="chat-messages" id="chat-messages">
1133
+ <div class="chat-msg chat-bot">Hi! I'm Tina4. Ask me about routes, ORM, database, queues, templates, auth, or any Tina4 feature.</div>
1134
+ </div>
1135
+ <div class="chat-input-row">
1136
+ <input type="text" id="chat-input" class="input" placeholder="Ask Tina4..." onkeydown="if(event.key==='Enter')sendChat()">
1137
+ <button class="btn btn-primary" onclick="sendChat()">Send</button>
1138
+ </div>
1139
+ </div>
1140
+ </div>
1141
+
1142
+ </div>
1143
+
1144
+ <script src="/__dev/js/tina4-dev-admin.min.js"></script>
1145
+ <script>
1146
+ // Self-diagnostic — detect if the external JS failed to load
1147
+ (function() {
1148
+ if (typeof showTab !== 'function') {
1149
+ var banner = document.createElement('div');
1150
+ banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#ef4444;color:#fff;padding:0.75rem 1rem;font-family:system-ui;font-size:0.85rem;text-align:center';
1151
+ banner.innerHTML = '<strong>Dev Admin Error:</strong> tina4-dev-admin.min.js failed to load. Check that /__dev/js/tina4-dev-admin.min.js is accessible.';
1152
+ document.body.insertBefore(banner, document.body.firstChild);
1153
+ }
1154
+ })();
1155
+ </script>
1156
+ </body>
1157
+ </html>
1158
+ HTML
1159
+ end
1160
+ end
1161
+ end
1162
+ end