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
@@ -1,45 +1,93 @@
1
+ <%# Longest completed step, so the duration meters below share one scale. %>
2
+ <% entries = timeline.entries %>
3
+ <%# completed_at >= started_at guards against a backwards step (clock skew / bad
4
+ data): a negative duration is dropped here, so it neither skews the scale nor
5
+ reaches the sqrt meter. %>
6
+ <% max_dur = entries.filter_map { |e| (e.completed_at - e.started_at).to_i if e.started_at && e.completed_at && e.completed_at >= e.started_at }.max || 1 %>
1
7
  <ol class="cf-timeline">
2
- <% entries = timeline.entries %>
3
- <% entries.each_with_index do |e, i| %>
8
+ <% entries.each do |e| %>
4
9
  <% current = timeline.current_position&.id == e.id %>
10
+ <% failed = e.status == "failed" %>
11
+ <% done = e.status == "completed" %>
12
+ <% pending = !failed && !done %>
13
+ <% secs = ((e.completed_at - e.started_at).to_i if e.started_at && e.completed_at && e.completed_at >= e.started_at) %>
14
+ <% waiting = pending && %w[wait continue sleep repeat_coordination].include?(e.kind.to_s) %>
15
+ <% since = e.started_at || e.last_executed_at %>
5
16
  <li class="relative border-l border-zinc-200 pb-5 pl-6 last:border-l-transparent last:pb-0">
6
- <span class="cf-dot cf-dot-<%= e.status %> absolute -left-[5px] top-1.5 ring-4 ring-white"></span>
7
- <div class="<%= "-ml-2 rounded-md bg-amber-50 px-2 py-1.5 ring-1 ring-amber-200" if current %>">
8
- <div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
9
- <span class="font-mono text-[11px] uppercase tracking-wider text-zinc-400"><%= cf_kind_label(e.kind) %></span>
17
+ <span class="cf-dot cf-dot-<%= e.status %> absolute -left-[5px] top-1.5 ring-4 ring-white <%= "animate-pulse" if pending && !waiting %>"></span>
18
+ <div class="<%= "-ml-2.5 rounded-md border px-2.5 py-2 " + (failed ? "border-rose-200 bg-rose-50" : "border-amber-200 bg-amber-50") if failed || current %>">
19
+
20
+ <%# Row 1 what step, and how long it took (or has been going). %>
21
+ <div class="flex flex-wrap items-center gap-x-2 gap-y-1">
22
+ <span class="rounded border border-zinc-200 bg-zinc-50 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wide text-zinc-500"><%= cf_kind_label(e.kind) %></span>
10
23
  <span class="font-mono text-sm font-medium text-zinc-900"><%= e.name %></span>
11
- <span class="text-xs <%= cf_status_color(e.status) %>"><%= e.status %></span>
12
- <span class="font-mono text-xs text-zinc-400">×<%= e.attempts %></span>
13
- <% if e.started_at && e.completed_at %>
14
- <span class="font-mono text-xs text-zinc-400" <%= cf_duration(e.started_at, e.completed_at) %></span>
24
+ <span class="grow"></span>
25
+ <% if secs %>
26
+ <span class="inline-flex items-center gap-2">
27
+ <span class="inline-block h-1.5 w-16 overflow-hidden rounded-full bg-zinc-200 align-middle" title="<%= cf_meter_title(secs, max_dur, e.started_at, e.completed_at) %>">
28
+ <span class="cf-bar <%= cf_duration_bar(secs, max_dur) %> block rounded-full <%= cf_slow_step?(secs) ? "bg-amber-400" : "bg-zinc-400" %>"></span>
29
+ </span>
30
+ <span class="w-14 text-right font-mono text-xs <%= cf_slow_step?(secs) ? "font-semibold text-amber-600" : "text-zinc-500" %>"><%= cf_secs(secs) %></span>
31
+ </span>
32
+ <% elsif pending && since %>
33
+ <span class="font-mono text-xs text-zinc-500" title="<%= since.iso8601 %>">
34
+ <%= waiting ? "waiting" : "running" %> <%= cf_duration(since, Time.current) %>
35
+ </span>
36
+ <% elsif failed %>
37
+ <span class="font-mono text-xs text-zinc-400"><%= cf_ago(e.started_at) %></span>
15
38
  <% end %>
