job_board 0.1.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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +129 -0
  5. data/app/controllers/job_board/application_controller.rb +24 -0
  6. data/app/controllers/job_board/assets_controller.rb +19 -0
  7. data/app/controllers/job_board/jobs_controller.rb +88 -0
  8. data/app/controllers/job_board/processes_controller.rb +7 -0
  9. data/app/controllers/job_board/queues/pauses_controller.rb +15 -0
  10. data/app/controllers/job_board/queues_controller.rb +9 -0
  11. data/app/controllers/job_board/recurring_tasks_controller.rb +7 -0
  12. data/app/helpers/job_board/application_helper.rb +40 -0
  13. data/app/models/job_board/job_presenter.rb +37 -0
  14. data/app/models/job_board/job_row.rb +48 -0
  15. data/app/models/job_board/jobs_query.rb +86 -0
  16. data/app/models/job_board/latency_sla.rb +31 -0
  17. data/app/models/job_board/page.rb +35 -0
  18. data/app/models/job_board/process_tree.rb +49 -0
  19. data/app/views/job_board/jobs/_filters.html.erb +16 -0
  20. data/app/views/job_board/jobs/_job_row.html.erb +31 -0
  21. data/app/views/job_board/jobs/index.html.erb +63 -0
  22. data/app/views/job_board/jobs/show.html.erb +56 -0
  23. data/app/views/job_board/processes/_process.html.erb +28 -0
  24. data/app/views/job_board/processes/index.html.erb +32 -0
  25. data/app/views/job_board/queues/index.html.erb +50 -0
  26. data/app/views/job_board/recurring_tasks/index.html.erb +37 -0
  27. data/app/views/job_board/shared/_nav.html.erb +9 -0
  28. data/app/views/job_board/shared/_pagination.html.erb +5 -0
  29. data/app/views/layouts/job_board/application.html.erb +18 -0
  30. data/config/routes.rb +25 -0
  31. data/lib/job_board/assets/application.css +288 -0
  32. data/lib/job_board/assets/application.js +39 -0
  33. data/lib/job_board/configuration.rb +32 -0
  34. data/lib/job_board/engine.rb +5 -0
  35. data/lib/job_board/version.rb +3 -0
  36. data/lib/job_board.rb +17 -0
  37. metadata +108 -0
