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
data/lib/tina4/queue.rb
CHANGED
|
@@ -44,7 +44,7 @@ module Tina4
|
|
|
44
44
|
def publish(topic, payload)
|
|
45
45
|
message = QueueMessage.new(topic: topic, payload: payload)
|
|
46
46
|
@backend.enqueue(message)
|
|
47
|
-
Tina4::
|
|
47
|
+
Tina4::Log.debug("Message published to #{topic}: #{message.id}")
|
|
48
48
|
message
|
|
49
49
|
end
|
|
50
50
|
|
|
@@ -53,6 +53,42 @@ module Tina4
|
|
|
53
53
|
end
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# Queue — convenience wrapper for queue management operations.
|
|
57
|
+
# Provides dead letter inspection, purging, and retry capabilities.
|
|
58
|
+
class Queue
|
|
59
|
+
attr_reader :topic, :max_retries
|
|
60
|
+
|
|
61
|
+
def initialize(topic:, backend: nil, max_retries: 3)
|
|
62
|
+
@topic = topic
|
|
63
|
+
@backend = backend || Tina4::QueueBackends::LiteBackend.new
|
|
64
|
+
@max_retries = max_retries
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Get dead letter jobs — messages that exceeded max retries.
|
|
68
|
+
def dead_letters
|
|
69
|
+
return [] unless @backend.respond_to?(:dead_letters)
|
|
70
|
+
@backend.dead_letters(@topic, max_retries: @max_retries)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Delete messages by status (completed, failed, dead).
|
|
74
|
+
def purge(status)
|
|
75
|
+
return 0 unless @backend.respond_to?(:purge)
|
|
76
|
+
@backend.purge(@topic, status)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Re-queue failed messages (under max_retries) back to pending.
|
|
80
|
+
# Returns the number of jobs re-queued.
|
|
81
|
+
def retry_failed
|
|
82
|
+
return 0 unless @backend.respond_to?(:retry_failed)
|
|
83
|
+
@backend.retry_failed(@topic, max_retries: @max_retries)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get the number of pending messages.
|
|
87
|
+
def size
|
|
88
|
+
@backend.size(@topic)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
56
92
|
class Consumer
|
|
57
93
|
def initialize(topic:, backend: nil, max_retries: 3)
|
|
58
94
|
@topic = topic
|
|
@@ -68,7 +104,7 @@ module Tina4
|
|
|
68
104
|
|
|
69
105
|
def start(poll_interval: 1)
|
|
70
106
|
@running = true
|
|
71
|
-
Tina4::
|
|
107
|
+
Tina4::Log.info("Consumer started for topic: #{@topic}")
|
|
72
108
|
|
|
73
109
|
while @running
|
|
74
110
|
message = @backend.dequeue(@topic)
|
|
@@ -82,7 +118,7 @@ module Tina4
|
|
|
82
118
|
|
|
83
119
|
def stop
|
|
84
120
|
@running = false
|
|
85
|
-
Tina4::
|
|
121
|
+
Tina4::Log.info("Consumer stopped for topic: #{@topic}")
|
|
86
122
|
end
|
|
87
123
|
|
|
88
124
|
def process_one
|
|
@@ -103,7 +139,7 @@ module Tina4
|
|
|
103
139
|
message.status = :completed
|
|
104
140
|
@backend.acknowledge(message)
|
|
105
141
|
rescue => e
|
|
106
|
-
Tina4::
|
|
142
|
+
Tina4::Log.error("Queue message failed: #{message.id} - #{e.message}")
|
|
107
143
|
message.status = :failed
|
|
108
144
|
|
|
109
145
|
if message.attempts < @max_retries
|
|
@@ -68,6 +68,94 @@ module Tina4
|
|
|
68
68
|
.select { |d| File.directory?(File.join(@dir, d)) }
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# Get dead letter jobs for a topic — messages that exceeded max retries.
|
|
72
|
+
def dead_letters(topic, max_retries: 3)
|
|
73
|
+
return [] unless Dir.exist?(@dead_letter_dir)
|
|
74
|
+
|
|
75
|
+
files = Dir.glob(File.join(@dead_letter_dir, "*.json")).sort_by { |f| File.mtime(f) }
|
|
76
|
+
jobs = []
|
|
77
|
+
|
|
78
|
+
files.each do |file|
|
|
79
|
+
data = JSON.parse(File.read(file))
|
|
80
|
+
next unless data["topic"] == topic.to_s
|
|
81
|
+
data["status"] = "dead"
|
|
82
|
+
jobs << data
|
|
83
|
+
rescue JSON::ParserError
|
|
84
|
+
next
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
jobs
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Delete messages by status (completed, failed, dead).
|
|
91
|
+
# For 'dead', removes from the dead_letter directory.
|
|
92
|
+
# For 'failed', removes from the topic directory (re-queued failed messages).
|
|
93
|
+
# Returns the number of jobs purged.
|
|
94
|
+
def purge(topic, status)
|
|
95
|
+
count = 0
|
|
96
|
+
|
|
97
|
+
if status.to_s == "dead"
|
|
98
|
+
return 0 unless Dir.exist?(@dead_letter_dir)
|
|
99
|
+
|
|
100
|
+
Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
|
|
101
|
+
data = JSON.parse(File.read(file))
|
|
102
|
+
if data["topic"] == topic.to_s
|
|
103
|
+
File.delete(file)
|
|
104
|
+
count += 1
|
|
105
|
+
end
|
|
106
|
+
rescue JSON::ParserError
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
elsif status.to_s == "failed" || status.to_s == "completed" || status.to_s == "pending"
|
|
110
|
+
dir = topic_path(topic)
|
|
111
|
+
return 0 unless Dir.exist?(dir)
|
|
112
|
+
|
|
113
|
+
Dir.glob(File.join(dir, "*.json")).each do |file|
|
|
114
|
+
data = JSON.parse(File.read(file))
|
|
115
|
+
if data["status"] == status.to_s
|
|
116
|
+
File.delete(file)
|
|
117
|
+
count += 1
|
|
118
|
+
end
|
|
119
|
+
rescue JSON::ParserError
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
count
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Re-queue failed messages (under max_retries) back to pending.
|
|
128
|
+
# Returns the number of jobs re-queued.
|
|
129
|
+
def retry_failed(topic, max_retries: 3)
|
|
130
|
+
return 0 unless Dir.exist?(@dead_letter_dir)
|
|
131
|
+
|
|
132
|
+
dir = topic_path(topic)
|
|
133
|
+
FileUtils.mkdir_p(dir)
|
|
134
|
+
count = 0
|
|
135
|
+
|
|
136
|
+
# Dead letter directory contains messages that the Consumer moved there.
|
|
137
|
+
# Only retry those whose attempts are under max_retries.
|
|
138
|
+
Dir.glob(File.join(@dead_letter_dir, "*.json")).each do |file|
|
|
139
|
+
data = JSON.parse(File.read(file))
|
|
140
|
+
next unless data["topic"] == topic.to_s
|
|
141
|
+
next if (data["attempts"] || 0) >= max_retries
|
|
142
|
+
|
|
143
|
+
data["status"] = "pending"
|
|
144
|
+
msg = Tina4::QueueMessage.new(
|
|
145
|
+
topic: data["topic"],
|
|
146
|
+
payload: data["payload"],
|
|
147
|
+
id: data["id"]
|
|
148
|
+
)
|
|
149
|
+
enqueue(msg)
|
|
150
|
+
File.delete(file)
|
|
151
|
+
count += 1
|
|
152
|
+
rescue JSON::ParserError
|
|
153
|
+
next
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
count
|
|
157
|
+
end
|
|
158
|
+
|
|
71
159
|
private
|
|
72
160
|
|
|
73
161
|
def topic_path(topic)
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -1,34 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "json"
|
|
3
|
+
require "securerandom"
|
|
3
4
|
|
|
4
5
|
module Tina4
|
|
5
6
|
class RackApp
|
|
6
7
|
STATIC_DIRS = %w[public src/public src/assets assets].freeze
|
|
7
8
|
|
|
8
|
-
#
|
|
9
|
-
CORS_HEADERS = {
|
|
10
|
-
"access-control-allow-origin" => "*",
|
|
11
|
-
"access-control-allow-methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
12
|
-
"access-control-allow-headers" => "Content-Type, Authorization, Accept",
|
|
13
|
-
"access-control-max-age" => "86400"
|
|
14
|
-
}.freeze
|
|
9
|
+
# CORS is now handled by Tina4::CorsMiddleware
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
# Framework's own public directory (bundled static assets like the logo)
|
|
12
|
+
FRAMEWORK_PUBLIC_DIR = File.expand_path("public", __dir__).freeze
|
|
17
13
|
|
|
18
14
|
def initialize(root_dir: Dir.pwd)
|
|
19
15
|
@root_dir = root_dir
|
|
20
16
|
# Pre-compute static roots at boot (not per-request)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
# Project dirs are checked first; framework's bundled public dir is the fallback
|
|
18
|
+
project_roots = STATIC_DIRS.map { |d| File.join(root_dir, d) }
|
|
19
|
+
.select { |d| Dir.exist?(d) }
|
|
20
|
+
fallback = Dir.exist?(FRAMEWORK_PUBLIC_DIR) ? [FRAMEWORK_PUBLIC_DIR] : []
|
|
21
|
+
@static_roots = (project_roots + fallback).freeze
|
|
24
22
|
end
|
|
25
23
|
|
|
26
24
|
def call(env)
|
|
27
25
|
method = env["REQUEST_METHOD"]
|
|
28
26
|
path = env["PATH_INFO"] || "/"
|
|
27
|
+
request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
29
28
|
|
|
30
|
-
# Fast-path: OPTIONS preflight
|
|
31
|
-
return
|
|
29
|
+
# Fast-path: OPTIONS preflight
|
|
30
|
+
return Tina4::CorsMiddleware.preflight_response(env) if method == "OPTIONS"
|
|
31
|
+
|
|
32
|
+
# Dev dashboard routes (handled before anything else)
|
|
33
|
+
if path.start_with?("/__dev")
|
|
34
|
+
dev_response = Tina4::DevAdmin.handle_request(env)
|
|
35
|
+
return dev_response if dev_response
|
|
36
|
+
end
|
|
32
37
|
|
|
33
38
|
# Fast-path: API routes skip static file + swagger checks entirely
|
|
34
39
|
unless path.start_with?("/api/")
|
|
@@ -49,10 +54,41 @@ module Tina4
|
|
|
49
54
|
result = Tina4::Router.find_route(path, method)
|
|
50
55
|
if result
|
|
51
56
|
route, path_params = result
|
|
52
|
-
handle_route(env, route, path_params)
|
|
57
|
+
rack_response = handle_route(env, route, path_params)
|
|
58
|
+
matched_pattern = route.path
|
|
53
59
|
else
|
|
54
|
-
handle_404(path)
|
|
60
|
+
rack_response = handle_404(path)
|
|
61
|
+
matched_pattern = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Capture request for dev inspector
|
|
65
|
+
if dev_mode? && !path.start_with?("/__dev")
|
|
66
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - request_start) * 1000).round(3)
|
|
67
|
+
Tina4::DevAdmin.request_inspector.capture(
|
|
68
|
+
method: method,
|
|
69
|
+
path: path,
|
|
70
|
+
status: rack_response[0],
|
|
71
|
+
duration: duration_ms
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Inject dev overlay button for HTML responses in dev mode
|
|
76
|
+
if dev_mode? && !path.start_with?("/__dev")
|
|
77
|
+
status, headers, body_parts = rack_response
|
|
78
|
+
content_type = headers["content-type"] || ""
|
|
79
|
+
if content_type.include?("text/html")
|
|
80
|
+
request_info = {
|
|
81
|
+
method: method,
|
|
82
|
+
path: path,
|
|
83
|
+
matched_pattern: matched_pattern || "(no match)",
|
|
84
|
+
}
|
|
85
|
+
joined = body_parts.join
|
|
86
|
+
overlay = inject_dev_overlay(joined, request_info)
|
|
87
|
+
rack_response = [status, headers, [overlay]]
|
|
88
|
+
end
|
|
55
89
|
end
|
|
90
|
+
|
|
91
|
+
rack_response
|
|
56
92
|
rescue => e
|
|
57
93
|
handle_500(e)
|
|
58
94
|
end
|
|
@@ -63,15 +99,30 @@ module Tina4
|
|
|
63
99
|
# Auth check
|
|
64
100
|
if route.auth_handler
|
|
65
101
|
auth_result = route.auth_handler.call(env)
|
|
66
|
-
return handle_403 unless auth_result
|
|
102
|
+
return handle_403(env["PATH_INFO"] || "/") unless auth_result
|
|
67
103
|
end
|
|
68
104
|
|
|
69
105
|
request = Tina4::Request.new(env, path_params)
|
|
70
106
|
response = Tina4::Response.new
|
|
71
107
|
|
|
108
|
+
# Run per-route middleware
|
|
109
|
+
if route.respond_to?(:run_middleware)
|
|
110
|
+
unless route.run_middleware(request, response)
|
|
111
|
+
return [403, { "content-type" => "text/html" }, ["403 Forbidden"]]
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
72
115
|
# Execute handler
|
|
73
116
|
result = route.handler.call(request, response)
|
|
74
117
|
|
|
118
|
+
# Template rendering: when a template is set and the handler returned a Hash,
|
|
119
|
+
# render the template with the hash as data and return the HTML response.
|
|
120
|
+
if route.template && result.is_a?(Hash)
|
|
121
|
+
html = Tina4::Template.render(route.template, result)
|
|
122
|
+
response.html(html)
|
|
123
|
+
return response.to_rack
|
|
124
|
+
end
|
|
125
|
+
|
|
75
126
|
# Skip auto_detect if handler already returned the response object
|
|
76
127
|
final_response = result.equal?(response) ? result : Tina4::Response.auto_detect(result, response)
|
|
77
128
|
final_response.to_rack
|
|
@@ -129,22 +180,262 @@ module Tina4
|
|
|
129
180
|
[200, { "content-type" => "application/json; charset=utf-8" }, [@openapi_json]]
|
|
130
181
|
end
|
|
131
182
|
|
|
132
|
-
def handle_403
|
|
133
|
-
body = Tina4::Template.render_error(403) rescue "403 Forbidden"
|
|
183
|
+
def handle_403(path = "")
|
|
184
|
+
body = Tina4::Template.render_error(403, { "path" => path }) rescue "403 Forbidden"
|
|
134
185
|
[403, { "content-type" => "text/html" }, [body]]
|
|
135
186
|
end
|
|
136
187
|
|
|
137
188
|
def handle_404(path)
|
|
138
|
-
|
|
139
|
-
|
|
189
|
+
# Show landing page for GET "/" when no user route or template index exists
|
|
190
|
+
if path == "/" && should_show_landing_page?
|
|
191
|
+
return render_landing_page
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
Tina4::Log.warning("404 Not Found: #{path}")
|
|
195
|
+
body = Tina4::Template.render_error(404, { "path" => path }) rescue "404 Not Found"
|
|
140
196
|
[404, { "content-type" => "text/html" }, [body]]
|
|
141
197
|
end
|
|
142
198
|
|
|
199
|
+
def should_show_landing_page?
|
|
200
|
+
# Check if any index template exists in src/templates/
|
|
201
|
+
templates_dir = File.join(@root_dir, "src", "templates")
|
|
202
|
+
%w[index.html index.twig index.erb].none? { |f| File.file?(File.join(templates_dir, f)) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def render_landing_page
|
|
206
|
+
port = ENV["PORT"] || "7145"
|
|
207
|
+
|
|
208
|
+
# Check deployed state for each gallery item
|
|
209
|
+
project_src = File.join(@root_dir, "src")
|
|
210
|
+
gallery_items = [
|
|
211
|
+
{ id: "rest-api", name: "REST API", desc: "A simple JSON API with GET and POST endpoints", icon: "🚀", accent: "red", try_url: "/api/gallery/hello", file_check: "routes/api/gallery_hello.rb" },
|
|
212
|
+
{ id: "orm", name: "ORM", desc: "Product model with CRUD endpoints", icon: "🗃", accent: "green", try_url: "/api/gallery/products", file_check: "routes/api/gallery_products.rb" },
|
|
213
|
+
{ id: "auth", name: "Auth", desc: "JWT login form with token display", icon: "🔒", accent: "purple", try_url: "/gallery/auth", file_check: "routes/api/gallery_auth.rb" },
|
|
214
|
+
{ id: "queue", name: "Queue", desc: "Background job producer and consumer", icon: "⚡", accent: "red", try_url: "/api/gallery/queue/produce", file_check: "routes/api/gallery_queue.rb" },
|
|
215
|
+
{ id: "templates", name: "Templates", desc: "Twig template with dynamic data", icon: "📄", accent: "green", try_url: "/gallery/page", file_check: "routes/gallery_page.rb" },
|
|
216
|
+
{ id: "database", name: "Database", desc: "Raw SQL queries with the Database class", icon: "📡", accent: "purple", try_url: "/api/gallery/db/tables", file_check: "routes/api/gallery_db.rb" },
|
|
217
|
+
{ id: "error-overlay", name: "Error Overlay", desc: "See the rich debug error page with stack trace", icon: "💥", accent: "red", try_url: "/api/gallery/crash", file_check: "routes/api/gallery_crash.rb" }
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
gallery_cards = gallery_items.map do |item|
|
|
221
|
+
deployed = File.file?(File.join(project_src, item[:file_check]))
|
|
222
|
+
deployed_badge = deployed ? '<span style="position:absolute;top:0.75rem;right:0.75rem;background:#22c55e;color:#fff;font-size:0.65rem;padding:0.15rem 0.5rem;border-radius:0.25rem;font-weight:600;">DEPLOYED</span>' : ''
|
|
223
|
+
try_btn = if deployed
|
|
224
|
+
%(<a href="#{item[:try_url]}" class="gbtn gbtn-try" target="_blank">Try It</a>)
|
|
225
|
+
else
|
|
226
|
+
%(<button class="gbtn gbtn-deploy" onclick="deployGallery('#{item[:id]}','#{item[:try_url]}')">Deploy & Try</button>)
|
|
227
|
+
end
|
|
228
|
+
view_btn = %(<button class="gbtn gbtn-view" onclick="viewGallery('#{item[:id]}')">View</button>)
|
|
229
|
+
|
|
230
|
+
<<~CARD
|
|
231
|
+
<div class="gallery-card">
|
|
232
|
+
<div class="accent accent-#{item[:accent]}"></div>
|
|
233
|
+
#{deployed_badge}
|
|
234
|
+
<div class="icon">#{item[:icon]}</div>
|
|
235
|
+
<h3>#{item[:name]}</h3>
|
|
236
|
+
<p>#{item[:desc]}</p>
|
|
237
|
+
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">#{try_btn}#{view_btn}</div>
|
|
238
|
+
</div>
|
|
239
|
+
CARD
|
|
240
|
+
end.join
|
|
241
|
+
|
|
242
|
+
html = <<~HTML
|
|
243
|
+
<!DOCTYPE html>
|
|
244
|
+
<html lang="en">
|
|
245
|
+
<head>
|
|
246
|
+
<meta charset="utf-8">
|
|
247
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
248
|
+
<title>Tina4Ruby</title>
|
|
249
|
+
<style>
|
|
250
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
251
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;position:relative}
|
|
252
|
+
.bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
|
|
253
|
+
.hero{text-align:center;z-index:1;padding:3rem 2rem 2rem}
|
|
254
|
+
.logo{width:120px;height:120px;margin-bottom:1.5rem}
|
|
255
|
+
h1{font-size:3rem;font-weight:700;margin-bottom:0.25rem;letter-spacing:-1px}
|
|
256
|
+
.tagline{color:#64748b;font-size:1.1rem;margin-bottom:2rem}
|
|
257
|
+
.actions{display:flex;gap:0.75rem;justify-content:center;flex-wrap:wrap;margin-bottom:2.5rem}
|
|
258
|
+
.btn{padding:0.6rem 1.5rem;border-radius:0.5rem;font-size:0.9rem;font-weight:600;cursor:pointer;text-decoration:none;transition:all 0.15s;border:1px solid #334155;color:#94a3b8;background:transparent;min-width:140px;text-align:center;display:inline-block}
|
|
259
|
+
.btn:hover{border-color:#64748b;color:#e2e8f0}
|
|
260
|
+
.status{display:flex;gap:2rem;justify-content:center;align-items:center;color:#64748b;font-size:0.85rem;margin-bottom:1.5rem}
|
|
261
|
+
.status .dot{width:8px;height:8px;border-radius:50%;background:#22c55e;display:inline-block;margin-right:0.4rem}
|
|
262
|
+
.footer{color:#334155;font-size:0.8rem;letter-spacing:0.5px}
|
|
263
|
+
.section{z-index:1;width:100%;max-width:800px;padding:0 2rem;margin-bottom:2.5rem}
|
|
264
|
+
.card{background:#1e293b;border-radius:0.75rem;padding:2rem;border:1px solid #334155}
|
|
265
|
+
.card h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0}
|
|
266
|
+
.code-block{background:#0f172a;border-radius:0.5rem;padding:1.25rem;overflow-x:auto;font-family:'SF Mono',SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:0.85rem;line-height:1.6;color:#4ade80;border:1px solid #1e293b}
|
|
267
|
+
.gallery{z-index:1;width:100%;max-width:900px;padding:0 2rem;margin-bottom:3rem}
|
|
268
|
+
.gallery h2{font-size:1.4rem;font-weight:600;margin-bottom:1.25rem;color:#e2e8f0;text-align:center}
|
|
269
|
+
.gallery-card{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:1.5rem;position:relative;overflow:hidden}
|
|
270
|
+
.gallery-card .accent{position:absolute;top:0;left:0;right:0;height:3px}
|
|
271
|
+
.gallery-card .accent-red{background:#CC342D}
|
|
272
|
+
.gallery-card .accent-green{background:#22c55e}
|
|
273
|
+
.gallery-card .accent-purple{background:#a78bfa}
|
|
274
|
+
.gallery-card .icon{font-size:1.5rem;margin-bottom:0.75rem}
|
|
275
|
+
.gallery-card h3{font-size:1rem;font-weight:600;margin-bottom:0.5rem;color:#e2e8f0}
|
|
276
|
+
.gallery-card p{font-size:0.85rem;color:#94a3b8;line-height:1.5}
|
|
277
|
+
.gbtn{padding:0.35rem 0.75rem;border-radius:0.375rem;font-size:0.75rem;font-weight:600;cursor:pointer;text-decoration:none;border:none;transition:all 0.15s}
|
|
278
|
+
.gbtn-try{background:#22c55e;color:#fff}
|
|
279
|
+
.gbtn-try:hover{background:#16a34a}
|
|
280
|
+
.gbtn-deploy{background:#CC342D;color:#fff}
|
|
281
|
+
.gbtn-deploy:hover{background:#a12a24}
|
|
282
|
+
.gbtn-view{background:transparent;color:#94a3b8;border:1px solid #334155}
|
|
283
|
+
.gbtn-view:hover{border-color:#64748b;color:#e2e8f0}
|
|
284
|
+
.view-modal{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.7);z-index:10000;align-items:center;justify-content:center}
|
|
285
|
+
.view-modal.active{display:flex}
|
|
286
|
+
.view-modal-content{background:#1e293b;border:1px solid #334155;border-radius:0.75rem;padding:2rem;max-width:700px;width:90%;max-height:80vh;overflow-y:auto;position:relative}
|
|
287
|
+
.view-modal-close{position:absolute;top:0.75rem;right:1rem;color:#94a3b8;cursor:pointer;font-size:1.25rem;background:none;border:none}
|
|
288
|
+
.view-modal-close:hover{color:#e2e8f0}
|
|
289
|
+
</style>
|
|
290
|
+
</head>
|
|
291
|
+
<body>
|
|
292
|
+
<img src="/images/tina4-logo-icon.webp" class="bg-watermark" alt="">
|
|
293
|
+
<div class="hero">
|
|
294
|
+
<img src="/images/tina4-logo-icon.webp" class="logo" alt="Tina4">
|
|
295
|
+
<h1>Tina4Ruby</h1>
|
|
296
|
+
<p class="tagline">This is not a framework</p>
|
|
297
|
+
<div class="actions">
|
|
298
|
+
<a href="https://tina4.com/ruby" class="btn" target="_blank">Website</a>
|
|
299
|
+
<a href="/__dev" class="btn">Dev Admin</a>
|
|
300
|
+
<a href="#gallery" class="btn">Gallery</a>
|
|
301
|
+
<a href="https://github.com/tina4stack/tina4-ruby" class="btn" target="_blank">GitHub</a>
|
|
302
|
+
<a href="https://github.com/tina4stack/tina4-ruby/stargazers" class="btn" target="_blank">⭐ Star</a>
|
|
303
|
+
</div>
|
|
304
|
+
<div class="status">
|
|
305
|
+
<span><span class="dot"></span>Server running</span>
|
|
306
|
+
<span>Port #{port}</span>
|
|
307
|
+
<span>v#{Tina4::VERSION}</span>
|
|
308
|
+
</div>
|
|
309
|
+
<p class="footer">Zero dependencies · Convention over configuration</p>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="section">
|
|
312
|
+
<div class="card">
|
|
313
|
+
<h2>Getting Started</h2>
|
|
314
|
+
<pre class="code-block"><code><span style="color:#64748b"># app.rb</span>
|
|
315
|
+
<span style="color:#c084fc">require</span> <span style="color:#4ade80">"tina4"</span>
|
|
316
|
+
|
|
317
|
+
Tina4::Router.<span style="color:#38bdf8">get</span>(<span style="color:#4ade80">"/hello"</span>) <span style="color:#c084fc">do</span> |request, response|
|
|
318
|
+
response.<span style="color:#38bdf8">json</span>({ <span style="color:#fbbf24">message:</span> <span style="color:#4ade80">"Hello World!"</span> })
|
|
319
|
+
<span style="color:#c084fc">end</span>
|
|
320
|
+
|
|
321
|
+
Tina4::WebServer.new(<span style="color:#fbbf24">port:</span> <span style="color:#38bdf8">7145</span>).start <span style="color:#64748b"># starts on port 7145</span></code></pre>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
<div class="gallery">
|
|
325
|
+
<h2 id="gallery">Gallery</h2>
|
|
326
|
+
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
|
|
327
|
+
#{gallery_cards}
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
<div class="view-modal" id="viewModal">
|
|
331
|
+
<div class="view-modal-content">
|
|
332
|
+
<button class="view-modal-close" onclick="document.getElementById('viewModal').classList.remove('active')">×</button>
|
|
333
|
+
<h3 id="viewModalTitle" style="margin-bottom:1rem;color:#e2e8f0;"></h3>
|
|
334
|
+
<div id="viewModalBody"></div>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
<script>
|
|
338
|
+
function deployGallery(name, tryUrl) {
|
|
339
|
+
if (!confirm('Deploy the "' + name + '" gallery example into your project?')) return;
|
|
340
|
+
var btn = event.target;
|
|
341
|
+
btn.disabled = true;
|
|
342
|
+
btn.textContent = 'Deploying...';
|
|
343
|
+
fetch('/__dev/api/gallery/deploy', {
|
|
344
|
+
method: 'POST',
|
|
345
|
+
headers: {'Content-Type': 'application/json'},
|
|
346
|
+
body: JSON.stringify({ name: name })
|
|
347
|
+
}).then(function(r) { return r.json(); }).then(function(d) {
|
|
348
|
+
if (d.error) {
|
|
349
|
+
alert('Deploy failed: ' + d.error);
|
|
350
|
+
btn.disabled = false;
|
|
351
|
+
btn.textContent = 'Deploy & Try';
|
|
352
|
+
} else {
|
|
353
|
+
window.location.href = tryUrl;
|
|
354
|
+
}
|
|
355
|
+
}).catch(function(e) {
|
|
356
|
+
alert('Deploy error: ' + e.message);
|
|
357
|
+
btn.disabled = false;
|
|
358
|
+
btn.textContent = 'Deploy & Try';
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
function viewGallery(name) {
|
|
362
|
+
fetch('/__dev/api/gallery').then(function(r) { return r.json(); }).then(function(d) {
|
|
363
|
+
var item = (d.gallery || []).find(function(g) { return g.id === name; });
|
|
364
|
+
if (!item) { alert('Gallery item not found'); return; }
|
|
365
|
+
var title = document.getElementById('viewModalTitle');
|
|
366
|
+
var body = document.getElementById('viewModalBody');
|
|
367
|
+
title.textContent = item.name + ' — ' + item.description;
|
|
368
|
+
var html = '<p style="color:#94a3b8;margin-bottom:1rem;">Files that will be deployed:</p><ul style="list-style:none;padding:0;">';
|
|
369
|
+
(item.files || []).forEach(function(f) {
|
|
370
|
+
html += '<li style="padding:0.25rem 0;color:#4ade80;font-family:monospace;font-size:0.85rem;">src/' + f + '</li>';
|
|
371
|
+
});
|
|
372
|
+
html += '</ul>';
|
|
373
|
+
if (item.try_url) {
|
|
374
|
+
html += '<p style="color:#94a3b8;margin-top:1rem;">Try URL: <code style="color:#38bdf8;">' + item.try_url + '</code></p>';
|
|
375
|
+
}
|
|
376
|
+
body.innerHTML = html;
|
|
377
|
+
document.getElementById('viewModal').classList.add('active');
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
document.getElementById('viewModal').addEventListener('click', function(e) {
|
|
381
|
+
if (e.target === this) this.classList.remove('active');
|
|
382
|
+
});
|
|
383
|
+
</script>
|
|
384
|
+
</body>
|
|
385
|
+
</html>
|
|
386
|
+
HTML
|
|
387
|
+
|
|
388
|
+
[200, { "content-type" => "text/html; charset=utf-8" }, [html]]
|
|
389
|
+
end
|
|
390
|
+
|
|
143
391
|
def handle_500(error)
|
|
144
|
-
Tina4::
|
|
145
|
-
Tina4::
|
|
146
|
-
|
|
392
|
+
Tina4::Log.error("500 Internal Server Error: #{error.message}")
|
|
393
|
+
Tina4::Log.error(error.backtrace&.first(10)&.join("\n"))
|
|
394
|
+
if dev_mode?
|
|
395
|
+
# Rich error overlay with stack trace, source context, and line numbers
|
|
396
|
+
body = Tina4::ErrorOverlay.render(error)
|
|
397
|
+
else
|
|
398
|
+
body = Tina4::Template.render_error(500, {
|
|
399
|
+
"error_message" => "#{error.message}\n#{error.backtrace&.first(10)&.join("\n")}",
|
|
400
|
+
"request_id" => SecureRandom.hex(6)
|
|
401
|
+
}) rescue "500 Internal Server Error: #{error.message}"
|
|
402
|
+
end
|
|
147
403
|
[500, { "content-type" => "text/html" }, [body]]
|
|
148
404
|
end
|
|
405
|
+
|
|
406
|
+
def dev_mode?
|
|
407
|
+
Tina4::Env.truthy?(ENV["TINA4_DEBUG"])
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def inject_dev_overlay(body, request_info)
|
|
411
|
+
version = Tina4::VERSION
|
|
412
|
+
method = request_info[:method]
|
|
413
|
+
path = request_info[:path]
|
|
414
|
+
matched_pattern = request_info[:matched_pattern]
|
|
415
|
+
request_id = Tina4::Log.request_id || "-"
|
|
416
|
+
route_count = Tina4::Router.routes.length
|
|
417
|
+
|
|
418
|
+
toolbar = <<~HTML.strip
|
|
419
|
+
<div id="tina4-dev-toolbar" style="position:fixed;bottom:0;left:0;right:0;background:#333;color:#fff;font-family:monospace;font-size:12px;padding:6px 16px;z-index:99999;display:flex;align-items:center;gap:16px;">
|
|
420
|
+
<span style="color:#d32f2f;font-weight:bold;">Tina4 v#{version}</span>
|
|
421
|
+
<span style="color:#4caf50;">#{method}</span>
|
|
422
|
+
<span>#{path}</span>
|
|
423
|
+
<span style="color:#666;">→ #{matched_pattern}</span>
|
|
424
|
+
<span style="color:#ffeb3b;">req:#{request_id}</span>
|
|
425
|
+
<span style="color:#90caf9;">#{route_count} routes</span>
|
|
426
|
+
<span style="color:#888;">Ruby #{RUBY_VERSION}</span>
|
|
427
|
+
<a href="#" onclick="(function(e){e.preventDefault();var p=document.getElementById('tina4-dev-panel');if(p){p.style.display=p.style.display==='none'?'block':'none';return;}var c=document.createElement('div');c.id='tina4-dev-panel';c.style.cssText='position:fixed;bottom:2rem;right:1rem;width:min(90vw,1200px);height:min(80vh,700px);z-index:99998;transition:all 0.2s';var f=document.createElement('iframe');f.src='/__dev';f.style.cssText='width:100%;height:100%;border:1px solid #CC342D;border-radius:0.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.5);background:#0f172a';c.appendChild(f);document.body.appendChild(c);})(event)" style="color:#ef9a9a;margin-left:auto;text-decoration:none;cursor:pointer;">Dashboard ↗</a>
|
|
428
|
+
<span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">✕</span>
|
|
429
|
+
</div>
|
|
430
|
+
HTML
|
|
431
|
+
|
|
432
|
+
if body.include?("</body>")
|
|
433
|
+
body.sub("</body>", "#{toolbar}\n</body>")
|
|
434
|
+
else
|
|
435
|
+
body + "\n" + toolbar
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
|
|
149
440
|
end
|
|
150
441
|
end
|