16
39
  </div>
17
- <% if e.started_at || e.last_executed_at %>
18
- <div class="mt-0.5 font-mono text-[11px] text-zinc-400">
19
- <% if e.started_at %>started <%= cf_ago(e.started_at) %><% end %>
20
- <% if e.completed_at %> · finished <%= cf_ago(e.completed_at) %><% elsif e.last_executed_at %> · last run <%= cf_ago(e.last_executed_at) %><% end %>
21
- </div>
22
- <% end %>
40
+
41
+ <%# Row 2 status, attempts (in words), and one meaningful timestamp. %>
42
+ <div class="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1">
43
+ <% if done %>
44
+ <span class="cf-pill cf-pill-completed">done</span>
45
+ <% elsif failed %>
46
+ <span class="cf-pill cf-pill-failed">failed</span>
47
+ <% elsif waiting %>
48
+ <span class="cf-pill cf-pill-stalled">waiting</span>
49
+ <% else %>
50
+ <span class="cf-pill cf-pill-running">running</span>
51
+ <% end %>
52
+
53
+ <% if (note = cf_attempts_note(e.kind, e.attempts, e.status)) %>
54
+ <% 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]] %>
55
+ <span class="rounded border px-1.5 py-0.5 font-mono text-[11px] <%= tone %>" title="<%= note[:title] %>"><%= note[:text] %></span>
56
+ <% end %>
57
+
58
+ <span class="font-mono text-[11px] text-zinc-400">
59
+ <% if done && e.completed_at %>finished <%= cf_ago(e.completed_at) %>
60
+ <% elsif e.started_at %>started <%= cf_ago(e.started_at) %>
61
+ <% elsif e.last_executed_at %>last run <%= cf_ago(e.last_executed_at) %><% end %>
62
+ </span>
63
+ </div>
64
+
65
+ <%# Metadata (wait deadlines, resume times, …). %>
23
66
  <% pairs = cf_meta_pairs(e.metadata) %>
24
67
  <% if pairs.any? %>
25
- <dl class="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[11px]">
68
+ <dl class="mt-1.5 flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[11px]">
26
69
  <% pairs.each do |label, value| %>
27
70
  <div class="min-w-0"><span class="text-zinc-400"><%= label %>:</span> <span class="text-zinc-600"><%= truncate(value, length: 80) %></span></div>
28
71
  <% end %>
29
72
  </dl>
30
73
  <% end %>
74
+
31
75
  <% if e.missing_error_id %>
32
76
  <div class="mt-1.5 rounded-md border border-zinc-200 bg-zinc-50 px-2.5 py-1.5 font-mono text-xs text-zinc-500">
33
77
  error log #<%= e.missing_error_id %> is no longer available
34
78
  </div>
35
79
  <% end %>
36
80
  <% if e.errors.any? %>
37
- <%= render partial: "error_card", collection: e.errors, as: :err %>
81
+ <div class="mt-1.5 flex flex-col gap-1.5">
82
+ <%= render partial: "error_card", collection: e.errors, as: :err %>
83
+ </div>
38
84
  <% elsif e.error_class %>
39
85
  <div class="mt-1 font-mono text-xs text-rose-600">
40
86
  <%= e.error_class %><% if e.error_message.present? %>: <span class="font-normal text-rose-500"><%= truncate(e.error_message, length: 160) %></span><% end %>
41
87
  </div>
42
88
  <% end %>
89
+
90
+ <%# Repeat coordination — iteration tally and catch-up skips. %>
43
91
  <% if e.kind == :repeat_coordination && e.iterations.to_i > 0 %>
