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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/MIT-LICENSE +20 -0
- data/README.md +129 -0
- data/app/controllers/job_board/application_controller.rb +24 -0
- data/app/controllers/job_board/assets_controller.rb +19 -0
- data/app/controllers/job_board/jobs_controller.rb +88 -0
- data/app/controllers/job_board/processes_controller.rb +7 -0
- data/app/controllers/job_board/queues/pauses_controller.rb +15 -0
- data/app/controllers/job_board/queues_controller.rb +9 -0
- data/app/controllers/job_board/recurring_tasks_controller.rb +7 -0
- data/app/helpers/job_board/application_helper.rb +40 -0
- data/app/models/job_board/job_presenter.rb +37 -0
- data/app/models/job_board/job_row.rb +48 -0
- data/app/models/job_board/jobs_query.rb +86 -0
- data/app/models/job_board/latency_sla.rb +31 -0
- data/app/models/job_board/page.rb +35 -0
- data/app/models/job_board/process_tree.rb +49 -0
- data/app/views/job_board/jobs/_filters.html.erb +16 -0
- data/app/views/job_board/jobs/_job_row.html.erb +31 -0
- data/app/views/job_board/jobs/index.html.erb +63 -0
- data/app/views/job_board/jobs/show.html.erb +56 -0
- data/app/views/job_board/processes/_process.html.erb +28 -0
- data/app/views/job_board/processes/index.html.erb +32 -0
- data/app/views/job_board/queues/index.html.erb +50 -0
- data/app/views/job_board/recurring_tasks/index.html.erb +37 -0
- data/app/views/job_board/shared/_nav.html.erb +9 -0
- data/app/views/job_board/shared/_pagination.html.erb +5 -0
- data/app/views/layouts/job_board/application.html.erb +18 -0
- data/config/routes.rb +25 -0
- data/lib/job_board/assets/application.css +288 -0
- data/lib/job_board/assets/application.js +39 -0
- data/lib/job_board/configuration.rb +32 -0
- data/lib/job_board/engine.rb +5 -0
- data/lib/job_board/version.rb +3 -0
- data/lib/job_board.rb +17 -0
- 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,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
|
+
}
|