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,133 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
# Time-bucketed completion/failure/duration metrics over a window.
|
|
4
|
+
#
|
|
5
|
+
# The aggregation runs in the database (GROUP BY a per-day bucket), so it
|
|
6
|
+
# returns one row per day regardless of how many workflows match — it never
|
|
7
|
+
# loads workflow rows into Ruby. The bucket and duration expressions are
|
|
8
|
+
# adapter-specific (SQLite / PostgreSQL / MySQL), chosen once per query.
|
|
9
|
+
#
|
|
10
|
+
# Scale note: completed workflows are bucketed (and windowed) by
|
|
11
|
+
# `completed_at`, which is the leading-range column of the existing
|
|
12
|
+
# `[state, completed_at]` index, so the heavy path (millions of completed
|
|
13
|
+
# rows) is an index range scan. Failed workflows have no `completed_at`, so
|
|
14
|
+
# they are bucketed by `updated_at` (when they reached the failed state) —
|
|
15
|
+
# a tiny set in practice. The two terminal axes are merged per day.
|
|
16
|
+
#
|
|
17
|
+
# Rates here are WORKFLOW-level, not execution-log level: a high count of
|
|
18
|
+
# failed *execution logs* is normal durably_repeat catch-up churn, whereas a
|
|
19
|
+
# failed *workflow* is a real incident. This query only ever counts
|
|
20
|
+
# workflows.
|
|
21
|
+
class AnalyticsQuery
|
|
22
|
+
WINDOWS = {"24h" => 1.day, "7d" => 7.days, "30d" => 30.days}.freeze
|
|
23
|
+
DEFAULT_WINDOW = "7d"
|
|
24
|
+
|
|
25
|
+
Bucket = Struct.new(:day, :completed, :failed, :avg_duration) do
|
|
26
|
+
def terminal = completed + failed
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(window: DEFAULT_WINDOW, job_class: nil, now: Time.current)
|
|
30
|
+
@window = WINDOWS.key?(window.presence) ? window : DEFAULT_WINDOW
|
|
31
|
+
@job_class = job_class.presence
|
|
32
|
+
@now = now
|
|
33
|
+
@since = now - WINDOWS.fetch(@window)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :window, :since, :job_class
|
|
37
|
+
|
|
38
|
+
def windows = WINDOWS.keys
|
|
39
|
+
|
|
40
|
+
# Per-day buckets within the window, oldest first.
|
|
41
|
+
def buckets = data[:buckets]
|
|
42
|
+
|
|
43
|
+
# Roll-ups over the whole window: counts, workflow-level rates (nil when no
|
|
44
|
+
# terminal workflows), and average completed duration in seconds.
|
|
45
|
+
def totals = data[:totals]
|
|
46
|
+
|
|
47
|
+
# The most frequent error classes in the window, highest first, as an
|
|
48
|
+
# ordered {error_class => count} hash. Scoped to the class when set.
|
|
49
|
+
def top_errors(limit: 8)
|
|
50
|
+
rel = ChronoForge::ErrorLog.where(created_at: @since..@now)
|
|
51
|
+
rel = rel.joins(:workflow).where(ChronoForge::Workflow.table_name => {job_class: @job_class}) if @job_class
|
|
52
|
+
rel.group(:error_class).order(Arel.sql("COUNT(*) DESC")).limit(limit).count
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def data
|
|
58
|
+
@data ||= compute
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def compute
|
|
62
|
+
completed_by_day = scope(:completed, "completed_at").group(day("completed_at")).count
|
|
63
|
+
failed_by_day = scope(:failed, "updated_at").group(day("updated_at")).count
|
|
64
|
+
durations_by_day = completed_with_duration.group(day("completed_at"))
|
|
65
|
+
.average(Arel.sql(duration_secs("#{table}.started_at", "#{table}.completed_at")))
|
|
66
|
+
|
|
67
|
+
days = (completed_by_day.keys + failed_by_day.keys).uniq.sort
|
|
68
|
+
buckets = days.map do |d|
|
|
69
|
+
Bucket.new(
|
|
70
|
+
day: d,
|
|
71
|
+
completed: completed_by_day[d].to_i,
|
|
72
|
+
failed: failed_by_day[d].to_i,
|
|
73
|
+
avg_duration: durations_by_day[d]&.to_f&.round
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
c = buckets.sum(&:completed)
|
|
78
|
+
f = buckets.sum(&:failed)
|
|
79
|
+
n = c + f
|
|
80
|
+
avg = completed_with_duration.average(Arel.sql(duration_secs("#{table}.started_at", "#{table}.completed_at")))
|
|
81
|
+
|
|
82
|
+
totals = {
|
|
83
|
+
completed: c, failed: f, terminal: n,
|
|
84
|
+
completion_rate: n.zero? ? nil : c.to_f / n,
|
|
85
|
+
failure_rate: n.zero? ? nil : f.to_f / n,
|
|
86
|
+
avg_duration: avg&.to_f&.round
|
|
87
|
+
}
|
|
88
|
+
{buckets: buckets, totals: totals}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Terminal workflows of one state within the window, by the given timestamp
|
|
92
|
+
# column. Optionally scoped to a single class.
|
|
93
|
+
def scope(state, time_col)
|
|
94
|
+
s = ChronoForge::Workflow
|
|
95
|
+
.where(state: ChronoForge::Workflow.states[state])
|
|
96
|
+
.where("#{table}.#{time_col}": @since..@now)
|
|
97
|
+
s = s.where(job_class: @job_class) if @job_class
|
|
98
|
+
s
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def completed_with_duration
|
|
102
|
+
scope(:completed, "completed_at").where.not(started_at: nil)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def table = ChronoForge::Workflow.table_name
|
|
106
|
+
|
|
107
|
+
def adapter_name
|
|
108
|
+
@adapter_name ||= ChronoForge::Workflow.with_connection { |c| c.adapter_name }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A 'YYYY-MM-DD' day key for the given timestamp column.
|
|
112
|
+
def day(col)
|
|
113
|
+
qualified = "#{table}.#{col}"
|
|
114
|
+
Arel.sql(
|
|
115
|
+
case adapter_name
|
|
116
|
+
when /postgres/i then "to_char(#{qualified}, 'YYYY-MM-DD')"
|
|
117
|
+
when /mysql|trilogy/i then "DATE_FORMAT(#{qualified}, '%Y-%m-%d')"
|
|
118
|
+
else "strftime('%Y-%m-%d', #{qualified})" # sqlite + fallback
|
|
119
|
+
end
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Elapsed seconds between two timestamp columns.
|
|
124
|
+
def duration_secs(from, to)
|
|
125
|
+
case adapter_name
|
|
126
|
+
when /postgres/i then "EXTRACT(EPOCH FROM (#{to} - #{from}))"
|
|
127
|
+
when /mysql|trilogy/i then "TIMESTAMPDIFF(SECOND, #{from}, #{to})"
|
|
128
|
+
else "(julianday(#{to}) - julianday(#{from})) * 86400" # sqlite + fallback
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
# The per-iteration run logs of a single durably_repeat step.
|
|
4
|
+
#
|
|
5
|
+
# These live on their own page rather than in the timeline: a long-running
|
|
6
|
+
# periodic step can accumulate many runs — mostly catch-up "tombstones"
|
|
7
|
+
# (expired/retried repetitions the engine marks failed and moves past) — and
|
|
8
|
+
# inlining them would both bury the timeline and load an unbounded set.
|
|
9
|
+
#
|
|
10
|
+
# All access is keyed on `[workflow_id, step_name LIKE 'durably_repeat$step$%']`,
|
|
11
|
+
# which rides the unique `[workflow_id, step_name]` index as a range scan, so
|
|
12
|
+
# the summary counts and keyset pages stay cheap regardless of history depth.
|
|
13
|
+
class RepetitionsQuery
|
|
14
|
+
DEFAULT_PER = 50
|
|
15
|
+
MAX_PER = 200
|
|
16
|
+
# Bound the metadata scan used to count fast-forwarded ticks (see #summary):
|
|
17
|
+
# a pre-upgrade step may carry a long history of legacy per-tick rows.
|
|
18
|
+
CATCHUP_SCAN_CAP = 1_000
|
|
19
|
+
|
|
20
|
+
def initialize(workflow:, step:, before: nil, after: nil, per: DEFAULT_PER)
|
|
21
|
+
@workflow = workflow
|
|
22
|
+
@step = step
|
|
23
|
+
@before = before.presence&.to_i
|
|
24
|
+
@after = after.presence&.to_i
|
|
25
|
+
@per = per.to_i.clamp(1, MAX_PER)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :workflow, :step, :per
|
|
29
|
+
|
|
30
|
+
def records
|
|
31
|
+
load
|
|
32
|
+
@records
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def has_next?
|
|
36
|
+
load
|
|
37
|
+
@has_next
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def has_prev?
|
|
41
|
+
load
|
|
42
|
+
@has_prev
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def next_cursor = records.last&.id
|
|
46
|
+
def prev_cursor = records.first&.id
|
|
47
|
+
|
|
48
|
+
# Cheap roll-up (counts + last run) without loading run rows. Grouping by
|
|
49
|
+
# the `state` enum yields string-label keys ("completed"/"failed"), not
|
|
50
|
+
# integers.
|
|
51
|
+
#
|
|
52
|
+
# `tombstones` is the number of catch-up *rows* (cheap group count).
|
|
53
|
+
# `skipped_ticks` is the true number of skipped ticks: a fast-forward
|
|
54
|
+
# summary row collapses N expired ticks into one failed row tagged
|
|
55
|
+
# `fast_forwarded: N`, so it counts as N, while a legacy per-tick tombstone
|
|
56
|
+
# counts as 1. They diverge only once a fast-forward has happened.
|
|
57
|
+
def summary
|
|
58
|
+
@summary ||= begin
|
|
59
|
+
by_state = scope.group(:state).count
|
|
60
|
+
failed = by_state["failed"].to_i
|
|
61
|
+
{
|
|
62
|
+
iterations: by_state.values.sum,
|
|
63
|
+
completed: by_state["completed"].to_i,
|
|
64
|
+
tombstones: failed,
|
|
65
|
+
skipped_ticks: failed.zero? ? 0 : skipped_tick_count,
|
|
66
|
+
last_run_at: scope.maximum(:started_at)
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def scope
|
|
72
|
+
@workflow.execution_logs.where(
|
|
73
|
+
"step_name LIKE ?",
|
|
74
|
+
"durably_repeat#{StepNameParser::DELIM}#{@step}#{StepNameParser::DELIM}%"
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
# Sum the skipped ticks across catch-up rows: each fast-forward summary row
|
|
81
|
+
# contributes its `fast_forwarded` count; a legacy per-tick row contributes
|
|
82
|
+
# 1. Bounded scan (only failed rows, capped) since metadata must be read.
|
|
83
|
+
def skipped_tick_count
|
|
84
|
+
scope.where(state: ChronoForge::ExecutionLog.states[:failed])
|
|
85
|
+
.limit(CATCHUP_SCAN_CAP).pluck(:metadata)
|
|
86
|
+
.sum { |m| [m&.dig("fast_forwarded").to_i, 1].max }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def load
|
|
90
|
+
return if @loaded
|
|
91
|
+
@loaded = true
|
|
92
|
+
col = "#{ChronoForge::ExecutionLog.table_name}.id"
|
|
93
|
+
|
|
94
|
+
if @after
|
|
95
|
+
rows = scope.where("#{col} > ?", @after).order(id: :asc).limit(@per + 1).to_a
|
|
96
|
+
@has_prev = rows.size > @per
|
|
97
|
+
@records = rows.first(@per).reverse
|
|
98
|
+
@has_next = true
|
|
99
|
+
else
|
|
100
|
+
s = scope
|
|
101
|
+
s = s.where("#{col} < ?", @before) if @before
|
|
102
|
+
rows = s.order(id: :desc).limit(@per + 1).to_a
|
|
103
|
+
@has_next = rows.size > @per
|
|
104
|
+
@records = rows.first(@per)
|
|
105
|
+
@has_prev = @before.present?
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
# Per-state workflow counts, each capped so the panel never pays for an
|
|
4
|
+
# unbounded COUNT. Each count is an index-only COUNT over a `LIMIT CAP`
|
|
5
|
+
# subquery on the (state, ...) index, so it costs O(CAP) regardless of how
|
|
6
|
+
# many rows match; a saturated count renders as "CAP+".
|
|
7
|
+
class StatsQuery
|
|
8
|
+
CAP = 5000
|
|
9
|
+
|
|
10
|
+
def initialize(base: ChronoForge::Workflow.all, cap: CAP)
|
|
11
|
+
@base = base
|
|
12
|
+
@cap = cap
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :cap
|
|
16
|
+
|
|
17
|
+
def counts
|
|
18
|
+
ChronoForge::Workflow.states.keys.index_with do |name|
|
|
19
|
+
capped(@base.where(state: ChronoForge::Workflow.states[name]))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def capped(relation)
|
|
26
|
+
ChronoForge::Workflow.from(relation.reorder(nil).select(:id).limit(@cap), :capped).count
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
# Keyset (cursor) pagination over workflows. Orders by primary key descending
|
|
4
|
+
# (newest first) and pages with `id < cursor` / `id > cursor` rather than
|
|
5
|
+
# OFFSET, and never issues a COUNT(*). Both degrade at scale; keyset stays
|
|
6
|
+
# constant-cost at any depth and over any number of rows. Accepts a `base`
|
|
7
|
+
# scope so it also drives bounded child lists (e.g. a branch's children).
|
|
8
|
+
class WorkflowsQuery
|
|
9
|
+
DEFAULT_PER = 50
|
|
10
|
+
MAX_PER = 200
|
|
11
|
+
|
|
12
|
+
def initialize(base: ChronoForge::Workflow.all, state: nil, job_class: nil, key: nil,
|
|
13
|
+
created_from: nil, created_to: nil, before: nil, after: nil, per: DEFAULT_PER)
|
|
14
|
+
@base = base
|
|
15
|
+
@state = state.presence
|
|
16
|
+
@job_class = job_class.presence
|
|
17
|
+
@key = key.presence
|
|
18
|
+
@created_from = created_from.presence
|
|
19
|
+
@created_to = created_to.presence
|
|
20
|
+
@before = before.presence&.to_i
|
|
21
|
+
@after = after.presence&.to_i
|
|
22
|
+
@per = per.to_i.clamp(1, MAX_PER)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def records
|
|
26
|
+
load
|
|
27
|
+
@records
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :per
|
|
31
|
+
|
|
32
|
+
def has_next? # older rows remain
|
|
33
|
+
load
|
|
34
|
+
@has_next
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def has_prev? # newer rows remain
|
|
38
|
+
load
|
|
39
|
+
@has_prev
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def next_cursor = records.last&.id
|
|
43
|
+
def prev_cursor = records.first&.id
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def load
|
|
48
|
+
return if @loaded
|
|
49
|
+
@loaded = true
|
|
50
|
+
col = "#{ChronoForge::Workflow.table_name}.id"
|
|
51
|
+
|
|
52
|
+
if @after
|
|
53
|
+
# Paging toward newer rows (Prev): ids above the cursor, ascending,
|
|
54
|
+
# then flipped back to descending for display.
|
|
55
|
+
rows = filtered.where("#{col} > ?", @after).order(id: :asc).limit(@per + 1).to_a
|
|
56
|
+
@has_prev = rows.size > @per
|
|
57
|
+
@records = rows.first(@per).reverse
|
|
58
|
+
@has_next = true
|
|
59
|
+
else
|
|
60
|
+
scope = filtered
|
|
61
|
+
scope = scope.where("#{col} < ?", @before) if @before
|
|
62
|
+
rows = scope.order(id: :desc).limit(@per + 1).to_a
|
|
63
|
+
@has_next = rows.size > @per
|
|
64
|
+
@records = rows.first(@per)
|
|
65
|
+
@has_prev = @before.present?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# "blocked" is a virtual filter (failed + stalled) used by the branch
|
|
70
|
+
# children triage view to default to the actionable subset.
|
|
71
|
+
BLOCKED_STATES = %i[failed stalled].map { |s| ChronoForge::Workflow.states[s] }.freeze
|
|
72
|
+
|
|
73
|
+
def filtered
|
|
74
|
+
s = @base
|
|
75
|
+
if @state == "blocked"
|
|
76
|
+
s = s.where(state: BLOCKED_STATES)
|
|
77
|
+
elsif @state && ChronoForge::Workflow.states.key?(@state)
|
|
78
|
+
s = s.where(state: ChronoForge::Workflow.states[@state])
|
|
79
|
+
end
|
|
80
|
+
s = s.where(job_class: @job_class) if @job_class
|
|
81
|
+
# Prefix match (not substring) so it can use the `key` index instead of
|
|
82
|
+
# full-scanning; LIKE wildcards in the input are escaped to literals.
|
|
83
|
+
s = s.where("key LIKE ?", "#{ChronoForge::Workflow.sanitize_sql_like(@key)}%") if @key
|
|
84
|
+
s = s.where(created_at: @created_from..) if @created_from
|
|
85
|
+
s = s.where(created_at: ..@created_to) if @created_to
|
|
86
|
+
s
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<div class="mb-5">
|
|
2
|
+
<div class="flex items-center justify-between gap-3">
|
|
3
|
+
<h1 class="text-lg font-semibold tracking-tight">Analytics</h1>
|
|
4
|
+
<div class="flex shrink-0 gap-1 rounded-md border border-zinc-200 bg-white p-0.5 text-sm">
|
|
5
|
+
<% @query.windows.each do |w| %>
|
|
6
|
+
<% active = @query.window == w %>
|
|
7
|
+
<%= link_to w, analytics_path(window: w, class: @job_class),
|
|
8
|
+
class: "rounded px-2.5 py-1 #{active ? "bg-zinc-900 text-white" : "text-zinc-500 hover:text-zinc-900"}" %>
|
|
9
|
+
<% end %>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
<% if @job_class %>
|
|
13
|
+
<p class="mt-1.5 break-all font-mono text-base font-medium text-zinc-800"><%= @job_class %></p>
|
|
14
|
+
<%= link_to "‹ All classes", analytics_path(window: @query.window), class: cf_chip("mt-1") %>
|
|
15
|
+
<% else %>
|
|
16
|
+
<p class="mt-1 text-sm text-zinc-500">Workflow outcomes over time. Counts are workflows, not execution-log steps.</p>
|
|
17
|
+
<% end %>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div class="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
21
|
+
<div class="cf-card p-4">
|
|
22
|
+
<div class="text-xs uppercase tracking-wide text-zinc-400">Completion rate</div>
|
|
23
|
+
<div class="mt-1 font-mono text-2xl font-semibold text-emerald-600"><%= cf_pct(@totals[:completion_rate]) %></div>
|
|
24
|
+
</div>
|
|
25
|
+
<div class="cf-card p-4">
|
|
26
|
+
<div class="text-xs uppercase tracking-wide text-zinc-400">Workflow failure rate</div>
|
|
27
|
+
<div class="mt-1 font-mono text-2xl font-semibold <%= (@totals[:failure_rate] || 0) > 0 ? "text-rose-600" : "text-zinc-900" %>"><%= cf_pct(@totals[:failure_rate]) %></div>
|
|
28
|
+
<div class="mt-0.5 text-xs text-zinc-400"><%= number_with_delimiter(@totals[:failed]) %> failed</div>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="cf-card p-4">
|
|
31
|
+
<div class="text-xs uppercase tracking-wide text-zinc-400">Avg duration</div>
|
|
32
|
+
<div class="mt-1 font-mono text-2xl font-semibold text-zinc-900"><%= cf_secs(@totals[:avg_duration]) %></div>
|
|
33
|
+
<div class="mt-0.5 text-xs text-zinc-400">completed only</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="cf-card p-4">
|
|
36
|
+
<div class="text-xs uppercase tracking-wide text-zinc-400">Completed</div>
|
|
37
|
+
<div class="mt-1 font-mono text-2xl font-semibold text-zinc-900"><%= number_with_delimiter(@totals[:completed]) %></div>
|
|
38
|
+
<div class="mt-0.5 text-xs text-zinc-400">in window</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<p class="mb-5 text-xs text-zinc-400">
|
|
43
|
+
Failure rate counts <strong class="font-medium text-zinc-500">workflows</strong> that ended in the failed state — not failed
|
|
44
|
+
execution-log steps, which are dominated by normal <code class="font-mono">durably_repeat</code> catch-up retries.
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
<% if @queue %>
|
|
48
|
+
<section class="cf-card mb-5 p-5">
|
|
49
|
+
<h2 class="mb-3 text-xs font-medium uppercase tracking-wide text-zinc-500">Queue health <span class="normal-case text-zinc-400">(current)</span></h2>
|
|
50
|
+
<div class="flex flex-wrap gap-4">
|
|
51
|
+
<% cf_state_order(@queue.counts.keys).each do |state| %>
|
|
52
|
+
<% count = @queue.counts[state] %>
|
|
53
|
+
<div class="flex items-center gap-2 text-sm">
|
|
54
|
+
<%= cf_dot(state) %>
|
|
55
|
+
<span class="text-zinc-500"><%= state %></span>
|
|
56
|
+
<span class="font-mono font-medium tabular-nums"><%= cf_capped(count, @queue.cap) %></span>
|
|
57
|
+
</div>
|
|
58
|
+
<% end %>
|
|
59
|
+
</div>
|
|
60
|
+
</section>
|
|
61
|
+
<% end %>
|
|
62
|
+
|
|
63
|
+
<% if @top_errors.any? %>
|
|
64
|
+
<section class="cf-card mb-5 p-5">
|
|
65
|
+
<h2 class="mb-3 text-xs font-medium uppercase tracking-wide text-zinc-500">Top error classes <span class="normal-case text-zinc-400">(in window)</span></h2>
|
|
66
|
+
<% emax = @top_errors.values.max.to_f %>
|
|
67
|
+
<div class="space-y-2">
|
|
68
|
+
<% @top_errors.each do |klass, count| %>
|
|
69
|
+
<div class="flex items-center gap-3 text-sm">
|
|
70
|
+
<div class="w-64 shrink-0 truncate font-mono text-xs text-rose-600" title="<%= klass %>"><%= klass || "(unknown)" %></div>
|
|
71
|
+
<div class="flex h-4 min-w-0 flex-1 overflow-hidden rounded bg-zinc-100">
|
|
72
|
+
<div class="cf-bar bg-rose-300 <%= cf_bar_width(count, emax) %>"></div>
|
|
73
|
+
</div>
|
|
74
|
+
<div class="w-16 shrink-0 text-right font-mono text-xs tabular-nums text-zinc-500"><%= number_with_delimiter(count) %></div>
|
|
75
|
+
</div>
|
|
76
|
+
<% end %>
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
<% end %>
|
|
80
|
+
|
|
81
|
+
<section class="cf-card p-5">
|
|
82
|
+
<h2 class="mb-4 text-xs font-medium uppercase tracking-wide text-zinc-500">Daily throughput</h2>
|
|
83
|
+
<% if @buckets.any? %>
|
|
84
|
+
<% max = @buckets.map(&:terminal).max.to_f %>
|
|
85
|
+
<div class="space-y-2">
|
|
86
|
+
<% @buckets.reverse_each do |b| %>
|
|
87
|
+
<div class="flex items-center gap-3 text-sm">
|
|
88
|
+
<div class="w-24 shrink-0 font-mono text-xs text-zinc-500"><%= b.day %></div>
|
|
89
|
+
<div class="flex h-4 min-w-0 flex-1 overflow-hidden rounded bg-zinc-100">
|
|
90
|
+
<div class="cf-bar bg-emerald-400 <%= cf_bar_width(b.completed, max) %>"></div>
|
|
91
|
+
<div class="cf-bar bg-rose-400 <%= cf_bar_width(b.failed, max) %>"></div>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="w-32 shrink-0 text-right font-mono text-xs tabular-nums text-zinc-500">
|
|
94
|
+
<span class="text-emerald-600"><%= number_with_delimiter(b.completed) %></span>
|
|
95
|
+
<% if b.failed > 0 %><span class="text-rose-600"> / <%= number_with_delimiter(b.failed) %></span><% end %>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
<% end %>
|
|
99
|
+
</div>
|
|
100
|
+
<% else %>
|
|
101
|
+
<p class="py-8 text-center text-sm text-zinc-500">No workflows completed or failed in this window.</p>
|
|
102
|
+
<% end %>
|
|
103
|
+
</section>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<%= link_to "‹ #{@workflow.key}", workflow_path(@workflow), class: cf_chip("mb-4 max-w-full truncate") %>
|
|
2
|
+
|
|
3
|
+
<div class="mb-5 flex flex-wrap items-start justify-between gap-3">
|
|
4
|
+
<div class="min-w-0">
|
|
5
|
+
<h1 class="text-lg font-semibold tracking-tight">Branch <span class="break-all font-mono"><%= @branch.name %></span></h1>
|
|
6
|
+
<p class="mt-1 break-all text-sm text-zinc-500">children of <span class="font-mono"><%= @workflow.key %></span></p>
|
|
7
|
+
</div>
|
|
8
|
+
<%= button_to "Retry all blocked", bulk_retry_workflow_branch_path(@workflow, @branch_log), method: :post,
|
|
9
|
+
form: {data: {confirm: "Re-enqueue every failed/stalled child of this branch?"}}, class: "cf-btn cf-btn-primary shrink-0" %>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div data-poll-region>
|
|
13
|
+
<% current = params.key?(:state) ? params[:state].to_s : "blocked" %>
|
|
14
|
+
<% blocked = @stats["failed"].to_i + @stats["stalled"].to_i %>
|
|
15
|
+
<% chip = ->(label, state, count, active) {
|
|
16
|
+
link_to workflow_branch_path(@workflow, @branch_log, state: state),
|
|
17
|
+
class: "flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition #{active ? "border-zinc-900 bg-zinc-50" : "border-zinc-200 bg-white hover:bg-zinc-50"}" do
|
|
18
|
+
safe_join([tag.span(label, class: "text-zinc-500"),
|
|
19
|
+
(count ? tag.span(cf_capped(count, @stats_cap), class: "font-mono font-medium tabular-nums") : "".html_safe)])
|
|
20
|
+
end
|
|
21
|
+
} %>
|
|
22
|
+
<div class="mb-4 flex flex-wrap gap-2">
|
|
23
|
+
<%= chip.call("blocked", "blocked", blocked, current == "blocked") %>
|
|
24
|
+
<%= chip.call("all", "", nil, current == "") %>
|
|
25
|
+
<% ChronoForge::Workflow.states.keys.each do |s| %>
|
|
26
|
+
<%= chip.call(s, s, @stats[s], current == s) %>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="cf-card overflow-hidden">
|
|
31
|
+
<div class="overflow-x-auto">
|
|
32
|
+
<table class="w-full min-w-[40rem] text-sm">
|
|
33
|
+
<thead>
|
|
34
|
+
<tr class="border-b border-zinc-200 bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
|
|
35
|
+
<th class="px-4 py-2.5 font-medium">Class</th>
|
|
36
|
+
<th class="px-4 py-2.5 font-medium">Key</th>
|
|
37
|
+
<th class="px-4 py-2.5 font-medium">State</th>
|
|
38
|
+
<th class="px-4 py-2.5 text-right font-medium">Started</th>
|
|
39
|
+
<th class="px-4 py-2.5 text-right font-medium">Next run</th>
|
|
40
|
+
<th class="px-4 py-2.5 text-right font-medium">Updated</th>
|
|
41
|
+
</tr>
|
|
42
|
+
</thead>
|
|
43
|
+
<tbody class="divide-y divide-zinc-100">
|
|
44
|
+
<%= render partial: "chrono_forge/dashboard/workflows/workflow_row", collection: @children, as: :workflow, locals: {waits: @waits} %>
|
|
45
|
+
</tbody>
|
|
46
|
+
</table>
|
|
47
|
+
</div>
|
|
48
|
+
<% if @children.none? %>
|
|
49
|
+
<p class="px-4 py-12 text-center text-sm text-zinc-500">No children match this filter.</p>
|
|
50
|
+
<% end %>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<% base_params = request.query_parameters.except("before", "after") %>
|
|
54
|
+
<nav class="mt-4 flex items-center justify-between text-sm">
|
|
55
|
+
<% if @query.has_prev? %><%= link_to "‹ Newer", base_params.merge(after: @query.prev_cursor), class: cf_chip %><% else %><span></span><% end %>
|
|
56
|
+
<% if @query.has_next? %><%= link_to "Older ›", base_params.merge(before: @query.next_cursor), class: cf_chip %><% else %><span></span><% end %>
|
|
57
|
+
</nav>
|
|
58
|
+
</div>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<%= link_to "‹ #{@workflow.key}", workflow_path(@workflow), class: cf_chip("mb-4 max-w-full truncate") %>
|
|
2
|
+
|
|
3
|
+
<div class="mb-5">
|
|
4
|
+
<h1 class="text-lg font-semibold tracking-tight">
|
|
5
|
+
Repetitions <span class="text-zinc-400">·</span> <span class="font-mono text-base"><%= @step %></span>
|
|
6
|
+
</h1>
|
|
7
|
+
<p class="mt-1 text-sm text-zinc-500">
|
|
8
|
+
<%= number_with_delimiter(@summary[:iterations]) %> iterations
|
|
9
|
+
<% if @summary[:skipped_ticks] > 0 %>· <span class="text-amber-600"><%= number_with_delimiter(@summary[:skipped_ticks]) %> catch-up tick<%= "s" unless @summary[:skipped_ticks] == 1 %> skipped</span><% end %>
|
|
10
|
+
<% if @summary[:last_run_at] %>· last run <%= cf_ago(@summary[:last_run_at]) %><% end %>
|
|
11
|
+
</p>
|
|
12
|
+
<p class="mt-1 text-xs text-zinc-400">
|
|
13
|
+
Skipped ticks are expired repetitions the engine steps past — normal <code class="font-mono">durably_repeat</code> catch-up, not workflow errors. They appear as a per-tick <span class="text-amber-700">tombstone</span> or, after a long gap, a single <span class="text-amber-700">caught up ×N</span> summary row.
|
|
14
|
+
</p>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="cf-card overflow-hidden">
|
|
18
|
+
<div class="overflow-x-auto">
|
|
19
|
+
<table class="w-full min-w-[44rem] text-sm">
|
|
20
|
+
<thead>
|
|
21
|
+
<tr class="border-b border-zinc-200 bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
|
|
22
|
+
<th class="px-4 py-2.5 font-medium">Scheduled</th>
|
|
23
|
+
<th class="px-4 py-2.5 font-medium">Status</th>
|
|
24
|
+
<th class="px-4 py-2.5 font-medium">Started</th>
|
|
25
|
+
<th class="px-4 py-2.5 text-right font-medium">Late by</th>
|
|
26
|
+
<th class="px-4 py-2.5 text-right font-medium">Duration</th>
|
|
27
|
+
<th class="px-4 py-2.5 text-right font-medium">Attempts</th>
|
|
28
|
+
<th class="px-4 py-2.5 font-medium">Error</th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody class="divide-y divide-zinc-100">
|
|
32
|
+
<% @runs.each do |run| %>
|
|
33
|
+
<% ts = ChronoForge::Dashboard::StepNameParser.parse(run.step_name).timestamp %>
|
|
34
|
+
<% ff = run.failed? ? run.metadata&.dig("fast_forwarded") : nil %>
|
|
35
|
+
<% tombstone = run.failed? && ff.nil? %>
|
|
36
|
+
<tr class="<%= "bg-amber-50" if run.failed? %>">
|
|
37
|
+
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600"><%= ts ? cf_ago(Time.zone.at(ts)) : "—" %></td>
|
|
38
|
+
<td class="px-4 py-2.5 text-xs">
|
|
39
|
+
<% if ff %>
|
|
40
|
+
<span class="font-medium text-amber-700" title="fast-forwarded <%= ff %> expired tick(s): <%= run.metadata["from"] %> → <%= run.metadata["to"] %>">caught up ×<%= ff %></span>
|
|
41
|
+
<% elsif tombstone %>
|
|
42
|
+
<span class="font-medium text-amber-700">tombstone</span>
|
|
43
|
+
<% else %>
|
|
44
|
+
<span class="<%= cf_status_color(run.state) %>"><%= run.state %></span>
|
|
45
|
+
<% end %>
|
|
46
|
+
</td>
|
|
47
|
+
<td class="px-4 py-2.5 text-zinc-600"><%= cf_ago(run.started_at) %></td>
|
|
48
|
+
<% late = (ts && run.started_at) ? (run.started_at - Time.zone.at(ts)).to_i : nil %>
|
|
49
|
+
<td class="px-4 py-2.5 text-right font-mono text-xs <%= (late && late > 60) ? "text-amber-600" : "text-zinc-500" %>">
|
|
50
|
+
<%= late.nil? ? "—" : (late <= 0 ? "on time" : cf_secs(late)) %>
|
|
51
|
+
</td>
|
|
52
|
+
<td class="px-4 py-2.5 text-right font-mono text-xs text-zinc-500"><%= cf_duration(run.started_at, run.completed_at) %></td>
|
|
53
|
+
<td class="px-4 py-2.5 text-right font-mono text-xs text-zinc-500">×<%= run.attempts %></td>
|
|
54
|
+
<td class="px-4 py-2.5 font-mono text-xs text-rose-600"><%= ff ? "—" : (run.error_class || "—") %></td>
|
|
55
|
+
</tr>
|
|
56
|
+
<% end %>
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
</div>
|
|
60
|
+
<% if @runs.none? %>
|
|
61
|
+
<p class="px-4 py-12 text-center text-sm text-zinc-500">No repetitions recorded for this step.</p>
|
|
62
|
+
<% end %>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<% base_params = request.query_parameters.except("before", "after") %>
|
|
66
|
+
<nav class="mt-4 flex items-center justify-between text-sm">
|
|
67
|
+
<% if @query.has_prev? %><%= link_to "‹ Newer", base_params.merge(after: @query.prev_cursor), class: cf_chip %><% else %><span></span><% end %>
|
|
68
|
+
<% if @query.has_next? %><%= link_to "Older ›", base_params.merge(before: @query.next_cursor), class: cf_chip %><% else %><span></span><% end %>
|
|
69
|
+
</nav>
|