44
92
  <div class="mt-1.5 flex flex-wrap items-baseline gap-x-2 text-xs text-zinc-500">
45
93
  <span><%= pluralize(e.iterations, "iteration") %></span>
@@ -1,7 +1,24 @@
1
1
  <tr class="cursor-pointer hover:bg-zinc-50" data-href="<%= workflow_path(workflow) %>">
2
2
  <td class="px-4 py-2.5 font-mono text-xs text-zinc-500"><%= workflow.job_class %></td>
3
3
  <td class="px-4 py-2.5"><%= link_to workflow.key, workflow_path(workflow), class: "font-mono text-zinc-900 hover:underline" %></td>
4
- <td class="px-4 py-2.5"><%= cf_state_badge(workflow, waits && waits[workflow.id]) %></td>
4
+ <td class="px-4 py-2.5">
5
+ <%= cf_state_badge(workflow, waits && waits[workflow.id]) %>
6
+ <% if cf_stranded?(workflow) %>
7
+ <span class="ml-1.5 whitespace-nowrap font-mono text-xs font-medium text-rose-600" title="lock stale <%= cf_duration(workflow.locked_at, Time.current) %> (worker <%= workflow.locked_by || "?" %> likely gone) — reap to recover">stranded ⚠</span>
8
+ <% end %>
9
+ </td>
10
+ <td class="px-4 py-2.5">
11
+ <% secs = cf_row_duration_secs(workflow) %>
12
+ <% if secs %>
13
+ <% stranded = cf_stranded?(workflow) %>
14
+ <div class="flex items-center justify-end gap-2">
15
+ <%= cf_meter(cf_duration_bar(secs, max_dur), stranded ? "bg-rose-400" : cf_slow_step?(secs) ? "bg-amber-400" : "bg-zinc-400", track: "w-12", title: "ran #{cf_secs(secs)}") %>
16
+ <span class="whitespace-nowrap font-mono text-xs <%= stranded ? "font-medium text-rose-600" : cf_slow_step?(secs) ? "text-amber-600" : "text-zinc-500" %>"><%= cf_secs(secs) %></span>
17
+ </div>
18
+ <% else %>
19
+ <span class="block text-right font-mono text-xs text-zinc-300">—</span>
20
+ <% end %>
21
+ </td>
5
22
  <td class="px-4 py-2.5 text-right text-xs text-zinc-500"><%= cf_ago(workflow.started_at) %></td>
6
23
  <td class="px-4 py-2.5 text-right text-xs text-zinc-500"><%= cf_ago((waits && waits[workflow.id])&.next_run_at) %></td>
7
24
  <td class="px-4 py-2.5 text-right text-xs text-zinc-500"><%= cf_ago(workflow.updated_at) %></td>
@@ -1,7 +1,11 @@
1
1
  <div class="mb-5 flex items-center justify-between">
2
2
  <h1 class="text-lg font-semibold tracking-tight">Workflows</h1>
3
+ <%# data-turbo-submits-with disables + relabels the button while the POST runs;
4
+ data-cf-poll-preserve on the form makes the poll morph skip it, so a tick
5
+ mid-request can't reconcile the button back to enabled (see dashboard.js). %>
3
6
  <%= button_to "Retry blocked", bulk_retry_workflows_path, method: :post,
4
- form: {data: {confirm: "Re-enqueue every blocked (failed and stalled) workflow?"}}, class: "cf-btn" %>
7
+ form: {data: {confirm: "Re-enqueue every blocked (failed and stalled) workflow?", cf_poll_preserve: true}},
8
+ data: {turbo_submits_with: "Retrying…"}, class: "cf-btn" %>
5
9
  </div>
6
10
 
7
11
  <div>
@@ -10,19 +14,22 @@
10
14
 
11
15
  <div class="cf-card overflow-hidden">
