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,50 @@
1
+ module SagaForge
2
+ module Dashboard
3
+ class ContextPresenter
4
+ Node = Struct.new(:key, :type, :bytes, :preview)
5
+
6
+ # Above this many elements, a Hash/Array value isn't fully serialized
7
+ # just to build a 200-char preview; only its size is reported. Guards
8
+ # against a single giant context value (e.g. a batch job's collected
9
+ # results) doing an unbounded JSON serialization on every page load.
10
+ LARGE_COLLECTION_THRESHOLD = 50
11
+
12
+ def initialize(state)
13
+ @context = state.context || {}
14
+ end
15
+
16
+ def user_nodes
17
+ @user_nodes ||= @context.except("__saga_forge").map { |k, v| node(k, v) }
18
+ end
19
+
20
+ def saga_meta = @context["__saga_forge"] # nil or the reserved sub-hash
21
+
22
+ private
23
+
24
+ def node(key, value)
25
+ type = ruby_type(value)
26
+ if large_collection?(value)
27
+ Node.new(key: key, type: type, bytes: nil, preview: "(#{type}, #{value.size} entries, too large to preview)")
28
+ else
29
+ json = value.to_json
30
+ Node.new(key: key, type: type, bytes: json.bytesize, preview: json.truncate(200))
31
+ end
32
+ end
33
+
34
+ def large_collection?(v)
35
+ (v.is_a?(Hash) || v.is_a?(Array)) && v.size > LARGE_COLLECTION_THRESHOLD
36
+ end
37
+
38
+ def ruby_type(v)
39
+ case v
40
+ when Hash then "object"
41
+ when Array then "array"
42
+ when Numeric then "number"
43
+ when TrueClass, FalseClass then "boolean"
44
+ when NilClass then "null"
45
+ else "string"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,59 @@
1
+ module SagaForge
2
+ module Dashboard
3
+ # Turns a Definition#to_graph (structured nodes + typed chain/jump/stay
4
+ # edges) into Cytoscape.js "elements", optionally overlaying one saga
5
+ # instance's status onto the nodes. Rendering-only: no DB, no analysis.
6
+ class SagaGraph
7
+ RANK = {"failed" => 3, "stalled" => 2, "processed" => 1}.freeze
8
+
9
+ def initialize(graph, definition, state = nil)
10
+ @graph = graph # SagaForge::Dashboard::Graph
11
+ @definition = definition
12
+ @state = state
13
+ end
14
+
15
+ def to_h
16
+ {nodes: node_elements, edges: edge_elements}
17
+ end
18
+
19
+ private
20
+
21
+ # Each event is attributed to a node via Definition#state_for_event,
22
+ # the EXACT handler table, not a scan of edge labels (which are only
23
+ # best-effort for jump/stay and would misattribute events on states
24
+ # reached solely by a computed transition). A state colors to the worst
25
+ # of its events' statuses (failed > stalled > processed; pending events
26
+ # don't color anything). The current state always wins as :active,
27
+ # regardless of what its own events say: it's "in progress", not
28
+ # judged by history.
29
+ def status_map
30
+ return {} unless @state
31
+ @status_map ||= begin
32
+ by_node = {}
33
+ @state.events.each do |e|
34
+ node = @definition.state_for_event(e.event_name)&.to_s
35
+ next unless node
36
+ rank = RANK[e.status.to_s] || 0
37
+ next if rank.zero?
38
+ by_node[node] = e.status.to_s if rank > (RANK[by_node[node]] || 0)
39
+ end
40
+ by_node[@state.current_state.to_s] = "active"
41
+ by_node
42
+ end
43
+ end
44
+
45
+ def node_elements
46
+ @graph.nodes.map do |n|
47
+ status = status_map[n.id] || "none"
48
+ {data: {id: n.id, label: n.label}, classes: "kind-#{n.kind} status-#{status}"}
49
+ end
50
+ end
51
+
52
+ def edge_elements
53
+ @graph.edges.each_with_index.map do |e, i|
54
+ {data: {id: "e#{i}", source: e.from, target: e.to, label: e.label.to_s}, classes: "kind-#{e.kind}"}
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,68 @@
1
+ module SagaForge
2
+ module Dashboard
3
+ # One time-ordered stream for a saga instance: the event ledger merged with
4
+ # the compensation progress the engine records in context["__saga_forge"].
5
+ # The engine does not pre-merge these, so the dashboard owns it.
6
+ class TimelinePresenter
7
+ # index: this entry's position in its own source array (the event ledger,
8
+ # or the compensated/comp_error list), a tertiary sort key so #sorted is
9
+ # deterministic. Array#sort_by is not guaranteed stable, and every
10
+ # compensation entry shares the same synthetic `at` (see below), so
11
+ # without this tiebreak a multi-step compensation can render out of its
12
+ # actual (recorded) order with no indication anything is wrong.
13
+ Entry = Struct.new(:at, :kind, :label, :status, :detail, :index)
14
+
15
+ # A stay-loop saga can accumulate thousands of event rows; rendering all
16
+ # of them (each with a full payload/backtrace) doesn't scale. Cap to the
17
+ # most recent MAX_ENTRIES, mirroring StatsQuery::CAP's spirit. Compensation
18
+ # entries are small (derived from context, not a separate table) and are
19
+ # never capped.
20
+ MAX_ENTRIES = 500
21
+
22
+ def initialize(state)
23
+ @state = state
24
+ end
25
+
26
+ def entries
27
+ @entries ||= event_entries + compensation_entries
28
+ end
29
+
30
+ def sorted
31
+ @sorted ||= entries.sort_by { |e| [e.at || Time.at(0), (e.kind == :event) ? 0 : 1, e.index] }
32
+ end
33
+
34
+ # True when the event ledger was truncated to the most recent MAX_ENTRIES.
35
+ # Side-effect-free (derived from total_event_count alone) so it gives the
36
+ # right answer even if called before entries/sorted have run.
37
+ def truncated? = total_event_count > MAX_ENTRIES
38
+
39
+ def total_event_count = @total_event_count ||= @state.events.count
40
+
41
+ private
42
+
43
+ def meta = @state.context["__saga_forge"] || {}
44
+
45
+ def event_entries
46
+ rows = @state.history.last(MAX_ENTRIES)
47
+ rows.each_with_index.map do |e, i|
48
+ Entry.new(at: e.created_at, kind: :event, label: e.event_name, status: e.status,
49
+ detail: {payload: e.payload, attempts: e.attempts, stall_count: e.stall_count,
50
+ retry_budgets: e.retry_budgets, error: e.error}, index: i)
51
+ end
52
+ end
53
+
54
+ def compensation_entries
55
+ done = meta["compensated"] || []
56
+ list = done.each_with_index.map do |name, i|
57
+ Entry.new(at: @state.updated_at, kind: :compensation, label: name, status: "processed",
58
+ detail: {attempts: meta.dig("comp_attempts", name)}, index: i)
59
+ end
60
+ if (err = meta["comp_error"])
61
+ list << Entry.new(at: @state.updated_at, kind: :compensation, label: err["name"], status: "failed",
62
+ detail: {error: err}, index: list.size)
63
+ end
64
+ list
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,32 @@
1
+ module SagaForge
2
+ module Dashboard
3
+ # Fleet-wide breakdown: how many instances of each saga class sit in each
4
+ # current_state. One GROUP BY scan feeds the whole "classes" frame, the
5
+ # honest cost of a "totals across everything" view (unlike StatsQuery's
6
+ # per-class counts, a fleet-wide total can't be capped the way a single
7
+ # class's hot list can).
8
+ class OverviewQuery
9
+ # {saga_class => {current_state => count}}. current_state is a plain
10
+ # string column here (no enum), so the GROUP BY key is already the raw
11
+ # label, no enum int-vs-label normalization needed.
12
+ def rows
13
+ SagaForge::State.group(:saga_class, :current_state).count
14
+ .each_with_object(Hash.new { |h, k| h[k] = {} }) do |((klass, state), n), acc|
15
+ acc[klass][state] = n
16
+ end
17
+ end
18
+
19
+ # Fleet-wide card totals, reusing the engine's own derived scopes
20
+ # (State.stalled/.suspended/.compensating) rather than reimplementing
21
+ # their subqueries here, same discipline as SagasQuery/StatsQuery.
22
+ def totals
23
+ {
24
+ all: SagaForge::State.count,
25
+ stalled: SagaForge::State.stalled.count,
26
+ suspended: SagaForge::State.suspended.count,
27
+ compensating: SagaForge::State.compensating.count
28
+ }
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,83 @@
1
+ module SagaForge
2
+ module Dashboard
3
+ # Keyset pagination over one saga class's State rows. Orders by PK desc,
4
+ # pages with id < / > cursor, never COUNTs. Virtual filters compose the
5
+ # engine's derived scopes (stalled/suspended/compensating).
6
+ class SagasQuery
7
+ DEFAULT_PER = 50
8
+ MAX_PER = 200
9
+
10
+ def initialize(saga_class:, filter: nil, correlation: nil, before: nil, after: nil, per: DEFAULT_PER)
11
+ @saga_class = saga_class
12
+ @filter = filter.presence
13
+ @correlation = correlation.presence
14
+ @before = before.presence&.to_i
15
+ @after = after.presence&.to_i
16
+ @per = per.to_i.clamp(1, MAX_PER)
17
+ end
18
+
19
+ def records
20
+ load
21
+ @records
22
+ end
23
+ attr_reader :per
24
+
25
+ def has_next? # older rows remain
26
+ load
27
+ @has_next
28
+ end
29
+
30
+ def has_prev? # newer rows remain
31
+ load
32
+ @has_prev
33
+ end
34
+
35
+ def next_cursor = records.last&.id
36
+ def prev_cursor = records.first&.id
37
+
38
+ private
39
+
40
+ def base
41
+ SagaForge::State.for_saga(@saga_class)
42
+ end
43
+
44
+ def filtered
45
+ s = case @filter
46
+ when "stalled" then base.stalled
47
+ when "suspended" then base.suspended
48
+ when "compensating" then base.compensating
49
+ when "finalized" then base.finalized
50
+ when "active" then base.active
51
+ when nil, "", "all" then base
52
+ else base.where(current_state: @filter)
53
+ end
54
+ if @correlation
55
+ s = s.where("correlation_id LIKE ?", "#{SagaForge::State.sanitize_sql_like(@correlation)}%")
56
+ end
57
+ s
58
+ end
59
+
60
+ def load
61
+ return if @loaded
62
+ @loaded = true
63
+ col = "#{SagaForge::State.table_name}.id"
64
+ if @after
65
+ rows = filtered.where("#{col} > ?", @after).order(id: :asc).limit(@per + 1).to_a
66
+ @has_prev = rows.size > @per
67
+ @records = rows.first(@per).reverse
68
+ # Reaching this page via a real prev_cursor means older rows exist (this
69
+ # page came from one); a stale/deleted-anchor cursor just self-heals to
70
+ # the first page on the next click, so hardcoding true here is safe.
71
+ @has_next = true
72
+ else
73
+ scope = filtered
74
+ scope = scope.where("#{col} < ?", @before) if @before
75
+ rows = scope.order(id: :desc).limit(@per + 1).to_a
76
+ @has_next = rows.size > @per
77
+ @records = rows.first(@per)
78
+ @has_prev = @before.present?
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,31 @@
1
+ module SagaForge
2
+ module Dashboard
3
+ class StatsQuery
4
+ CAP = 5000
5
+
6
+ def initialize(saga_class:)
7
+ @saga_class = saga_class
8
+ end
9
+
10
+ def counts
11
+ base = SagaForge::State.for_saga(@saga_class)
12
+ {
13
+ all: capped(base),
14
+ stalled: capped(base.stalled),
15
+ suspended: capped(base.suspended),
16
+ compensating: capped(base.compensating),
17
+ finalized: capped(base.finalized),
18
+ active: capped(base.active)
19
+ }
20
+ end
21
+
22
+ def label(n) = (n >= CAP) ? "#{CAP}+" : n.to_s
23
+
24
+ private
25
+
26
+ def capped(relation)
27
+ SagaForge::State.from(relation.select(:id).limit(CAP), :capped).count
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,69 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>SagaForge</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <link rel="stylesheet" href="<%= "#{request.script_name}/assets/dashboard.css?v=#{SagaForge::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=<%= SagaForge::Dashboard.asset_digest("turbo.min.js") %>"></script>
12
+ </head>
13
+ <body class="min-h-screen bg-[var(--sf-bg)] font-sans text-[var(--sf-fg)] antialiased" data-poll-interval="<%= poll_interval %>">
14
+ <% if flash.any? %>
15
+ <div role="status" aria-live="polite" class="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
16
+ <% flash.each do |type, msg| %>
17
+ <div data-flash class="pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md transition-opacity duration-300 <%= type.to_s == "alert" ? "border-[var(--sf-flash-err-border)] bg-[var(--sf-flash-err-bg)] text-[var(--sf-flash-err-fg)]" : "border-[var(--sf-flash-ok-border)] bg-[var(--sf-flash-ok-bg)] text-[var(--sf-flash-ok-fg)]" %>"><%= msg %></div>
18
+ <% end %>
19
+ </div>
20
+ <% end %>
21
+ <header class="border-b border-[var(--sf-border)] bg-[var(--sf-surface)]">
22
+ <div class="mx-auto flex max-w-6xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3">
23
+ <a href="<%= root_path %>" class="flex items-center gap-2 font-semibold tracking-tight">
24
+ <span class="inline-block h-3.5 w-3.5 rounded-sm bg-[var(--sf-accent)]"></span>
25
+ SagaForge
26
+ </a>
27
+ <nav class="flex items-center gap-5 text-sm">
28
+ <% [
29
+ ["Sagas", sagas_path],
30
+ ["Overview", "#{request.script_name}/overview"],
31
+ ["Stalled", "#{request.script_name}/stalled"],
32
+ ["Suspended", "#{request.script_name}/suspended"]
33
+ ].each do |label, href| %>
34
+ <% active = request.path == href %>
35
+ <a href="<%= href %>"<% if active %> aria-current="page"<% end %> class="<%= active ? "font-medium text-[var(--sf-fg)]" : "text-[var(--sf-soft)] hover:text-[var(--sf-fg)]" %>"><%= label %></a>
36
+ <% end %>
37
+ </nav>
38
+ <div class="ml-auto flex flex-wrap items-center gap-2">
39
+ <%# Hidden on pages that opt out of auto-refresh (poll_region? false), so
40
+ a control that does nothing there doesn't confuse. %>
41
+ <% if poll_region? %>
42
+ <% current_poll = poll_interval %>
43
+ <label class="flex items-center gap-1 text-xs text-[var(--sf-soft)]" title="Auto-refresh">
44
+ <span>refresh</span>
45
+ <select data-poll-select class="rounded-md border border-[var(--sf-border)] bg-[var(--sf-surface)] px-1.5 py-1 text-xs text-[var(--sf-fg)]">
46
+ <% (poll_options | [current_poll]).sort.each do |secs| %>
47
+ <option value="<%= secs %>" <%= "selected" if secs == current_poll %>><%= poll_label(secs) %></option>
48
+ <% end %>
49
+ </select>
50
+ </label>
51
+ <% end %>
52
+ <div class="flex items-center gap-0.5 rounded-md border border-[var(--sf-border)] p-0.5 text-xs" title="Timestamp display">
53
+ <button type="button" data-time-set="relative" aria-pressed="<%= !absolute_time? %>" class="rounded px-2 py-0.5 <%= absolute_time? ? "text-[var(--sf-soft)] hover:text-[var(--sf-fg)]" : "bg-[var(--sf-accent)] text-[var(--sf-accent-fg)]" %>">relative</button>
54
+ <button type="button" data-time-set="absolute" aria-pressed="<%= absolute_time? %>" class="rounded px-2 py-0.5 <%= absolute_time? ? "bg-[var(--sf-accent)] text-[var(--sf-accent-fg)]" : "text-[var(--sf-soft)] hover:text-[var(--sf-fg)]" %>">absolute</button>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ </header>
59
+ <%# data-poll-region / id: the JS morph-refreshes this region in place (idiomorph
60
+ preserves filter text, focus, and scroll). Marking <main> makes pages refresh
61
+ in place; the nav and the refresh/time controls sit in <header>, outside the
62
+ morph. A page can opt out (poll_region? false) when an in-place refresh would
63
+ break it (e.g. the definition graph's live Cytoscape canvas). %>
64
+ <main id="sf-poll-region" class="mx-auto max-w-6xl px-4 py-6" <%= "data-poll-region" if poll_region? %>>
65
+ <%= yield %>
66
+ </main>
67
+ <script src="<%= "#{request.script_name}/assets/dashboard.js?v=#{SagaForge::Dashboard.asset_digest("dashboard.js")}" %>"></script>
68
+ </body>
69
+ </html>
@@ -0,0 +1,7 @@
1
+ <div class="mb-5">
2
+ <h1 class="text-lg font-semibold tracking-tight">State machine</h1>
3
+ </div>
4
+ <div class="sf-card px-4 py-12 text-center text-sm text-[var(--sf-soft)]">
5
+ <p><code class="font-mono"><%= params[:class] %></code> isn't a registered saga class.</p>
6
+ <p class="mt-1">It may have been renamed, removed, or never registered with <code class="font-mono">SagaForge::Router</code>.</p>
7
+ </div>
@@ -0,0 +1,48 @@
1
+ <div class="mb-4 flex items-center justify-between">
2
+ <div>
3
+ <h1 class="text-lg font-semibold tracking-tight">State machine</h1>
4
+ <p class="font-mono text-sm text-[var(--sf-soft)]"><%= @saga_class.name %></p>
5
+ </div>
6
+ <% if @state %>
7
+ <%= link_to "‹ #{@state.correlation_id}", saga_path(@state), class: "sf-btn" %>
8
+ <% else %>
9
+ <%= link_to "‹ #{@saga_class.name}", sagas_path(class: @saga_class.name), class: "sf-btn" %>
10
+ <% end %>
11
+ </div>
12
+
13
+ <div class="sf-card overflow-hidden">
14
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 border-b border-[var(--sf-border)] px-4 py-2.5 text-[11px] text-[var(--sf-soft)]">
15
+ <span class="inline-flex items-center gap-1.5">
16
+ <span class="inline-block h-3 w-4 border-t-2 border-[var(--sf-soft)]"></span>chain (event handler order)
17
+ </span>
18
+ <span class="inline-flex items-center gap-1.5">
19
+ <span class="sf-legend-dash inline-block h-3 w-4 border-t-2 border-dashed border-[#8b5cf6]"></span>jump (<code>transition_to</code>)
20
+ </span>
21
+ <% {active: "current", processed: "processed event", stalled: "stalled event", failed: "failed event", none: "untouched"}.each do |status, label| %>
22
+ <span class="inline-flex items-center gap-1.5">
23
+ <span class="h-2.5 w-2.5 rounded-sm border sf-legend-<%= status %>"></span><%= label %>
24
+ </span>
25
+ <% end %>
26
+ </div>
27
+ <p class="border-b border-[var(--sf-border)] px-4 py-2 text-[11px] text-[var(--sf-soft)]">
28
+ Jump edges are best-effort: they're detected by scanning handler source for a
29
+ literal <code>transition_to</code>, so a computed or conditional
30
+ transition may not appear here even though the saga can take it at runtime.
31
+ </p>
32
+ <%# The graph is passed as JSON in a data attribute, ERB-escaped, so labels
33
+ round-trip through the DOM untouched. %>
34
+ <div id="sf-graph" data-graph="<%= @graph.to_json %>" class="h-[68vh] w-full bg-[var(--sf-bg)]"></div>
35
+ <div id="sf-graph-detail" class="min-h-[3rem] border-t border-[var(--sf-border)] bg-[var(--sf-surface)] px-4 py-3 text-xs"></div>
36
+ </div>
37
+
38
+ <style>
39
+ .sf-legend-active{background:var(--sf-badge-indigo-bg);border-color:var(--sf-badge-indigo-border)}
40
+ .sf-legend-processed{background:var(--sf-badge-green-bg);border-color:var(--sf-badge-green-border)}
41
+ .sf-legend-stalled{background:var(--sf-badge-amber-bg);border-color:var(--sf-badge-amber-border)}
42
+ .sf-legend-failed{background:var(--sf-badge-red-bg);border-color:var(--sf-badge-red-border)}
43
+ .sf-legend-none{background:var(--sf-badge-slate-bg);border-color:var(--sf-badge-slate-border)}
44
+ </style>
45
+
46
+ <% %w[cytoscape.min.js dagre.min.js cytoscape-dagre.js saga_graph.js].each do |asset| %>
47
+ <script src="<%= "#{request.script_name}/assets/#{asset}?v=#{SagaForge::Dashboard.asset_digest(asset)}" %>"></script>
48
+ <% end %>
@@ -0,0 +1,4 @@
1
+ <%= link_to href, class: "sf-card block p-4 hover:border-[var(--sf-accent)]" do %>
2
+ <div class="text-xs uppercase tracking-wide text-[var(--sf-soft)]"><%= label %></div>
3
+ <div class="mt-1 font-mono text-2xl font-semibold tabular-nums <%= color %>"><%= number_with_delimiter(count) %></div>
4
+ <% end %>
@@ -0,0 +1,37 @@
1
+ <turbo-frame id="sf-overview-classes">
2
+ <div class="sf-card overflow-hidden">
3
+ <div class="overflow-x-auto">
4
+ <table class="w-full min-w-[40rem] text-sm">
5
+ <thead>
6
+ <tr class="border-b border-[var(--sf-border)] bg-[var(--sf-bg)] text-left text-xs uppercase tracking-wide text-[var(--sf-soft)]">
7
+ <th class="px-4 py-2.5 font-medium">Class</th>
8
+ <th class="px-4 py-2.5 text-right font-medium">Total</th>
9
+ <th class="px-4 py-2.5 font-medium">By state</th>
10
+ </tr>
11
+ </thead>
12
+ <tbody class="divide-y divide-[var(--sf-border)]">
13
+ <% @rows.sort_by { |klass, _| klass }.each do |klass, states| %>
14
+ <tr class="hover:bg-[var(--sf-bg)]">
15
+ <td class="px-4 py-2.5">
16
+ <%= link_to klass, sagas_path(class: klass), class: "font-mono text-[var(--sf-fg)] hover:underline" %>
17
+ </td>
18
+ <td class="px-4 py-2.5 text-right font-mono tabular-nums"><%= number_with_delimiter(states.values.sum) %></td>
19
+ <td class="px-4 py-2.5">
20
+ <div class="flex flex-wrap gap-1.5">
21
+ <% states.sort_by { |_, n| -n }.each do |state, n| %>
22
+ <%= link_to sagas_path(class: klass, filter: state), class: "sf-badge sf-badge-slate" do %>
23
+ <%= state %> <span class="opacity-70"><%= n %></span>
24
+ <% end %>
25
+ <% end %>
26
+ </div>
27
+ </td>
28
+ </tr>
29
+ <% end %>
30
+ </tbody>
31
+ </table>
32
+ </div>
33
+ <% if @rows.none? %>
34
+ <p class="px-4 py-12 text-center text-sm text-[var(--sf-soft)]">No sagas yet.</p>
35
+ <% end %>
36
+ </div>
37
+ </turbo-frame>
@@ -0,0 +1,33 @@
1
+ <div class="mb-5">
2
+ <h1 class="text-lg font-semibold tracking-tight">Overview</h1>
3
+ <p class="mt-1 text-sm text-[var(--sf-soft)]">Fleet-wide counts across every saga class, all time.</p>
4
+ </div>
5
+
6
+ <%# Cards are cheap independent COUNTs; the "Stalled"/"Suspended" cards link
7
+ straight to their dedicated triage pages. "Compensating" has no dedicated
8
+ fleet-wide page (sagas#index is scoped to one class), so it links into the
9
+ default class's list pre-filtered, a modest fallback until/unless a
10
+ fleet-wide compensating triage page exists. %>
11
+ <div class="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
12
+ <%= render "stat_card", label: "All", count: @totals[:all], href: sagas_path,
13
+ color: "text-[var(--sf-fg)]" %>
14
+ <%= render "stat_card", label: "Stalled", count: @totals[:stalled], href: stalled_index_path,
15
+ color: (@totals[:stalled] > 0) ? "text-[var(--sf-badge-amber-fg)]" : "text-[var(--sf-fg)]" %>
16
+ <%= render "stat_card", label: "Suspended", count: @totals[:suspended], href: suspended_index_path,
17
+ color: (@totals[:suspended] > 0) ? "text-[var(--sf-badge-red-fg)]" : "text-[var(--sf-fg)]" %>
18
+ <%= render "stat_card", label: "Compensating", count: @totals[:compensating], href: sagas_path(filter: "compensating"),
19
+ color: "text-[var(--sf-fg)]" %>
20
+ </div>
21
+
22
+ <%# The per-class GROUP BY, isolated in its own lazily-loaded frame so it
23
+ streams in on its own clock without holding up the cards above. %>
24
+ <turbo-frame id="sf-overview-classes" src="<%= overview_classes_path %>" loading="lazy" target="_top">
25
+ <div class="sf-card p-6">
26
+ <div class="h-4 w-40 animate-pulse rounded bg-[var(--sf-bg)]"></div>
27
+ <div class="mt-4 space-y-3">
28
+ <% 5.times do %>
29
+ <div class="h-3 w-full animate-pulse rounded bg-[var(--sf-bg)]"></div>
30
+ <% end %>
31
+ </div>
32
+ </div>
33
+ </turbo-frame>
@@ -0,0 +1,27 @@
1
+ <%# Operator actions for one saga instance. Retry stalled / Resume only make
2
+ sense when there's something eligible (@stalled / @suspended, set by
3
+ SagasController#show); Compensate / Cancel are shown whenever the saga
4
+ isn't already terminal or mid-compensation, the same "recovery blocked" line
5
+ the model itself uses to no-op, kept here only for button visibility
6
+ (the model is still the enforcement point; a stale button just flashes
7
+ "Nothing to do."). Both require confirmation since they're one-way. %>
8
+ <% recoverable = @state.finalized_at.nil? && @state.current_state != SagaForge::State::COMPENSATING.to_s %>
9
+ <% if @stalled || @suspended || recoverable %>
10
+ <div class="flex flex-wrap items-center gap-2">
11
+ <% if @stalled %>
12
+ <%= button_to "Retry stalled", retry_stalled_saga_path(@state), method: :post, class: "sf-btn sf-btn-primary" %>
13
+ <% end %>
14
+ <% if @suspended %>
15
+ <%= button_to "Resume", resume_saga_path(@state), method: :post, class: "sf-btn sf-btn-primary" %>
16
+ <% end %>
17
+ <% if recoverable %>
18
+ <%= button_to "Compensate", compensate_saga_path(@state), method: :post,
19
+ form: {data: {confirm: "Compensate #{@state.correlation_id}? This runs the saga's compensating steps and cannot be undone."}}, class: "sf-btn sf-btn-danger" %>
20
+ <%= form_with url: cancel_saga_path(@state), method: :post, class: "flex items-center gap-1",
21
+ data: {confirm: "Cancel #{@state.correlation_id}? This compensates the saga toward :cancelled and cannot be undone."} do |f| %>
22
+ <%= f.text_field :reason, placeholder: "reason (optional)", class: "w-36 rounded-md border border-[var(--sf-border)] bg-[var(--sf-surface)] px-2.5 py-1.5 text-xs placeholder:text-[var(--sf-soft)]" %>
23
+ <%= f.submit "Cancel", class: "sf-btn sf-btn-danger" %>
24
+ <% end %>
25
+ <% end %>
26
+ </div>
27
+ <% end %>
@@ -0,0 +1,41 @@
1
+ <% meta = @context.saga_meta %>
2
+ <div class="sf-card p-4">
3
+ <h2 class="mb-3 text-sm font-semibold">Compensation</h2>
4
+ <dl class="mb-3 flex flex-wrap gap-x-6 gap-y-1.5 text-sm">
5
+ <% if meta["target"].present? %>
6
+ <div>
7
+ <dt class="text-xs uppercase tracking-wide text-[var(--sf-soft)]">Target</dt>
8
+ <dd class="font-mono"><%= meta["target"] %></dd>
9
+ </div>
10
+ <% end %>
11
+ <% if meta["failure_reason"].present? %>
12
+ <div>
13
+ <dt class="text-xs uppercase tracking-wide text-[var(--sf-soft)]">Failure reason</dt>
14
+ <dd><%= meta["failure_reason"] %></dd>
15
+ </div>
16
+ <% end %>
17
+ </dl>
18
+
19
+ <% compensated = meta["compensated"] || [] %>
20
+ <% if compensated.any? %>
21
+ <p class="mb-1 text-xs uppercase tracking-wide text-[var(--sf-soft)]">Compensated steps (most recent first)</p>
22
+ <ol class="mb-3 flex flex-col gap-1 text-sm">
23
+ <% compensated.reverse.each do |name| %>
24
+ <li class="flex items-center gap-2 font-mono">
25
+ <%= status_badge("processed") %>
26
+ <span><%= name %></span>
27
+ <% attempts = meta.dig("comp_attempts", name) %>
28
+ <% if attempts.present? %><span class="text-xs text-[var(--sf-soft)]">(attempts: <%= attempts %>)</span><% end %>
29
+ </li>
30
+ <% end %>
31
+ </ol>
32
+ <% else %>
33
+ <p class="mb-3 text-sm text-[var(--sf-soft)]">No steps compensated yet.</p>
34
+ <% end %>
35
+
36
+ <% if (err = meta["comp_error"]) %>
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
+ <span class="font-mono"><%= err["name"] %></span> failed to compensate: <strong><%= err["class"] %></strong><%= ": #{err["message"]}" if err["message"].present? %>
39
+ </div>
40
+ <% end %>
41
+ </div>
@@ -0,0 +1,37 @@
1
+ <div class="sf-card overflow-hidden">
2
+ <h2 class="border-b border-[var(--sf-border)] px-4 py-2.5 text-sm font-semibold">Context</h2>
3
+
4
+ <div class="overflow-x-auto">
5
+ <table class="w-full text-sm">
6
+ <thead>
7
+ <tr class="border-b border-[var(--sf-border)] bg-[var(--sf-bg)] text-left text-xs uppercase tracking-wide text-[var(--sf-soft)]">
8
+ <th class="px-4 py-2 font-medium">Key</th>
9
+ <th class="px-4 py-2 font-medium">Type</th>
10
+ <th class="px-4 py-2 font-medium">Size</th>
11
+ <th class="px-4 py-2 font-medium">Preview</th>
12
+ </tr>
13
+ </thead>
14
+ <tbody class="divide-y divide-[var(--sf-border)]">
15
+ <% user_nodes = @context.user_nodes %>
16
+ <% user_nodes.each do |node| %>
17
+ <tr>
18
+ <td class="px-4 py-2 font-mono"><%= node.key %></td>
19
+ <td class="px-4 py-2"><span class="sf-badge sf-badge-slate"><%= node.type %></span></td>
20
+ <td class="px-4 py-2 font-mono text-xs text-[var(--sf-soft)]"><%= node.bytes ? format_bytes(node.bytes) : "n/a" %></td>
21
+ <td class="px-4 py-2 font-mono text-xs text-[var(--sf-soft)]"><%= node.preview %></td>
22
+ </tr>
23
+ <% end %>
24
+ </tbody>
25
+ </table>
26
+ <% if user_nodes.empty? %>
27
+ <p class="px-4 py-8 text-center text-sm text-[var(--sf-soft)]">No user context keys.</p>
28
+ <% end %>
29
+ </div>
30
+
31
+ <% if @context.saga_meta.present? %>
32
+ <details class="border-t border-[var(--sf-border)] px-4 py-2.5 text-sm">
33
+ <summary class="cursor-pointer select-none text-xs uppercase tracking-wide text-[var(--sf-soft)]">Engine metadata (__saga_forge)</summary>
34
+ <pre class="mt-2 overflow-x-auto whitespace-pre-wrap font-mono text-xs"><%= JSON.pretty_generate(@context.saga_meta) %></pre>
35
+ </details>
36
+ <% end %>
37
+ </div>
@@ -0,0 +1,9 @@
1
+ <%# enforce_utf8: false keeps the legacy `utf8=✓` IE hack out of the GET query string. %>
2
+ <%= form_with url: sagas_path, method: :get, enforce_utf8: false, class: "mb-4 flex flex-wrap items-center gap-2" do |f| %>
3
+ <%= hidden_field_tag :class, @saga_class.name %>
4
+ <% if params[:filter].present? %><%= hidden_field_tag :filter, params[:filter] %><% end %>
5
+ <%# A poll-morph "preserve this field's value across refreshes" hook isn't wired
6
+ up yet, but the stable field id keeps a target for that future JS. %>
7
+ <%= f.text_field :q, value: params[:q], placeholder: "Correlation ID prefix…", id: "sf-filter-q", class: "rounded-md border border-[var(--sf-border)] bg-[var(--sf-surface)] px-2.5 py-1.5 text-sm placeholder:text-[var(--sf-soft)]" %>
8
+ <%= f.submit "Search", class: "sf-btn" %>
9
+ <% end %>