@@ -0,0 +1,16 @@
1
+ <%= form_with url: jobs_path, method: :get, class: "filters" do |form| %>
2
+ <%= form.hidden_field :status, value: @query.status %>
3
+ <label>
4
+ Queue
5
+ <%= form.select :queue_name, options_for_select(@queues.map(&:name), params[:queue_name]),
6
+ { include_blank: "All queues" } %>
7
+ </label>
8
+ <label>
9
+ Class
10
+ <%= form.text_field :class_name, value: params[:class_name], placeholder: "e.g. MyJob" %>
11
+ </label>
12
+ <%= form.submit "Filter", class: "btn" %>
13
+ <% if params[:queue_name].present? || params[:class_name].present? %>
14
+ <%= link_to "Clear", jobs_path(status: @query.status), class: "filters__clear" %>
15
+ <% end %>
16
+ <% end %>
@@ -0,0 +1,31 @@
1
+ <tr>
2
+ <td>
3
+ <%= link_to row.class_name, job_path(row.job) %>
4
+ <span class="muted">#<%= row.id %></span>
5
+ </td>
6
+ <td><%= row.queue_name %></td>
7
+ <% if list_status == "scheduled" %>
8
+ <td><%= time_ago(row.scheduled_at) %></td>
9
+ <% elsif list_status == "in_progress" %>
10
+ <td><%= row.process ? row.process.name : tag.span("orphaned", class: "badge badge--stale") %></td>
11
+ <% elsif list_status == "blocked" %>
12
+ <td><code><%= row.concurrency_key %></code></td>
13
+ <% elsif list_status == "failed" %>
14
+ <td class="error-cell">
15
+ <strong><%= row.error_class %></strong>
16
+ <span class="muted"><%= truncate(row.error_message, length: 80) %></span>
17
+ </td>
18
+ <% elsif list_status == "finished" %>
19
+ <td><%= time_ago(row.finished_at) %></td>
20
+ <% else %>
21
+ <td class="num"><%= row.priority %></td>
22
+ <% end %>
23
+ <td><%= time_ago(row.enqueued_at) %></td>
24
+ <% if list_status == "failed" %>
25
+ <td class="actions">
26
+ <%= button_to "Retry", retry_job_path(row.job), method: :post, class: "btn" %>
27
+ <%= button_to "Discard", discard_job_path(row.job), method: :delete, class: "btn btn--danger",
28
+ form: { data: { confirm: "Permanently discard job ##{row.id}? This can't be undone." } } %>
29
+ </td>
30
+ <% end %>
31
+ </tr>
@@ -0,0 +1,63 @@
1
+ <h1>Jobs</h1>
2
+
3
+ <%= render "job_board/jobs/filters" %>
4
+
5
+ <nav class="tabs">
6
+ <% JobBoard::JobsQuery::STATUSES.each do |s| %>
7
+ <%= link_to jobs_path(status: s, queue_name: params[:queue_name], class_name: params[:class_name]),
8
+ class: "tab #{"tab--active" if @query.status == s}" do %>
9
+ <%= s.humanize %> <span class="tab__count"><%= @counts[s] %></span>
10
+ <% end %>
11
+ <% end %>
12
+ </nav>
13
+
14
+ <% if @query.status == "failed" && @counts["failed"].positive? %>
15
+ <div class="bulk-actions">
16
+ <%= button_to "Retry all (#{@counts["failed"]})",
17
+ retry_all_jobs_path(queue_name: params[:queue_name], class_name: params[:class_name]),
18
+ method: :post, class: "btn",
19
+ form: { data: { confirm: "Retry all #{@counts["failed"]} failed jobs matching the current filters?" } } %>
20
+ <%= button_to "Discard all (#{@counts["failed"]})",
21
+ discard_all_jobs_path(queue_name: params[:queue_name], class_name: params[:class_name]),
22
+ method: :post, class: "btn btn--danger",
23
+ form: { data: { confirm: "Permanently discard all #{@counts["failed"]} failed jobs matching the current filters? This can't be undone." } } %>
24
+ </div>
25
+ <% end %>
26
+
27
+ <div <%= 'data-poll-region="jobs"'.html_safe unless params[:before] %>>
28
+ <% if @page.empty? %>
29
+ <p class="empty-state">
30
+ <% if @query.status == "finished" && !SolidQueue.preserve_finished_jobs? %>
31
+ Finished jobs are not preserved (<code>SolidQueue.preserve_finished_jobs</code> is false),
32
+ so this list will always be empty.
33
+ <% else %>
34
+ No <%= @query.status.humanize.downcase %> jobs<%= " matching the current filters" if params[:queue_name].present? || params[:class_name].present? %>.
35
+ <% end %>
36
+ </p>
37
+ <% else %>
38
+ <table class="data-table">
39
+ <thead>
40
+ <tr>
41
+ <th>Job</th>
42
+ <th>Queue</th>
43
+ <% if @query.status == "scheduled" %><th>Scheduled at</th>
44
+ <% elsif @query.status == "in_progress" %><th>Process</th>
45
+ <% elsif @query.status == "blocked" %><th>Concurrency key</th>
46
+ <% elsif @query.status == "failed" %><th>Error</th>
47
+ <% elsif @query.status == "finished" %><th>Finished</th>
48
+ <% else %><th class="num">Priority</th>
49
+ <% end %>
50
+ <th>Enqueued</th>
51
+ <% if @query.status == "failed" %><th class="actions"></th><% end %>
52
+ </tr>
53
+ </thead>
54
+ <tbody>
55
+ <% @page.each do |row| %>
56
+ <%= render "job_board/jobs/job_row", row: row, list_status: @query.status %>
57
+ <% end %>
58
+ </tbody>
59
+ </table>
60
+ <% end %>
61
+ </div>
62
+
63
+ <%= render "job_board/shared/pagination", page: @page %>
@@ -0,0 +1,56 @@
1
+ <h1>
2
+ <%= @job.class_name %>
3
+ <span class="muted">#<%= @job.id %></span>
4
+ <%= status_badge(@job.status || :discarded) %>
5
+ </h1>
6
+
7
+ <table class="detail-table">
8
+ <tr><th>Queue</th><td><%= link_to @job.queue_name, jobs_path(queue_name: @job.queue_name) %></td></tr>
9
+ <tr><th>Priority</th><td><%= @job.priority %></td></tr>
10
+ <tr><th>Active Job ID</th><td><code><%= @job.active_job_id %></code></td></tr>
11
+ <% if @job.concurrency_key.present? %>
12
+ <tr><th>Concurrency key</th><td><code><%= @job.concurrency_key %></code></td></tr>
13
+ <% end %>
14
+ <tr><th>Enqueued</th><td><%= time_ago(@job.created_at) %></td></tr>
15
+ <% if @job.scheduled_at.present? %>
16
+ <tr><th>Scheduled</th><td><%= time_ago(@job.scheduled_at) %></td></tr>
17
+ <% end %>
18
+ <% if @job.finished_at.present? %>
19
+ <tr><th>Finished</th><td><%= time_ago(@job.finished_at) %></td></tr>
20
+ <% end %>
21
+ <% if @job.process %>
22
+ <tr><th>Claimed by</th><td><%= @job.process.name %> (<%= @job.process.hostname %>:<%= @job.process.pid %>)</td></tr>
23
+ <% end %>
24
+ <% if @job.executions_count.to_i.positive? %>
25
+ <tr><th>Executions</th><td><%= @job.executions_count %></td></tr>
26
+ <% end %>
27
+ </table>
28
+
29
+ <% if @job.error %>
30
+ <section class="error-panel">
31
+ <h2><%= @job.error.exception_class %></h2>
32
+ <p><%= @job.error.message %></p>
33
+ <% if @job.error.backtrace.present? %>
34
+ <pre><%= @job.error.backtrace.join("\n") %></pre>
35
+ <% end %>
36
+ <div class="actions-row">
37
+ <%= button_to "Retry", retry_job_path(@job.job), method: :post, class: "btn" %>
38
+ <%= button_to "Discard", discard_job_path(@job.job), method: :delete, class: "btn btn--danger",
39
+ form: { data: { confirm: "Permanently discard job ##{@job.id}? This can't be undone." } } %>
40
+ </div>
41
+ </section>
42
+ <% end %>
43
+
44
+ <section>
45
+ <h2>Arguments</h2>
46
+ <pre class="code-block"><%= format_json(@job.arguments) %></pre>
47
+ </section>
48
+
49
+ <% if @job.exception_executions.present? %>
50
+ <section>
51
+ <h2>Exception executions</h2>
52
+ <pre class="code-block"><%= format_json(@job.exception_executions) %></pre>
53
+ </section>
54
+ <% end %>
55
+
56
+ <p><%= link_to "← Back to jobs", jobs_path %></p>
@@ -0,0 +1,28 @@
1
+ <% process = node.process %>
2
+ <tr>
3
+ <td style="padding-left: <%= 12 + depth * 24 %>px"><%= process.name %></td>
4
+ <td><span class="badge badge--kind"><%= process.kind %></span></td>
5
+ <td><%= process.hostname %>:<%= process.pid %></td>
6
+ <td class="meta-cell">
7
+ <% if process.metadata.present? %>
8
+ <% process.metadata.each do |key, value| %>
9
+ <span class="chip"><%= key %>: <%= value.is_a?(Array) ? value.join(", ") : value %></span>
10
+ <% end %>
11
+ <% end %>
12
+ </td>
13
+ <td>
14
+ <%= time_ago(process.last_heartbeat_at) %>
15
+ <% if node.stale %><span class="badge badge--stale">stale</span><% end %>
16
+ </td>
17
+ <td class="num">
18
+ <%= node.claimed_count %>
19
+ <% tree.claimed_executions_for(process).first(5).each do |execution| %>
20
+ <% if execution.job %>
21
+ <%= link_to "##{execution.job.id}", job_path(execution.job), class: "muted" %>
22
+ <% end %>
23
+ <% end %>
24
+ </td>
25
+ </tr>
26
+ <% node.children.each do |child| %>
27
+ <%= render "job_board/processes/process", node: child, tree: tree, depth: depth + 1 %>
28
+ <% end %>
@@ -0,0 +1,32 @@
1
+ <h1>Workers</h1>
2
+
3
+ <div data-poll-region="processes">
4
+ <% if @tree.orphaned_claimed_count.positive? %>
5
+ <div class="flash flash--alert">
6
+ <%= @tree.orphaned_claimed_count %> in-progress
7
+ <%= "job".pluralize(@tree.orphaned_claimed_count) %> claimed by processes that no longer exist.
8
+ </div>
9
+ <% end %>
10
+
11
+ <% if @tree.empty? %>
12
+ <p class="empty-state">No processes registered. Start Solid Queue with <code>bin/jobs</code>.</p>
13
+ <% else %>
14
+ <table class="data-table">
15
+ <thead>
16
+ <tr>
17
+ <th>Process</th>
18
+ <th>Kind</th>
19
+ <th>Host</th>
20
+ <th>Details</th>
21
+ <th>Heartbeat</th>
22
+ <th class="num">In progress</th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ <% @tree.roots.each do |node| %>
27
+ <%= render "job_board/processes/process", node: node, tree: @tree, depth: 0 %>
28
+ <% end %>
29
+ </tbody>
30
+ </table>
31
+ <% end %>
32
+ </div>
@@ -0,0 +1,50 @@
1
+ <h1>Queues</h1>
2
+
3
+ <div data-poll-region="queues">
4
+ <% if @queues.empty? %>
5
+ <p class="empty-state">No queues yet — queues appear once jobs are enqueued.</p>
6
+ <% else %>
7
+ <table class="data-table">
8
+ <thead>
9
+ <tr>
10
+ <th>Queue</th>
11
+ <th class="num">Ready jobs</th>
12
+ <th class="num">Failed</th>
13
+ <th class="num">Latency</th>
14
+ <th>Status</th>
15
+ <th class="actions"></th>
16
+ </tr>
17
+ </thead>
18
+ <tbody>
19
+ <% @queues.each do |queue| %>
20
+ <tr>
21
+ <td><%= link_to queue.name, jobs_path(status: "ready", queue_name: queue.name) %></td>
22
+ <td class="num"><%= queue.size %></td>
23
+ <td class="num">
24
+ <% failed = @failed_counts[queue.name] || 0 %>
25
+ <% if failed.positive? %>
26
+ <%= link_to failed, jobs_path(status: "failed", queue_name: queue.name), class: "failed-count" %>
27
+ <% else %>
28
+ <span class="muted">0</span>
29
+ <% end %>
30
+ </td>
31
+ <td class="num">
32
+ <span class="latency <%= "latency--high" if queue.latency > latency_threshold_for(queue.name) %>" title="<%= queue.latency %> seconds">
33
+ <%= duration(queue.latency) %>
34
+ </span>
35
+ </td>
36
+ <td><%= queue.paused? ? status_badge(:paused) : status_badge(:active) %></td>
37
+ <td class="actions">
38
+ <% if queue.paused? %>
39
+ <%= button_to "Resume", queue_pause_path(queue.name), method: :delete, class: "btn" %>
40
+ <% else %>
41
+ <%= button_to "Pause", queue_pause_path(queue.name), method: :post, class: "btn",
42
+ form: { data: { confirm: "Pause queue \"#{queue.name}\"? Workers will stop picking up its jobs." } } %>
43
+ <% end %>
44
+ </td>
45
+ </tr>
46
+ <% end %>
47
+ </tbody>
48
+ </table>
49
+ <% end %>
50
+ </div>
@@ -0,0 +1,37 @@
1
+ <h1>Recurring tasks</h1>
2
+
3
+ <div data-poll-region="recurring">
4
+ <% if @tasks.empty? %>
5
+ <p class="empty-state">
6
+ No recurring tasks. Define them in <code>config/recurring.yml</code> and run a scheduler.
7
+ </p>
8
+ <% else %>
9
+ <table class="data-table">
10
+ <thead>
11
+ <tr>
12
+ <th>Key</th>
13
+ <th>Schedule</th>
14
+ <th>Job</th>
15
+ <th>Queue</th>
16
+ <th>Last run</th>
17
+ <th>Next run</th>
18
+ </tr>
19
+ </thead>
20
+ <tbody>
21
+ <% @tasks.each do |task| %>
22
+ <tr>
23
+ <td>
24
+ <%= task.key %>
25
+ <% unless task.static %><span class="badge badge--kind">dynamic</span><% end %>
26
+ </td>
27
+ <td><code><%= task.schedule %></code></td>
28
+ <td><%= task.class_name.presence || task.command %></td>
29
+ <td><%= task.queue_name.presence || "default" %></td>
30
+ <td><%= time_ago(task.last_enqueued_time) %></td>
31
+ <td><%= time_ago(task.next_time) %></td>
32
+ </tr>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ <% end %>
37
+ </div>
@@ -0,0 +1,9 @@
1
+ <header class="nav">
2
+ <span class="nav__brand"><%= link_to "Job Board", root_path %></span>
3
+ <nav class="nav__links">
4
+ <%= link_to "Queues", queues_path, class: "nav__link #{"nav__link--active" if controller_name == "queues"}" %>
5
+ <%= link_to "Jobs", jobs_path, class: "nav__link #{"nav__link--active" if controller_name == "jobs"}" %>
6
+ <%= link_to "Workers", processes_path, class: "nav__link #{"nav__link--active" if controller_name == "processes"}" %>
7
+ <%= link_to "Recurring", recurring_tasks_path, class: "nav__link #{"nav__link--active" if controller_name == "recurring_tasks"}" %>
8
+ </nav>
9
+ </header>
@@ -0,0 +1,5 @@
1
+ <% if page.more? %>
2
+ <div class="pagination">
3
+ <%= link_to "Older →", jobs_path(request.query_parameters.merge(before: page.next_before)), class: "btn" %>
4
+ </div>
5
+ <% end %>
@@ -0,0 +1,18 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Job Board</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <link rel="stylesheet" href="<%= static_asset_path("application.css", v: JobBoard::VERSION) %>">
8
+ <script src="<%= static_asset_path("application.js", v: JobBoard::VERSION) %>" defer></script>
9
+ </head>
10
+ <body data-poll-interval="<%= JobBoard.config.poll_interval.to_i %>">
11
+ <%= render "job_board/shared/nav" %>
12
+ <main>
13
+ <% if notice.present? %><div class="flash flash--notice"><%= notice %></div><% end %>
14
+ <% if alert.present? %><div class="flash flash--alert"><%= alert %></div><% end %>
15
+ <%= yield %>
16
+ </main>
17
+ </body>
18
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,25 @@
1
+ JobBoard::Engine.routes.draw do
2
+ root to: "queues#index"
3
+
4
+ get "queues", to: "queues#index", as: :queues
5
+ scope "queues/:queue_name", constraints: { queue_name: /[^\/]+/ }, format: false do
6
+ post "pause", to: "queues/pauses#create", as: :queue_pause
7
+ delete "pause", to: "queues/pauses#destroy"
8
+ end
9
+
10
+ resources :jobs, only: [:index, :show] do
11
+ member do
12
+ post :retry
13
+ delete :discard
14
+ end
15
+ collection do
16
+ post :retry_all
17
+ post :discard_all
18
+ end
19
+ end
20
+
21
+ resources :processes, only: :index
22
+ resources :recurring_tasks, only: :index
23
+
24
+ get "assets/:name", to: "assets#show", as: :static_asset, constraints: { name: /[a-z_]+\.(css|js)/ }
25
+ end
@@ -0,0 +1,288 @@
1
+ :root {
2
+ --bg: #f8f9fb;
3
+ --surface: #ffffff;
4
+ --text: #1c2333;
5
+ --text-muted: #6b7280;
6
+ --border: #e2e5ec;
7
+ --accent: #2563eb;
8
+ --accent-soft: #dbeafe;
9
+ --danger: #dc2626;
10
+ --danger-soft: #fee2e2;
11
+ --success: #16a34a;
12
+ --success-soft: #dcfce7;
13
+ --warning: #d97706;
14
+ --warning-soft: #fef3c7;
15
+ --purple: #7c3aed;
16
+ --purple-soft: #ede9fe;
17
+ --gray-soft: #e5e7eb;
18
+ --code-bg: #f1f3f7;
19
+ }
20
+
21
+ @media (prefers-color-scheme: dark) {
22
+ :root {
23
+ --bg: #0f1218;
24
+ --surface: #171b24;
25
+ --text: #e5e9f0;
26
+ --text-muted: #9aa3b2;
27
+ --border: #2a3040;
28
+ --accent: #60a5fa;
29
+ --accent-soft: #1e3a5f;
30
+ --danger: #f87171;
31
+ --danger-soft: #4c1d1d;
32
+ --success: #4ade80;
33
+ --success-soft: #14351f;
34
+ --warning: #fbbf24;
35
+ --warning-soft: #3d2e0a;
36
+ --purple: #a78bfa;
37
+ --purple-soft: #2e2350;
38
+ --gray-soft: #2a3040;
39
+ --code-bg: #10141c;
40
+ }
41
+ }
42
+
43
+ * { box-sizing: border-box; }
44
+
45
+ body {
46
+ margin: 0;
47
+ background: var(--bg);
48
+ color: var(--text);
49
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
50
+ font-size: 14px;
51
+ line-height: 1.5;
52
+ }
53
+
54
+ main {
55
+ max-width: 1100px;
56
+ margin: 0 auto;
57
+ padding: 1.5rem 1rem 4rem;
58
+ }
59
+
60
+ h1 { font-size: 1.4rem; margin: 0.5rem 0 1rem; }
61
+ h2 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; }
62
+
63
+ a { color: var(--accent); text-decoration: none; }
64
+ a:hover { text-decoration: underline; }
65
+
66
+ code {
67
+ background: var(--code-bg);
68
+ border-radius: 4px;
69
+ padding: 0.1em 0.35em;
70
+ font-size: 0.9em;
71
+ }
72
+
73
+ .muted { color: var(--text-muted); }
74
+
75
+ /* Nav */
76
+ .nav {
77
+ display: flex;
78
+ align-items: center;
79
+ gap: 1.5rem;
80
+ padding: 0.6rem 1rem;
81
+ background: var(--surface);
82
+ border-bottom: 1px solid var(--border);
83
+ }
84
+ .nav__brand a { color: var(--text); font-weight: 700; }
85
+ .nav__links { display: flex; gap: 0.25rem; }
86
+ .nav__link {
87
+ color: var(--text-muted);
88
+ padding: 0.3rem 0.7rem;
89
+ border-radius: 6px;
90
+ }
91
+ .nav__link:hover { color: var(--text); text-decoration: none; }
92
+ .nav__link--active { color: var(--accent); background: var(--accent-soft); }
93
+
94
+ /* Flash */
95
+ .flash {
96
+ padding: 0.6rem 0.9rem;
97
+ border-radius: 8px;
98
+ margin-bottom: 1rem;
99
+ border: 1px solid var(--border);
100
+ }
101
+ .flash--notice { background: var(--success-soft); color: var(--success); border-color: transparent; }
102
+ .flash--alert { background: var(--danger-soft); color: var(--danger); border-color: transparent; }
103
+
104
+ /* Tables */
105
+ .data-table {
106
+ width: 100%;
107
+ border-collapse: collapse;
108
+ background: var(--surface);
109
+ border: 1px solid var(--border);
110
+ border-radius: 10px;
111
+ overflow: hidden;
112
+ }
113
+ .data-table th, .data-table td {
114
+ text-align: left;
115
+ padding: 0.55rem 0.75rem;
116
+ border-bottom: 1px solid var(--border);
117
+ vertical-align: top;
118
+ }
119
+ .data-table thead th {
120
+ position: sticky;
121
+ top: 0;
122
+ background: var(--surface);
123
+ color: var(--text-muted);
124
+ font-weight: 600;
125
+ font-size: 0.78rem;
126
+ text-transform: uppercase;
127
+ letter-spacing: 0.03em;
128
+ }
129
+ .data-table tbody tr:last-child td { border-bottom: none; }
130
+ .data-table tbody tr:hover td { background: color-mix(in srgb, var(--accent) 4%, transparent); }
131
+ .num, .data-table .num { text-align: right; font-variant-numeric: tabular-nums; }
132
+ .actions, .data-table .actions { text-align: right; white-space: nowrap; }
133
+ .actions form { display: inline-block; margin-left: 0.3rem; }
134
+ .error-cell strong { display: block; }
135
+ .meta-cell { max-width: 320px; }
136
+
137
+ .detail-table { border-collapse: collapse; margin-bottom: 1rem; }
138
+ .detail-table th {
139
+ text-align: left;
140
+ color: var(--text-muted);
141
+ font-weight: 500;
142
+ padding: 0.25rem 1.5rem 0.25rem 0;
143
+ white-space: nowrap;
144
+ vertical-align: top;
145
+ }
146
+ .detail-table td { padding: 0.25rem 0; }
147
+
148
+ /* Badges */
149
+ .badge {
150
+ display: inline-block;
151
+ padding: 0.1rem 0.5rem;
152
+ border-radius: 999px;
153
+ font-size: 0.75rem;
154
+ font-weight: 600;
155
+ }
156
+ .badge--ready { background: var(--accent-soft); color: var(--accent); }
157
+ .badge--claimed { background: var(--warning-soft); color: var(--warning); }
158
+ .badge--failed { background: var(--danger-soft); color: var(--danger); }
159
+ .badge--finished { background: var(--success-soft); color: var(--success); }
160
+ .badge--scheduled { background: var(--purple-soft); color: var(--purple); }
161
+ .badge--blocked { background: var(--gray-soft); color: var(--text-muted); }
162
+ .badge--paused { background: var(--warning-soft); color: var(--warning); }
163
+ .badge--active { background: var(--success-soft); color: var(--success); }
164
+ .badge--stale { background: var(--danger-soft); color: var(--danger); }
165
+ .badge--discarded { background: var(--gray-soft); color: var(--text-muted); }
166
+ .badge--kind { background: var(--gray-soft); color: var(--text-muted); font-weight: 500; }
167
+
168
+ .chip {
169
+ display: inline-block;
170
+ background: var(--code-bg);
171
+ border: 1px solid var(--border);
172
+ border-radius: 6px;
173
+ padding: 0.05rem 0.4rem;
174
+ font-size: 0.75rem;
175
+ margin: 0.1rem 0.15rem 0.1rem 0;
176
+ color: var(--text-muted);
177
+ }
178
+
179
+ .latency { font-weight: 600; }
180
+ .latency--high { color: var(--danger); }
181
+ .failed-count { color: var(--danger); font-weight: 600; }
182
+
183
+ /* Tabs */
184
+ .tabs {
185
+ display: flex;
186
+ flex-wrap: wrap;
187
+ gap: 0.25rem;
188
+ margin-bottom: 1rem;
189
+ border-bottom: 1px solid var(--border);
190
+ padding-bottom: 0.5rem;
191
+ }
192
+ .tab {
193
+ color: var(--text-muted);
194
+ padding: 0.3rem 0.7rem;
195
+ border-radius: 6px;
196
+ }
197
+ .tab:hover { color: var(--text); text-decoration: none; }
198
+ .tab--active { color: var(--accent); background: var(--accent-soft); }
199
+ .tab__count {
200
+ font-size: 0.75rem;
201
+ font-variant-numeric: tabular-nums;
202
+ opacity: 0.8;
203
+ }
204
+
205
+ /* Buttons & forms */
206
+ .btn {
207
+ display: inline-block;
208
+ background: var(--surface);
209
+ color: var(--text);
210
+ border: 1px solid var(--border);
211
+ border-radius: 6px;
212
+ padding: 0.25rem 0.7rem;
213
+ font-size: 0.8rem;
214
+ cursor: pointer;
215
+ }
216
+ .btn:hover { border-color: var(--accent); color: var(--accent); }
217
+ .btn--danger:hover { border-color: var(--danger); color: var(--danger); }
218
+
219
+ .filters {
220
+ display: flex;
221
+ align-items: end;
222
+ gap: 0.75rem;
223
+ margin-bottom: 1rem;
224
+ flex-wrap: wrap;
225
+ }
226
+ .filters label {
227
+ display: flex;
228
+ flex-direction: column;
229
+ gap: 0.2rem;
230
+ font-size: 0.78rem;
231
+ color: var(--text-muted);
232
+ }
233
+ .filters select, .filters input[type="text"] {
234
+ background: var(--surface);
235
+ color: var(--text);
236
+ border: 1px solid var(--border);
237
+ border-radius: 6px;
238
+ padding: 0.3rem 0.5rem;
239
+ font-size: 0.85rem;
240
+ min-width: 160px;
241
+ }
242
+ .filters__clear { font-size: 0.85rem; padding-bottom: 0.35rem; }
243
+
244
+ .bulk-actions {
245
+ display: flex;
246
+ gap: 0.5rem;
247
+ margin-bottom: 1rem;
248
+ }
249
+
250
+ .actions-row { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
251
+
252
+ .pagination { margin-top: 1rem; text-align: center; }
253
+
254
+ /* Panels & code */
255
+ .error-panel {
256
+ background: var(--danger-soft);
257
+ border-radius: 10px;
258
+ padding: 0.9rem 1rem;
259
+ margin: 1rem 0;
260
+ }
261
+ .error-panel h2 { margin: 0 0 0.25rem; color: var(--danger); }
262
+ .error-panel pre {
263
+ background: var(--code-bg);
264
+ color: var(--text);
265
+ border-radius: 8px;
266
+ padding: 0.75rem;
267
+ overflow-x: auto;
268
+ font-size: 0.78rem;
269
+ max-height: 320px;
270
+ }
271
+
272
+ .code-block {
273
+ background: var(--code-bg);
274
+ border: 1px solid var(--border);
275
+ border-radius: 8px;
276
+ padding: 0.75rem;
277
+ overflow-x: auto;
278
+ font-size: 0.82rem;
279
+ }
280
+
281
+ .empty-state {
282
+ background: var(--surface);
283
+ border: 1px dashed var(--border);
284
+ border-radius: 10px;
285
+ padding: 2rem;
286
+ text-align: center;
287
+ color: var(--text-muted);
288
+ }