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,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 %>
|