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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1 -1
- data/README.md +360 -559
- data/exe/{tina4 → tina4ruby} +1 -0
- data/lib/tina4/ai.rb +312 -0
- data/lib/tina4/auth.rb +44 -3
- data/lib/tina4/auto_crud.rb +163 -0
- data/lib/tina4/cli.rb +242 -77
- data/lib/tina4/constants.rb +46 -0
- data/lib/tina4/cors.rb +74 -0
- data/lib/tina4/database/sqlite3_adapter.rb +139 -0
- data/lib/tina4/database.rb +43 -7
- data/lib/tina4/debug.rb +4 -79
- data/lib/tina4/dev_admin.rb +1162 -0
- data/lib/tina4/dev_mailbox.rb +191 -0
- data/lib/tina4/dev_reload.rb +9 -9
- data/lib/tina4/drivers/firebird_driver.rb +19 -3
- data/lib/tina4/drivers/mssql_driver.rb +3 -3
- data/lib/tina4/drivers/mysql_driver.rb +4 -4
- data/lib/tina4/drivers/postgres_driver.rb +9 -2
- data/lib/tina4/drivers/sqlite_driver.rb +1 -1
- data/lib/tina4/env.rb +42 -2
- data/lib/tina4/error_overlay.rb +252 -0
- data/lib/tina4/events.rb +90 -0
- data/lib/tina4/field_types.rb +4 -0
- data/lib/tina4/frond.rb +1336 -0
- data/lib/tina4/gallery/auth/meta.json +1 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +114 -0
- data/lib/tina4/gallery/database/meta.json +1 -0
- data/lib/tina4/gallery/database/src/routes/api/gallery_db.rb +43 -0
- data/lib/tina4/gallery/error-overlay/meta.json +1 -0
- data/lib/tina4/gallery/error-overlay/src/routes/api/gallery_crash.rb +17 -0
- data/lib/tina4/gallery/orm/meta.json +1 -0
- data/lib/tina4/gallery/orm/src/routes/api/gallery_products.rb +16 -0
- data/lib/tina4/gallery/queue/meta.json +1 -0
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +27 -0
- data/lib/tina4/gallery/rest-api/meta.json +1 -0
- data/lib/tina4/gallery/rest-api/src/routes/api/gallery_hello.rb +14 -0
- data/lib/tina4/gallery/templates/meta.json +1 -0
- data/lib/tina4/gallery/templates/src/routes/gallery_page.rb +12 -0
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +257 -0
- data/lib/tina4/health.rb +39 -0
- data/lib/tina4/html_element.rb +148 -0
- data/lib/tina4/localization.rb +2 -2
- data/lib/tina4/log.rb +203 -0
- data/lib/tina4/messenger.rb +484 -0
- data/lib/tina4/migration.rb +132 -29
- data/lib/tina4/orm.rb +337 -31
- data/lib/tina4/public/css/tina4.css +178 -1
- data/lib/tina4/public/css/tina4.min.css +1 -2
- data/lib/tina4/public/favicon.ico +0 -0
- data/lib/tina4/public/images/logo.svg +5 -0
- data/lib/tina4/public/images/tina4-logo-icon.webp +0 -0
- data/lib/tina4/public/js/frond.min.js +420 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +367 -0
- data/lib/tina4/public/js/tina4.min.js +93 -0
- data/lib/tina4/public/swagger/index.html +90 -0
- data/lib/tina4/public/swagger/oauth2-redirect.html +63 -0
- data/lib/tina4/queue.rb +40 -4
- data/lib/tina4/queue_backends/lite_backend.rb +88 -0
- data/lib/tina4/rack_app.rb +314 -23
- data/lib/tina4/rate_limiter.rb +123 -0
- data/lib/tina4/request.rb +61 -15
- data/lib/tina4/response.rb +54 -24
- data/lib/tina4/response_cache.rb +134 -0
- data/lib/tina4/router.rb +90 -15
- data/lib/tina4/scss_compiler.rb +2 -2
- data/lib/tina4/seeder.rb +56 -61
- data/lib/tina4/service_runner.rb +303 -0
- data/lib/tina4/session.rb +85 -0
- data/lib/tina4/session_handlers/mongo_handler.rb +1 -1
- data/lib/tina4/session_handlers/valkey_handler.rb +43 -0
- data/lib/tina4/shutdown.rb +84 -0
- data/lib/tina4/sql_translation.rb +295 -0
- data/lib/tina4/template.rb +36 -6
- data/lib/tina4/templates/base.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +14 -0
- data/lib/tina4/templates/errors/401.twig +9 -0
- data/lib/tina4/templates/errors/403.twig +22 -15
- data/lib/tina4/templates/errors/404.twig +22 -15
- data/lib/tina4/templates/errors/500.twig +31 -15
- data/lib/tina4/templates/errors/502.twig +9 -0
- data/lib/tina4/templates/errors/503.twig +12 -0
- data/lib/tina4/templates/errors/base.twig +37 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +28 -18
- data/lib/tina4.rb +57 -21
- metadata +51 -19
- data/lib/tina4/public/js/tina4.js +0 -134
- 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">✔ 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">✘ 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
|