async-background 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -0
  3. data/README.md +36 -15
  4. data/async-background.gemspec +13 -7
  5. data/lib/async/background/job.rb +75 -28
  6. data/lib/async/background/metrics.rb +41 -33
  7. data/lib/async/background/min_heap.rb +16 -12
  8. data/lib/async/background/queue/client.rb +11 -8
  9. data/lib/async/background/queue/options.rb +3 -3
  10. data/lib/async/background/queue/schema.rb +19 -15
  11. data/lib/async/background/queue/socket_notifier.rb +14 -4
  12. data/lib/async/background/queue/socket_waker.rb +139 -56
  13. data/lib/async/background/queue/sql.rb +31 -31
  14. data/lib/async/background/queue/store.rb +44 -45
  15. data/lib/async/background/runner/lifecycle.rb +131 -0
  16. data/lib/async/background/runner/queue_execution.rb +101 -57
  17. data/lib/async/background/runner/schedule.rb +20 -17
  18. data/lib/async/background/runner.rb +98 -77
  19. data/lib/async/background/runtime/notification.rb +42 -0
  20. data/lib/async/background/runtime/semaphore.rb +151 -0
  21. data/lib/async/background/runtime/task.rb +146 -0
  22. data/lib/async/background/runtime/task_group.rb +81 -0
  23. data/lib/async/background/runtime.rb +246 -0
  24. data/lib/async/background/scheduler.rb +145 -0
  25. data/lib/async/background/version.rb +1 -1
  26. data/lib/async/background/web/app.rb +43 -40
  27. data/lib/async/background/web/assets/app.css +174 -0
  28. data/lib/async/background/web/assets/app.js +486 -0
  29. data/lib/async/background/web/assets/index.html +29 -0
  30. data/lib/async/background/web/assets.rb +4 -697
  31. data/lib/async/background/web/auth.rb +3 -3
  32. data/lib/async/background/web/configuration.rb +40 -93
  33. data/lib/async/background/web/cursor.rb +22 -26
  34. data/lib/async/background/web/event_hub.rb +12 -7
  35. data/lib/async/background/web/metrics_reader.rb +28 -36
  36. data/lib/async/background/web/response.rb +39 -51
  37. data/lib/async/background/web/serializer.rb +23 -63
  38. data/lib/async/background/web/snapshot.rb +35 -81
  39. data/lib/async/background/web/sql.rb +63 -69
  40. data/lib/async/background/web/stream.rb +52 -32
  41. data/lib/async/background/web.rb +1 -1
  42. data/lib/async/background.rb +1 -2
  43. metadata +30 -22
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module Async
6
+ module Background
7
+ class Error < StandardError; end
8
+
9
+ module Runtime
10
+ class Error < Background::Error; end
11
+ class SchedulerRequired < Error; end
12
+ class TimeoutError < Error; end
13
+ class Cancel < Exception; end
14
+ class Deadline < Exception; end
15
+
16
+ UNSET = Object.new
17
+
18
+ CURRENT_TASK_KEY = :async_background_current_task
19
+ WAITER_KEY = :async_background_waiter
20
+
21
+ DEADLINE_MESSAGE = 'execution expired'
22
+
23
+ NO_SCHEDULER_MESSAGE = <<~MESSAGE
24
+ Async::Background requires an active Fiber scheduler.
25
+
26
+ Install one in the host process before calling this, for example:
27
+
28
+ require "async/background/scheduler"
29
+ Async::Background::Scheduler.run { runner.run }
30
+
31
+ or install one yourself:
32
+
33
+ Fiber.set_scheduler(Itsi::Scheduler.new) # itsi-scheduler
34
+ Async { runner.run } # async / falcon
35
+ MESSAGE
36
+
37
+ MISSING_TIMEOUT_HOOK_WARNING = <<~MESSAGE
38
+ Async::Background: %s does not implement #timeout_after.
39
+
40
+ Falling back to stdlib Timeout, which uses Thread#raise and can deliver
41
+ the timeout to an unrelated fiber. Job timeouts are therefore not safe
42
+ on this scheduler. Use a scheduler that implements #timeout_after
43
+ (async, itsi-scheduler) or run jobs with `timeout: nil`.
44
+ MESSAGE
45
+
46
+ @error_handler = nil
47
+ @warned_schedulers = {}
48
+
49
+ module_function
50
+
51
+ module FiberLocals
52
+ def current_task
53
+ fiber_local(CURRENT_TASK_KEY)
54
+ end
55
+
56
+ def current_task=(task)
57
+ set_fiber_local(CURRENT_TASK_KEY, task)
58
+ end
59
+
60
+ def fiber_local(key)
61
+ Fiber[key]
62
+ rescue ArgumentError, FiberError
63
+ fiber_local_fallback[key]
64
+ end
65
+
66
+ def set_fiber_local(key, value)
67
+ Fiber[key] = value
68
+ rescue ArgumentError, FiberError
69
+ fiber_local_fallback[key] = value
70
+ end
71
+
72
+ def clear_fiber_local(key, expected)
73
+ current = fiber_local(key)
74
+ set_fiber_local(key, nil) if current.equal?(expected)
75
+ end
76
+
77
+ def fiber_local_fallback
78
+ fiber = Fiber.current
79
+ store = fiber.instance_variable_get(:@async_background_locals)
80
+ store || fiber.instance_variable_set(:@async_background_locals, {})
81
+ end
82
+ end
83
+ extend FiberLocals
84
+
85
+ def spawn(name: nil, on_error: UNSET, &block)
86
+ Task.spawn(name: name, on_error: on_error, &block)
87
+ end
88
+
89
+ def scheduler
90
+ Fiber.scheduler
91
+ end
92
+
93
+ def scheduler!
94
+ Fiber.scheduler or raise SchedulerRequired, NO_SCHEDULER_MESSAGE
95
+ end
96
+
97
+ def native_timeouts?(target = Fiber.scheduler)
98
+ !target.nil? && target.respond_to?(:timeout_after)
99
+ end
100
+
101
+ def build_waiter(blocker)
102
+ existing = fiber_local(WAITER_KEY)
103
+ if existing&.[](:blocker)
104
+ raise Error, "waiter already parked on #{existing[:blocker].class}; " \
105
+ 'a fiber may only park in one place at a time'
106
+ end
107
+
108
+ waiter = {
109
+ fiber: Fiber.current,
110
+ scheduler: scheduler!,
111
+ ready: false,
112
+ queued: false,
113
+ blocker: blocker
114
+ }
115
+ set_fiber_local(WAITER_KEY, waiter)
116
+ waiter
117
+ end
118
+
119
+ def park(blocker, waiter, deadline = nil)
120
+ scheduler = waiter[:scheduler]
121
+ task = current_task
122
+ task&.enter_block(waiter)
123
+ park_until_ready(scheduler, blocker, waiter, deadline, task) { yield }
124
+ ensure
125
+ waiter[:blocker] = nil
126
+ clear_fiber_local(WAITER_KEY, waiter)
127
+ task&.exit_block
128
+ end
129
+
130
+ def park_until_ready(scheduler, blocker, waiter, deadline, task)
131
+ until waiter[:ready] || yield
132
+ return false if wait_for_blocker(scheduler, blocker, deadline) == :expired
133
+
134
+ task&.raise_if_cancelled!
135
+ end
136
+ true
137
+ end
138
+
139
+ def wait_for_blocker(scheduler, blocker, deadline)
140
+ return scheduler.block(blocker, nil) unless deadline
141
+
142
+ remaining = deadline - monotonic_now
143
+ return :expired if remaining <= 0
144
+
145
+ scheduler.block(blocker, remaining)
146
+ end
147
+
148
+ def with_waiter(blocker, waiters)
149
+ waiter = build_waiter(blocker)
150
+ waiter[:queued] = true
151
+ waiters << waiter
152
+ yield waiter
153
+ ensure
154
+ waiters.delete(waiter) if waiter[:queued]
155
+ end
156
+
157
+ def wake_dequeued(waiter, blocker)
158
+ waiter[:queued] = false
159
+ wake(waiter, blocker)
160
+ end
161
+
162
+ def wake(waiter, blocker)
163
+ return false if waiter[:ready]
164
+
165
+ waiter[:ready] = true
166
+ fiber = waiter[:fiber]
167
+ return false unless fiber.alive?
168
+
169
+ waiter[:scheduler].unblock(blocker, fiber)
170
+ true
171
+ end
172
+
173
+ def monotonic_now
174
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
175
+ end
176
+
177
+ def deadline_for(timeout)
178
+ return nil if timeout.nil?
179
+
180
+ seconds = Float(timeout)
181
+ raise ArgumentError, 'timeout must be non-negative and finite' unless seconds.finite? && seconds >= 0
182
+
183
+ monotonic_now + seconds
184
+ end
185
+
186
+ attr_writer :error_handler
187
+ attr_reader :error_handler
188
+ module_function :error_handler=, :error_handler
189
+
190
+ def with_error_handler(handler)
191
+ previous = @error_handler
192
+ @error_handler = handler
193
+ yield
194
+ ensure
195
+ @error_handler = previous
196
+ end
197
+
198
+ def report_error(task, error, handler = UNSET)
199
+ handler = @error_handler if UNSET.equal?(handler)
200
+ return false unless handler
201
+
202
+ handler.call(task, error)
203
+ true
204
+ rescue StandardError
205
+ false
206
+ end
207
+
208
+ def with_timeout(duration, on_timeout: UNSET, &block)
209
+ return yield if duration.nil?
210
+
211
+ seconds = Float(duration)
212
+ raise ArgumentError, 'timeout must be positive and finite' unless seconds.finite? && seconds.positive?
213
+
214
+ begin
215
+ arm_deadline(seconds, &block)
216
+ rescue Deadline => e
217
+ raise TimeoutError, e.message if UNSET.equal?(on_timeout)
218
+
219
+ on_timeout
220
+ end
221
+ end
222
+
223
+ def arm_deadline(seconds, &block)
224
+ target = scheduler!
225
+
226
+ return target.timeout_after(seconds, Deadline, DEADLINE_MESSAGE, &block) if native_timeouts?(target)
227
+
228
+ warn_missing_timeout_hook(target)
229
+ ::Timeout.timeout(seconds, Deadline, DEADLINE_MESSAGE, &block)
230
+ end
231
+
232
+ def warn_missing_timeout_hook(target)
233
+ key = target.class
234
+ return if @warned_schedulers[key]
235
+
236
+ @warned_schedulers[key] = true
237
+ warn(format(MISSING_TIMEOUT_HOOK_WARNING, key))
238
+ end
239
+ end
240
+ end
241
+ end
242
+
243
+ require_relative 'runtime/notification'
244
+ require_relative 'runtime/semaphore'
245
+ require_relative 'runtime/task'
246
+ require_relative 'runtime/task_group'
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'runtime'
4
+
5
+ module Async
6
+ module Background
7
+ module Scheduler
8
+ ENV_KEY = 'ASYNC_BACKGROUND_SCHEDULER'
9
+ THREAD_ENV_KEY = 'ASYNC_BACKGROUND_SCHEDULER_THREAD'
10
+ KNOWN = %i[async itsi].freeze
11
+
12
+ class UnknownScheduler < ArgumentError; end
13
+ class Unavailable < Background::Error; end
14
+
15
+ module_function
16
+
17
+ def installed? = !Fiber.scheduler.nil?
18
+
19
+ def current
20
+ return nil unless installed?
21
+
22
+ Fiber.scheduler.class.name
23
+ end
24
+
25
+ def preload!(kind = nil)
26
+ case resolve(kind)
27
+ when :async then require 'async'
28
+ when :itsi then require 'itsi/scheduler'
29
+ end
30
+ true
31
+ end
32
+
33
+ def run(kind = nil, &block)
34
+ raise ArgumentError, 'block required' unless block
35
+ return yield if installed?
36
+
37
+ case resolve(kind)
38
+ when :async then run_async(&block)
39
+ when :itsi then run_itsi(&block)
40
+ end
41
+ end
42
+
43
+ def resolve(kind = nil)
44
+ name = (kind || ENV.fetch(ENV_KEY, 'auto')).to_s.downcase
45
+ return name.to_sym if KNOWN.include?(name.to_sym)
46
+ return detect if name == 'auto'
47
+
48
+ raise UnknownScheduler, "unknown scheduler #{name.inspect}, expected one of: #{KNOWN.join(', ')}, auto"
49
+ end
50
+
51
+ def detect
52
+ return :async if defined?(::Async::Scheduler)
53
+ return :itsi if defined?(::Itsi::Scheduler)
54
+
55
+ return :async if available?('async')
56
+ return :itsi if available?('itsi/scheduler')
57
+
58
+ raise Unavailable, 'no fiber scheduler available: add `async` or `itsi-scheduler` to your bundle'
59
+ end
60
+
61
+ def available?(feature)
62
+ !Gem.find_files(feature).empty? || !Gem.find_files("#{feature}.rb").empty?
63
+ rescue StandardError
64
+ try_require(feature)
65
+ end
66
+
67
+ def try_require(feature)
68
+ require feature
69
+ true
70
+ rescue LoadError
71
+ false
72
+ end
73
+
74
+ def run_async(&block)
75
+ require 'async'
76
+
77
+ result = nil
78
+ send(:Async) { result = yield }
79
+ result
80
+ end
81
+
82
+ def run_itsi(&block)
83
+ require 'itsi/scheduler'
84
+
85
+ return run_on_thread(::Itsi::Scheduler, &block) if threaded?
86
+
87
+ run_on_current_thread(::Itsi::Scheduler.new, &block)
88
+ end
89
+
90
+ def threaded?
91
+ %w[1 true yes].include?(ENV.fetch(THREAD_ENV_KEY, '').to_s.downcase)
92
+ end
93
+
94
+ def run_on_current_thread(scheduler, &block)
95
+ result = nil
96
+ failure = nil
97
+ finished = false
98
+
99
+ previous = Fiber.scheduler
100
+ Fiber.set_scheduler(scheduler)
101
+
102
+ begin
103
+ Fiber.schedule do
104
+ result = yield
105
+ rescue Exception => e # rubocop:disable Lint/RescueException
106
+ failure = e
107
+ ensure
108
+ finished = true
109
+ end
110
+
111
+ scheduler.run if scheduler.respond_to?(:run)
112
+ ensure
113
+ Fiber.set_scheduler(previous)
114
+ end
115
+
116
+ raise failure if failure
117
+ unless finished
118
+ raise Unavailable,
119
+ "#{scheduler.class} did not run the scheduled fiber to completion on close; " \
120
+ "set #{THREAD_ENV_KEY}=1 to fall back to a dedicated scheduler thread"
121
+ end
122
+
123
+ result
124
+ end
125
+
126
+ def run_on_thread(scheduler_class, &block)
127
+ result = nil
128
+ failure = nil
129
+
130
+ thread = Thread.new do
131
+ Fiber.set_scheduler(scheduler_class.new)
132
+ Fiber.schedule do
133
+ result = yield
134
+ rescue Exception => e # rubocop:disable Lint/RescueException
135
+ failure = e
136
+ end
137
+ end
138
+ thread.join
139
+ raise failure if failure
140
+
141
+ result
142
+ end
143
+ end
144
+ end
145
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Async
4
4
  module Background
