saga_forge-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +205 -0
  5. data/app/assets/saga_forge/dashboard/cytoscape-dagre.js +397 -0
  6. data/app/assets/saga_forge/dashboard/cytoscape.min.js +32 -0
  7. data/app/assets/saga_forge/dashboard/dagre.min.js +3809 -0
  8. data/app/assets/saga_forge/dashboard/dashboard.css +2 -0
  9. data/app/assets/saga_forge/dashboard/dashboard.js +132 -0
  10. data/app/assets/saga_forge/dashboard/saga_graph.js +143 -0
  11. data/app/assets/saga_forge/dashboard/tailwind.css +299 -0
  12. data/app/assets/saga_forge/dashboard/turbo.min.js +34 -0
  13. data/app/controllers/saga_forge/dashboard/actions_controller.rb +36 -0
  14. data/app/controllers/saga_forge/dashboard/assets_controller.rb +29 -0
  15. data/app/controllers/saga_forge/dashboard/base_controller.rb +29 -0
  16. data/app/controllers/saga_forge/dashboard/definitions_controller.rb +24 -0
  17. data/app/controllers/saga_forge/dashboard/overview_controller.rb +18 -0
  18. data/app/controllers/saga_forge/dashboard/sagas_controller.rb +25 -0
  19. data/app/controllers/saga_forge/dashboard/stalled_controller.rb +29 -0
  20. data/app/controllers/saga_forge/dashboard/suspended_controller.rb +33 -0
  21. data/app/helpers/saga_forge/dashboard/dashboard_helper.rb +77 -0
  22. data/app/jobs/saga_forge/dashboard/bulk_recovery_job.rb +20 -0
  23. data/app/presenters/saga_forge/dashboard/context_presenter.rb +50 -0
  24. data/app/presenters/saga_forge/dashboard/saga_graph.rb +59 -0
  25. data/app/presenters/saga_forge/dashboard/timeline_presenter.rb +68 -0
  26. data/app/queries/saga_forge/dashboard/overview_query.rb +32 -0
  27. data/app/queries/saga_forge/dashboard/sagas_query.rb +83 -0
  28. data/app/queries/saga_forge/dashboard/stats_query.rb +31 -0
  29. data/app/views/layouts/saga_forge/dashboard/application.html.erb +69 -0
  30. data/app/views/saga_forge/dashboard/definitions/missing.html.erb +7 -0
  31. data/app/views/saga_forge/dashboard/definitions/show.html.erb +48 -0
  32. data/app/views/saga_forge/dashboard/overview/_stat_card.html.erb +4 -0
  33. data/app/views/saga_forge/dashboard/overview/classes.html.erb +37 -0
  34. data/app/views/saga_forge/dashboard/overview/index.html.erb +33 -0
  35. data/app/views/saga_forge/dashboard/sagas/_actions.html.erb +27 -0
  36. data/app/views/saga_forge/dashboard/sagas/_compensation.html.erb +41 -0
  37. data/app/views/saga_forge/dashboard/sagas/_context_tree.html.erb +37 -0
  38. data/app/views/saga_forge/dashboard/sagas/_filters.html.erb +9 -0
  39. data/app/views/saga_forge/dashboard/sagas/_saga_row.html.erb +6 -0
  40. data/app/views/saga_forge/dashboard/sagas/_stats.html.erb +16 -0
  41. data/app/views/saga_forge/dashboard/sagas/_summary.html.erb +43 -0
  42. data/app/views/saga_forge/dashboard/sagas/_timeline.html.erb +48 -0
  43. data/app/views/saga_forge/dashboard/sagas/empty.html.erb +6 -0
  44. data/app/views/saga_forge/dashboard/sagas/index.html.erb +46 -0
  45. data/app/views/saga_forge/dashboard/sagas/show.html.erb +19 -0
  46. data/app/views/saga_forge/dashboard/stalled/index.html.erb +51 -0
  47. data/app/views/saga_forge/dashboard/suspended/index.html.erb +57 -0
  48. data/config/routes.rb +39 -0
  49. data/lib/saga_forge/dashboard/configuration.rb +30 -0
  50. data/lib/saga_forge/dashboard/engine.rb +9 -0
  51. data/lib/saga_forge/dashboard/version.rb +5 -0
  52. data/lib/saga_forge/dashboard.rb +33 -0
  53. metadata +142 -0
