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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/MIT-LICENSE +21 -0
- data/README.md +205 -0
- data/app/assets/saga_forge/dashboard/cytoscape-dagre.js +397 -0
- data/app/assets/saga_forge/dashboard/cytoscape.min.js +32 -0
- data/app/assets/saga_forge/dashboard/dagre.min.js +3809 -0
- data/app/assets/saga_forge/dashboard/dashboard.css +2 -0
- data/app/assets/saga_forge/dashboard/dashboard.js +132 -0
- data/app/assets/saga_forge/dashboard/saga_graph.js +143 -0
- data/app/assets/saga_forge/dashboard/tailwind.css +299 -0
- data/app/assets/saga_forge/dashboard/turbo.min.js +34 -0
- data/app/controllers/saga_forge/dashboard/actions_controller.rb +36 -0
- data/app/controllers/saga_forge/dashboard/assets_controller.rb +29 -0
- data/app/controllers/saga_forge/dashboard/base_controller.rb +29 -0
- data/app/controllers/saga_forge/dashboard/definitions_controller.rb +24 -0
- data/app/controllers/saga_forge/dashboard/overview_controller.rb +18 -0
- data/app/controllers/saga_forge/dashboard/sagas_controller.rb +25 -0
- data/app/controllers/saga_forge/dashboard/stalled_controller.rb +29 -0
- data/app/controllers/saga_forge/dashboard/suspended_controller.rb +33 -0
- data/app/helpers/saga_forge/dashboard/dashboard_helper.rb +77 -0
- data/app/jobs/saga_forge/dashboard/bulk_recovery_job.rb +20 -0
- data/app/presenters/saga_forge/dashboard/context_presenter.rb +50 -0
- data/app/presenters/saga_forge/dashboard/saga_graph.rb +59 -0
- data/app/presenters/saga_forge/dashboard/timeline_presenter.rb +68 -0
- data/app/queries/saga_forge/dashboard/overview_query.rb +32 -0
- data/app/queries/saga_forge/dashboard/sagas_query.rb +83 -0
- data/app/queries/saga_forge/dashboard/stats_query.rb +31 -0
- data/app/views/layouts/saga_forge/dashboard/application.html.erb +69 -0
- data/app/views/saga_forge/dashboard/definitions/missing.html.erb +7 -0
- data/app/views/saga_forge/dashboard/definitions/show.html.erb +48 -0
- data/app/views/saga_forge/dashboard/overview/_stat_card.html.erb +4 -0
- data/app/views/saga_forge/dashboard/overview/classes.html.erb +37 -0
- data/app/views/saga_forge/dashboard/overview/index.html.erb +33 -0
- data/app/views/saga_forge/dashboard/sagas/_actions.html.erb +27 -0
- data/app/views/saga_forge/dashboard/sagas/_compensation.html.erb +41 -0
- data/app/views/saga_forge/dashboard/sagas/_context_tree.html.erb +37 -0
- data/app/views/saga_forge/dashboard/sagas/_filters.html.erb +9 -0
- data/app/views/saga_forge/dashboard/sagas/_saga_row.html.erb +6 -0
- data/app/views/saga_forge/dashboard/sagas/_stats.html.erb +16 -0
- data/app/views/saga_forge/dashboard/sagas/_summary.html.erb +43 -0
- data/app/views/saga_forge/dashboard/sagas/_timeline.html.erb +48 -0
- data/app/views/saga_forge/dashboard/sagas/empty.html.erb +6 -0
- data/app/views/saga_forge/dashboard/sagas/index.html.erb +46 -0
- data/app/views/saga_forge/dashboard/sagas/show.html.erb +19 -0
- data/app/views/saga_forge/dashboard/stalled/index.html.erb +51 -0
- data/app/views/saga_forge/dashboard/suspended/index.html.erb +57 -0
- data/config/routes.rb +39 -0
- data/lib/saga_forge/dashboard/configuration.rb +30 -0
- data/lib/saga_forge/dashboard/engine.rb +9 -0
- data/lib/saga_forge/dashboard/version.rb +5 -0
- data/lib/saga_forge/dashboard.rb +33 -0
- 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,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,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: []
|