cosmonats 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -7
  3. data/lib/cosmo/api/busy.rb +66 -0
  4. data/lib/cosmo/api/counter.rb +70 -0
  5. data/lib/cosmo/api/job.rb +46 -0
  6. data/lib/cosmo/api/kv.rb +63 -0
  7. data/lib/cosmo/api/stats.rb +44 -0
  8. data/lib/cosmo/api/stream.rb +110 -0
  9. data/lib/cosmo/api.rb +11 -0
  10. data/lib/cosmo/cli.rb +6 -4
  11. data/lib/cosmo/client.rb +33 -2
  12. data/lib/cosmo/config.rb +8 -6
  13. data/lib/cosmo/defaults.yml +31 -30
  14. data/lib/cosmo/job/processor.rb +52 -18
  15. data/lib/cosmo/job.rb +1 -1
  16. data/lib/cosmo/logger.rb +4 -0
  17. data/lib/cosmo/processor.rb +1 -1
  18. data/lib/cosmo/utils/overrides.rb +15 -0
  19. data/lib/cosmo/utils/warnings.rb +17 -0
  20. data/lib/cosmo/utils.rb +14 -0
  21. data/lib/cosmo/version.rb +1 -1
  22. data/lib/cosmo/web/assets/app.css +431 -0
  23. data/lib/cosmo/web/assets/htmx.2.0.8.min.js.gz +0 -0
  24. data/lib/cosmo/web/context.rb +28 -0
  25. data/lib/cosmo/web/controllers/actions.rb +16 -0
  26. data/lib/cosmo/web/controllers/application.rb +43 -0
  27. data/lib/cosmo/web/controllers/jobs.rb +97 -0
  28. data/lib/cosmo/web/controllers/streams.rb +44 -0
  29. data/lib/cosmo/web/helpers/application.rb +76 -0
  30. data/lib/cosmo/web/renderer.rb +58 -0
  31. data/lib/cosmo/web/views/actions/index.erb +7 -0
  32. data/lib/cosmo/web/views/jobs/_busy.erb +50 -0
  33. data/lib/cosmo/web/views/jobs/_dead.erb +65 -0
  34. data/lib/cosmo/web/views/jobs/_enqueued.erb +60 -0
  35. data/lib/cosmo/web/views/jobs/_scheduled.erb +49 -0
  36. data/lib/cosmo/web/views/jobs/_stats.erb +69 -0
  37. data/lib/cosmo/web/views/jobs/busy.erb +16 -0
  38. data/lib/cosmo/web/views/jobs/dead.erb +17 -0
  39. data/lib/cosmo/web/views/jobs/enqueued.erb +16 -0
  40. data/lib/cosmo/web/views/jobs/index.erb +12 -0
  41. data/lib/cosmo/web/views/jobs/scheduled.erb +17 -0
  42. data/lib/cosmo/web/views/layout.erb +33 -0
  43. data/lib/cosmo/web/views/streams/_info.erb +89 -0
  44. data/lib/cosmo/web/views/streams/_table.erb +42 -0
  45. data/lib/cosmo/web/views/streams/index.erb +11 -0
  46. data/lib/cosmo/web/views/streams/info.erb +11 -0
  47. data/lib/cosmo/web.rb +66 -0
  48. data/lib/cosmo.rb +2 -7
  49. data/sig/cosmo/api/busy.rbs +35 -0
  50. data/sig/cosmo/api/counter.rbs +34 -0
  51. data/sig/cosmo/api/job.rbs +31 -0
  52. data/sig/cosmo/api/kv.rbs +30 -0
  53. data/sig/cosmo/api/stats.rbs +21 -0
  54. data/sig/cosmo/api/stream.rbs +44 -0
  55. data/sig/cosmo/client.rbs +13 -3
  56. metadata +58 -2
@@ -2,22 +2,32 @@
2
2
 
3
3
  module Cosmo
4
4
  module Job
5
- class Processor < ::Cosmo::Processor
5
+ class Processor < ::Cosmo::Processor # rubocop:disable Metrics/ClassLength
6
6
  def initialize(pool, running, options)
7
7
  super
8
8
  @weights = []
9
9
  end
10
10
 