5
- VERSION = '1.0.2'
5
+ VERSION = '1.1.1'
6
6
  end
7
7
  end
@@ -32,6 +32,23 @@ module Async
32
32
 
33
33
  private
34
34
 
35
+ LIST_ROUTES = {
36
+ executing: [:executing, :executing, nil].freeze,
37
+ claimed: [:claimed, :claimed, nil].freeze,
38
+ done: %i[recent_done done finished_cursor].freeze,
39
+ failed: %i[recent_failed failed finished_cursor].freeze,
40
+ pending: %i[pending pending pending_cursor].freeze
41
+ }.freeze
42
+ STATIC_ROUTES = {
43
+ index: :index_response,
44
+ javascript: :javascript_response,
45
+ stylesheet: :stylesheet_response,
46
+ overview: :overview_response,
47
+ metrics: :metrics_response,
48
+ config: :config_response,
49
+ stream: :stream_response
50
+ }.freeze
51
+
35
52
  def handle(env, head:)
36
53
  return Response.unauthorized unless @auth.authorized?(env)
37
54
 
@@ -43,15 +60,15 @@ module Async
43
60
  end
44
61
 
45
62
  dispatch(route, env)
46
- rescue RequestError => error
47
- Response.bad_request(error.message)
63
+ rescue RequestError => e
64
+ Response.bad_request(e.message)
48
65
  rescue UnavailableError, ClosedError