12
16
  <div class="overflow-x-auto">
13
- <table class="w-full min-w-[40rem] text-sm">
17
+ <table class="w-full min-w-[48rem] text-sm">
14
18
  <thead>
15
19
  <tr class="border-b border-zinc-200 bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
16
20
  <th class="px-4 py-2.5 font-medium">Class</th>
17
21
  <th class="px-4 py-2.5 font-medium">Key</th>
18
22
  <th class="px-4 py-2.5 font-medium">State</th>
23
+ <th class="px-4 py-2.5 text-right font-medium">Duration</th>
19
24
  <th class="px-4 py-2.5 text-right font-medium">Started</th>
20
25
  <th class="px-4 py-2.5 text-right font-medium">Next run</th>
21
26
  <th class="px-4 py-2.5 text-right font-medium">Updated</th>
22
27
  </tr>
23
28
  </thead>
24
29
  <tbody class="divide-y divide-zinc-100">
25
- <%= render partial: "workflow_row", collection: @workflows, as: :workflow, locals: {waits: @waits} %>
30
+ <%# One duration scale across the visible page (in-memory, no query). %>
31
+ <% max_dur = @workflows.filter_map { |w| cf_row_duration_secs(w) }.max || 1 %>
32
+ <%= render partial: "workflow_row", collection: @workflows, as: :workflow, locals: {waits: @waits, max_dur: max_dur} %>
26
33
  </tbody>
27
34
  </table>
28
35
  </div>
@@ -21,6 +21,8 @@
21
21
  <%= button_to "Retry", retry_workflow_path(@workflow), method: :post, class: "cf-btn cf-btn-primary" %>
22
22
  <% end %>
23
23
  <% if @workflow.running? %>
24
+ <%= button_to "Reap", reap_workflow_path(@workflow), method: :post,
25
+ form: {data: {confirm: "Reap this workflow? It re-enqueues the run to steal its stale lock and replay. Steps with external side effects must be idempotent."}}, class: "cf-btn" %>
24
26
  <%= button_to "Force unlock", unlock_workflow_path(@workflow), method: :post,
25
27
  form: {data: {confirm: "Force-unlocking a running workflow can cause duplicate execution. Continue?"}}, class: "cf-btn cf-btn-danger" %>
26
28
  <% end %>
@@ -32,12 +34,37 @@
32
34
  <div><dt class="text-xs uppercase tracking-wide text-zinc-400">Created</dt><dd class="font-mono text-zinc-700"><%= cf_ago(@workflow.created_at) %></dd></div>
33
35
  <div><dt class="text-xs uppercase tracking-wide text-zinc-400">Started</dt><dd class="font-mono text-zinc-700"><%= cf_ago(@workflow.started_at) %></dd></div>
34
36
  <div><dt class="text-xs uppercase tracking-wide text-zinc-400">Completed</dt><dd class="font-mono text-zinc-700"><%= cf_ago(@workflow.completed_at) %></dd></div>
35
- <div><dt class="text-xs uppercase tracking-wide text-zinc-400">Duration</dt><dd class="font-mono text-zinc-700"><%= cf_duration(@workflow.started_at, @workflow.completed_at || @workflow.updated_at) %></dd></div>
37
+ <div><dt class="text-xs uppercase tracking-wide text-zinc-400">Duration</dt><dd class="font-mono text-zinc-700"><%= cf_duration(@workflow.started_at, @workflow.completed_at || (@workflow.running? ? Time.current : @workflow.updated_at)) %></dd></div>
36
38
  <div><dt class="text-xs uppercase tracking-wide text-zinc-400">Locked by</dt><dd class="break-all font-mono text-zinc-700"><%= @workflow.locked_by || "—" %></dd></div>
37
39
  <div><dt class="text-xs uppercase tracking-wide text-zinc-400">Locked at</dt><dd class="font-mono text-zinc-700"><%= cf_ago(@workflow.locked_at) %></dd></div>