11
+ def stop(timeout = Config[:timeout])
12
+ @running.make_false
13
+ @pool.shutdown
14
+ @consumers.each { |(s, _)| s.unsubscribe rescue nil }
15
+ @pool.wait_for_termination(timeout)
16
+ [@work_thread, @schedule_thread].compact.each { _1.join(timeout) || _1.kill }
17
+ @consumers.clear
18
+ end
19
+
11
20
  private
12
21
 
13
22
  def run_loop
14
- Thread.new { work_loop }
15
- Thread.new { schedule_loop }
23
+ @work_thread = Thread.new { work_loop }
24
+ @schedule_thread = Thread.new { schedule_loop }
16
25
  end
17
26
 
18
27
  def setup
19
28
  jobs_config = Config.dig(:consumers, :jobs)
20
29
  jobs_config&.each do |stream_name, config|
30
+ config = config.dup
21
31
  consumer_name = "consumer-#{stream_name}"
22
32
  subject = config.delete(:subject)
23
33
  priority = config.delete(:priority)
@@ -40,7 +50,7 @@ module Cosmo
40
50
  timeout = ENV.fetch("COSMO_JOBS_FETCH_TIMEOUT", 0.1).to_f
41
51
  @pool.post do
42
52
  subscription = @consumers.find { |(_, sn)| sn == stream_name }&.first
43
- messages = fetch(subscription, batch_size: 1, timeout:)
53
+ messages = lock(stream_name) { fetch(subscription, batch_size: 1, timeout:) }
44
54
  process(messages) if messages&.any?
45
55
  end
46
56
  rescue Concurrent::RejectedExecutionError
@@ -85,17 +95,19 @@ module Cosmo
85
95
  Logger.debug "received messages #{messages.inspect}"
86
96
  data = Utils::Json.parse(message.data)
87
97
  unless data
88
- Logger.debug ArgumentError.new("malformed payload")
98
+ Logger.error ArgumentError.new("malformed payload")
99
+ move_message(message)
89
100
  return
90
101
  end
91
102
 
92
103
  worker_class = Utils::String.safe_constantize(data[:class])
93
104
  unless worker_class
94
- Logger.debug ArgumentError.new("#{data[:class]} class not found")
105
+ Logger.error ArgumentError.new("#{data[:class]} class not found")
106
+ move_message(message, data)
95
107
  return
96
108
  end
97
109
 
98
- begin
110
+ with_stats(message) do
99
111
  sw = stopwatch
100
112
  Logger.with(jid: data[:jid])
101
113
  Logger.info "start"
@@ -104,10 +116,12 @@ module Cosmo
104
116
  instance.perform(*data[:args])
105
117
  message.ack
106
118
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "done" }
119
+ true
107
120
  rescue StandardError => e
108
121
  Logger.debug e
109
122
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
110
- handle_failure(message, data)
123
+ dropped = handle_failure(message, data)
124
+ false if dropped
111
125
  rescue Exception # rubocop:disable Lint/RescueException
112
126
  Logger.with(elapsed: sw.elapsed_seconds) { Logger.info "fail" }
113
127
  raise
@@ -117,26 +131,46 @@ module Cosmo
117
131
  Logger.debug "processed message #{message.inspect}"
118
132
  end
119
133
 
120
- def handle_failure(message, data) # rubocop:disable Metrics/AbcSize
134
+ def handle_failure(message, data) # rubocop:disable Naming/PredicateMethod
121
135
  current_attempt = message.metadata.num_delivered
122
136
  max_retries = data[:retry].to_i + 1
123
137
 
124
138
  if current_attempt < max_retries
125
- # NATS will auto-retry based on max_deliver with exponential backoff
139
+ # NATS will auto-retry with delay (exponential backoff based on current attempt).
140
+ # When max_deliver is reached, NATS stops redelivering the message and marks it as "max deliveries exceeded".
141
+ # The message is effectively abandoned by NATS — it stays in the stream (consuming a slot) but will never be delivered again to that consumer.
126
142
  delay_ns = ((current_attempt**4) + 15) * 1_000_000_000
127
143
  message.nak(delay: delay_ns)
128
- return
144
+ return false
129
145
  end