49
66
  Response.unavailable
50
- rescue StandardError => error
67
+ rescue StandardError => e
51
68
  # Do not turn internal class names, paths or database errors into an
52
69
  # unauthenticated information disclosure channel — but do surface
53
70
  # them to the operator via the configured logger.
54
- log_internal_error(env, error)
71
+ log_internal_error(env, e)
55
72
  Response.internal_error
56
73
  end
57
74
 
@@ -68,51 +85,37 @@ module Async
68
85
  end
69
86
 
70
87
  def dispatch(route, env)
71
- case route
72
- when :index then Response.html(Assets.render_index(@config))
73
- when :javascript then Response.javascript(Assets::JS)
74
- when :stylesheet then Response.stylesheet(Assets::CSS)
75
- when :overview then overview_response
76
- when :executing then in_flight_response(:executing, env)
77
- when :claimed then in_flight_response(:claimed, env)
78
- when :done then terminal_response(:done, env)
79
- when :failed then terminal_response(:failed, env)
80
- when :pending then pending_response(env)
81
- when :metrics then metrics_response
82
- when :config then config_response
83
- when :stream then stream_response
84
- else Response.not_found
85
- end
86
- end
88
+ return list_response(route, env) if LIST_ROUTES.key?(route)
87
89
 
88
- def overview_response
89
- Response.json(@serializer.overview(@snapshot.overview, metrics_payload))
90
+ handler = STATIC_ROUTES[route]
91
+ handler ? send(handler) : Response.not_found
90
92
  end