38
40
  </dl>
39
41
  </div>
40
42
 
43
+ <%# Lead with the problem: where a blocked run stopped, or that a run is dragging. %>
44
+ <% blocking = (@workflow.failed? || @workflow.stalled?) ? @timeline.entries.reverse.find { |e| e.status == "failed" } : nil %>
45
+ <% if blocking %>
46
+ <% last_err = blocking.errors.last %>
47
+ <div class="mb-5 flex items-start gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3">
48
+ <span class="mt-px text-rose-600" aria-hidden="true">▲</span>
49
+ <div class="min-w-0 flex-1 text-sm">
50
+ <p class="font-semibold text-zinc-900"><%= @workflow.stalled? ? "Stalled" : "Failed" %> at <span class="font-mono"><%= blocking.name %></span><% if blocking.attempts.to_i > 1 %> — failed after <%= pluralize(blocking.attempts, "attempt") %><% end %></p>
51
+ <% if last_err %>
52
+ <p class="mt-0.5 text-zinc-600">Last error <span class="font-mono text-rose-600"><%= last_err.error_class %></span><% if last_err.error_message.present? %>: <%= truncate(last_err.error_message, length: 120) %><% end %></p>
53
+ <% end %>
54
+ </div>
55
+ </div>
56
+ <% elsif cf_stranded?(@workflow) %>
57
+ <div class="mb-5 flex items-start gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3">
58
+ <span class="mt-px text-rose-600" aria-hidden="true">▲</span>
59
+ <div class="min-w-0 flex-1 text-sm">
60
+ <p class="font-semibold text-zinc-900">Stranded — lock stale for <%= cf_duration(@workflow.locked_at, Time.current) %></p>
61
+ <p class="mt-0.5 text-zinc-600">Worker <span class="font-mono"><%= @workflow.locked_by || "?" %></span> hasn't refreshed the lock in <%= cf_secs(cf_reap_stale_after.to_i) %>, so nothing is driving this run — its worker was likely hard-killed mid-pass. <strong class="font-medium text-zinc-700">Reap</strong> re-enqueues it to steal the stale lock and replay.</p>
62
+ </div>
63
+ <%= button_to "Reap", reap_workflow_path(@workflow), method: :post,
64
+ form: {class: "shrink-0", data: {confirm: "Reap this workflow? It re-enqueues the run to steal its stale lock and replay. Steps with external side effects must be idempotent."}}, class: "cf-btn" %>
65
+ </div>
66
+ <% end %>
67
+
41
68
  <%= render "wait_callout", wait: @wait if @wait %>
42
69
 
43
70
  <section class="cf-card mb-5 p-5">
@@ -5,6 +5,10 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <%= csrf_meta_tags %>
7
7
  <link rel="stylesheet" href="<%= "#{request.script_name}/assets/dashboard.css?v=#{ChronoForge::Dashboard.asset_digest("dashboard.css")}" %>">
8
+ <%# Turbo drives all navigation and form submits (SPA-style, no full reloads).
9
+ It lives in <head> so Turbo Drive keeps it across body swaps and never
10
+ re-initialises it — a body <script> would re-run on every visit. %>
11
+ <script src="<%= request.script_name %>/assets/turbo.min.js?v=<%= ChronoForge::Dashboard.asset_digest("turbo.min.js") %>"></script>
8
12
  </head>
9
13
  <body class="min-h-screen bg-zinc-50 font-sans text-zinc-900 antialiased" data-poll-interval="<%= cf_poll_interval %>">
10
14
  <% if flash.any? %>
@@ -22,7 +26,9 @@
22
26
  </a>
23
27
  <nav class="flex items-center gap-5 text-sm">
24
28
  <a href="<%= root_path %>" class="<%= controller_name == "workflows" ? "font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900" %>">Workflows</a>
