chrono_forge-dashboard 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +36 -15
- data/app/assets/chrono_forge/dashboard/dashboard.css +1 -1
- data/app/assets/chrono_forge/dashboard/dashboard.js +86 -80
- data/app/assets/chrono_forge/dashboard/turbo.min.js +34 -0
- data/app/controllers/chrono_forge/dashboard/actions_controller.rb +41 -21
- data/app/controllers/chrono_forge/dashboard/assets_controller.rb +1 -0
- data/app/controllers/chrono_forge/dashboard/definitions_controller.rb +3 -3
- data/app/controllers/chrono_forge/dashboard/overview_controller.rb +45 -0
- data/app/controllers/chrono_forge/dashboard/stranded_controller.rb +29 -0
- data/app/helpers/chrono_forge/dashboard/dashboard_helper.rb +135 -1
- data/app/jobs/chrono_forge/dashboard/bulk_reap_job.rb +11 -0
- data/app/jobs/chrono_forge/dashboard/bulk_retry_job.rb +24 -0
- data/app/queries/chrono_forge/dashboard/overview_query.rb +62 -0
- data/app/queries/chrono_forge/dashboard/workflows_query.rb +6 -1
- data/app/views/chrono_forge/dashboard/analytics/index.html.erb +26 -7
- data/app/views/chrono_forge/dashboard/branch_children/show.html.erb +8 -3
- data/app/views/chrono_forge/dashboard/overview/_card_skeleton.html.erb +5 -0
- data/app/views/chrono_forge/dashboard/overview/_stat_card.html.erb +5 -0
- data/app/views/chrono_forge/dashboard/overview/blocked.html.erb +5 -0
- data/app/views/chrono_forge/dashboard/overview/classes.html.erb +65 -0
- data/app/views/chrono_forge/dashboard/overview/in_flight.html.erb +4 -0
- data/app/views/chrono_forge/dashboard/overview/index.html.erb +36 -0
- data/app/views/chrono_forge/dashboard/overview/processed.html.erb +4 -0
- data/app/views/chrono_forge/dashboard/repetitions/index.html.erb +30 -8
- data/app/views/chrono_forge/dashboard/stranded/index.html.erb +73 -0
- data/app/views/chrono_forge/dashboard/wait_states/index.html.erb +32 -8
- data/app/views/chrono_forge/dashboard/workflows/_error_card.html.erb +1 -1
- data/app/views/chrono_forge/dashboard/workflows/_filters.html.erb +9 -3
- data/app/views/chrono_forge/dashboard/workflows/_timeline.html.erb +66 -18
- data/app/views/chrono_forge/dashboard/workflows/_workflow_row.html.erb +18 -1
- data/app/views/chrono_forge/dashboard/workflows/index.html.erb +10 -3
- data/app/views/chrono_forge/dashboard/workflows/show.html.erb +28 -1
- data/app/views/layouts/chrono_forge/dashboard/application.html.erb +12 -5
- data/config/routes.rb +15 -1
- data/lib/chrono_forge/dashboard/version.rb +1 -1
- metadata +16 -2
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
# Fans out per-workflow retries off the request thread. The "Retry blocked"
|
|
4
|
+
# buttons enqueue one of these instead of looping in the controller, so the
|
|
5
|
+
# HTTP request stays fast even when thousands of workflows are blocked.
|
|
6
|
+
class BulkRetryJob < ActiveJob::Base
|
|
7
|
+
# Both failed and stalled workflows are retryable (matching the per-workflow
|
|
8
|
+
# Retry, which uses `retryable?`).
|
|
9
|
+
RETRYABLE_STATES = %i[failed stalled].map { |s| ChronoForge::Workflow.states[s] }.freeze
|
|
10
|
+
|
|
11
|
+
# The blocked workflows a run would retry: all of them, or just one branch's
|
|
12
|
+
# spawned children. Exposed so the controller can report a count up front.
|
|
13
|
+
def self.retryable(branch_log = nil)
|
|
14
|
+
base = branch_log ? branch_log.spawned_workflows : ChronoForge::Workflow.all
|
|
15
|
+
base.where(state: RETRYABLE_STATES)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def perform(branch_log_id = nil)
|
|
19
|
+
branch_log = branch_log_id && ChronoForge::ExecutionLog.find(branch_log_id)
|
|
20
|
+
self.class.retryable(branch_log).find_each(&:retry_later)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
# Fleet-wide throughput: how much each workflow class has processed, plus its
|
|
4
|
+
# current in-flight and blocked load. One GROUP BY (job_class, state) scan
|
|
5
|
+
# feeds the whole page — the honest cost of a "totals across everything" view
|
|
6
|
+
# (a total can't be capped the way StatsQuery caps the hot workflow list).
|
|
7
|
+
class OverviewQuery
|
|
8
|
+
IN_FLIGHT = %i[idle running].map { |s| ChronoForge::Workflow.states[s] }.freeze
|
|
9
|
+
BLOCKED = %i[failed stalled].map { |s| ChronoForge::Workflow.states[s] }.freeze
|
|
10
|
+
|
|
11
|
+
# A class's counts, bucketed the way the Overview reads them: processed
|
|
12
|
+
# (done), in-flight (live work), blocked (needs triage).
|
|
13
|
+
Row = Struct.new(:job_class, :processed, :in_flight, :blocked) do
|
|
14
|
+
def total = processed + in_flight + blocked
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Fleet-wide card totals — each an independent COUNT so its turbo-frame can
|
|
18
|
+
# load without paying for the per-class GROUP BY the table frame runs.
|
|
19
|
+
def self.processed_total = ChronoForge::Workflow.completed.count
|
|
20
|
+
|
|
21
|
+
def self.in_flight_total = ChronoForge::Workflow.where(state: IN_FLIGHT).count
|
|
22
|
+
|
|
23
|
+
def self.blocked_total = ChronoForge::Workflow.where(state: BLOCKED).count
|
|
24
|
+
|
|
25
|
+
def rows
|
|
26
|
+
@rows ||= build_rows
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Synthetic bottom row summing every class.
|
|
30
|
+
def totals
|
|
31
|
+
@totals ||= Row.new(
|
|
32
|
+
job_class: nil,
|
|
33
|
+
processed: rows.sum(&:processed),
|
|
34
|
+
in_flight: rows.sum(&:in_flight),
|
|
35
|
+
blocked: rows.sum(&:blocked)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def build_rows
|
|
42
|
+
grouped = ChronoForge::Workflow.group(:job_class, :state).count
|
|
43
|
+
# enum key normalization: group(:state).count may key by the raw integer
|
|
44
|
+
# (older Rails) or the label (newer) — invert states so either resolves.
|
|
45
|
+
state_name = ChronoForge::Workflow.states.invert
|
|
46
|
+
by_class = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
47
|
+
grouped.each do |(klass, raw_state), n|
|
|
48
|
+
by_class[klass][state_name[raw_state] || raw_state.to_s] += n
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
by_class.map { |klass, counts|
|
|
52
|
+
Row.new(
|
|
53
|
+
job_class: klass,
|
|
54
|
+
processed: counts["completed"],
|
|
55
|
+
in_flight: counts["idle"] + counts["running"],
|
|
56
|
+
blocked: counts["failed"] + counts["stalled"]
|
|
57
|
+
)
|
|
58
|
+
}.sort_by { |r| [-r.processed, r.job_class.to_s] }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -73,13 +73,18 @@ module ChronoForge
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
# "blocked" is a virtual filter (failed + stalled) used by the branch
|
|
76
|
-
# children triage view to default to the actionable subset.
|
|
76
|
+
# children triage view to default to the actionable subset. "in_flight"
|
|
77
|
+
# (idle + running) is its live-work counterpart, drilled into from the
|
|
78
|
+
# Overview's in-flight column.
|
|
77
79
|
BLOCKED_STATES = %i[failed stalled].map { |s| ChronoForge::Workflow.states[s] }.freeze
|
|
80
|
+
IN_FLIGHT_STATES = %i[idle running].map { |s| ChronoForge::Workflow.states[s] }.freeze
|
|
78
81
|
|
|
79
82
|
def filtered
|
|
80
83
|
s = @base
|
|
81
84
|
if @state == "blocked"
|
|
82
85
|
s = s.where(state: BLOCKED_STATES)
|
|
86
|
+
elsif @state == "in_flight"
|
|
87
|
+
s = s.where(state: IN_FLIGHT_STATES)
|
|
83
88
|
elsif @state && ChronoForge::Workflow.states.key?(@state)
|
|
84
89
|
s = s.where(state: ChronoForge::Workflow.states[@state])
|
|
85
90
|
end
|
|
@@ -17,15 +17,26 @@
|
|
|
17
17
|
<% end %>
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
+
<% trend = cf_window_trend(@buckets) %>
|
|
20
21
|
<div class="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
21
|
-
<div class="cf-card p-4">
|
|
22
|
+
<div class="cf-card flex flex-col p-4">
|
|
22
23
|
<div class="text-xs uppercase tracking-wide text-zinc-400">Completion rate</div>
|
|
23
24
|
<div class="mt-1 font-mono text-2xl font-semibold text-emerald-600"><%= cf_pct(@totals[:completion_rate]) %></div>
|
|
25
|
+
<% if trend %>
|
|
26
|
+
<div class="mt-auto pt-1 text-xs <%= trend[:completion] > 0 ? "text-emerald-600" : trend[:completion] < 0 ? "text-rose-600" : "text-zinc-400" %>">
|
|
27
|
+
<%= cf_trend_arrow(trend[:completion]) %> <%= cf_trend_points(trend[:completion]) %> <span class="text-zinc-400">vs first half</span>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
24
30
|
</div>
|
|
25
|
-
<div class="cf-card p-4">
|
|
31
|
+
<div class="cf-card flex flex-col p-4">
|
|
26
32
|
<div class="text-xs uppercase tracking-wide text-zinc-400">Workflow failure rate</div>
|
|
27
33
|
<div class="mt-1 font-mono text-2xl font-semibold <%= (@totals[:failure_rate] || 0) > 0 ? "text-rose-600" : "text-zinc-900" %>"><%= cf_pct(@totals[:failure_rate]) %></div>
|
|
28
34
|
<div class="mt-0.5 text-xs text-zinc-400"><%= number_with_delimiter(@totals[:failed]) %> failed</div>
|
|
35
|
+
<% if trend && trend[:failure] != 0 %>
|
|
36
|
+
<div class="mt-auto pt-1 text-xs <%= trend[:failure] > 0 ? "text-rose-600" : "text-emerald-600" %>">
|
|
37
|
+
<%= cf_trend_arrow(trend[:failure]) %> <%= cf_trend_points(trend[:failure]) %> <span class="text-zinc-400">vs first half</span>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
29
40
|
</div>
|
|
30
41
|
<div class="cf-card p-4">
|
|
31
42
|
<div class="text-xs uppercase tracking-wide text-zinc-400">Avg duration</div>
|
|
@@ -79,24 +90,32 @@
|
|
|
79
90
|
<% end %>
|
|
80
91
|
|
|
81
92
|
<section class="cf-card p-5">
|
|
82
|
-
<h2 class="mb-4 text-xs font-medium uppercase tracking-wide text-zinc-500">Daily throughput</h2>
|
|
93
|
+
<h2 class="mb-4 text-xs font-medium uppercase tracking-wide text-zinc-500">Daily throughput <span class="normal-case text-zinc-400">— volume & health</span></h2>
|
|
83
94
|
<% if @buckets.any? %>
|
|
84
95
|
<% max = @buckets.map(&:terminal).max.to_f %>
|
|
85
|
-
<div class="space-y-
|
|
96
|
+
<div class="space-y-1">
|
|
86
97
|
<% @buckets.reverse_each do |b| %>
|
|
87
|
-
|
|
88
|
-
|
|
98
|
+
<% flagged = cf_day_flagged?(b) %>
|
|
99
|
+
<% rate = cf_day_rate(b) %>
|
|
100
|
+
<div class="-mx-2 flex items-center gap-3 rounded px-2 py-1 text-sm <%= "bg-amber-50" if flagged %>">
|
|
101
|
+
<div class="w-24 shrink-0 font-mono text-xs <%= flagged ? "text-amber-700" : "text-zinc-500" %>"><%= b.day %></div>
|
|
89
102
|
<div class="flex h-4 min-w-0 flex-1 overflow-hidden rounded bg-zinc-100">
|
|
90
103
|
<div class="cf-bar bg-emerald-400 <%= cf_bar_width(b.completed, max) %>"></div>
|
|
91
104
|
<div class="cf-bar bg-rose-400 <%= cf_bar_width(b.failed, max) %>"></div>
|
|
92
105
|
</div>
|
|
93
|
-
<div class="w-
|
|
106
|
+
<div class="w-28 shrink-0 text-right font-mono text-xs tabular-nums text-zinc-500">
|
|
94
107
|
<span class="text-emerald-600"><%= number_with_delimiter(b.completed) %></span>
|
|
95
108
|
<% if b.failed > 0 %><span class="text-rose-600"> / <%= number_with_delimiter(b.failed) %></span><% end %>
|
|
96
109
|
</div>
|
|
110
|
+
<div class="w-16 shrink-0 text-right">
|
|
111
|
+
<% if rate %>
|
|
112
|
+
<span class="cf-pill <%= flagged ? "cf-pill-stalled" : "cf-pill-completed" %>" title="<%= number_with_delimiter(b.completed) %> of <%= number_with_delimiter(b.terminal) %> terminal workflows completed"><%= cf_pct(rate) %><%= " ⚠" if flagged %></span>
|
|
113
|
+
<% end %>
|
|
114
|
+
</div>
|
|
97
115
|
</div>
|
|
98
116
|
<% end %>
|
|
99
117
|
</div>
|
|
118
|
+
<p class="mt-3 text-xs text-zinc-400">Completion rate per day; a row is flagged when at least <%= (ChronoForge::Dashboard::DashboardHelper::DAY_FAILURE_FLAG * 100).to_i %>% of the day's terminal workflows failed.</p>
|
|
100
119
|
<% else %>
|
|
101
120
|
<p class="py-8 text-center text-sm text-zinc-500">No workflows completed or failed in this window.</p>
|
|
102
121
|
<% end %>
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
<h1 class="text-lg font-semibold tracking-tight">Branch <span class="break-all font-mono"><%= @branch.name %></span></h1>
|
|
6
6
|
<p class="mt-1 break-all text-sm text-zinc-500">children of <span class="font-mono"><%= @workflow.key %></span></p>
|
|
7
7
|
</div>
|
|
8
|
+
<%# Disable + relabel while submitting, and survive the poll morph — see the
|
|
9
|
+
matching button on the workflows index for the full rationale. %>
|
|
8
10
|
<%= button_to "Retry all blocked", bulk_retry_workflow_branch_path(@workflow, @branch_log), method: :post,
|
|
9
|
-
form: {data: {confirm: "Re-enqueue every failed/stalled child of this branch?"
|
|
11
|
+
form: {data: {confirm: "Re-enqueue every failed/stalled child of this branch?", cf_poll_preserve: true}},
|
|
12
|
+
data: {turbo_submits_with: "Retrying…"}, class: "cf-btn cf-btn-primary shrink-0" %>
|
|
10
13
|
</div>
|
|
11
14
|
|
|
12
15
|
<%# Live poll stats from the branch log's metadata (throughput/ETA while draining,
|
|
@@ -74,19 +77,21 @@
|
|
|
74
77
|
|
|
75
78
|
<div class="cf-card overflow-hidden">
|
|
76
79
|
<div class="overflow-x-auto">
|
|
77
|
-
<table class="w-full min-w-[
|
|
80
|
+
<table class="w-full min-w-[48rem] text-sm">
|
|
78
81
|
<thead>
|
|
79
82
|
<tr class="border-b border-zinc-200 bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
|
|
80
83
|
<th class="px-4 py-2.5 font-medium">Class</th>
|
|
81
84
|
<th class="px-4 py-2.5 font-medium">Key</th>
|
|
82
85
|
<th class="px-4 py-2.5 font-medium">State</th>
|
|
86
|
+
<th class="px-4 py-2.5 text-right font-medium">Duration</th>
|
|
83
87
|
<th class="px-4 py-2.5 text-right font-medium">Started</th>
|
|
84
88
|
<th class="px-4 py-2.5 text-right font-medium">Next run</th>
|
|
85
89
|
<th class="px-4 py-2.5 text-right font-medium">Updated</th>
|
|
86
90
|
</tr>
|
|
87
91
|
</thead>
|
|
88
92
|
<tbody class="divide-y divide-zinc-100">
|
|
89
|
-
|
|
93
|
+
<% max_dur = @children.filter_map { |w| cf_row_duration_secs(w) }.max || 1 %>
|
|
94
|
+
<%= render partial: "chrono_forge/dashboard/workflows/workflow_row", collection: @children, as: :workflow, locals: {waits: @waits, max_dur: max_dur} %>
|
|
90
95
|
</tbody>
|
|
91
96
|
</table>
|
|
92
97
|
</div>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<%= link_to href, class: "cf-card block p-4 hover:border-zinc-300" do %>
|
|
2
|
+
<div class="text-xs uppercase tracking-wide text-zinc-400"><%= label %></div>
|
|
3
|
+
<div class="mt-1 font-mono text-2xl font-semibold tabular-nums <%= color %>"><%= number_with_delimiter(count) %></div>
|
|
4
|
+
<div class="mt-0.5 text-xs text-zinc-400"><%= sub %></div>
|
|
5
|
+
<% end %>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<% totals = @overview.totals %>
|
|
2
|
+
<turbo-frame id="cf-ov-classes">
|
|
3
|
+
<div class="cf-card overflow-hidden">
|
|
4
|
+
<div class="overflow-x-auto">
|
|
5
|
+
<table class="w-full min-w-[46rem] text-sm">
|
|
6
|
+
<thead>
|
|
7
|
+
<tr class="border-b border-zinc-200 bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
|
|
8
|
+
<th class="px-4 py-2.5 font-medium">Class</th>
|
|
9
|
+
<th class="px-4 py-2.5 text-right font-medium">Processed</th>
|
|
10
|
+
<th class="px-4 py-2.5 font-medium">Share of processed</th>
|
|
11
|
+
<th class="px-4 py-2.5 text-right font-medium">In flight</th>
|
|
12
|
+
<th class="px-4 py-2.5 text-right font-medium">Blocked</th>
|
|
13
|
+
</tr>
|
|
14
|
+
</thead>
|
|
15
|
+
<tbody class="divide-y divide-zinc-100">
|
|
16
|
+
<% @overview.rows.each do |row| %>
|
|
17
|
+
<tr class="hover:bg-zinc-50">
|
|
18
|
+
<td class="px-4 py-2.5">
|
|
19
|
+
<%= link_to row.job_class, analytics_path(class: row.job_class), class: "font-mono text-zinc-900 hover:underline" %>
|
|
20
|
+
</td>
|
|
21
|
+
<td class="px-4 py-2.5 text-right font-mono tabular-nums">
|
|
22
|
+
<%= link_to number_with_delimiter(row.processed), workflows_path(job_class: row.job_class, state: "completed", hide_branches: "0"), class: "text-zinc-900 hover:underline" %>
|
|
23
|
+
</td>
|
|
24
|
+
<td class="px-4 py-2.5">
|
|
25
|
+
<% share = totals.processed.zero? ? 0 : (row.processed * 100.0 / totals.processed) %>
|
|
26
|
+
<div class="flex items-center gap-2">
|
|
27
|
+
<%= cf_meter(cf_bar_width(row.processed, totals.processed), "bg-zinc-400", track: "w-24", title: "#{number_with_delimiter(row.processed)} of #{number_with_delimiter(totals.processed)} processed") %>
|
|
28
|
+
<span class="w-10 shrink-0 text-right font-mono text-xs tabular-nums text-zinc-500"><%= share.round %>%</span>
|
|
29
|
+
</div>
|
|
30
|
+
</td>
|
|
31
|
+
<td class="px-4 py-2.5 text-right font-mono tabular-nums">
|
|
32
|
+
<% if row.in_flight.zero? %>
|
|
33
|
+
<span class="text-zinc-300">0</span>
|
|
34
|
+
<% else %>
|
|
35
|
+
<%= link_to number_with_delimiter(row.in_flight), workflows_path(job_class: row.job_class, state: "in_flight", hide_branches: "0"), class: "text-zinc-600 hover:underline" %>
|
|
36
|
+
<% end %>
|
|
37
|
+
</td>
|
|
38
|
+
<td class="px-4 py-2.5 text-right font-mono tabular-nums">
|
|
39
|
+
<% if row.blocked.zero? %>
|
|
40
|
+
<span class="text-zinc-300">0</span>
|
|
41
|
+
<% else %>
|
|
42
|
+
<%= link_to "#{number_with_delimiter(row.blocked)} ⚠", workflows_path(job_class: row.job_class, state: "blocked", hide_branches: "0"), class: "whitespace-nowrap font-medium text-rose-600 hover:underline" %>
|
|
43
|
+
<% end %>
|
|
44
|
+
</td>
|
|
45
|
+
</tr>
|
|
46
|
+
<% end %>
|
|
47
|
+
</tbody>
|
|
48
|
+
<% if @overview.rows.any? %>
|
|
49
|
+
<tfoot>
|
|
50
|
+
<tr class="border-t border-zinc-200 bg-zinc-50 font-medium">
|
|
51
|
+
<td class="px-4 py-2.5">Totals</td>
|
|
52
|
+
<td class="px-4 py-2.5 text-right font-mono tabular-nums text-zinc-900"><%= number_with_delimiter(totals.processed) %></td>
|
|
53
|
+
<td class="px-4 py-2.5 text-xs text-zinc-400"><%= @overview.rows.size %> <%= "class".pluralize(@overview.rows.size) %></td>
|
|
54
|
+
<td class="px-4 py-2.5 text-right font-mono tabular-nums text-zinc-600"><%= number_with_delimiter(totals.in_flight) %></td>
|
|
55
|
+
<td class="px-4 py-2.5 text-right font-mono tabular-nums <%= totals.blocked > 0 ? "text-rose-600" : "text-zinc-600" %>"><%= number_with_delimiter(totals.blocked) %></td>
|
|
56
|
+
</tr>
|
|
57
|
+
</tfoot>
|
|
58
|
+
<% end %>
|
|
59
|
+
</table>
|
|
60
|
+
</div>
|
|
61
|
+
<% if @overview.rows.none? %>
|
|
62
|
+
<p class="px-4 py-12 text-center text-sm text-zinc-500">No workflows yet — nothing has been processed.</p>
|
|
63
|
+
<% end %>
|
|
64
|
+
</div>
|
|
65
|
+
</turbo-frame>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<div class="mb-5">
|
|
2
|
+
<h1 class="text-lg font-semibold tracking-tight">Overview</h1>
|
|
3
|
+
<p class="mt-1 text-sm text-zinc-500">
|
|
4
|
+
How much every workflow class has processed, and what it's carrying right now. Counts are workflows across all time.
|
|
5
|
+
</p>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<%# Fleet totals — each card loads in its own frame (one COUNT), so they appear as
|
|
9
|
+
soon as they're ready without waiting on the per-class table below. %>
|
|
10
|
+
<%# target="_top": each frame's content is links (a whole card, class names, counts)
|
|
11
|
+
that must navigate the full page — without this they'd resolve inside the frame
|
|
12
|
+
and Turbo would report "content missing". It doesn't affect the src load. %>
|
|
13
|
+
<div class="mb-5 grid grid-cols-3 gap-3">
|
|
14
|
+
<turbo-frame id="cf-ov-processed" src="<%= overview_processed_path %>" loading="eager" target="_top">
|
|
15
|
+
<%= render "card_skeleton", label: "Processed" %>
|
|
16
|
+
</turbo-frame>
|
|
17
|
+
<turbo-frame id="cf-ov-in-flight" src="<%= overview_in_flight_path %>" loading="eager" target="_top">
|
|
18
|
+
<%= render "card_skeleton", label: "In flight" %>
|
|
19
|
+
</turbo-frame>
|
|
20
|
+
<turbo-frame id="cf-ov-blocked" src="<%= overview_blocked_path %>" loading="eager" target="_top">
|
|
21
|
+
<%= render "card_skeleton", label: "Blocked" %>
|
|
22
|
+
</turbo-frame>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<%# Per-class breakdown — the heavy GROUP BY, isolated in its own frame so it
|
|
26
|
+
streams in on its own clock. %>
|
|
27
|
+
<turbo-frame id="cf-ov-classes" src="<%= overview_classes_path %>" loading="eager" target="_top">
|
|
28
|
+
<div class="cf-card p-6">
|
|
29
|
+
<div class="h-4 w-40 animate-pulse rounded bg-zinc-100"></div>
|
|
30
|
+
<div class="mt-4 space-y-3">
|
|
31
|
+
<% 5.times do %>
|
|
32
|
+
<div class="h-3 w-full animate-pulse rounded bg-zinc-100"></div>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</turbo-frame>
|
|
@@ -28,29 +28,51 @@
|
|
|
28
28
|
<th class="px-4 py-2.5 font-medium">Error</th>
|
|
29
29
|
</tr>
|
|
30
30
|
</thead>
|
|
31
|
+
<%# One scale per page so the meters below are comparable: the latest run's
|
|
32
|
+
biggest lateness and longest duration. In-memory over the loaded page. %>
|
|
33
|
+
<% ts_for = ->(run) { ChronoForge::Dashboard::StepNameParser.parse(run.step_name).timestamp } %>
|
|
34
|
+
<% max_late = @runs.filter_map { |r| t = ts_for.call(r); (r.started_at - Time.zone.at(t)).to_i if t && r.started_at }.select(&:positive?).max || 1 %>
|
|
35
|
+
<% max_dur = @runs.filter_map { |r| (r.completed_at - r.started_at).to_i if r.started_at && r.completed_at }.max || 1 %>
|
|
31
36
|
<tbody class="divide-y divide-zinc-100">
|
|
32
37
|
<% @runs.each do |run| %>
|
|
33
|
-
<% ts =
|
|
38
|
+
<% ts = ts_for.call(run) %>
|
|
34
39
|
<% ff = run.failed? ? run.metadata&.dig("fast_forwarded") : nil %>
|
|
35
40
|
<% tombstone = run.failed? && ff.nil? %>
|
|
36
41
|
<tr class="<%= "bg-amber-50" if run.failed? %>">
|
|
37
42
|
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600"><%= ts ? cf_ago(Time.zone.at(ts)) : "—" %></td>
|
|
38
43
|
<td class="px-4 py-2.5 text-xs">
|
|
39
44
|
<% if ff %>
|
|
40
|
-
<span class="
|
|
45
|
+
<span class="cf-pill cf-pill-stalled" title="fast-forwarded <%= ff %> expired tick(s): <%= run.metadata["from"] %> → <%= run.metadata["to"] %>">caught up ×<%= ff %></span>
|
|
41
46
|
<% elsif tombstone %>
|
|
42
|
-
<span class="
|
|
47
|
+
<span class="cf-pill cf-pill-stalled" title="an expired tick the engine stepped past — normal catch-up, not an error">tombstone</span>
|
|
48
|
+
<% elsif run.completed? %>
|
|
49
|
+
<span class="cf-pill cf-pill-completed">done</span>
|
|
43
50
|
<% else %>
|
|
44
|
-
<span class="
|
|
51
|
+
<span class="cf-pill cf-pill-running"><%= run.state %></span>
|
|
45
52
|
<% end %>
|
|
46
53
|
</td>
|
|
47
54
|
<td class="px-4 py-2.5 text-zinc-600"><%= cf_ago(run.started_at) %></td>
|
|
48
55
|
<% late = (ts && run.started_at) ? (run.started_at - Time.zone.at(ts)).to_i : nil %>
|
|
49
|
-
|
|
50
|
-
|
|
56
|
+
<% late_amber = late && late > 60 %>
|
|
57
|
+
<td class="px-4 py-2.5">
|
|
58
|
+
<div class="flex items-center justify-end gap-2">
|
|
59
|
+
<% if late&.positive? %><%= cf_meter(cf_bar_width(late, max_late), late_amber ? "bg-amber-400" : "bg-zinc-400", track: "w-11", title: "started #{cf_secs(late)} after its scheduled tick") %><% end %>
|
|
60
|
+
<span class="whitespace-nowrap text-right font-mono text-xs <%= late_amber ? "font-medium text-amber-600" : "text-zinc-500" %>"><%= late.nil? ? "—" : (late <= 0 ? "on time" : cf_secs(late)) %></span>
|
|
61
|
+
</div>
|
|
62
|
+
</td>
|
|
63
|
+
<% dur = (run.started_at && run.completed_at) ? (run.completed_at - run.started_at).to_i : nil %>
|
|
64
|
+
<td class="px-4 py-2.5">
|
|
65
|
+
<div class="flex items-center justify-end gap-2">
|
|
66
|
+
<% if dur %><%= cf_meter(cf_duration_bar(dur, max_dur), cf_slow_step?(dur) ? "bg-amber-400" : "bg-zinc-400", track: "w-11", title: cf_meter_title(dur, max_dur, run.started_at, run.completed_at)) %><% end %>
|
|
67
|
+
<span class="whitespace-nowrap text-right font-mono text-xs <%= cf_slow_step?(dur) ? "font-medium text-amber-600" : "text-zinc-500" %>"><%= cf_duration(run.started_at, run.completed_at) %></span>
|
|
68
|
+
</div>
|
|
69
|
+
</td>
|
|
70
|
+
<td class="px-4 py-2.5 text-right">
|
|
71
|
+
<% if (note = cf_attempts_note(:execute, run.attempts, run.state)) %>
|
|
72
|
+
<% tone = {muted: "border-zinc-200 bg-zinc-50 text-zinc-500", warn: "border-amber-200 bg-amber-50 text-amber-700", crit: "border-rose-200 bg-rose-50 text-rose-700"}[note[:tone]] %>
|
|
73
|
+
<span class="rounded border px-1.5 py-0.5 font-mono text-[11px] <%= tone %>" title="<%= note[:title] %>"><%= note[:text] %></span>
|
|
74
|
+
<% end %>
|
|
51
75
|
</td>
|
|
52
|
-
<td class="px-4 py-2.5 text-right font-mono text-xs text-zinc-500"><%= cf_duration(run.started_at, run.completed_at) %></td>
|
|
53
|
-
<td class="px-4 py-2.5 text-right font-mono text-xs text-zinc-500">×<%= run.attempts %></td>
|
|
54
76
|
<td class="px-4 py-2.5 font-mono text-xs text-rose-600"><%= ff ? "—" : (run.error_class || "—") %></td>
|
|
55
77
|
</tr>
|
|
56
78
|
<% end %>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<div class="mb-1 flex flex-wrap items-center justify-between gap-3">
|
|
2
|
+
<h1 class="text-lg font-semibold tracking-tight">Stranded workflows</h1>
|
|
3
|
+
<% if @stranded.any? %>
|
|
4
|
+
<%# Background sweep — mirrors "Retry blocked". Disables + relabels while the
|
|
5
|
+
POST runs and survives the poll morph (see dashboard.js). %>
|
|
6
|
+
<%= button_to "Reap all stranded", reap_all_stranded_index_path, method: :post,
|
|
7
|
+
form: {data: {confirm: "Reap every stranded workflow? Each is re-enqueued to steal its stale lock and replay; steps with external side effects must be idempotent.", cf_poll_preserve: true}},
|
|
8
|
+
data: {turbo_submits_with: "Reaping…"}, class: "cf-btn cf-btn-primary" %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
<p class="mb-5 text-sm text-zinc-500">
|
|
12
|
+
Workflows still in <span class="font-mono">running</span> whose lock hasn't been refreshed in
|
|
13
|
+
<%= cf_secs(@stale_after.to_i) %> (<span class="font-mono">reap_stale_after</span>) — no worker is driving them.
|
|
14
|
+
A worker hard-killed mid-pass (SIGKILL / OOM / eviction) leaves the row locked with nothing scheduled to wake it.
|
|
15
|
+
<strong class="font-medium text-zinc-600">Reaping</strong> re-enqueues each so the executor steals the stale lock and replays completed steps as no-ops.
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<% if @stranded.any? %>
|
|
19
|
+
<div class="mb-5 flex items-start gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3">
|
|
20
|
+
<span class="mt-px text-rose-600" aria-hidden="true">▲</span>
|
|
21
|
+
<div class="min-w-0 flex-1 text-sm">
|
|
22
|
+
<p class="font-semibold text-zinc-900"><%= @capped ? "#{@cap}+" : @stranded.size %> stranded <%= "workflow".pluralize(@stranded.size) %> to reap</p>
|
|
23
|
+
<p class="mt-0.5 text-zinc-600">These match <span class="font-mono">Workflow.reap_stalled</span> exactly — the same sweep a periodic reaper would run. Reap them here, or let the scheduled sweep pick them up.</p>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
<% end %>
|
|
27
|
+
|
|
28
|
+
<div class="cf-card overflow-hidden">
|
|
29
|
+
<div class="overflow-x-auto">
|
|
30
|
+
<table class="w-full min-w-[44rem] text-sm">
|
|
31
|
+
<thead>
|
|
32
|
+
<tr class="border-b border-zinc-200 bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
|
|
33
|
+
<th class="px-4 py-2.5 font-medium">Key</th>
|
|
34
|
+
<th class="px-4 py-2.5 font-medium">Class</th>
|
|
35
|
+
<th class="px-4 py-2.5 font-medium">Locked by</th>
|
|
36
|
+
<th class="px-4 py-2.5 font-medium">Lock stale for</th>
|
|
37
|
+
<th class="px-4 py-2.5 text-right font-medium">Started</th>
|
|
38
|
+
<th class="px-4 py-2.5 text-right font-medium">Reap</th>
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody class="divide-y divide-zinc-100">
|
|
42
|
+
<% max_age = @stranded.filter_map { |wf| cf_lock_age(wf) }.max || 1 %>
|
|
43
|
+
<% @stranded.each do |wf| %>
|
|
44
|
+
<% age = cf_lock_age(wf) %>
|
|
45
|
+
<tr class="bg-rose-50/40 hover:bg-rose-50">
|
|
46
|
+
<td class="px-4 py-2.5"><%= link_to wf.key, workflow_path(wf), class: "font-mono hover:underline" %></td>
|
|
47
|
+
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600"><%= wf.job_class %></td>
|
|
48
|
+
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500"><%= wf.locked_by || "—" %></td>
|
|
49
|
+
<td class="px-4 py-2.5">
|
|
50
|
+
<div class="flex items-center gap-2">
|
|
51
|
+
<%= cf_meter(cf_bar_width(age, max_age), "bg-rose-400", track: "w-14", title: "lock last refreshed #{cf_secs(age)} ago") %>
|
|
52
|
+
<span class="whitespace-nowrap font-medium text-rose-700"><%= cf_duration(wf.locked_at, Time.current) %></span>
|
|
53
|
+
</div>
|
|
54
|
+
</td>
|
|
55
|
+
<td class="px-4 py-2.5 text-right text-xs text-zinc-500"><%= cf_ago(wf.started_at) %></td>
|
|
56
|
+
<td class="px-4 py-2.5 text-right">
|
|
57
|
+
<%= button_to "Reap", reap_workflow_path(wf), method: :post,
|
|
58
|
+
form: {data: {confirm: "Reap #{wf.key}? It re-enqueues the run to steal its stale lock and replay. Steps with external side effects must be idempotent.", cf_poll_preserve: true}},
|
|
59
|
+
data: {turbo_submits_with: "Reaping…"}, class: "cf-btn" %>
|
|
60
|
+
</td>
|
|
61
|
+
</tr>
|
|
62
|
+
<% end %>
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
<% if @stranded.none? %>
|
|
67
|
+
<p class="px-4 py-12 text-center text-sm text-zinc-500">No stranded workflows — every running workflow has a live lock. 🎉</p>
|
|
68
|
+
<% end %>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<% if @capped %>
|
|
72
|
+
<p class="mt-3 text-xs text-zinc-400">Showing the <%= @cap %> longest-stranded. "Reap all stranded" sweeps every one, not just these.</p>
|
|
73
|
+
<% end %>
|
|
@@ -5,6 +5,20 @@
|
|
|
5
5
|
arrives sits here indefinitely.
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
|
+
<%# Lead with the problem: event waits that have blown past the threshold. These
|
|
9
|
+
never time out, so nothing else will surface them. %>
|
|
10
|
+
<% stalled_events = @waits.select { |h| h[:wait].event_wait? && h[:wait].waiting_since && (Time.current - h[:wait].waiting_since) > @threshold } %>
|
|
11
|
+
<% if stalled_events.any? %>
|
|
12
|
+
<% oldest = stalled_events.first %>
|
|
13
|
+
<div class="mb-5 flex items-start gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3">
|
|
14
|
+
<span class="mt-px text-rose-600" aria-hidden="true">▲</span>
|
|
15
|
+
<div class="min-w-0 flex-1 text-sm">
|
|
16
|
+
<p class="font-semibold text-zinc-900"><%= pluralize(stalled_events.size, "event-wait") %> stalled beyond <%= cf_secs(@threshold) %></p>
|
|
17
|
+
<p class="mt-0.5 text-zinc-600">A <span class="font-mono">continue_if</span> waits on an external signal with no timeout — if it never fires, the workflow waits forever. Oldest: <span class="font-mono text-rose-600"><%= oldest[:workflow].job_class %> · <%= oldest[:wait].condition %></span>, waiting <%= distance_of_time_in_words(oldest[:wait].waiting_since, Time.current) %>.</p>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
<% end %>
|
|
21
|
+
|
|
8
22
|
<% if @oldest_event_by_class.any? %>
|
|
9
23
|
<section class="cf-card mb-5 border-amber-200 bg-amber-50/40 p-5">
|
|
10
24
|
<h2 class="mb-1 text-xs font-medium uppercase tracking-wide text-amber-700">Oldest unresolved event wait, by class</h2>
|
|
@@ -44,20 +58,30 @@
|
|
|
44
58
|
</thead>
|
|
45
59
|
<tbody class="divide-y divide-zinc-100">
|
|
46
60
|
<% @waits.each do |h| %>
|
|
47
|
-
<%
|
|
48
|
-
|
|
61
|
+
<% wait = h[:wait] %>
|
|
62
|
+
<% age = wait.waiting_since ? (Time.current - wait.waiting_since).to_i : nil %>
|
|
63
|
+
<% long = age && age > @threshold %>
|
|
64
|
+
<%# An over-threshold event wait is the urgent case (no timeout); a long
|
|
65
|
+
poll is merely slow. Escalate the former to rose, the latter amber. %>
|
|
66
|
+
<% urgent = long && wait.event_wait? %>
|
|
67
|
+
<tr class="cursor-pointer <%= urgent ? "cf-wait--long bg-rose-50" : "cf-wait--long bg-amber-50" if long %> hover:bg-zinc-50" data-href="<%= workflow_path(h[:workflow]) %>">
|
|
49
68
|
<td class="px-4 py-2.5"><%= link_to h[:workflow].key, workflow_path(h[:workflow]), class: "font-mono hover:underline" %></td>
|
|
50
69
|
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600"><%= h[:workflow].job_class %></td>
|
|
51
70
|
<td class="px-4 py-2.5 text-xs">
|
|
52
|
-
<% if
|
|
53
|
-
<span class="
|
|
71
|
+
<% if wait.event_wait? %>
|
|
72
|
+
<span class="cf-pill cf-pill-stalled" title="continue_if — waits on an external event, no timeout">event</span>
|
|
54
73
|
<% else %>
|
|
55
|
-
<span class="
|
|
74
|
+
<span class="cf-pill cf-pill-idle" title="wait_until — polls on a schedule, times out">poll</span>
|
|
56
75
|
<% end %>
|
|
57
76
|
</td>
|
|
58
|
-
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600"><%=
|
|
59
|
-
<td class="px-4 py-2.5
|
|
60
|
-
|
|
77
|
+
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600"><%= wait.condition %></td>
|
|
78
|
+
<td class="px-4 py-2.5">
|
|
79
|
+
<div class="flex items-center gap-2">
|
|
80
|
+
<% if age %><%= cf_meter(cf_bar_width([age, @threshold].min, @threshold), urgent ? "bg-rose-400" : long ? "bg-amber-400" : "bg-zinc-400", track: "w-12", title: "waiting #{cf_secs(age)} of the #{cf_secs(@threshold)} threshold") %><% end %>
|
|
81
|
+
<span class="whitespace-nowrap text-sm <%= urgent ? "font-medium text-rose-700" : long ? "font-medium text-amber-700" : "text-zinc-600" %>"><%= distance_of_time_in_words(wait.waiting_since, Time.current) %></span>
|
|
82
|
+
</div>
|
|
83
|
+
</td>
|
|
84
|
+
<td class="px-4 py-2.5 text-right font-mono text-xs text-zinc-500"><%= wait.event_wait? ? "none" : (wait.timeout_at || "—") %></td>
|
|
61
85
|
</tr>
|
|
62
86
|
<% end %>
|
|
63
87
|
</tbody>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
<% if err.backtrace.present? %>
|
|
8
8
|
<details class="mt-1">
|
|
9
9
|
<summary class="cursor-pointer text-[11px] text-zinc-500">backtrace</summary>
|
|
10
|
-
<pre class="mt-1 overflow-
|
|
10
|
+
<pre class="mt-1 max-h-64 overflow-auto rounded bg-white p-2 font-mono text-[11px] text-zinc-600"><%= err.backtrace %></pre>
|
|
11
11
|
</details>
|
|
12
12
|
<% end %>
|
|
13
13
|
</div>
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
<%# enforce_utf8: false keeps the legacy `utf8=✓` IE hack out of the GET query string. %>
|
|
2
2
|
<%= form_with url: workflows_path, method: :get, enforce_utf8: false, class: "mb-4 flex flex-wrap items-center gap-2" do |f| %>
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
<%# State filtering lives in the stat chips above (one click, with counts and the
|
|
4
|
+
blocked virtual filter) — this hidden field just carries the chip-selected
|
|
5
|
+
state through a job-class/key submit so the search doesn't clear it. %>
|
|
6
|
+
<%= f.hidden_field :state, value: params[:state] %>
|
|
7
|
+
<%# data-cf-poll-preserve: the polling morph refresh (see dashboard.js) skips
|
|
8
|
+
these nodes in place, so text a user is typing — and its caret — survives a
|
|
9
|
+
tick instead of being reset to the last-submitted server value. %>
|
|
10
|
+
<%= f.text_field :job_class, value: params[:job_class], placeholder: "Job class", id: "cf-filter-job-class", data: {cf_poll_preserve: true}, class: "rounded-md border border-zinc-300 px-2.5 py-1.5 text-sm placeholder:text-zinc-400" %>
|
|
11
|
+
<%= f.text_field :key, value: params[:key], placeholder: "Key", id: "cf-filter-key", data: {cf_poll_preserve: true}, class: "rounded-md border border-zinc-300 px-2.5 py-1.5 text-sm placeholder:text-zinc-400" %>
|
|
6
12
|
<%= f.submit "Filter", class: "cf-btn" %>
|
|
7
13
|
|
|
8
14
|
<%# Far right: hide spawned branch children (on by default) so a large fan-out
|