91
93
 
92
- def in_flight_response(kind, env)
93
- request = Request.new(env, @config)
94
- rows = kind == :executing ? @snapshot.executing(limit: request.limit) : @snapshot.claimed(limit: request.limit)
95
- payload = kind == :executing ? @serializer.executing(rows) : @serializer.claimed(rows)
96
- Response.json({items: payload})
97
- end
94
+ def index_response = Response.html(Assets.render_index(@config))
95
+
96
+ def javascript_response = Response.javascript(Assets::JS)
98
97
 
99
- def terminal_response(kind, env)
98
+ def stylesheet_response = Response.stylesheet(Assets::CSS)
99
+
100
+ def list_response(route, env)
101
+ reader, shape, cursor_kind = LIST_ROUTES.fetch(route)
100
102
  request = Request.new(env, @config)
101
- cursor = request.finished_cursor
102
- rows = kind == :done ? @snapshot.recent_done(limit: request.limit, cursor: cursor) :
103
- @snapshot.recent_failed(limit: request.limit, cursor: cursor)
104
- payload = kind == :done ? @serializer.done(rows) : @serializer.failed(rows)
105
- Response.json(payload)
103
+
104
+ unless cursor_kind
105
+ rows = @snapshot.public_send(reader, limit: request.limit)
106
+ return Response.json({ items: @serializer.public_send(shape, rows) })
107
+ end
108
+
109
+ rows = @snapshot.public_send(reader, limit: request.limit, cursor: request.public_send(cursor_kind))
110
+ Response.json(@serializer.public_send(shape, rows))
106
111
  end