@@ -0,0 +1,6 @@
1
+ <tr class="cursor-pointer hover:bg-[var(--sf-bg)]" data-href="<%= saga_path(row) %>">
2
+ <td class="px-4 py-2.5"><%= link_to row.correlation_id, saga_path(row), class: "font-mono text-[var(--sf-fg)] hover:underline" %></td>
3
+ <td class="px-4 py-2.5"><%= state_badge(row.current_state) %></td>
4
+ <td class="px-4 py-2.5 text-right font-mono text-xs text-[var(--sf-soft)]"><%= row.version %></td>
5
+ <td class="px-4 py-2.5 text-right text-xs text-[var(--sf-soft)]"><%= time_tag(row.updated_at) %></td>
6
+ </tr>
@@ -0,0 +1,16 @@
1
+ <div class="mb-4 flex flex-wrap gap-2">
2
+ <% base = {class: @saga_class.name} %>
3
+ <% base[:q] = params[:q] if params[:q].present? %>
4
+ <% counts = stats.counts %>
5
+ <% counts.except(:active).each do |key, n| %>
6
+ <% href = (key == :all) ? base : base.merge(filter: key) %>
7
+ <% active = (key == :all) ? params[:filter].blank? : params[:filter].to_s == key.to_s %>
8
+ <%= link_to sagas_path(href), class: "sf-badge #{active ? "sf-badge-indigo" : "sf-badge-slate"}" do %>
9
+ <%= key %> <span class="opacity-70"><%= stats.label(n) %></span>
10
+ <% end %>
11
+ <% end %>
12
+ <% active_filter_selected = params[:filter].to_s == "active" %>
13
+ <%= link_to sagas_path(base.merge(filter: "active")), class: "sf-badge #{active_filter_selected ? "sf-badge-indigo" : "sf-badge-slate"}" do %>
14
+ active <span class="opacity-70"><%= stats.label(counts[:active]) %></span>
15
+ <% end %>
16
+ </div>
@@ -0,0 +1,43 @@
1
+ <div class="sf-card p-4">
2
+ <div class="flex flex-wrap items-center justify-between gap-3 text-sm">
3
+ <dl class="flex flex-wrap gap-x-6 gap-y-1.5">
4
+ <div>
5
+ <dt class="text-xs uppercase tracking-wide text-[var(--sf-soft)]">Saga class</dt>
6
+ <dd class="font-mono"><%= @state.saga_class %></dd>
7
+ </div>
8
+ <div>
9
+ <dt class="text-xs uppercase tracking-wide text-[var(--sf-soft)]">Version</dt>
10
+ <dd class="font-mono"><%= @state.version %></dd>
11
+ </div>
12
+ <div>
13
+ <dt class="text-xs uppercase tracking-wide text-[var(--sf-soft)]">Created</dt>
14
+ <dd><%= time_tag(@state.created_at) %></dd>
15
+ </div>
16
+ <div>
17
+ <dt class="text-xs uppercase tracking-wide text-[var(--sf-soft)]">Updated</dt>
18
+ <dd><%= time_tag(@state.updated_at) %></dd>
19
+ </div>
20
+ </dl>
21
+ <%= link_to "Definition graph ›", saga_definition_path(class: @state.saga_class, correlation_id: @state.correlation_id), class: "sf-btn" %>
22
+ </div>
23
+
24
+ <% if @stalled || @suspended || @comp_error.present? %>
25
+ <div class="mt-4 flex flex-col gap-2 border-t border-[var(--sf-border)] pt-4">
26
+ <% if @stalled %>
27
+ <div class="rounded-md border border-[var(--sf-badge-amber-border)] bg-[var(--sf-badge-amber-bg)] px-3 py-2 text-sm text-[var(--sf-badge-amber-fg)]">
28
+ One or more events are <strong>stalled</strong>: they've exhausted their retry budget and are waiting to be retried.
29
+ </div>
30
+ <% end %>
31
+ <% if @suspended %>
32
+ <div class="rounded-md border border-[var(--sf-badge-red-border)] bg-[var(--sf-badge-red-bg)] px-3 py-2 text-sm text-[var(--sf-badge-red-fg)]">
33
+ One or more events have <strong>failed</strong>: the saga is suspended pending a resume or compensate decision.
34
+ </div>
35
+ <% end %>
36
+ <% if @comp_error.present? %>
37
+ <div class="rounded-md border border-[var(--sf-badge-red-border)] bg-[var(--sf-badge-red-bg)] px-3 py-2 text-sm text-[var(--sf-badge-red-fg)]">
38
+ Compensation is <strong>stuck</strong> on <code class="font-mono"><%= @comp_error["name"] %></code> (<%= @comp_error["class"] %>: <%= @comp_error["message"] %>)
39
+ </div>
40
+ <% end %>
41
+ </div>
42
+ <% end %>
43
+ </div>
@@ -0,0 +1,48 @@
1
+ <% sorted = @timeline.sorted %>
2
+ <div class="sf-card overflow-hidden">
3
+ <h2 class="border-b border-[var(--sf-border)] px-4 py-2.5 text-sm font-semibold">Timeline</h2>
4
+ <% if @timeline.truncated? %>
5
+ <p class="border-b border-[var(--sf-border)] bg-[var(--sf-bg)] px-4 py-2 text-xs text-[var(--sf-soft)]">
6
+ Showing the latest <%= SagaForge::Dashboard::TimelinePresenter::MAX_ENTRIES %> of <%= @timeline.total_event_count %> events.
7
+ </p>
8
+ <% end %>
9
+ <ul class="divide-y divide-[var(--sf-border)]">
10
+ <% sorted.each do |entry| %>
11
+ <li class="px-4 py-3">
12
+ <div class="flex flex-wrap items-center gap-2 text-sm">
13
+ <span class="sf-badge sf-badge-slate"><%= entry.kind %></span>
14
+ <span class="font-mono font-medium"><%= entry.label %></span>
15
+ <%= status_badge(entry.status) %>
16
+ <% if entry.kind == :compensation %>
17
+ <span class="ml-auto text-xs text-[var(--sf-soft)]" title="approximate: pinned to the saga's last update, not a per-step time"><%= time_tag(entry.at) %></span>
18
+ <% else %>
19
+ <span class="ml-auto text-xs text-[var(--sf-soft)]"><%= time_tag(entry.at) %></span>
20
+ <% end %>
21
+ </div>
22
+
23
+ <% error = entry.detail[:error] %>
24
+ <% if entry.status.to_s == "failed" && error.present? %>
25
+ <div class="mt-2 rounded-md border border-[var(--sf-badge-red-border)] bg-[var(--sf-badge-red-bg)] px-3 py-2 text-xs text-[var(--sf-badge-red-fg)]">
26
+ <div><strong><%= error["class"] %></strong>: <%= error["message"] %></div>
27
+ <% if error["backtrace"].present? %>
28
+ <details class="mt-1">
29
+ <summary class="cursor-pointer select-none">Backtrace</summary>
30
+ <pre class="mt-1 overflow-x-auto whitespace-pre-wrap font-mono text-[11px]"><%= Array(error["backtrace"]).join("\n") %></pre>
31
+ </details>
32
+ <% end %>
33
+ </div>
34
+ <% elsif entry.kind == :event %>
35
+ <div class="mt-1 text-xs text-[var(--sf-soft)]">
36
+ attempts: <%= entry.detail[:attempts] %>
37
+ <% if entry.detail[:stall_count].to_i > 0 %> · stalls: <%= entry.detail[:stall_count] %><% end %>
38
+ </div>
39
+ <% elsif entry.kind == :compensation && entry.detail[:attempts].present? %>
40
+ <div class="mt-1 text-xs text-[var(--sf-soft)]">attempts: <%= entry.detail[:attempts] %></div>
41
+ <% end %>
42
+ </li>
43
+ <% end %>
44
+ <% if sorted.empty? %>
45
+ <li class="px-4 py-8 text-center text-sm text-[var(--sf-soft)]">No events yet.</li>
46
+ <% end %>
47
+ </ul>
48
+ </div>
@@ -0,0 +1,6 @@
1
+ <div class="mb-5">
2
+ <h1 class="text-lg font-semibold tracking-tight">Sagas</h1>
3
+ </div>
4
+ <div class="sf-card px-4 py-12 text-center text-sm text-[var(--sf-soft)]">
5
+ No saga classes are registered.
6
+ </div>
@@ -0,0 +1,46 @@
1
+ <div class="mb-5 flex items-center justify-between">
2
+ <h1 class="text-lg font-semibold tracking-tight">Sagas</h1>
3
+ <%# Plain GET select, no JS dependency for the core navigation path. %>
4
+ <%= form_with url: sagas_path, method: :get, class: "flex items-center gap-2" do |f| %>
5
+ <select name="class" data-autosubmit class="rounded-md border border-[var(--sf-border)] bg-[var(--sf-surface)] px-2 py-1.5 text-sm">
6
+ <% @saga_classes.each do |k| %>
7
+ <option value="<%= k.name %>" <%= "selected" if k == @saga_class %>><%= k.name %></option>
8
+ <% end %>
9
+ </select>
10
+ <%= f.submit "Go", class: "sf-btn" %>
11
+ <% end %>
12
+ </div>
13
+
14
+ <div>
15
+ <%= render "stats", stats: @stats %>
16
+ <%= render "filters" %>
17
+
18
+ <div class="sf-card overflow-hidden">
19
+ <div class="overflow-x-auto">
20
+ <table class="w-full min-w-[36rem] text-sm">
21
+ <thead>
22
+ <tr class="border-b border-[var(--sf-border)] bg-[var(--sf-bg)] text-left text-xs uppercase tracking-wide text-[var(--sf-soft)]">
23
+ <th class="px-4 py-2.5 font-medium">Correlation ID</th>
24
+ <th class="px-4 py-2.5 font-medium">State</th>
25
+ <th class="px-4 py-2.5 text-right font-medium">Version</th>
26
+ <th class="px-4 py-2.5 text-right font-medium">Updated</th>
27
+ </tr>
28
+ </thead>
29
+ <tbody class="divide-y divide-[var(--sf-border)]">
30
+ <%= render partial: "saga_row", collection: @query.records, as: :row %>
31
+ </tbody>
32
+ </table>
33
+ </div>
34
+ <% if @query.records.none? %>
35
+ <p class="px-4 py-12 text-center text-sm text-[var(--sf-soft)]">No sagas match these filters.</p>
36
+ <% end %>
37
+ </div>
38
+
39
+ <% base_params = {class: @saga_class.name} %>
40
+ <% base_params[:filter] = params[:filter] if params[:filter].present? %>
41
+ <% base_params[:q] = params[:q] if params[:q].present? %>
42
+ <nav class="mt-4 flex items-center justify-between text-sm">
43
+ <% if @query.has_prev? %><%= link_to "‹ Newer", sagas_path(base_params.merge(after: @query.prev_cursor)), class: "sf-btn" %><% else %><span></span><% end %>
44
+ <% if @query.has_next? %><%= link_to "Older ›", sagas_path(base_params.merge(before: @query.next_cursor)), class: "sf-btn" %><% else %><span></span><% end %>
45
+ </nav>
46
+ </div>
@@ -0,0 +1,19 @@
1
+ <div class="mb-5 flex items-center justify-between">
2
+ <div>
3
+ <%= link_to "‹ #{@state.saga_class}", sagas_path(class: @state.saga_class), class: "text-sm text-[var(--sf-soft)] hover:text-[var(--sf-fg)]" %>
4
+ <h1 class="mt-1 font-mono text-lg font-semibold tracking-tight"><%= @state.correlation_id %></h1>
5
+ </div>
6
+ <div class="flex flex-col items-end gap-2">
7
+ <%= state_badge(@state.current_state) %>
8
+ <%= render "actions" %>
9
+ </div>
10
+ </div>
11
+
12
+ <div class="flex flex-col gap-5">
13
+ <%= render "summary" %>
14
+ <%= render "timeline" %>
15
+ <% if @context.saga_meta.present? %>
16
+ <%= render "compensation" %>
17
+ <% end %>
18
+ <%= render "context_tree" %>
19
+ </div>
@@ -0,0 +1,51 @@
1
+ <h1 class="mb-1 text-lg font-semibold tracking-tight">Stalled sagas</h1>
2
+ <p class="mb-3 text-sm text-[var(--sf-soft)]">
3
+ Instances (across every saga class) with at least one event parked in
4
+ <span class="font-mono">stalled</span>: it exhausted its retry budget and is waiting to be retried.
5
+ </p>
6
+
7
+ <%# Bulk retry is scoped to one saga class, so offer a button per class
8
+ present in the list rather than a single all-classes action. %>
9
+ <% if @saga_classes.any? %>
10
+ <div class="mb-5 flex flex-wrap items-center gap-2">
11
+ <% @saga_classes.each do |klass| %>
12
+ <%= button_to "Retry all stalled (#{klass})", bulk_action_path(saga_class: klass, mode: "retry_stalled"), method: :post,
13
+ form: {data: {confirm: "Retry every stalled event for #{klass}? This acts on ALL stalled instances of #{klass}, not just the #{SagaForge::Dashboard::StalledController::CAP} shown on this page, and re-delivers each parked event."}},
14
+ title: "Acts on every stalled instance of #{klass}, not only the rows shown on this page.", class: "sf-btn sf-btn-primary" %>
15
+ <% end %>
16
+ </div>
17
+ <% end %>
18
+
19
+ <div class="sf-card overflow-hidden">
20
+ <div class="overflow-x-auto">
21
+ <table class="w-full min-w-[42rem] text-sm">
22
+ <thead>
23
+ <tr class="border-b border-[var(--sf-border)] bg-[var(--sf-bg)] text-left text-xs uppercase tracking-wide text-[var(--sf-soft)]">
24
+ <th class="px-4 py-2.5 font-medium">Correlation ID</th>
25
+ <th class="px-4 py-2.5 font-medium">Class</th>
26
+ <th class="px-4 py-2.5 font-medium">State</th>
27
+ <th class="px-4 py-2.5 font-medium">Parked event(s)</th>
28
+ <th class="px-4 py-2.5 text-right font-medium">Updated</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody class="divide-y divide-[var(--sf-border)]">
32
+ <% @states.each do |state| %>
33
+ <tr class="cursor-pointer bg-[var(--sf-badge-amber-bg)]/40 hover:bg-[var(--sf-badge-amber-bg)]" data-href="<%= saga_path(state) %>">
34
+ <td class="px-4 py-2.5"><%= link_to state.correlation_id, saga_path(state), class: "font-mono text-[var(--sf-fg)] hover:underline" %></td>
35
+ <td class="px-4 py-2.5 font-mono text-xs text-[var(--sf-soft)]"><%= state.saga_class %></td>
36
+ <td class="px-4 py-2.5"><%= state_badge(state.current_state) %></td>
37
+ <td class="px-4 py-2.5 font-mono text-xs text-[var(--sf-badge-amber-fg)]"><%= @event_names[state.id]&.join(", ") %></td>
38
+ <td class="px-4 py-2.5 text-right text-xs text-[var(--sf-soft)]"><%= time_tag(state.updated_at) %></td>
39
+ </tr>
40
+ <% end %>
41
+ </tbody>
42
+ </table>
43
+ </div>
44
+ <% if @states.none? %>
45
+ <p class="px-4 py-12 text-center text-sm text-[var(--sf-soft)]">No stalled sagas. Every parked event has been retried or resolved.</p>
46
+ <% end %>
47
+ </div>
48
+
49
+ <% if @capped %>
50
+ <p class="mt-3 text-xs text-[var(--sf-soft)]">Showing the <%= SagaForge::Dashboard::StalledController::CAP %> most recently updated. Narrow with the per-class sagas list for more.</p>
51
+ <% end %>
@@ -0,0 +1,57 @@
1
+ <h1 class="mb-1 text-lg font-semibold tracking-tight">Suspended sagas</h1>
2
+ <p class="mb-3 text-sm text-[var(--sf-soft)]">
3
+ Instances (across every saga class) with at least one event that ran out of retries and landed in
4
+ <span class="font-mono">failed</span>: suspended pending a resume or compensate decision.
5
+ </p>
6
+
7
+ <%# Bulk resume is scoped to one saga class, so offer a button per class
8
+ present in the list rather than a single all-classes action. %>
9
+ <% if @saga_classes.any? %>
10
+ <div class="mb-5 flex flex-wrap items-center gap-2">
11
+ <% @saga_classes.each do |klass| %>
12
+ <%= button_to "Resume all (#{klass})", bulk_action_path(saga_class: klass, mode: "resume"), method: :post,
13
+ form: {data: {confirm: "Resume every failed event for #{klass}? This acts on ALL suspended instances of #{klass}, not just the #{SagaForge::Dashboard::SuspendedController::CAP} shown on this page, and re-fires each after assuming the underlying issue is fixed."}},
14
+ title: "Acts on every suspended instance of #{klass}, not only the rows shown on this page.", class: "sf-btn sf-btn-primary" %>
15
+ <% end %>
16
+ </div>
17
+ <% end %>
18
+
19
+ <div class="sf-card overflow-hidden">
20
+ <div class="overflow-x-auto">
21
+ <table class="w-full min-w-[42rem] text-sm">
22
+ <thead>
23
+ <tr class="border-b border-[var(--sf-border)] bg-[var(--sf-bg)] text-left text-xs uppercase tracking-wide text-[var(--sf-soft)]">
24
+ <th class="px-4 py-2.5 font-medium">Correlation ID</th>
25
+ <th class="px-4 py-2.5 font-medium">Class</th>
26
+ <th class="px-4 py-2.5 font-medium">State</th>
27
+ <th class="px-4 py-2.5 font-medium">Failed event</th>
28
+ <th class="px-4 py-2.5 text-right font-medium">Updated</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody class="divide-y divide-[var(--sf-border)]">
32
+ <% @states.each do |state| %>
33
+ <% ev = @failed_event[state.id] %>
34
+ <tr class="cursor-pointer bg-[var(--sf-badge-red-bg)]/40 hover:bg-[var(--sf-badge-red-bg)]" data-href="<%= saga_path(state) %>">
35
+ <td class="px-4 py-2.5"><%= link_to state.correlation_id, saga_path(state), class: "font-mono text-[var(--sf-fg)] hover:underline" %></td>
36
+ <td class="px-4 py-2.5 font-mono text-xs text-[var(--sf-soft)]"><%= state.saga_class %></td>
37
+ <td class="px-4 py-2.5"><%= state_badge(state.current_state) %></td>
38
+ <td class="px-4 py-2.5 font-mono text-xs text-[var(--sf-badge-red-fg)]">
39
+ <% if ev %>
40
+ <span class="text-[var(--sf-fg)]"><%= ev.event_name %></span>
41
+ (<%= ev.error["class"] %>: <%= ev.error["message"] %>)
42
+ <% end %>
43
+ </td>
44
+ <td class="px-4 py-2.5 text-right text-xs text-[var(--sf-soft)]"><%= time_tag(state.updated_at) %></td>
45
+ </tr>
46
+ <% end %>
47
+ </tbody>
48
+ </table>
49
+ </div>
50
+ <% if @states.none? %>
51
+ <p class="px-4 py-12 text-center text-sm text-[var(--sf-soft)]">No suspended sagas. Nothing has run out of retries.</p>
52
+ <% end %>
53
+ </div>
54
+
55
+ <% if @capped %>
56
+ <p class="mt-3 text-xs text-[var(--sf-soft)]">Showing the <%= SagaForge::Dashboard::SuspendedController::CAP %> most recently updated. Narrow with the per-class sagas list for more.</p>
57
+ <% end %>
data/config/routes.rb ADDED
@@ -0,0 +1,39 @@
1
+ SagaForge::Dashboard::Engine.routes.draw do
2
+ root to: "sagas#index"
3
+ resources :sagas, only: %i[index show] do
4
+ member do
5
+ post :retry_stalled, to: "actions#retry_stalled"
6
+ post :resume, to: "actions#resume"
7
+ post :compensate, to: "actions#compensate"
8
+ post :cancel, to: "actions#cancel"
9
+ end
10
+ end
11
+
12
+ # Bulk recovery, scoped to one saga class (the stalled/suspended lists are
13
+ # cross-class, so the class is named explicitly rather than inferred).
14
+ post "bulk/:saga_class/:mode", to: "actions#bulk", as: :bulk_action,
15
+ constraints: {saga_class: /[\w:]+/, mode: /retry_stalled|resume/}
16
+
17
+ # Fleet overview: a lazy-loaded turbo-frame shell (index) plus the frame it
18
+ # loads (classes), which carries the one GROUP BY query in its own request.
19
+ get "overview", to: "overview#index", as: :overview
20
+ scope "overview", as: :overview do
21
+ get "classes", to: "overview#classes"
22
+ end
23
+ resources :stalled, only: :index
24
+ resources :suspended, only: :index
25
+
26
+ # The per-class state-machine graph, keyed by fully-qualified saga class
27
+ # name (which may contain "::"), with an optional ?correlation_id= overlay.
28
+ get "definitions/:class", to: "definitions#show", as: :saga_definition, constraints: {class: /[\w:]+/}
29
+
30
+ # Explicit allowlist (mirrors AssetsController::TYPES) so unknown assets 404 at
31
+ # the routing layer rather than reaching the controller.
32
+ # Do NOT anchor this regex with \A/\z: the segment is already anchored by the
33
+ # literal "assets/" prefix, and an inline \A/\z here would make the route
34
+ # match nothing (a documented Rails routing gotcha). The controller's
35
+ # exact-string TYPES lookup is the real gate; this regex is defense-in-depth.
36
+ get "assets/:file", to: "assets#show", constraints: {
37
+ file: /(dashboard\.(css|js)|turbo\.min\.js|cytoscape\.min\.js|dagre\.min\.js|cytoscape-dagre\.js|saga_graph\.js)/
38
+ }
39
+ end
@@ -0,0 +1,30 @@
1
+ module SagaForge
2
+ module Dashboard
3
+ class AuthenticationNotConfigured < StandardError
4
+ MESSAGE = <<~MSG.freeze
5
+ SagaForge::Dashboard has no authentication configured. Do one of:
6
+ - SagaForge::Dashboard.configure { |c| c.http_basic = { username:, password: } }
7
+ - SagaForge::Dashboard.configure { |c| c.authenticate { |controller| ... } }
8
+ - SagaForge::Dashboard.configure { |c| c.authentication = :none } # then guard the mount yourself
9
+ MSG
10
+ def initialize(msg = MESSAGE) = super
11
+ end
12
+
13
+ class Configuration
14
+ attr_accessor :http_basic, :authentication
15
+ attr_reader :auth_hook
16
+ attr_accessor :polling_interval, :polling_interval_options, :page_size
17
+
18
+ def initialize
19
+ @http_basic = nil
20
+ @authentication = nil
21
+ @auth_hook = nil
22
+ @polling_interval = 15
23
+ @polling_interval_options = [0, 5, 10, 15, 30, 60, 300]
24
+ @page_size = 50
25
+ end
26
+
27
+ def authenticate(&block) = @auth_hook = block
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ require "rails/engine"
2
+
3
+ module SagaForge
4
+ module Dashboard
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace SagaForge::Dashboard
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ module SagaForge
2
+ module Dashboard
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,33 @@
1
+ require "saga_forge"
2
+ require "saga_forge/dashboard/version"
3
+ require "saga_forge/dashboard/configuration"
4
+ require "saga_forge/dashboard/engine"
5
+
6
+ module SagaForge
7
+ module Dashboard
8
+ ASSET_ROOT = "app/assets/saga_forge/dashboard"
9
+
10
+ class << self
11
+ def config = (@config ||= Configuration.new)
12
+
13
+ def configure = yield(config)
14
+
15
+ def reset_configuration! = @config = Configuration.new
16
+
17
+ # Short content digest of a shipped asset, to cache-bust the served CSS/JS
18
+ # despite the immutable cache header. Memoized once per boot.
19
+ def asset_digest(file)
20
+ @asset_digests ||= {}
21
+ @asset_digests[file] ||= begin
22
+ require "digest"
23
+ Digest::SHA256.file(Engine.root.join(ASSET_ROOT, file)).hexdigest[0, 12]
24
+ rescue Errno::ENOENT
25
+ VERSION
26
+ rescue => e
27
+ Rails.logger.warn { "[saga_forge-dashboard] asset_digest(#{file}) failed: #{e.class}: #{e.message}" } if defined?(Rails) && Rails.logger
28
+ VERSION
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: saga_forge-dashboard
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stefan Froelich
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: saga_forge
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 0.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 0.1.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: railties
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: actionpack
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '7.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '7.1'
55
+ description: 'Mountable Rails engine: browse saga instances, read the event ledger,
56
+ view state-machine graphs, and run recovery actions.'
57
+ email:
58
+ - sfroelich01@gmail.com
59
+ executables: []
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - CHANGELOG.md
64
+ - MIT-LICENSE
65
+ - README.md
66
+ - app/assets/saga_forge/dashboard/cytoscape-dagre.js
67
+ - app/assets/saga_forge/dashboard/cytoscape.min.js
68
+ - app/assets/saga_forge/dashboard/dagre.min.js
69
+ - app/assets/saga_forge/dashboard/dashboard.css
70
+ - app/assets/saga_forge/dashboard/dashboard.js
71
+ - app/assets/saga_forge/dashboard/saga_graph.js
72
+ - app/assets/saga_forge/dashboard/tailwind.css
73
+ - app/assets/saga_forge/dashboard/turbo.min.js
74
+ - app/controllers/saga_forge/dashboard/actions_controller.rb
75
+ - app/controllers/saga_forge/dashboard/assets_controller.rb
76
+ - app/controllers/saga_forge/dashboard/base_controller.rb
77
+ - app/controllers/saga_forge/dashboard/definitions_controller.rb
78
+ - app/controllers/saga_forge/dashboard/overview_controller.rb
79
+ - app/controllers/saga_forge/dashboard/sagas_controller.rb
80
+ - app/controllers/saga_forge/dashboard/stalled_controller.rb
81
+ - app/controllers/saga_forge/dashboard/suspended_controller.rb
82
+ - app/helpers/saga_forge/dashboard/dashboard_helper.rb
83
+ - app/jobs/saga_forge/dashboard/bulk_recovery_job.rb
84
+ - app/presenters/saga_forge/dashboard/context_presenter.rb
85
+ - app/presenters/saga_forge/dashboard/saga_graph.rb
86
+ - app/presenters/saga_forge/dashboard/timeline_presenter.rb
87
+ - app/queries/saga_forge/dashboard/overview_query.rb
88
+ - app/queries/saga_forge/dashboard/sagas_query.rb
89
+ - app/queries/saga_forge/dashboard/stats_query.rb
90
+ - app/views/layouts/saga_forge/dashboard/application.html.erb
91
+ - app/views/saga_forge/dashboard/definitions/missing.html.erb
92
+ - app/views/saga_forge/dashboard/definitions/show.html.erb
93
+ - app/views/saga_forge/dashboard/overview/_stat_card.html.erb
94
+ - app/views/saga_forge/dashboard/overview/classes.html.erb
95
+ - app/views/saga_forge/dashboard/overview/index.html.erb
96
+ - app/views/saga_forge/dashboard/sagas/_actions.html.erb
97
+ - app/views/saga_forge/dashboard/sagas/_compensation.html.erb
98
+ - app/views/saga_forge/dashboard/sagas/_context_tree.html.erb
99
+ - app/views/saga_forge/dashboard/sagas/_filters.html.erb
100
+ - app/views/saga_forge/dashboard/sagas/_saga_row.html.erb
101
+ - app/views/saga_forge/dashboard/sagas/_stats.html.erb
102
+ - app/views/saga_forge/dashboard/sagas/_summary.html.erb
103
+ - app/views/saga_forge/dashboard/sagas/_timeline.html.erb
104
+ - app/views/saga_forge/dashboard/sagas/empty.html.erb
105
+ - app/views/saga_forge/dashboard/sagas/index.html.erb
106
+ - app/views/saga_forge/dashboard/sagas/show.html.erb
107
+ - app/views/saga_forge/dashboard/stalled/index.html.erb
108
+ - app/views/saga_forge/dashboard/suspended/index.html.erb
109
+ - config/routes.rb
110
+ - lib/saga_forge/dashboard.rb
111
+ - lib/saga_forge/dashboard/configuration.rb
112
+ - lib/saga_forge/dashboard/engine.rb
113
+ - lib/saga_forge/dashboard/version.rb
114
+ homepage: https://github.com/radioactive-labs/saga_forge
115
+ licenses:
116
+ - MIT
117
+ metadata:
118
+ allowed_push_host: https://rubygems.org
119
+ homepage_uri: https://github.com/radioactive-labs/saga_forge
120
+ source_code_uri: https://github.com/radioactive-labs/saga_forge
121
+ changelog_uri: https://github.com/radioactive-labs/saga_forge/blob/main/saga_forge-dashboard/CHANGELOG.md
122
+ rubygems_mfa_required: 'true'
123
+ post_install_message:
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: 3.2.2
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ requirements: []
138
+ rubygems_version: 3.5.16
139
+ signing_key:
140
+ specification_version: 4
141
+ summary: A management dashboard for SagaForge sagas.
142
+ test_files: []