130
146
 
131
- if data[:dead]
132
- Client.instance.publish("jobs.dead.#{Utils::String.underscore(data[:class])}", message.data)
133
- message.ack
134
- Logger.debug "job moved #{data[:jid]} to DLQ"
135
- else
136
- message.term
137
- Logger.debug "job dropped #{data[:jid]}"
147
+ data[:dead] ? move_message(message, data) : drop_message(message, data)
148
+ true
149
+ end
150
+
151
+ def drop_message(message, data)
152
+ message.term
153
+ Logger.debug "job dropped #{data[:jid]}"
154
+ end
155
+
156
+ def move_message(message, data = nil)
157
+ klass = data ? Utils::String.underscore(data[:class]) : "default"
158
+ headers = { "X-Stream" => message.metadata.stream, "X-Subject" => message.subject }
159
+ Client.instance.publish("jobs.dead.#{klass}", message.data, header: headers)
160
+ message.ack
161
+ Logger.debug "job moved #{data&.dig(:jid)} to DLQ"
162
+ end
163
+
164
+ def with_stats(message, &block)
165
+ API::Busy.instance.with(message) do
166
+ API::Counter.instance.with(&block)
138
167
  end
139
168
  end
169
+
170
+ def lock(stream_name, &)
171
+ @mutexes ||= Hash.new { |h, k| h[k] = Mutex.new }
172
+ @mutexes[stream_name].synchronize(&)
173
+ end
140
174
  end
141
175
  end
142
176
  end
data/lib/cosmo/job.rb CHANGED
@@ -54,7 +54,7 @@ module Cosmo
54
54
  end
55
55
  end
56
56
 
57
- attr_reader :jid
57
+ attr_accessor :jid
58
58
 
59
59
  def perform(...)
60
60
  raise NotImplementedError, "#{self.class}#perform must be implemented"
data/lib/cosmo/logger.rb CHANGED
@@ -62,5 +62,9 @@ module Cosmo
62
62
  def self.instance
63
63
  @instance ||= ::Logger.new($stdout).tap { _1.formatter = SimpleFormatter.new }
64
64
  end
65
+
66
+ def self.instance=(logger)
67
+ @instance = logger
68
+ end
65
69
  end
66
70
  end
@@ -45,7 +45,7 @@ module Cosmo
45
45
  # No messages, continue
46
46
  rescue StandardError => e
47
47
  Logger.error "Snap! Error just happened"
48
- Logger.error "#{e.class}: #{e.message}"
48
+ Logger.error "#{e.class}: #{e.message}\n#{e.backtrace.join("\n")}"
49
49
 
50
50
  backoff = ENV.fetch("COSMO_STREAMS_FETCH_BACKOFF", 5).to_f