107
112
 
108
- def pending_response(env)
109
- request = Request.new(env, @config)
110
- rows = @snapshot.pending(limit: request.limit, cursor: request.pending_cursor)
111
- Response.json(@serializer.pending(rows))
113
+ def overview_response
114
+ Response.json(@serializer.overview(@snapshot.overview, metrics_payload))
112
115
  end
113
116
 
114
117
  def metrics_response
115
- Response.json(metrics_payload || {available: false, workers: [], totals: MetricsReader::EMPTY_TOTALS})
118
+ Response.json(metrics_payload || MetricsReader::UNAVAILABLE)
116
119
  end
117
120
 
118
121
  def metrics_payload
@@ -148,7 +151,7 @@ module Async
148
151
 
149
152
  def log_internal_error(env, error)
150
153
  @logger&.error(
151
- "[async-background-web] internal error on " \
154
+ '[async-background-web] internal error on ' \
152
155
  "#{env['REQUEST_METHOD']} #{env['PATH_INFO']}: " \
153
156
  "#{error.class}: #{error.message}"
154
157
  )
@@ -0,0 +1,174 @@
1
+ :root {
2
+ --bg: #0f1115;
3
+ --panel: #161a20;
4
+ --panel-soft: #1c2128;
5
+ --border: #2a313b;
6
+ --text: #e6e8ec;
7
+ --text-dim: #98a2b3;
8
+ --accent: #4f8ef7;
9
+ --green: #4ade80;
10
+ --amber: #fbbf24;
11
+ --red: #f87171;
12
+ --blue: #60a5fa;
13
+ --gray: #94a3b8;
14
+ }
15
+ * { box-sizing: border-box; }
16
+ body {
17
+ margin: 0;
18
+ font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
19
+ background: var(--bg);
20
+ color: var(--text);
21
+ }
22
+ header {
23
+ display: flex;
24
+ align-items: center;
25
+ gap: 16px;
26
+ padding: 14px 24px;
27
+ border-bottom: 1px solid var(--border);
28
+ background: var(--panel);
29
+ }
30
+ header h1 { font-size: 16px; margin: 0; font-weight: 600; }
31
+ header .meta { color: var(--text-dim); font-size: 12px; }
32
+ header .status-dot {
33
+ width: 8px; height: 8px; border-radius: 50%;
34
+ background: var(--gray);
35
+ display: inline-block; margin-right: 6px;
36
+ }
37
+ header .status-dot.ok { background: var(--green); }
38
+ header .status-dot.stale { background: var(--amber); }
39
+ header .status-dot.error { background: var(--red); }
40
+ main { padding: 16px 24px; }
41
+ .counts {
42
+ display: grid;
43
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
44
+ gap: 12px;
45
+ margin-bottom: 20px;
46
+ }
47
+ .count-card {
48
+ background: var(--panel);
49
+ border: 1px solid var(--border);
50
+ border-radius: 8px;
51
+ padding: 12px 14px;
52
+ }
53
+ .count-card .label { color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
54
+ .count-card .value { font-size: 24px; font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; }
55
+ .count-card.executing .value { color: var(--blue); }
56
+ .count-card.claimed .value { color: var(--amber); }
57
+ .count-card.pending .value { color: var(--text); }
58
+ .count-card.done .value { color: var(--green); }
59
+ .count-card.failed .value { color: var(--red); }
60
+ .totals {
61
+ display: grid;
62
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
63
+ gap: 12px;
64
+ margin-bottom: 20px;
65
+ }
66
+ .total-card {
67
+ background: var(--panel-soft);
68
+ border: 1px solid var(--border);
69
+ border-radius: 8px;
70
+ padding: 10px 12px;
71
+ }
72
+ .total-card .label { color: var(--text-dim); font-size: 11px; }
73
+ .total-card .value { font-variant-numeric: tabular-nums; font-size: 18px; margin-top: 2px; }
74
+ nav.tabs {
75
+ display: flex;
76
+ gap: 2px;
77
+ border-bottom: 1px solid var(--border);
78
+ margin-bottom: 12px;
79
+ flex-wrap: wrap;
80
+ }
81
+ nav.tabs button {
82
+ background: transparent;
83
+ color: var(--text-dim);
84
+ border: none;
85
+ border-bottom: 2px solid transparent;
86
+ padding: 10px 14px;
87
+ font: inherit;
88
+ cursor: pointer;
89
+ border-radius: 0;
90
+ }
91
+ nav.tabs button:hover { color: var(--text); }
92
+ nav.tabs button.active {
93
+ color: var(--text);
94
+ border-bottom-color: var(--accent);
95
+ }
96
+ nav.tabs button .badge {
97
+ display: inline-block;
98
+ background: var(--panel-soft);
99
+ color: var(--text-dim);
100
+ border-radius: 10px;
101
+ padding: 1px 8px;
102
+ font-size: 11px;
103
+ margin-left: 6px;
104
+ font-variant-numeric: tabular-nums;
105
+ }
106
+ table {
107
+ width: 100%;
108
+ border-collapse: collapse;
109
+ background: var(--panel);
110
+ border: 1px solid var(--border);
111
+ border-radius: 8px;
112
+ overflow: hidden;
113
+ }
114
+ th, td {
115
+ text-align: left;
116
+ padding: 9px 12px;
117
+ border-bottom: 1px solid var(--border);
118
+ vertical-align: top;
119
+ font-variant-numeric: tabular-nums;
120
+ }
121
+ tbody tr:last-child td { border-bottom: none; }
122
+ th {
123
+ background: var(--panel-soft);
124
+ color: var(--text-dim);
125
+ font-weight: 500;
126
+ font-size: 11px;
127
+ text-transform: uppercase;
128
+ letter-spacing: .04em;
129
+ }
130
+ td.dim { color: var(--text-dim); }
131
+ td.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
132
+ .empty {
133
+ padding: 32px;
134
+ text-align: center;
135
+ color: var(--text-dim);
136
+ background: var(--panel);
137
+ border: 1px solid var(--border);
138
+ border-radius: 8px;
139
+ }
140
+ .pagination {
141
+ display: flex;
142
+ gap: 8px;
143
+ margin-top: 12px;
144
+ }
145
+ button.btn {
146
+ background: var(--panel-soft);
147
+ color: var(--text);
148
+ border: 1px solid var(--border);
149
+ border-radius: 6px;
150
+ padding: 6px 12px;
151
+ font: inherit;
152
+ cursor: pointer;
153
+ }
154
+ button.btn:hover { border-color: var(--accent); }
155
+ button.btn:disabled { opacity: .4; cursor: not-allowed; }
156
+ .err-msg {
157
+ color: var(--red);
158
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
159
+ font-size: 12px;
160
+ max-width: 480px;
161
+ white-space: pre-wrap;
162
+ word-break: break-word;
163
+ }
164
+ .err-class { color: var(--amber); font-weight: 600; }
165
+ .args-cell pre {
166
+ margin: 0;
167
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
168
+ font-size: 12px;
169
+ color: var(--text-dim);
170
+ white-space: pre-wrap;
171
+ word-break: break-all;
172
+ max-width: 380px;
173
+ }
174
+ .args-redacted { color: var(--text-dim); font-style: italic; }