chrono_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 +21 -0
- data/MIT-LICENSE +21 -0
- data/README.md +147 -0
- data/app/assets/chrono_forge/dashboard/dashboard.css +2 -0
- data/app/assets/chrono_forge/dashboard/dashboard.js +89 -0
- data/app/assets/chrono_forge/dashboard/tailwind.css +69 -0
- data/app/controllers/chrono_forge/dashboard/actions_controller.rb +58 -0
- data/app/controllers/chrono_forge/dashboard/analytics_controller.rb +23 -0
- data/app/controllers/chrono_forge/dashboard/assets_controller.rb +21 -0
- data/app/controllers/chrono_forge/dashboard/base_controller.rb +29 -0
- data/app/controllers/chrono_forge/dashboard/branch_children_controller.rb +33 -0
- data/app/controllers/chrono_forge/dashboard/repetitions_controller.rb +20 -0
- data/app/controllers/chrono_forge/dashboard/wait_states_controller.rb +38 -0
- data/app/controllers/chrono_forge/dashboard/workflows_controller.rb +31 -0
- data/app/helpers/chrono_forge/dashboard/dashboard_helper.rb +153 -0
- data/app/presenters/chrono_forge/dashboard/branch_presenter.rb +64 -0
- data/app/presenters/chrono_forge/dashboard/branches_presenter.rb +62 -0
- data/app/presenters/chrono_forge/dashboard/context_presenter.rb +17 -0
- data/app/presenters/chrono_forge/dashboard/periodic_health_presenter.rb +77 -0
- data/app/presenters/chrono_forge/dashboard/timeline_presenter.rb +90 -0
- data/app/presenters/chrono_forge/dashboard/wait_state_presenter.rb +64 -0
- data/app/queries/chrono_forge/dashboard/analytics_query.rb +133 -0
- data/app/queries/chrono_forge/dashboard/repetitions_query.rb +110 -0
- data/app/queries/chrono_forge/dashboard/stats_query.rb +30 -0
- data/app/queries/chrono_forge/dashboard/workflows_query.rb +90 -0
- data/app/views/chrono_forge/dashboard/analytics/index.html.erb +103 -0
- data/app/views/chrono_forge/dashboard/branch_children/show.html.erb +58 -0
- data/app/views/chrono_forge/dashboard/repetitions/index.html.erb +69 -0
- data/app/views/chrono_forge/dashboard/wait_states/index.html.erb +73 -0
- data/app/views/chrono_forge/dashboard/workflows/_branches.html.erb +57 -0
- data/app/views/chrono_forge/dashboard/workflows/_context_tree.html.erb +16 -0
- data/app/views/chrono_forge/dashboard/workflows/_error_card.html.erb +13 -0
- data/app/views/chrono_forge/dashboard/workflows/_filters.html.erb +6 -0
- data/app/views/chrono_forge/dashboard/workflows/_parent_breadcrumb.html.erb +9 -0
- data/app/views/chrono_forge/dashboard/workflows/_periodic.html.erb +24 -0
- data/app/views/chrono_forge/dashboard/workflows/_stats.html.erb +14 -0
- data/app/views/chrono_forge/dashboard/workflows/_timeline.html.erb +67 -0
- data/app/views/chrono_forge/dashboard/workflows/_wait_callout.html.erb +5 -0
- data/app/views/chrono_forge/dashboard/workflows/_workflow_row.html.erb +8 -0
- data/app/views/chrono_forge/dashboard/workflows/index.html.erb +39 -0
- data/app/views/chrono_forge/dashboard/workflows/show.html.erb +79 -0
- data/app/views/layouts/chrono_forge/dashboard/application.html.erb +50 -0
- data/config/routes.rb +20 -0
- data/lib/chrono_forge/dashboard/configuration.rb +32 -0
- data/lib/chrono_forge/dashboard/engine.rb +9 -0
- data/lib/chrono_forge/dashboard/step_name_parser.rb +32 -0
- data/lib/chrono_forge/dashboard/version.rb +5 -0
- data/lib/chrono_forge/dashboard.rb +30 -0
- metadata +237 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
class WorkflowsController < BaseController
|
|
4
|
+
def index
|
|
5
|
+
@query = WorkflowsQuery.new(**list_params)
|
|
6
|
+
@workflows = @query.records
|
|
7
|
+
@waits = WaitStatePresenter.active_map(@workflows)
|
|
8
|
+
stats = StatsQuery.new
|
|
9
|
+
@stats = stats.counts
|
|
10
|
+
@stats_cap = stats.cap
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def show
|
|
14
|
+
@workflow = ChronoForge::Workflow.find(params[:id])
|
|
15
|
+
@timeline = TimelinePresenter.new(@workflow)
|
|
16
|
+
@context = ContextPresenter.new(@workflow)
|
|
17
|
+
@wait = WaitStatePresenter.new(@workflow).active
|
|
18
|
+
@periodic = PeriodicHealthPresenter.new(@workflow).tasks
|
|
19
|
+
@branches = BranchesPresenter.new(@workflow)
|
|
20
|
+
@parent_log = @workflow.parent_execution_log
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def list_params
|
|
26
|
+
params.permit(:state, :job_class, :key, :created_from, :created_to, :before, :after)
|
|
27
|
+
.to_h.symbolize_keys.merge(per: ChronoForge::Dashboard.config.page_size)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
module DashboardHelper
|
|
4
|
+
# Display order for state counts: active work first, terminal last. Any
|
|
5
|
+
# unknown states are appended so a new core state never silently vanishes.
|
|
6
|
+
STATE_ORDER = %w[running idle stalled failed completed].freeze
|
|
7
|
+
|
|
8
|
+
def cf_state_order(keys)
|
|
9
|
+
(STATE_ORDER & keys) + (keys - STATE_ORDER)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def cf_badge(state)
|
|
13
|
+
tag.span(state, class: "cf-pill cf-pill-#{state}")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Shared "chip" treatment for inline nav/action links (metrics, details,
|
|
17
|
+
# repetitions, open, pagination, back) — a subtle bordered button, never an
|
|
18
|
+
# underlined text link. Pass extra utility classes (margins, truncation).
|
|
19
|
+
def cf_chip(extra = nil)
|
|
20
|
+
["inline-flex items-center rounded-md border border-zinc-200 px-2 py-0.5 text-xs text-zinc-600 hover:bg-zinc-50", extra].compact.join(" ")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# State badge, upgraded to "scheduled" for an idle workflow parked on a
|
|
24
|
+
# wait whose wake time is still in the future — so genuinely-scheduled work
|
|
25
|
+
# doesn't read as "stuck idle".
|
|
26
|
+
def cf_state_badge(workflow, wait = nil)
|
|
27
|
+
return cf_badge("scheduled") if workflow.idle? && wait&.scheduled?
|
|
28
|
+
cf_badge(workflow.state)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cf_dot(state)
|
|
32
|
+
tag.span(class: "cf-dot cf-dot-#{state}")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def cf_time(t)
|
|
36
|
+
t&.iso8601 || "—"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A capped count: shows "5000+" once the count saturates its cap.
|
|
40
|
+
def cf_capped(count, cap)
|
|
41
|
+
(count >= cap) ? "#{cap}+" : count.to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Whether the viewer prefers absolute timestamps (cookie-persisted nav toggle).
|
|
45
|
+
def cf_absolute_time?
|
|
46
|
+
cookies[:cf_time_format] == "absolute"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Auto-refresh interval in seconds (0 = off). A cookie-persisted nav control
|
|
50
|
+
# overrides the configured default per viewer; options come from config.
|
|
51
|
+
def cf_poll_options = ChronoForge::Dashboard.config.polling_interval_options
|
|
52
|
+
|
|
53
|
+
def cf_poll_interval
|
|
54
|
+
raw = cookies[:cf_poll_interval]
|
|
55
|
+
return raw.to_i if raw.present? && raw.match?(/\A\d+\z/)
|
|
56
|
+
ChronoForge::Dashboard.config.polling_interval.to_i
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def cf_poll_label(secs)
|
|
60
|
+
return "off" if secs.zero?
|
|
61
|
+
(secs % 60 == 0) ? "#{secs / 60}m" : "#{secs}s"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A timestamp shown relative ("3 minutes ago") or absolute (raw ISO8601)
|
|
65
|
+
# per the viewer's preference, with the other form available on hover.
|
|
66
|
+
def cf_ago(t)
|
|
67
|
+
return "—" unless t
|
|
68
|
+
rel = "#{time_ago_in_words(t)} ago"
|
|
69
|
+
abs = t.iso8601
|
|
70
|
+
shown, hover = cf_absolute_time? ? [abs, rel] : [rel, abs]
|
|
71
|
+
tag.span(shown, title: hover, class: "cursor-help")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Human duration between two times (e.g. "1m 04s"); "—" if unfinished.
|
|
75
|
+
def cf_duration(from, to)
|
|
76
|
+
return "—" unless from && to
|
|
77
|
+
cf_secs((to - from).to_i)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Human duration from a number of seconds, scaled to the two most-significant
|
|
81
|
+
# units (e.g. "45s", "1m 04s", "3h 12m", "2d 21h"); "—" if nil.
|
|
82
|
+
def cf_secs(secs)
|
|
83
|
+
return "—" if secs.nil?
|
|
84
|
+
secs = secs.to_i
|
|
85
|
+
return "#{secs}s" if secs < 60
|
|
86
|
+
return "#{secs / 60}m #{(secs % 60).to_s.rjust(2, "0")}s" if secs < 3600
|
|
87
|
+
return "#{secs / 3600}h #{(secs % 3600 / 60).to_s.rjust(2, "0")}m" if secs < 86400
|
|
88
|
+
"#{secs / 86400}d #{(secs % 86400 / 3600).to_s.rjust(2, "0")}h"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Class name for a stacked-bar segment, width quantized to 5% steps so it
|
|
92
|
+
# stays CSP-safe (no inline style — see .cf-bar-{0..100} in tailwind.css).
|
|
93
|
+
def cf_bar_width(value, max)
|
|
94
|
+
pct = (max.to_f.zero? ? 0 : (value / max.to_f * 100))
|
|
95
|
+
"cf-bar-#{(pct / 5).round * 5}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A rate (0.0–1.0) as a percentage; "—" if nil. Keeps tiny non-zero rates
|
|
99
|
+
# visible (a 0.0008% workflow-failure rate shows "<0.01%", never "0%").
|
|
100
|
+
def cf_pct(rate)
|
|
101
|
+
return "—" if rate.nil?
|
|
102
|
+
pct = rate * 100
|
|
103
|
+
return "0%" if pct.zero?
|
|
104
|
+
return "<0.01%" if pct < 0.01
|
|
105
|
+
(pct < 1) ? "#{pct.round(2)}%" : "#{pct.round}%"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Concise latency summary (avg + most recent) from a list of run seconds.
|
|
109
|
+
def cf_latency_summary(latencies)
|
|
110
|
+
return "—" if latencies.blank?
|
|
111
|
+
avg = (latencies.sum.to_f / latencies.size).round
|
|
112
|
+
"avg #{avg}s · last #{latencies.last}s"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Short, readable label for a parsed step kind.
|
|
116
|
+
KIND_LABELS = {
|
|
117
|
+
execute: "execute", sleep: "wait", wait: "wait until", continue: "continue if",
|
|
118
|
+
repeat_coordination: "repeat", repeat_run: "run", lifecycle: "workflow",
|
|
119
|
+
branch: "branch", merge: "merge", unknown: "step"
|
|
120
|
+
}.freeze
|
|
121
|
+
|
|
122
|
+
def cf_kind_label(kind)
|
|
123
|
+
KIND_LABELS.fetch(kind, kind.to_s)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Human-friendly [label, value] pairs of a step's metadata for the timeline
|
|
127
|
+
# — surfaces things like a wait's resume time, a wait_until timeout, or a
|
|
128
|
+
# durably_repeat's last execution. Keys are humanized; values are stringified
|
|
129
|
+
# (the view truncates). Blank values are dropped.
|
|
130
|
+
# Internal bookkeeping surfaced elsewhere (the linked error is rendered
|
|
131
|
+
# inline; branch poll state + spawn cursors show in the Branches panel), so
|
|
132
|
+
# they'd just be noise in the timeline's metadata line. poll_token is the
|
|
133
|
+
# merge poller's fencing token — pure plumbing, never user-facing.
|
|
134
|
+
META_SKIP = %w[error_log_id poll poll_token cursors].freeze
|
|
135
|
+
|
|
136
|
+
def cf_meta_pairs(metadata)
|
|
137
|
+
return [] unless metadata.is_a?(Hash)
|
|
138
|
+
metadata
|
|
139
|
+
.reject { |k, v| v.nil? || v == "" || META_SKIP.include?(k.to_s) }
|
|
140
|
+
.map { |k, v| [k.to_s.tr("_", " "), v.to_s] }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Text color for an execution-log status (pending/completed/failed).
|
|
144
|
+
def cf_status_color(status)
|
|
145
|
+
case status
|
|
146
|
+
when "completed" then "text-emerald-600"
|
|
147
|
+
when "failed" then "text-rose-600"
|
|
148
|
+
else "text-zinc-500"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
# Health of a single branch (a branch$<name> execution log) for the parent's
|
|
4
|
+
# detail page. Every child count is CAPPED and index-only on
|
|
5
|
+
# (parent_execution_log_id, state) — a branch can hold hundreds of thousands
|
|
6
|
+
# of children, so we never count the full set, only up to CAP (shown "CAP+").
|
|
7
|
+
class BranchPresenter
|
|
8
|
+
CAP = 5000
|
|
9
|
+
|
|
10
|
+
# merge_state: :merged | :merging | nil (not yet merged)
|
|
11
|
+
def initialize(log, merge_state = nil)
|
|
12
|
+
@log = log
|
|
13
|
+
@merge_state = merge_state
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
attr_reader :log, :merge_state
|
|
17
|
+
|
|
18
|
+
def name = StepNameParser.parse(@log.step_name).name
|
|
19
|
+
|
|
20
|
+
# The branch is "sealed" once its block closed (done dispatching children).
|
|
21
|
+
def sealed? = @log.completed?
|
|
22
|
+
|
|
23
|
+
def dispatched = capped(children)
|
|
24
|
+
def pending = capped(children.where.not(state: ChronoForge::Workflow.states[:completed]))
|
|
25
|
+
def blocked = capped(children.where(state: BLOCKED_STATES))
|
|
26
|
+
|
|
27
|
+
def cap = CAP
|
|
28
|
+
|
|
29
|
+
BLOCKED_STATES = %i[failed stalled].map { |s| ChronoForge::Workflow.states[s] }.freeze
|
|
30
|
+
|
|
31
|
+
# A scheduled next poll this far past due means the BranchMergeJob poller
|
|
32
|
+
# likely never ran (queue latency aside) — a heuristic, hence "potential".
|
|
33
|
+
POLL_OVERDUE_GRACE = 120 # seconds
|
|
34
|
+
|
|
35
|
+
# The BranchMergeJob stamps its poll state onto the branch log's metadata
|
|
36
|
+
# (it can't be queried from the backend; ActiveJob has no such API).
|
|
37
|
+
def polled? = poll.present?
|
|
38
|
+
def last_polled_at = parse_time(poll&.dig("last_polled_at"))
|
|
39
|
+
def next_poll_at = parse_time(poll&.dig("next_poll_at"))
|
|
40
|
+
def polls = poll&.dig("polls").to_i
|
|
41
|
+
|
|
42
|
+
# next_poll_at is nil once the merge completes, so a finished merge never
|
|
43
|
+
# looks overdue; a non-nil time well in the past = the poller is likely dead.
|
|
44
|
+
def poll_overdue?
|
|
45
|
+
t = next_poll_at
|
|
46
|
+
t.present? && t < Time.current - POLL_OVERDUE_GRACE
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def children = @log.spawned_workflows
|
|
52
|
+
|
|
53
|
+
def poll = @log.metadata&.dig("poll")
|
|
54
|
+
|
|
55
|
+
def parse_time(value) = value.present? ? Time.zone.parse(value.to_s) : nil
|
|
56
|
+
|
|
57
|
+
# Index-only COUNT over a LIMIT CAP subquery — O(CAP) regardless of how many
|
|
58
|
+
# children match (mirrors StatsQuery).
|
|
59
|
+
def capped(relation)
|
|
60
|
+
ChronoForge::Workflow.from(relation.reorder(nil).select(:id).limit(CAP), :capped).count
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
# A workflow's branches for its detail page. Loads only the coordination logs
|
|
4
|
+
# (branch$<name> and merge$<names>) — a tiny set — and derives each branch's
|
|
5
|
+
# merge state from the merge logs (the core doesn't persist a "merged" flag).
|
|
6
|
+
class BranchesPresenter
|
|
7
|
+
# One merge join (a merge$<names> log = a BranchMergeJob's durable target).
|
|
8
|
+
# state: :merging (pending — a poller is joining) | :merged (completed).
|
|
9
|
+
Merge = Struct.new(:names, :state, :started_at) do
|
|
10
|
+
def merging? = state == :merging
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(workflow) = @workflow = workflow
|
|
14
|
+
|
|
15
|
+
def any? = branch_logs.any?
|
|
16
|
+
|
|
17
|
+
def branches
|
|
18
|
+
@branches ||= branch_logs
|
|
19
|
+
.sort_by(&:step_name)
|
|
20
|
+
.map { |log| BranchPresenter.new(log, merge_states[StepNameParser.parse(log.step_name).name]) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The merge joins on this workflow, in-progress first. A long-pending merge
|
|
24
|
+
# with no blocked children is the sign of a dropped BranchMergeJob poller.
|
|
25
|
+
def merges
|
|
26
|
+
@merges ||= merge_logs
|
|
27
|
+
.map { |log| Merge.new(StepNameParser.parse(log.step_name).name.split(","), log.completed? ? :merged : :merging, log.started_at) }
|
|
28
|
+
.sort_by { |m| [m.merging? ? 0 : 1, m.started_at || Time.current] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def coordination_logs
|
|
34
|
+
d = StepNameParser::DELIM
|
|
35
|
+
@coordination_logs ||= @workflow.execution_logs
|
|
36
|
+
.where("step_name LIKE ? OR step_name LIKE ?", "branch#{d}%", "merge#{d}%")
|
|
37
|
+
.to_a
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def branch_logs
|
|
41
|
+
coordination_logs.select { |l| StepNameParser.parse(l.step_name).kind == :branch }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def merge_logs
|
|
45
|
+
coordination_logs.select { |l| StepNameParser.parse(l.step_name).kind == :merge }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# branch name => :merged (merge log completed) | :merging (pending). A merge
|
|
49
|
+
# log covers one or more comma-joined branch names; "merged" wins if a name
|
|
50
|
+
# appears in both a completed and a pending merge.
|
|
51
|
+
def merge_states
|
|
52
|
+
@merge_states ||= merge_logs
|
|
53
|
+
.each_with_object({}) do |log, map|
|
|
54
|
+
state = log.completed? ? :merged : :merging
|
|
55
|
+
StepNameParser.parse(log.step_name).name.split(",").each do |nm|
|
|
56
|
+
map[nm] = state unless map[nm] == :merged
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
class ContextPresenter
|
|
4
|
+
def initialize(workflow) = @workflow = workflow
|
|
5
|
+
|
|
6
|
+
def nodes
|
|
7
|
+
context.map { |k, v| {key: k, value: v, type: v.class.name, bytes: v.to_json.bytesize} }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def byte_size = context.to_json.bytesize
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def context = @workflow.context || {}
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
# Health of a workflow's durably_repeat tasks. Never materializes the full run
|
|
4
|
+
# history (which can be huge) — coordination logs are a tiny set, and each
|
|
5
|
+
# task's run aggregates are computed with bounded/scoped queries that ride the
|
|
6
|
+
# [workflow_id, step_name] index as range scans.
|
|
7
|
+
class PeriodicHealthPresenter
|
|
8
|
+
Task = Struct.new(:name, :last_execution_at, :next_scheduled_at, :timed_out_count, :latencies)
|
|
9
|
+
|
|
10
|
+
RECENT = 20
|
|
11
|
+
# Bound the metadata scan used to count missed ticks (see #missed_ticks).
|
|
12
|
+
SCAN_CAP = 1_000
|
|
13
|
+
|
|
14
|
+
def initialize(workflow) = @workflow = workflow
|
|
15
|
+
|
|
16
|
+
def tasks
|
|
17
|
+
coordinations.map do |coord|
|
|
18
|
+
name = StepNameParser.parse(coord.step_name).name
|
|
19
|
+
Task.new(
|
|
20
|
+
name: name,
|
|
21
|
+
last_execution_at: parse_time(coord.metadata&.dig("last_execution_at")),
|
|
22
|
+
next_scheduled_at: next_scheduled(name),
|
|
23
|
+
timed_out_count: missed_ticks(name),
|
|
24
|
+
latencies: recent_latencies(name)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# The coordination logs (durably_repeat$name, no $ts suffix) — one per task.
|
|
32
|
+
def coordinations
|
|
33
|
+
@workflow.execution_logs
|
|
34
|
+
.where("step_name LIKE ?", "durably_repeat#{d}%")
|
|
35
|
+
.where.not("step_name LIKE ?", "durably_repeat#{d}%#{d}%")
|
|
36
|
+
.to_a
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def runs(name)
|
|
40
|
+
@workflow.execution_logs.where("step_name LIKE ?", "durably_repeat#{d}#{name}#{d}%")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Missed (timed-out) ticks. A fast-forward catch-up collapses N expired ticks
|
|
44
|
+
# into one TimeoutError row tagged fast_forwarded:N, so count it as N; a plain
|
|
45
|
+
# per-tick timeout counts as 1. Bounded metadata scan.
|
|
46
|
+
def missed_ticks(name)
|
|
47
|
+
runs(name).where(error_class: "TimeoutError")
|
|
48
|
+
.limit(SCAN_CAP).pluck(:metadata)
|
|
49
|
+
.sum { |m| [m&.dig("fast_forwarded").to_i, 1].max }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Next run = the furthest-out not-yet-completed scheduled repetition. Pending
|
|
53
|
+
# runs are few (the future-scheduled ones), so loading them is bounded.
|
|
54
|
+
def next_scheduled(name)
|
|
55
|
+
ts = runs(name).where(state: ChronoForge::ExecutionLog.states[:pending])
|
|
56
|
+
.filter_map { |r| StepNameParser.parse(r.step_name).timestamp }.max
|
|
57
|
+
Time.zone.at(ts) if ts
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Durations (seconds) of the most recent completed runs, oldest-first so the
|
|
61
|
+
# summary's "last" is the newest. Bounded to RECENT rows.
|
|
62
|
+
def recent_latencies(name)
|
|
63
|
+
runs(name).where(state: ChronoForge::ExecutionLog.states[:completed])
|
|
64
|
+
.where.not(started_at: nil, completed_at: nil)
|
|
65
|
+
.order(id: :desc).limit(RECENT)
|
|
66
|
+
.map { |r| (r.completed_at - r.started_at).to_i }.reverse
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_time(value)
|
|
70
|
+
return nil if value.blank?
|
|
71
|
+
value.is_a?(Time) ? value : Time.zone.parse(value.to_s)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def d = StepNameParser::DELIM
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
class TimelinePresenter
|
|
4
|
+
Entry = Struct.new(:id, :kind, :name, :step_name, :status, :attempts,
|
|
5
|
+
:started_at, :completed_at, :last_executed_at, :error_class, :error_message,
|
|
6
|
+
:metadata, :errors, :missing_error_id, :iterations, :tombstones, :skipped_ticks, :last_run_at)
|
|
7
|
+
|
|
8
|
+
# Per-iteration run logs of a durably_repeat step are excluded from the
|
|
9
|
+
# timeline (they get their own paginated page) and summarized instead.
|
|
10
|
+
RUN_PATTERN = "durably_repeat#{StepNameParser::DELIM}%#{StepNameParser::DELIM}%".freeze
|
|
11
|
+
|
|
12
|
+
def initialize(workflow) = @workflow = workflow
|
|
13
|
+
|
|
14
|
+
attr_reader :workflow
|
|
15
|
+
|
|
16
|
+
def entries
|
|
17
|
+
@entries ||= build
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Error logs not shown on any step — workflow-level failures whose step_name
|
|
21
|
+
# is nil and that aren't linked to a $workflow_failure$ marker. Surfaced so
|
|
22
|
+
# a failure is never invisible. (Repeat-run errors live on the repetitions
|
|
23
|
+
# page, so they're excluded.)
|
|
24
|
+
def orphan_errors
|
|
25
|
+
entries
|
|
26
|
+
@orphan_errors
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def current_position
|
|
30
|
+
logs = ordered_logs
|
|
31
|
+
logs.reverse.find { |l| l.failed? } ||
|
|
32
|
+
logs.reverse.find { |l| l.pending? && StepNameParser.parse(l.step_name).kind == :wait } ||
|
|
33
|
+
logs.last
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def ordered_logs
|
|
39
|
+
@ordered_logs ||= @workflow.execution_logs
|
|
40
|
+
.where.not("step_name LIKE ?", RUN_PATTERN)
|
|
41
|
+
.order(Arel.sql("started_at, id")).to_a
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build
|
|
45
|
+
all_errors = @workflow.error_logs.order(:attempt, :created_at).to_a
|
|
46
|
+
by_step = all_errors.group_by(&:step_name)
|
|
47
|
+
by_id = all_errors.index_by(&:id)
|
|
48
|
+
shown = []
|
|
49
|
+
|
|
50
|
+
entries = ordered_logs.map do |l|
|
|
51
|
+
p = StepNameParser.parse(l.step_name)
|
|
52
|
+
errors = (by_step[l.step_name] || []).dup
|
|
53
|
+
# A workflow-level failure ($workflow_failure$<id>) records its error
|
|
54
|
+
# with a nil step_name, so attach it to the marker by id. If that error
|
|
55
|
+
# log is gone (independently pruned), note the id so the marker still
|
|
56
|
+
# says *something* rather than rendering an errorless failure.
|
|
57
|
+
missing_error_id = nil
|
|
58
|
+
if p.kind == :lifecycle && p.name == "failure" && p.timestamp
|
|
59
|
+
if (err = by_id[p.timestamp])
|
|
60
|
+
errors << err unless errors.include?(err)
|
|
61
|
+
else
|
|
62
|
+
missing_error_id = p.timestamp
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
shown.concat(errors)
|
|
66
|
+
entry = Entry.new(id: l.id, kind: p.kind, name: p.name, step_name: l.step_name,
|
|
67
|
+
status: l.state, attempts: l.attempts, started_at: l.started_at,
|
|
68
|
+
completed_at: l.completed_at, last_executed_at: l.last_executed_at,
|
|
69
|
+
error_class: l.error_class, error_message: l.error_message,
|
|
70
|
+
metadata: l.metadata, errors: errors, missing_error_id: missing_error_id)
|
|
71
|
+
summarize_repetitions(entry, p.name) if p.kind == :repeat_coordination
|
|
72
|
+
entry
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
@orphan_errors = (all_errors - shown).reject { |e| e.step_name.to_s.match?(RUN_PATTERN_RX) }
|
|
76
|
+
entries
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
RUN_PATTERN_RX = /\Adurably_repeat#{Regexp.escape(StepNameParser::DELIM)}.+#{Regexp.escape(StepNameParser::DELIM)}/
|
|
80
|
+
|
|
81
|
+
def summarize_repetitions(entry, name)
|
|
82
|
+
s = RepetitionsQuery.new(workflow: @workflow, step: name).summary
|
|
83
|
+
entry.iterations = s[:iterations]
|
|
84
|
+
entry.tombstones = s[:tombstones]
|
|
85
|
+
entry.skipped_ticks = s[:skipped_ticks]
|
|
86
|
+
entry.last_run_at = s[:last_run_at]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
class WaitStatePresenter
|
|
4
|
+
# kind: :wait (wait_until — polls, has a timeout) or :continue (continue_if
|
|
5
|
+
# — waits on an external event, NO timeout, never self-resumes). A stuck
|
|
6
|
+
# continue_if is the silent killer: a webhook that never arrives leaves the
|
|
7
|
+
# workflow parked forever with nothing to flag it.
|
|
8
|
+
Active = Struct.new(:kind, :condition, :waiting_since, :timeout_at) do
|
|
9
|
+
# Only a time-based wait with a wake time still in the future is
|
|
10
|
+
# "scheduled" (intentionally parked until then). Event waits never are.
|
|
11
|
+
def scheduled?
|
|
12
|
+
return false unless kind == :wait && timeout_at
|
|
13
|
+
t = timeout_at.is_a?(Time) ? timeout_at : Time.zone.parse(timeout_at.to_s)
|
|
14
|
+
t&.future? || false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def event_wait? = kind == :continue
|
|
18
|
+
|
|
19
|
+
# The scheduled wake time as a Time, or nil for event waits / no timeout.
|
|
20
|
+
def next_run_at
|
|
21
|
+
return nil unless kind == :wait && timeout_at
|
|
22
|
+
timeout_at.is_a?(Time) ? timeout_at : Time.zone.parse(timeout_at.to_s)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(workflow) = @workflow = workflow
|
|
27
|
+
|
|
28
|
+
# Reuses the batch resolver so a single workflow and a page of them agree:
|
|
29
|
+
# it looks at the latest *pending wait/continue* log, ignoring durably_repeat
|
|
30
|
+
# run logs (which stamp started_at = now and would otherwise mask the wait).
|
|
31
|
+
def active
|
|
32
|
+
self.class.active_map([@workflow])[@workflow.id]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Active waits for a batch of workflows, in two queries instead of one per
|
|
36
|
+
# row. Returns {workflow_id => Active} for idle workflows currently parked
|
|
37
|
+
# on a pending wait_until or continue_if. Bounded by the caller's set.
|
|
38
|
+
def self.active_map(workflows)
|
|
39
|
+
ids = workflows.select(&:idle?).map(&:id)
|
|
40
|
+
return {} if ids.empty?
|
|
41
|
+
|
|
42
|
+
d = StepNameParser::DELIM
|
|
43
|
+
latest = {}
|
|
44
|
+
ChronoForge::ExecutionLog
|
|
45
|
+
.where(workflow_id: ids, state: ChronoForge::ExecutionLog.states[:pending])
|
|
46
|
+
.where("step_name LIKE ? OR step_name LIKE ?", "wait_until#{d}%", "continue_if#{d}%")
|
|
47
|
+
.order(Arel.sql("started_at, id"))
|
|
48
|
+
.each { |log| latest[log.workflow_id] = log }
|
|
49
|
+
|
|
50
|
+
latest.transform_values { |log| build(log) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.build(log)
|
|
54
|
+
p = StepNameParser.parse(log.step_name)
|
|
55
|
+
Active.new(
|
|
56
|
+
kind: p.kind,
|
|
57
|
+
condition: p.name,
|
|
58
|
+
waiting_since: log.last_executed_at || log.started_at,
|
|
59
|
+
timeout_at: log.metadata&.dig("timeout_at")
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|