51
51
  sleep([timeout, backoff].max) # backoff before retry
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ Cosmo::Utils::Warnings.silence do
4
+ members = NATS::JetStream::API::StreamConfig.members + [:allow_msg_counter]
5
+ NATS::JetStream::API::StreamConfig = Struct.new(*members, keyword_init: true) do
6
+ def initialize(opts = {})
7
+ rem = opts.keys - members
8
+ opts.delete_if { |k| rem.include?(k) }
9
+ super
10
+ end
11
+ end
12
+
13
+ members = NATS::JetStream::PubAck.members + [:val]
14
+ NATS::JetStream::PubAck = Struct.new(*members, keyword_init: true)
15
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cosmo
4
+ module Utils
5
+ module Warnings
6
+ module_function
7
+
8
+ def silence
9
+ verbose = $VERBOSE
10
+ $VERBOSE = nil
11
+ yield
12
+ ensure
13
+ $VERBOSE = verbose
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/utils/hash"
4
+ require "cosmo/utils/json"
5
+ require "cosmo/utils/string"
6
+ require "cosmo/utils/signal"
7
+ require "cosmo/utils/warnings"
8
+ require "cosmo/utils/stopwatch"
9
+ require "cosmo/utils/thread_pool"
10
+
11
+ module Cosmo
12
+ module Utils
13
+ end
14
+ end
data/lib/cosmo/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cosmo
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,431 @@
1
+ :root {
2
+ --color-primary: oklch(0.66 0.188 250.179);
3
+ --color-bg: oklch(99% 0.005 256);
4
+ --color-elevated: oklch(100% 0 256);
5
+ --color-border: oklch(95% 0.005 256);
6
+ --color-selected: oklch(93% 0.005 256);
7
+ --color-table-bg-alt: oklch(99% 0.005 256);
8
+ --color-shadow: oklch(27% 0.005 256 / 5%);
9
+ --color-text: oklch(27% 0.005 256);
10
+ --color-text-light: oklch(52% 0.005 256);
11
+ --color-success: oklch(0.70 0.15 145);
12
+ --color-info: oklch(0.66 0.188 250.179);
13
+ --color-danger: oklch(0.65 0.22 25);
14
+ --color-warning: oklch(0.75 0.15 85);
15
+
16
+ --space-1-2: 4px;
17
+ --space: 8px;
18
+ --space-2x: 16px;
19
+ --space-3x: 24px;
20
+ --space-4x: 32px;
21
+
22
+ --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
23
+ Oxygen, Ubuntu, Cantarell, sans-serif;
24
+ --font-mono: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas,
25
+ 'DejaVu Sans Mono', monospace;
26
+ --font-size: 16px;
27
+ --font-size-large: 1.728rem;
28
+ --font-size-small: 0.833rem;
29
+ }
30
+
31
+ @media (prefers-color-scheme: dark) {
32
+ :root {
33
+ --color-primary: oklch(0.72 0.188 250.179);
34
+ --color-bg: oklch(20% 0.005 256);
35
+ --color-elevated: oklch(24% 0.005 256);
36
+ --color-border: oklch(30% 0.005 256);
37
+ --color-selected: oklch(28% 0.005 256);
38
+ --color-table-bg-alt: oklch(22% 0.005 256);
39
+ --color-shadow: oklch(10% 0.005 256 / 30%);
40
+ --color-text: oklch(90% 0.005 256);
41
+ --color-text-light: oklch(65% 0.005 256);
42
+ }
43
+ }
44
+
45
+ /* ── Reset ─────────────────────────────────────────────────────────────── */
46
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
47
+
48
+ /* ── Base ──────────────────────────────────────────────────────────────── */
49
+ body {
50
+ font-family: var(--font-sans);
51
+ font-size: var(--font-size);
52
+ line-height: 1.5;
53
+ background: var(--color-bg);
54
+ color: var(--color-text);
55
+ font-variant-numeric: tabular-nums;
56
+ -webkit-font-smoothing: antialiased;
57
+ min-height: 100vh;
58
+ }
59
+
60
+ h1, h2, h3 {
61
+ font-size: var(--font-size-large);
62
+ font-weight: normal;
63
+ overflow-wrap: break-word;
64
+ margin: var(--space) 0 var(--space);
65
+ padding: 0;
66
+ }
67
+
68
+ code {
69
+ background-color: var(--color-elevated);
70
+ border-radius: 2px;
71
+ box-shadow: 0 0 0 1px var(--color-border);
72
+ color: var(--color-text-light);
73
+ display: inline-block;
74
+ font-family: var(--font-mono);
75
+ font-size: var(--font-size-small);
76
+ padding: var(--space-1-2);
77
+ word-wrap: anywhere;
78
+ }
79
+
80
+ a { color: inherit; text-decoration: none; }
81
+ a.active, a:hover { color: oklch(from var(--color-primary) calc(l + 0.06) c h); }
82
+
83
+ /* ── Buttons ───────────────────────────────────────────────────────────── */
84
+ button, .btn {
85
+ background: linear-gradient(
86
+ oklch(from var(--color-text) calc(l + 0.06) c h),
87
+ var(--color-text)
88
+ );
89
+ border: none;
90
+ border-radius: var(--space-1-2);
91
+ box-shadow: 0 1px 1px 1px var(--color-shadow);
92
+ color: var(--color-bg);
93
+ cursor: pointer;
94
+ display: inline-block;
95
+ font-family: inherit;
96
+ font-size: var(--font-size);
97
+ font-weight: 700;
98
+ line-height: var(--space-3x);
99
+ padding: var(--space) var(--space-2x);
100
+ text-decoration: none;
101
+ white-space: nowrap;
102
+ }
103
+
104
+ button:hover, .btn:hover {
105
+ background: linear-gradient(
106
+ oklch(from var(--color-text) calc(l + 0.16) c h),
107
+ oklch(from var(--color-text) calc(l + 0.1) c h)
108
+ );
109
+ color: var(--color-bg);
110
+ }
111
+
112
+ .btn-primary {
113
+ background: linear-gradient(
114
+ oklch(from var(--color-primary) calc(l + 0.06) c h),
115
+ var(--color-primary)
116
+ );
117
+ }
118
+ .btn-primary:hover {
119
+ background: linear-gradient(
120
+ oklch(from var(--color-primary) calc(l + 0.16) c h),
121
+ oklch(from var(--color-primary) calc(l + 0.1) c h)
122
+ );
123
+ }
124
+
125
+ .btn-danger {
126
+ background: linear-gradient(
127
+ oklch(from var(--color-danger) calc(l + 0.06) c h),
128
+ var(--color-danger)
129
+ );
130
+ }
131
+ .btn-danger:hover {
132
+ background: linear-gradient(
133
+ oklch(from var(--color-danger) calc(l + 0.16) c h),
134
+ oklch(from var(--color-danger) calc(l + 0.1) c h)
135
+ );
136
+ }
137
+
138
+ /* ── Alerts ────────────────────────────────────────────────────────────── */
139
+ .alert {
140
+ padding: var(--space-2x);
141
+ border-radius: var(--space-1-2);
142
+ margin: var(--space-2x) 0;
143
+ }
144
+ .alert-success {
145
+ background: oklch(from var(--color-success) l c h / 10%);
146
+ color: var(--color-success);
147
+ border-left: 4px solid var(--color-success);
148
+ }
149
+ .alert-info {
150
+ background: oklch(from var(--color-info) l c h / 10%);
151
+ color: var(--color-info);
152
+ border-left: 4px solid var(--color-info);
153
+ }
154
+ .alert-warning {
155
+ background: oklch(from var(--color-warning) l c h / 10%);
156
+ color: var(--color-warning);
157
+ border-left: 4px solid var(--color-warning);
158
+ }
159
+ .alert-danger {
160
+ background: oklch(from var(--color-danger) l c h / 10%);
161
+ color: var(--color-danger);
162
+ border-left: 4px solid var(--color-danger);
163
+ }
164
+
165
+ /* ── Layout ────────────────────────────────────────────────────────────── */
166
+ header {
167
+ background: var(--color-elevated);
168
+ box-shadow: 0 2px 4px 0 var(--color-shadow);
169
+ height: 56px;
170
+ position: fixed;
171
+ left: 0; right: 0; top: 0;
172
+ z-index: 10;
173
+ }
174
+
175
+ .container {
176
+ margin: 0 auto;
177
+ max-width: 1280px;
178
+ padding: var(--space-2x);
179
+ }
180
+
181
+ header .container {
182
+ display: flex;
183
+ padding: var(--space) var(--space-2x);
184
+ }
185
+
186
+ .nav {
187
+ display: flex;
188
+ align-items: center;
189
+ width: 100%;
190
+ gap: var(--space);
191
+ }
192
+
193
+ .navbar-brand {
194
+ color: var(--color-primary);
195
+ font-size: 1.125rem;
196
+ font-weight: 700;
197
+ line-height: 24px;
198
+ margin-right: var(--space-2x);
199
+ text-decoration: none;
200
+ }
201
+
202
+ .nav-list {
203
+ display: flex;
204
+ list-style-type: none;
205
+ gap: var(--space-1-2);
206
+ margin: 0;
207
+ padding: 0;
208
+ }
209
+
210
+ .nav-list li a {
211
+ display: block;
212
+ padding: var(--space) var(--space-2x);
213
+ line-height: 24px;
214
+ color: inherit;
215
+ text-decoration: none;
216
+ border-radius: var(--space-1-2);
217
+ transition: background 0.2s;
218
+ }
219
+
220
+ .nav-list li a:hover,
221
+ .nav-list li a.active {
222
+ background: var(--color-selected);
223
+ color: var(--color-text);
224
+ }
225
+
226
+ main.container {
227
+ padding-top: calc(56px + var(--space-4x)); /* header height + breathing room = 88px */
228
+ min-height: 100vh;
229
+ }
230
+
231
+ /* ── Sections ──────────────────────────────────────────────────────────── */
232
+ section { margin-bottom: var(--space-4x); }
233
+
234
+ section > header {
235
+ display: flex;
236
+ align-items: center;
237
+ gap: var(--space);
238
+ margin-bottom: var(--space-2x);
239
+ /* reset the global fixed-header styles */
240
+ background: transparent;
241
+ box-shadow: none;
242
+ height: auto;
243
+ padding: 0;
244
+ position: static;
245
+ width: auto;
246
+ z-index: auto;
247
+ }
248
+
249
+ section .nav {
250
+ display: flex;
251
+ gap: var(--space);
252
+ flex-wrap: wrap;
253
+ width: fit-content;
254
+ margin: var(--space-3x) auto;
255
+ }
256
+
257
+ section .pending {
258
+ margin-bottom: 0.75rem;
259
+ display: flex;
260
+ justify-content: space-between;
261
+ align-items: center;
262
+ }
263
+
264
+ /* ── Stat cards ────────────────────────────────────────────────────────── */
265
+ .cards-container {
266
+ display: grid;
267
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
268
+ gap: var(--space-2x);
269
+ margin-bottom: var(--space-3x);
270
+ }
271
+
272
+ .stat-card {
273
+ background: var(--color-elevated);
274
+ border-radius: var(--space-1-2);
275
+ box-shadow: 0 1px 3px var(--color-shadow);
276
+ padding: var(--space-3x);
277
+ text-align: center;
278
+ transition: transform 0.2s, box-shadow 0.2s;
279
+ }
280
+ .stat-card:hover {
281
+ transform: translateY(-2px);
282
+ box-shadow: 0 4px 8px var(--color-shadow);
283
+ }
284
+ .stat-card .row {
285
+ display: flex;
286
+ justify-content: center;
287
+ align-items: center;
288
+ gap: var(--space);
289
+ }
290
+ .stat-card .row p {
291
+ white-space: nowrap;
292
+ text-align: left;
293
+ }
294
+ .stat-card .row h4 {
295
+ width: 100%;
296
+ white-space: nowrap;
297
+ text-align: right;
298
+ }
299
+ .stat-card h3 {
300
+ color: var(--color-text);
301
+ font-size: 2rem;
302
+ font-weight: 700;
303
+ margin: 0 0 var(--space) 0;
304
+ }
305
+ .stat-card p {
306
+ color: var(--color-text-light);
307
+ font-size: var(--font-size-small);
308
+ letter-spacing: 0.05em;
309
+ margin: 0;
310
+ text-transform: uppercase;
311
+ }
312
+
313
+ /* ── Tables ────────────────────────────────────────────────────────────── */
314
+ .table-container {
315
+ background: var(--color-elevated);
316
+ border-radius: var(--space-1-2);
317
+ box-shadow: 0 1px 3px var(--color-shadow);
318
+ margin-bottom: var(--space-4x);
319
+ overflow: hidden;
320
+ }
321
+
322
+ table { border-collapse: collapse; width: 100%; }
323
+
324
+ th {
325
+ background: var(--color-table-bg-alt);
326
+ border-bottom: 1px solid var(--color-border);
327
+ color: var(--color-text-light);
328
+ font-size: var(--font-size-small);
329
+ font-weight: 600;
330
+ letter-spacing: 0.05em;
331
+ padding: var(--space-2x);
332
+ text-align: left;
333
+ text-transform: uppercase;
334
+ }
335
+
336
+ td {
337
+ border-bottom: 1px solid var(--color-border);
338
+ color: var(--color-text);
339
+ padding: var(--space-2x);
340
+ vertical-align: top;
341
+ }
342
+
343
+ tr:last-child td { border-bottom: none; }
344
+ tr:hover { background: var(--color-table-bg-alt); }
345
+
346
+ /* ── Subjects ──────────────────────────────────────────────────────────── */
347
+ .subjects { display: flex; flex-wrap: wrap; gap: var(--space-1-2); }
348
+
349
+ .subject-tag {
350
+ background: oklch(from var(--color-info) l c h / 15%);
351
+ border-radius: var(--space-1-2);
352
+ color: var(--color-info);
353
+ font-family: var(--font-mono);
354
+ font-size: var(--font-size-small);
355
+ font-weight: 500;
356
+ padding: var(--space-1-2) var(--space);
357
+ }
358
+
359
+ /* ── Job rows ──────────────────────────────────────────────────────────── */
360
+ .job-class {
361
+ color: var(--color-text);
362
+ font-weight: 600;
363
+ margin-bottom: var(--space-1-2);
364
+ }
365
+ .job-id {
366
+ color: var(--color-text-light);
367
+ font-size: var(--font-size-small);
368
+ margin-bottom: var(--space);
369
+ }
370
+
371
+ .time-badge {
372
+ background: var(--color-success);
373
+ border-radius: var(--space-1-2);
374
+ color: var(--color-elevated);
375
+ display: inline-block;
376
+ font-size: var(--font-size-small);
377
+ font-weight: 700;
378
+ margin-bottom: var(--space-1-2);
379
+ padding: var(--space-1-2) var(--space);
380
+ }
381
+
382
+ time {
383
+ color: var(--color-text-light);
384
+ display: block;
385
+ font-size: var(--font-size-small);
386
+ }
387
+
388
+ .dead-row { border-left: 4px solid var(--color-danger); }
389
+
390
+ .error-message strong {
391
+ color: var(--color-danger);
392
+ display: block;
393
+ margin-bottom: var(--space-1-2);
394
+ }
395
+ .error-message p {
396
+ color: var(--color-text);
397
+ font-size: var(--font-size-small);
398
+ word-wrap: break-word;
399
+ }
400
+
401
+ /* ── Links ─────────────────────────────────────────────────────────────── */
402
+ .stream-link { color: var(--color-primary); font-weight: 600; }
403
+ .stream-link:hover { text-decoration: underline; }
404
+
405
+ /* ── Actions ───────────────────────────────────────────────────────────── */
406
+ .actions-form { display: flex; flex-direction: column; gap: var(--space-1-2); }
407
+
408
+ /* ── Details / summary ─────────────────────────────────────────────────── */
409
+ details summary {
410
+ color: var(--color-text-light);
411
+ cursor: pointer;
412
+ font-size: var(--font-size-small);
413
+ user-select: none;
414
+ }
415
+ details summary code { display: inline; }
416
+ details[open] summary { margin-bottom: var(--space); }
417
+ details code { display: block; max-width: 400px; white-space: pre-wrap; }
418
+
419
+ /* ── HTMX ──────────────────────────────────────────────────────────────── */
420
+ .htmx-indicator { opacity: 0; transition: opacity 200ms; }
421
+ .htmx-request .htmx-indicator,
422
+ .htmx-request.htmx-indicator { opacity: 1; }
423
+
424
+ /* ── Responsive ────────────────────────────────────────────────────────── */
425
+ @media (max-width: 768px) {
426
+ .nav { flex-wrap: wrap; }
427
+ .nav-list { order: 3; width: 100%; margin-top: var(--space); }
428
+ .cards-container { grid-template-columns: repeat(2, 1fr); }
429
+ .table-container { overflow-x: auto; }
430
+ th, td { font-size: var(--font-size-small); padding: var(--space); }
431
+ }
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cosmo/web/helpers/application"
4
+
5
+ module Cosmo
6
+ class Web
7
+ class Context
8
+ include Helpers::Application
9
+
10
+ def initialize(locals, content_for = nil)
11
+ @content_for = Hash(content_for)
12
+ locals.each { |k, v| instance_variable_set("@#{k}", v) }
13
+ end
14
+
15
+ def binding # rubocop:disable Lint/UselessMethodDefinition
16
+ super
17
+ end
18
+
19
+ def content_for(name)
20
+ @content_for[name]
21
+ end
22
+
23
+ def content_for?(name)
24
+ @content_for.key?(name)
25
+ end
26
+ end
27
+ end
28
+ end