29
+ <a href="<%= overview_path %>" class="<%= controller_name == "overview" ? "font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900" %>">Overview</a>
25
30
  <a href="<%= wait_states_path %>" class="<%= controller_name == "wait_states" ? "font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900" %>">Waiting</a>
31
+ <a href="<%= stranded_index_path %>" class="<%= controller_name == "stranded" ? "font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900" %>">Stranded</a>
26
32
  <a href="<%= analytics_path %>" class="<%= controller_name == "analytics" ? "font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900" %>">Analytics</a>
27
33
  </nav>
28
34
  <div class="ml-auto flex flex-wrap items-center gap-2">
@@ -46,11 +52,12 @@
46
52
  </div>
47
53
  </div>
48
54
  </header>
49
- <%# data-poll-region: the JS auto-refreshes this region in place (preserving
50
- filter text, focus, and scroll). Marking <main> makes pages refresh — the
51
- nav and the refresh/time controls sit in <header>, outside the swap. A page
52
- can opt out (cf_poll_region? false) when an in-place swap would break it. %>
53
- <main class="mx-auto max-w-6xl px-4 py-6" <%= "data-poll-region" if cf_poll_region? %>>
55
+ <%# data-poll-region / id: the JS morph-refreshes this region in place (idiomorph
56
+ preserves filter text, focus, and scroll). Marking <main> makes pages refresh
57
+ — the nav and the refresh/time controls sit in <header>, outside the morph. A
58
+ page can opt out (cf_poll_region? false) when an in-place refresh would break
59
+ it. %>
60
+ <main id="cf-poll-region" class="mx-auto max-w-6xl px-4 py-6" <%= "data-poll-region" if cf_poll_region? %>>
54
61
  <%= yield %>
55
62
  </main>
56
63
  <script src="<%= "#{request.script_name}/assets/dashboard.js?v=#{ChronoForge::Dashboard.asset_digest("dashboard.js")}" %>"></script>
data/config/routes.rb CHANGED
@@ -5,6 +5,7 @@ ChronoForge::Dashboard::Engine.routes.draw do
5
5
  post :retry, to: "actions#retry"
6
6
  post :resume, to: "actions#resume"
7
7
  post :unlock, to: "actions#unlock"
8
+ post :reap, to: "actions#reap"
8
9
  get :repetitions, to: "repetitions#index"
9
10
  get :definition, to: "definitions#show"
10
11
  end
@@ -16,10 +17,23 @@ ChronoForge::Dashboard::Engine.routes.draw do
16
17
  end
17
18
  end
18
19
  resources :wait_states, only: :index
20
+ resources :stranded, only: :index do
21
+ collection { post :reap_all, to: "actions#bulk_reap" }
22
+ end
23
+ get "overview", to: "overview#index", as: :overview
24
+ # Each Overview section loads in its own turbo-frame (see overview/index) so the
25
+ # shell paints instantly and the heavy per-class scan can't block the cheap card
26
+ # counts. One action per frame.
27
+ scope "overview", as: :overview do
28
+ get "processed", to: "overview#processed"
29
+ get "in_flight", to: "overview#in_flight"
30
+ get "blocked", to: "overview#blocked"
31
+ get "classes", to: "overview#classes"
32
+ end
19
33
  get "analytics", to: "analytics#index", as: :analytics
20
34
  # Explicit allowlist (mirrors AssetsController::TYPES) so unknown assets 404 at
21
35
  # the routing layer rather than reaching the controller.
22
36
  get "assets/:file", to: "assets#show", constraints: {
23
- file: /(dashboard\.(css|js)|cytoscape\.min\.js|dagre\.min\.js|cytoscape-dagre\.js|definition_graph\.js)/
37
+ file: /(dashboard\.(css|js)|turbo\.min\.js|cytoscape\.min\.js|dagre\.min\.js|cytoscape-dagre\.js|definition_graph\.js)/
24
38
  }
25
39
  end
@@ -1,5 +1,5 @@
1
1
  module ChronoForge
