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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -80
- data/LICENSE.txt +21 -21
- data/README.md +137 -137
- data/exe/tina4ruby +5 -5
- data/lib/tina4/ai.rb +696 -696
- data/lib/tina4/api.rb +189 -189
- data/lib/tina4/auth.rb +305 -305
- data/lib/tina4/auto_crud.rb +244 -244
- data/lib/tina4/cache.rb +154 -154
- data/lib/tina4/cli.rb +1449 -1449
- data/lib/tina4/constants.rb +46 -46
- data/lib/tina4/container.rb +74 -74
- data/lib/tina4/cors.rb +74 -74
- data/lib/tina4/crud.rb +692 -692
- data/lib/tina4/database/sqlite3_adapter.rb +165 -165
- data/lib/tina4/database.rb +625 -625
- data/lib/tina4/database_result.rb +208 -208
- data/lib/tina4/debug.rb +8 -8
- data/lib/tina4/dev.rb +14 -14
- data/lib/tina4/dev_admin.rb +935 -935
- data/lib/tina4/dev_mailbox.rb +191 -191
- data/lib/tina4/drivers/firebird_driver.rb +124 -110
- data/lib/tina4/drivers/mongodb_driver.rb +561 -561
- data/lib/tina4/drivers/mssql_driver.rb +112 -112
- data/lib/tina4/drivers/mysql_driver.rb +90 -90
- data/lib/tina4/drivers/odbc_driver.rb +191 -191
- data/lib/tina4/drivers/postgres_driver.rb +116 -106
- data/lib/tina4/drivers/sqlite_driver.rb +122 -122
- data/lib/tina4/env.rb +95 -95
- data/lib/tina4/error_overlay.rb +252 -252
- data/lib/tina4/events.rb +109 -109
- data/lib/tina4/field_types.rb +154 -154
- data/lib/tina4/frond.rb +2025 -2025
- data/lib/tina4/gallery/auth/meta.json +1 -1
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -114
- data/lib/tina4/gallery/database/meta.json +1 -1
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -43
- data/lib/tina4/gallery/error-overlay/meta.json +1 -1
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -17
- data/lib/tina4/gallery/orm/meta.json +1 -1
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -16
- data/lib/tina4/gallery/queue/meta.json +1 -1
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +325 -325
- data/lib/tina4/gallery/rest-api/meta.json +1 -1
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -14
- data/lib/tina4/gallery/templates/meta.json +1 -1
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -12
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -257
- data/lib/tina4/graphql.rb +966 -966
- data/lib/tina4/health.rb +39 -39
- data/lib/tina4/html_element.rb +170 -170
- data/lib/tina4/job.rb +80 -80
- data/lib/tina4/localization.rb +168 -168
- data/lib/tina4/log.rb +203 -203
- data/lib/tina4/mcp.rb +696 -696
- data/lib/tina4/messenger.rb +587 -587
- data/lib/tina4/metrics.rb +793 -793
- data/lib/tina4/middleware.rb +445 -445
- data/lib/tina4/migration.rb +451 -451
- data/lib/tina4/orm.rb +790 -790
- data/lib/tina4/public/css/tina4.css +2463 -2463
- data/lib/tina4/public/css/tina4.min.css +1 -1
- data/lib/tina4/public/images/logo.svg +5 -5
- data/lib/tina4/public/js/frond.min.js +2 -2
- data/lib/tina4/public/js/tina4-dev-admin.js +565 -565
- data/lib/tina4/public/js/tina4-dev-admin.min.js +480 -480
- data/lib/tina4/public/js/tina4.min.js +92 -92
- data/lib/tina4/public/js/tina4js.min.js +48 -48
- data/lib/tina4/public/swagger/index.html +90 -90
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -63
- data/lib/tina4/query_builder.rb +380 -380
- data/lib/tina4/queue.rb +366 -366
- data/lib/tina4/queue_backends/kafka_backend.rb +80 -80
- data/lib/tina4/queue_backends/lite_backend.rb +298 -298
- data/lib/tina4/queue_backends/mongo_backend.rb +126 -126
- data/lib/tina4/queue_backends/rabbitmq_backend.rb +73 -73
- data/lib/tina4/rack_app.rb +817 -817
- data/lib/tina4/rate_limiter.rb +130 -130
- data/lib/tina4/request.rb +268 -255
- data/lib/tina4/response.rb +346 -346
- data/lib/tina4/response_cache.rb +551 -551
- data/lib/tina4/router.rb +406 -406
- data/lib/tina4/scss/tina4css/_alerts.scss +34 -34
- data/lib/tina4/scss/tina4css/_badges.scss +22 -22
- data/lib/tina4/scss/tina4css/_buttons.scss +69 -69
- data/lib/tina4/scss/tina4css/_cards.scss +49 -49
- data/lib/tina4/scss/tina4css/_forms.scss +156 -156
- data/lib/tina4/scss/tina4css/_grid.scss +81 -81
- data/lib/tina4/scss/tina4css/_modals.scss +84 -84
- data/lib/tina4/scss/tina4css/_nav.scss +149 -149
- data/lib/tina4/scss/tina4css/_reset.scss +94 -94
- data/lib/tina4/scss/tina4css/_tables.scss +54 -54
- data/lib/tina4/scss/tina4css/_typography.scss +55 -55
- data/lib/tina4/scss/tina4css/_utilities.scss +197 -197
- data/lib/tina4/scss/tina4css/_variables.scss +117 -117
- data/lib/tina4/scss/tina4css/base.scss +1 -1
- data/lib/tina4/scss/tina4css/colors.scss +48 -48
- data/lib/tina4/scss/tina4css/tina4.scss +17 -17
- data/lib/tina4/scss_compiler.rb +178 -178
- data/lib/tina4/seeder.rb +567 -567
- data/lib/tina4/service_runner.rb +303 -303
- data/lib/tina4/session.rb +297 -297
- data/lib/tina4/session_handlers/database_handler.rb +72 -72
- data/lib/tina4/session_handlers/file_handler.rb +67 -67
- data/lib/tina4/session_handlers/mongo_handler.rb +49 -49
- data/lib/tina4/session_handlers/redis_handler.rb +43 -43
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -43
- data/lib/tina4/shutdown.rb +84 -84
- data/lib/tina4/sql_translation.rb +158 -158
- data/lib/tina4/swagger.rb +124 -124
- data/lib/tina4/template.rb +894 -894
- data/lib/tina4/templates/base.twig +26 -26
- data/lib/tina4/templates/errors/302.twig +14 -14
- data/lib/tina4/templates/errors/401.twig +9 -9
- data/lib/tina4/templates/errors/403.twig +29 -29
- data/lib/tina4/templates/errors/404.twig +29 -29
- data/lib/tina4/templates/errors/500.twig +38 -38
- data/lib/tina4/templates/errors/502.twig +9 -9
- data/lib/tina4/templates/errors/503.twig +12 -12
- data/lib/tina4/templates/errors/base.twig +37 -37
- data/lib/tina4/test_client.rb +159 -159
- data/lib/tina4/testing.rb +340 -340
- data/lib/tina4/validator.rb +174 -174
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +312 -312
- data/lib/tina4/websocket.rb +343 -343
- data/lib/tina4/websocket_backplane.rb +190 -190
- data/lib/tina4/wsdl.rb +564 -564
- data/lib/tina4.rb +458 -458
- data/lib/tina4ruby.rb +4 -4
- metadata +3 -3
data/lib/tina4/dev_admin.rb
CHANGED
|
@@ -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
|