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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +147 -0
  5. data/app/assets/chrono_forge/dashboard/dashboard.css +2 -0
  6. data/app/assets/chrono_forge/dashboard/dashboard.js +89 -0
  7. data/app/assets/chrono_forge/dashboard/tailwind.css +69 -0
  8. data/app/controllers/chrono_forge/dashboard/actions_controller.rb +58 -0
  9. data/app/controllers/chrono_forge/dashboard/analytics_controller.rb +23 -0
  10. data/app/controllers/chrono_forge/dashboard/assets_controller.rb +21 -0
  11. data/app/controllers/chrono_forge/dashboard/base_controller.rb +29 -0
  12. data/app/controllers/chrono_forge/dashboard/branch_children_controller.rb +33 -0
  13. data/app/controllers/chrono_forge/dashboard/repetitions_controller.rb +20 -0
  14. data/app/controllers/chrono_forge/dashboard/wait_states_controller.rb +38 -0
  15. data/app/controllers/chrono_forge/dashboard/workflows_controller.rb +31 -0
  16. data/app/helpers/chrono_forge/dashboard/dashboard_helper.rb +153 -0
  17. data/app/presenters/chrono_forge/dashboard/branch_presenter.rb +64 -0
  18. data/app/presenters/chrono_forge/dashboard/branches_presenter.rb +62 -0
  19. data/app/presenters/chrono_forge/dashboard/context_presenter.rb +17 -0
  20. data/app/presenters/chrono_forge/dashboard/periodic_health_presenter.rb +77 -0
  21. data/app/presenters/chrono_forge/dashboard/timeline_presenter.rb +90 -0
  22. data/app/presenters/chrono_forge/dashboard/wait_state_presenter.rb +64 -0
  23. data/app/queries/chrono_forge/dashboard/analytics_query.rb +133 -0
  24. data/app/queries/chrono_forge/dashboard/repetitions_query.rb +110 -0
  25. data/app/queries/chrono_forge/dashboard/stats_query.rb +30 -0
  26. data/app/queries/chrono_forge/dashboard/workflows_query.rb +90 -0
  27. data/app/views/chrono_forge/dashboard/analytics/index.html.erb +103 -0
  28. data/app/views/chrono_forge/dashboard/branch_children/show.html.erb +58 -0
  29. data/app/views/chrono_forge/dashboard/repetitions/index.html.erb +69 -0
  30. data/app/views/chrono_forge/dashboard/wait_states/index.html.erb +73 -0
  31. data/app/views/chrono_forge/dashboard/workflows/_branches.html.erb +57 -0
  32. data/app/views/chrono_forge/dashboard/workflows/_context_tree.html.erb +16 -0
  33. data/app/views/chrono_forge/dashboard/workflows/_error_card.html.erb +13 -0
  34. data/app/views/chrono_forge/dashboard/workflows/_filters.html.erb +6 -0
  35. data/app/views/chrono_forge/dashboard/workflows/_parent_breadcrumb.html.erb +9 -0
  36. data/app/views/chrono_forge/dashboard/workflows/_periodic.html.erb +24 -0
  37. data/app/views/chrono_forge/dashboard/workflows/_stats.html.erb +14 -0
  38. data/app/views/chrono_forge/dashboard/workflows/_timeline.html.erb +67 -0
  39. data/app/views/chrono_forge/dashboard/workflows/_wait_callout.html.erb +5 -0
  40. data/app/views/chrono_forge/dashboard/workflows/_workflow_row.html.erb +8 -0
  41. data/app/views/chrono_forge/dashboard/workflows/index.html.erb +39 -0
  42. data/app/views/chrono_forge/dashboard/workflows/show.html.erb +79 -0
  43. data/app/views/layouts/chrono_forge/dashboard/application.html.erb +50 -0
  44. data/config/routes.rb +20 -0
  45. data/lib/chrono_forge/dashboard/configuration.rb +32 -0
  46. data/lib/chrono_forge/dashboard/engine.rb +9 -0
  47. data/lib/chrono_forge/dashboard/step_name_parser.rb +32 -0
  48. data/lib/chrono_forge/dashboard/version.rb +5 -0
  49. data/lib/chrono_forge/dashboard.rb +30 -0
  50. 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&nbsp;up&nbsp;×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>