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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +36 -15
  4. data/app/assets/chrono_forge/dashboard/dashboard.css +1 -1
  5. data/app/assets/chrono_forge/dashboard/dashboard.js +86 -80
  6. data/app/assets/chrono_forge/dashboard/turbo.min.js +34 -0
  7. data/app/controllers/chrono_forge/dashboard/actions_controller.rb +41 -21
  8. data/app/controllers/chrono_forge/dashboard/assets_controller.rb +1 -0
  9. data/app/controllers/chrono_forge/dashboard/definitions_controller.rb +3 -3
  10. data/app/controllers/chrono_forge/dashboard/overview_controller.rb +45 -0
  11. data/app/controllers/chrono_forge/dashboard/stranded_controller.rb +29 -0
  12. data/app/helpers/chrono_forge/dashboard/dashboard_helper.rb +135 -1
  13. data/app/jobs/chrono_forge/dashboard/bulk_reap_job.rb +11 -0
  14. data/app/jobs/chrono_forge/dashboard/bulk_retry_job.rb +24 -0
  15. data/app/queries/chrono_forge/dashboard/overview_query.rb +62 -0
  16. data/app/queries/chrono_forge/dashboard/workflows_query.rb +6 -1
  17. data/app/views/chrono_forge/dashboard/analytics/index.html.erb +26 -7
  18. data/app/views/chrono_forge/dashboard/branch_children/show.html.erb +8 -3
  19. data/app/views/chrono_forge/dashboard/overview/_card_skeleton.html.erb +5 -0
  20. data/app/views/chrono_forge/dashboard/overview/_stat_card.html.erb +5 -0
  21. data/app/views/chrono_forge/dashboard/overview/blocked.html.erb +5 -0
  22. data/app/views/chrono_forge/dashboard/overview/classes.html.erb +65 -0
  23. data/app/views/chrono_forge/dashboard/overview/in_flight.html.erb +4 -0
  24. data/app/views/chrono_forge/dashboard/overview/index.html.erb +36 -0
  25. data/app/views/chrono_forge/dashboard/overview/processed.html.erb +4 -0
  26. data/app/views/chrono_forge/dashboard/repetitions/index.html.erb +30 -8
  27. data/app/views/chrono_forge/dashboard/stranded/index.html.erb +73 -0
  28. data/app/views/chrono_forge/dashboard/wait_states/index.html.erb +32 -8
  29. data/app/views/chrono_forge/dashboard/workflows/_error_card.html.erb +1 -1
  30. data/app/views/chrono_forge/dashboard/workflows/_filters.html.erb +9 -3
  31. data/app/views/chrono_forge/dashboard/workflows/_timeline.html.erb +66 -18
  32. data/app/views/chrono_forge/dashboard/workflows/_workflow_row.html.erb +18 -1
  33. data/app/views/chrono_forge/dashboard/workflows/index.html.erb +10 -3
  34. data/app/views/chrono_forge/dashboard/workflows/show.html.erb +28 -1
  35. data/app/views/layouts/chrono_forge/dashboard/application.html.erb +12 -5
  36. data/config/routes.rb +15 -1
  37. data/lib/chrono_forge/dashboard/version.rb +1 -1
  38. 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 &amp; health</span></h2>
83
94
  <% if @buckets.any? %>
84
95
  <% max = @buckets.map(&:terminal).max.to_f %>
85
- <div class="space-y-2">
96
+ <div class="space-y-1">
86
97
  <% @buckets.reverse_each do |b| %>
87
- <div class="flex items-center gap-3 text-sm">
88
- <div class="w-24 shrink-0 font-mono text-xs text-zinc-500"><%= b.day %></div>
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-32 shrink-0 text-right font-mono text-xs tabular-nums text-zinc-500">
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?"}}, class: "cf-btn cf-btn-primary shrink-0" %>
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-[40rem] text-sm">
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
- <%= render partial: "chrono_forge/dashboard/workflows/workflow_row", collection: @children, as: :workflow, locals: {waits: @waits} %>
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
+ <div class="cf-card p-4">
2
+ <div class="text-xs uppercase tracking-wide text-zinc-400"><%= label %></div>
3
+ <div class="mt-2 h-7 w-20 animate-pulse rounded bg-zinc-100"></div>
4
+ <div class="mt-2 h-3 w-24 animate-pulse rounded bg-zinc-100"></div>
5
+ </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,5 @@
1
+ <turbo-frame id="cf-ov-blocked">
2
+ <%= render "stat_card", href: workflows_path(state: "blocked", hide_branches: "0"),
3
+ label: "Blocked", count: @count, sub: "failed + stalled",
4
+ color: (@count > 0) ? "text-rose-600" : "text-zinc-900" %>
5
+ </turbo-frame>
@@ -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,4 @@
1
+ <turbo-frame id="cf-ov-in-flight">
2
+ <%= render "stat_card", href: workflows_path(state: "in_flight", hide_branches: "0"),
3
+ label: "In flight", count: @count, sub: "idle + running", color: "text-zinc-900" %>
4
+ </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>
@@ -0,0 +1,4 @@
1
+ <turbo-frame id="cf-ov-processed">
2
+ <%= render "stat_card", href: workflows_path(state: "completed", hide_branches: "0"),
3
+ label: "Processed", count: @count, sub: "completed, all classes", color: "text-emerald-600" %>
4
+ </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 = ChronoForge::Dashboard::StepNameParser.parse(run.step_name).timestamp %>
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="font-medium text-amber-700" title="fast-forwarded <%= ff %> expired tick(s): <%= run.metadata["from"] %> → <%= run.metadata["to"] %>">caught up ×<%= ff %></span>
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="font-medium text-amber-700">tombstone</span>
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="<%= cf_status_color(run.state) %>"><%= run.state %></span>
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
- <td class="px-4 py-2.5 text-right font-mono text-xs <%= (late && late > 60) ? "text-amber-600" : "text-zinc-500" %>">
50
- <%= late.nil? ? "—" : (late <= 0 ? "on time" : cf_secs(late)) %>
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
- <% long = (Time.current - (h[:wait].waiting_since || Time.current)) > @threshold %>
48
- <tr class="cursor-pointer <%= "cf-wait--long bg-amber-50" if long %> hover:bg-zinc-50" data-href="<%= workflow_path(h[:workflow]) %>">
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 h[:wait].event_wait? %>
53
- <span class="font-medium text-amber-700">event</span>
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="text-zinc-500">poll</span>
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"><%= h[:wait].condition %></td>
59
- <td class="px-4 py-2.5 <%= long ? "font-medium text-amber-700" : "text-zinc-600" %>"><%= distance_of_time_in_words(h[:wait].waiting_since, Time.current) %></td>
60
- <td class="px-4 py-2.5 text-right font-mono text-xs text-zinc-500"><%= h[:wait].event_wait? ? "none" : (h[:wait].timeout_at || "—") %></td>
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-x-auto rounded bg-white p-2 font-mono text-[11px] text-zinc-600"><%= err.backtrace %></pre>
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
- <%= f.select :state, options_for_select([["All states", ""], *ChronoForge::Workflow.states.keys], params[:state]), {}, class: "rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm", data: {autosubmit: true} %>
4
- <%= f.text_field :job_class, value: params[:job_class], placeholder: "Job class", class: "rounded-md border border-zinc-300 px-2.5 py-1.5 text-sm placeholder:text-zinc-400" %>
5
- <%= f.text_field :key, value: params[:key], placeholder: "Key", class: "rounded-md border border-zinc-300 px-2.5 py-1.5 text-sm placeholder:text-zinc-400" %>
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