2
2
  module Dashboard
3
- VERSION = "0.2.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chrono_forge-dashboard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-04 00:00:00.000000000 Z
11
+ date: 2026-07-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: chrono_forge
@@ -169,16 +169,21 @@ files:
169
169
  - app/assets/chrono_forge/dashboard/dashboard.js
170
170
  - app/assets/chrono_forge/dashboard/definition_graph.js
171
171
  - app/assets/chrono_forge/dashboard/tailwind.css
172
+ - app/assets/chrono_forge/dashboard/turbo.min.js
172
173
  - app/controllers/chrono_forge/dashboard/actions_controller.rb
173
174
  - app/controllers/chrono_forge/dashboard/analytics_controller.rb
174
175
  - app/controllers/chrono_forge/dashboard/assets_controller.rb
175
176
  - app/controllers/chrono_forge/dashboard/base_controller.rb
176
177
  - app/controllers/chrono_forge/dashboard/branch_children_controller.rb
177
178
  - app/controllers/chrono_forge/dashboard/definitions_controller.rb
179
+ - app/controllers/chrono_forge/dashboard/overview_controller.rb
178
180
  - app/controllers/chrono_forge/dashboard/repetitions_controller.rb
181
+ - app/controllers/chrono_forge/dashboard/stranded_controller.rb
179
182
  - app/controllers/chrono_forge/dashboard/wait_states_controller.rb
180
183
  - app/controllers/chrono_forge/dashboard/workflows_controller.rb
181
184
  - app/helpers/chrono_forge/dashboard/dashboard_helper.rb
185
+ - app/jobs/chrono_forge/dashboard/bulk_reap_job.rb
186
+ - app/jobs/chrono_forge/dashboard/bulk_retry_job.rb
182
187
  - app/presenters/chrono_forge/dashboard/branch_presenter.rb
183
188
  - app/presenters/chrono_forge/dashboard/branches_presenter.rb
184
189
  - app/presenters/chrono_forge/dashboard/context_presenter.rb
@@ -188,13 +193,22 @@ files:
188
193
  - app/presenters/chrono_forge/dashboard/timeline_presenter.rb
189
194
  - app/presenters/chrono_forge/dashboard/wait_state_presenter.rb
190
195
  - app/queries/chrono_forge/dashboard/analytics_query.rb
196
+ - app/queries/chrono_forge/dashboard/overview_query.rb
191
197
  - app/queries/chrono_forge/dashboard/repetitions_query.rb
192
198
  - app/queries/chrono_forge/dashboard/stats_query.rb
193
199
  - app/queries/chrono_forge/dashboard/workflows_query.rb
194
200
  - app/views/chrono_forge/dashboard/analytics/index.html.erb
195
201
  - app/views/chrono_forge/dashboard/branch_children/show.html.erb
196
202
  - app/views/chrono_forge/dashboard/definitions/show.html.erb
203
+ - app/views/chrono_forge/dashboard/overview/_card_skeleton.html.erb
204
+ - app/views/chrono_forge/dashboard/overview/_stat_card.html.erb
205
+ - app/views/chrono_forge/dashboard/overview/blocked.html.erb
206
+ - app/views/chrono_forge/dashboard/overview/classes.html.erb
207
+ - app/views/chrono_forge/dashboard/overview/in_flight.html.erb
208
+ - app/views/chrono_forge/dashboard/overview/index.html.erb
209
+ - app/views/chrono_forge/dashboard/overview/processed.html.erb
197
210
  - app/views/chrono_forge/dashboard/repetitions/index.html.erb
211
+ - app/views/chrono_forge/dashboard/stranded/index.html.erb
198
212
  - app/views/chrono_forge/dashboard/wait_states/index.html.erb
199
213
  - app/views/chrono_forge/dashboard/workflows/_branches.html.erb
200
214
  - app/views/chrono_forge/dashboard/workflows/_context_tree.html.erb