Module: ChronoForge::Dashboard::DashboardHelper
- Defined in:
- app/helpers/chrono_forge/dashboard/dashboard_helper.rb
Constant Summary collapse
- STATE_ORDER =
Display order for state counts: active work first, terminal last. Any unknown states are appended so a new core state never silently vanishes.
%w[running idle stalled failed completed].freeze
- KIND_LABELS =
Short, readable label for a parsed step kind.
{ execute: "execute", sleep: "wait", wait: "wait until", continue: "continue if", repeat_coordination: "repeat", repeat_run: "run", lifecycle: "workflow", branch: "branch", merge: "merge", unknown: "step" }.freeze
- META_SKIP =
Human-friendly [label, value] pairs of a step's metadata for the timeline — surfaces things like a wait's resume time, a wait_until timeout, or a durably_repeat's last execution. Keys are humanized; values are stringified (the view truncates). Blank values are dropped. Internal bookkeeping surfaced elsewhere (the linked error is rendered inline; branch poll state + spawn cursors show in the Branches panel), so they'd just be noise in the timeline's metadata line. poll_token is the merge poller's fencing token — pure plumbing, never user-facing.
%w[error_log_id poll poll_token cursors].freeze
- POLLING_KINDS =
The attempts count made legible — nil when there's nothing worth saying. The number means different things per step kind, so it's labelled per kind: an execution retried, a wait/gate polled. Repeat coordination shows its iteration count elsewhere, so it opts out here.
%i[wait continue sleep].freeze
- DAY_FAILURE_FLAG =
--- Analytics day health ------------------------------------------------ A day's failure rate high enough to flag the row. Ten percent of terminal workflows failing in a day is a real signal, not catch-up churn.
0.1
Instance Method Summary collapse
-
#cf_absolute_time? ⇒ Boolean
Whether the viewer prefers absolute timestamps (cookie-persisted nav toggle).
-
#cf_ago(t) ⇒ Object
A timestamp shown relative ("3 minutes ago") or absolute (raw ISO8601) per the viewer's preference, with the other form available on hover.
- #cf_attempts_note(kind, attempts, status) ⇒ Object
- #cf_badge(state) ⇒ Object
-
#cf_bar_width(value, max) ⇒ Object
Class name for a stacked-bar segment, width quantized to 5% steps so it stays CSP-safe (no inline style — see .cf-bar-0.0..100 in tailwind.css).
-
#cf_capped(count, cap) ⇒ Object
A capped count: shows "5000+" once the count saturates its cap.
-
#cf_chip(extra = nil) ⇒ Object
Shared "chip" treatment for inline nav/action links (metrics, details, repetitions, open, pagination, back) — a subtle bordered button, never an underlined text link.
- #cf_day_flagged?(bucket) ⇒ Boolean
-
#cf_day_rate(bucket) ⇒ Object
A day's completion rate (0.0–1.0), or nil when nothing terminated.
- #cf_dot(state) ⇒ Object
-
#cf_duration(from, to) ⇒ Object
Human duration between two times (e.g. "1m 04s"); "—" if unfinished.
-
#cf_duration_bar(seconds, max) ⇒ Object
Width class (reusing the CSP-safe cf-bar-0.0..100 steps) for a duration meter, on a sqrt scale so a 2-second step stays visible next to a 7-minute one instead of collapsing to nothing.
-
#cf_filter_chip(href, label:, active:, count: nil, cap: nil, dot: nil) ⇒ Object
A filter chip: optional colored state dot, a label, and an optional capped count, with active styling.
- #cf_kind_label(kind) ⇒ Object
-
#cf_latency_summary(latencies) ⇒ Object
Concise latency summary (avg + most recent) from a list of run seconds.
-
#cf_lock_age(workflow) ⇒ Object
How long a workflow's lock has been stale (nil if never locked).
- #cf_meta_pairs(metadata) ⇒ Object
-
#cf_meter(width_class, color_class, track: "w-16", title: nil) ⇒ Object
A CSP-safe proportional meter (the timeline's bar, reusable): a fixed track with a filled inner bar whose width is a pre-generated cf-bar-n class.
-
#cf_meter_title(seconds, max, from, to) ⇒ Object
Hover text for a duration meter: what the visible number can't say — this step's share of the run's longest step, and the wall-clock span it covered.
-
#cf_pct(rate) ⇒ Object
A rate (0.0–1.0) as a percentage; "—" if nil.
- #cf_poll_interval ⇒ Object
- #cf_poll_label(secs) ⇒ Object
-
#cf_poll_options ⇒ Object
Auto-refresh interval in seconds (0 = off).
-
#cf_poll_region? ⇒ Boolean
Whether the main region opts into auto-refresh.
-
#cf_reap_stale_after ⇒ Object
Age past which a running workflow's lock is considered stale (its worker gone) — the reaper's own threshold, so the dashboard flags exactly what ChronoForge::Workflow.reap_stalled would re-enqueue.
-
#cf_row_duration_secs(workflow) ⇒ Object
Run length in seconds for a workflow row: elapsed for a live run, final span for a run that ran and stopped (completed / failed / stalled), nil for a parked (idle/scheduled) one whose "duration" would just be how long it's been waiting on a condition, not how long it actually ran.
-
#cf_secs(secs) ⇒ Object
Human duration from a number of seconds, scaled to the two most-significant units (e.g. "45s", "1m 04s", "3h 12m", "2d 21h"); "—" if nil.
-
#cf_slow_step?(seconds) ⇒ Boolean
A step slow enough to emphasize (a minute or more) vs.
-
#cf_state_badge(workflow, wait = nil) ⇒ Object
State badge, upgraded to "scheduled" for an idle workflow parked on a wait whose wake time is still in the future — so genuinely-scheduled work doesn't read as "stuck idle".
- #cf_state_order(keys) ⇒ Object
-
#cf_status_color(status) ⇒ Object
Text color for an execution-log status (pending/completed/failed).
-
#cf_stranded?(workflow) ⇒ Boolean
A workflow stranded in :running by a hard-killed worker: still running, but its lock hasn't been refreshed within reap_stale_after, so nothing is driving it.
- #cf_time(t) ⇒ Object
- #cf_trend_arrow(points) ⇒ Object
- #cf_trend_points(points) ⇒ Object
-
#cf_when(t) ⇒ Object
Like cf_ago but direction-aware: future times read "in 3 minutes", past times "3 minutes ago" — for things like a poller's next scheduled check.
-
#cf_window_trend(buckets) ⇒ Object
Trend across the window: the newer half's completion/failure rate minus the older half's, in percentage points — so a stat card can say whether things are getting better or worse.
Instance Method Details
#cf_absolute_time? ⇒ Boolean
Whether the viewer prefers absolute timestamps (cookie-persisted nav toggle).
62 63 64 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 62 def cf_absolute_time? [:cf_time_format] == "absolute" end |
#cf_ago(t) ⇒ Object
A timestamp shown relative ("3 minutes ago") or absolute (raw ISO8601) per the viewer's preference, with the other form available on hover.
89 90 91 92 93 94 95 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 89 def cf_ago(t) return "—" unless t rel = "#{time_ago_in_words(t)} ago" abs = t.iso8601 shown, hover = cf_absolute_time? ? [abs, rel] : [rel, abs] tag.span(shown, title: hover, class: "cursor-help") end |
#cf_attempts_note(kind, attempts, status) ⇒ Object
210 211 212 213 214 215 216 217 218 219 220 221 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 210 def cf_attempts_note(kind, attempts, status) return nil if attempts.to_i <= 1 return nil if kind == :repeat_coordination if POLLING_KINDS.include?(kind) {text: "checked #{attempts}×", tone: :muted, title: "Polled #{attempts} times before this step resolved"} else retries = attempts.to_i - 1 {text: "#{attempts} attempts", tone: ((status.to_s == "failed") ? :crit : :warn), title: "Ran #{attempts} times — #{pluralize(retries, "retry")} after the first attempt"} end end |
#cf_badge(state) ⇒ Object
12 13 14 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 12 def cf_badge(state) tag.span(state, class: "cf-pill cf-pill-#{state}") end |
#cf_bar_width(value, max) ⇒ Object
Class name for a stacked-bar segment, width quantized to 5% steps so it stays CSP-safe (no inline style — see .cf-bar-ChronoForge::Dashboard::DashboardHelper.0.0..100 in tailwind.css).
126 127 128 129 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 126 def (value, max) pct = (max.to_f.zero? ? 0 : (value / max.to_f * 100)) "cf-bar-#{(pct / 5).round * 5}" end |
#cf_capped(count, cap) ⇒ Object
A capped count: shows "5000+" once the count saturates its cap.
57 58 59 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 57 def cf_capped(count, cap) (count >= cap) ? "#{cap}+" : count.to_s end |
#cf_chip(extra = nil) ⇒ Object
Shared "chip" treatment for inline nav/action links (metrics, details, repetitions, open, pagination, back) — a subtle bordered button, never an underlined text link. Pass extra utility classes (margins, truncation).
19 20 21 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 19 def cf_chip(extra = nil) ["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(" ") end |
#cf_day_flagged?(bucket) ⇒ Boolean
266 267 268 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 266 def cf_day_flagged?(bucket) bucket.terminal > 0 && (bucket.failed.to_f / bucket.terminal) >= DAY_FAILURE_FLAG end |
#cf_day_rate(bucket) ⇒ Object
A day's completion rate (0.0–1.0), or nil when nothing terminated.
262 263 264 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 262 def cf_day_rate(bucket) bucket.terminal.zero? ? nil : bucket.completed.to_f / bucket.terminal end |
#cf_dot(state) ⇒ Object
31 32 33 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 31 def cf_dot(state) tag.span(class: "cf-dot cf-dot-#{state}") end |
#cf_duration(from, to) ⇒ Object
Human duration between two times (e.g. "1m 04s"); "—" if unfinished.
108 109 110 111 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 108 def cf_duration(from, to) return "—" unless from && to cf_secs((to - from).to_i) end |
#cf_duration_bar(seconds, max) ⇒ Object
Width class (reusing the CSP-safe cf-bar-ChronoForge::Dashboard::DashboardHelper.0.0..100 steps) for a duration meter, on a sqrt scale so a 2-second step stays visible next to a 7-minute one instead of collapsing to nothing.
226 227 228 229 230 231 232 233 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 226 def (seconds, max) # A non-positive duration (clock skew, or a step whose completed_at # predates started_at) has no bar — and guards Math.sqrt against a # negative argument, which would otherwise raise a domain error. return "cf-bar-0" if seconds.nil? || seconds.to_f <= 0 || max.to_f <= 0 pct = Math.sqrt(seconds.to_f / max) * 100 "cf-bar-#{[(pct / 5).round * 5, 100].min}" end |
#cf_filter_chip(href, label:, active:, count: nil, cap: nil, dot: nil) ⇒ Object
A filter chip: optional colored state dot, a label, and an optional capped
count, with active styling. Shared by the index stats header and the
branch-children filter row. The caller computes href (the two views build
different URLs); dot is a state name for the colored dot, or nil for none
(e.g. the "all" chip, or a composite like "blocked").
40 41 42 43 44 45 46 47 48 49 50 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 40 def cf_filter_chip(href, label:, active:, count: nil, cap: nil, dot: nil) classes = "cf-stat 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") link_to href, class: classes do safe_join([ dot ? cf_dot(dot) : "", tag.span(label, class: "text-zinc-500"), count ? tag.span(cf_capped(count, cap), class: "font-mono font-medium tabular-nums") : "" ]) end end |
#cf_kind_label(kind) ⇒ Object
155 156 157 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 155 def cf_kind_label(kind) KIND_LABELS.fetch(kind, kind.to_s) end |
#cf_latency_summary(latencies) ⇒ Object
Concise latency summary (avg + most recent) from a list of run seconds.
142 143 144 145 146 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 142 def cf_latency_summary(latencies) return "—" if latencies.blank? avg = (latencies.sum.to_f / latencies.size).round "avg #{avg}s · last #{latencies.last}s" end |
#cf_lock_age(workflow) ⇒ Object
How long a workflow's lock has been stale (nil if never locked).
200 201 202 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 200 def cf_lock_age(workflow) workflow.locked_at ? (Time.current - workflow.locked_at).to_i : nil end |
#cf_meta_pairs(metadata) ⇒ Object
169 170 171 172 173 174 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 169 def () return [] unless .is_a?(Hash) .reject { |k, v| v.nil? || v == "" || META_SKIP.include?(k.to_s) } .map { |k, v| [k.to_s.tr("_", " "), v.to_s] } end |
#cf_meter(width_class, color_class, track: "w-16", title: nil) ⇒ Object
A CSP-safe proportional meter (the timeline's bar, reusable): a fixed
track with a filled inner bar whose width is a pre-generated cf-bar-n
class. width_class is that class; color_class tints the fill.
250 251 252 253 254 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 250 def cf_meter(width_class, color_class, track: "w-16", title: nil) content_tag(:span, class: "inline-block h-1.5 #{track} overflow-hidden rounded-full bg-zinc-200 align-middle", title: title) do content_tag(:span, "", class: "cf-bar #{width_class} block rounded-full #{color_class}") end end |
#cf_meter_title(seconds, max, from, to) ⇒ Object
Hover text for a duration meter: what the visible number can't say — this step's share of the run's longest step, and the wall-clock span it covered. The bar is relative, so the percentage is what it's encoding.
242 243 244 245 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 242 def cf_meter_title(seconds, max, from, to) share = (max.to_f > 0) ? (seconds.to_f / max * 100).round : 0 "#{cf_secs(seconds)} · #{share}% of the longest step · #{from.iso8601} → #{to.iso8601}" end |
#cf_pct(rate) ⇒ Object
A rate (0.0–1.0) as a percentage; "—" if nil. Keeps tiny non-zero rates visible (a 0.0008% workflow-failure rate shows "<0.01%", never "0%").
133 134 135 136 137 138 139 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 133 def cf_pct(rate) return "—" if rate.nil? pct = rate * 100 return "0%" if pct.zero? return "<0.01%" if pct < 0.01 (pct < 1) ? "#{pct.round(2)}%" : "#{pct.round}%" end |
#cf_poll_interval ⇒ Object
70 71 72 73 74 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 70 def cf_poll_interval raw = [:cf_poll_interval] return raw.to_i if raw.present? && raw.match?(/\A\d+\z/) ChronoForge::Dashboard.config.polling_interval.to_i end |
#cf_poll_label(secs) ⇒ Object
76 77 78 79 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 76 def cf_poll_label(secs) return "off" if secs.zero? (secs % 60 == 0) ? "#{secs / 60}m" : "#{secs}s" end |
#cf_poll_options ⇒ Object
Auto-refresh interval in seconds (0 = off). A cookie-persisted nav control overrides the configured default per viewer; options come from config.
68 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 68 def = ChronoForge::Dashboard.config. |
#cf_poll_region? ⇒ Boolean
Whether the main region opts into auto-refresh. A page sets Cytoscape canvas can't survive the poll's morph region refresh). Without a [data-poll-region] the JS never starts a poll timer for the page.
85 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 85 def cf_poll_region? = !@cf_disable_polling |
#cf_reap_stale_after ⇒ Object
Age past which a running workflow's lock is considered stale (its worker gone) — the reaper's own threshold, so the dashboard flags exactly what ChronoForge::Workflow.reap_stalled would re-enqueue.
188 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 188 def cf_reap_stale_after = ChronoForge.config.reap_stale_after |
#cf_row_duration_secs(workflow) ⇒ Object
Run length in seconds for a workflow row: elapsed for a live run, final span for a run that ran and stopped (completed / failed / stalled), nil for a parked (idle/scheduled) one whose "duration" would just be how long it's been waiting on a condition, not how long it actually ran.
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 302 def cf_row_duration_secs(workflow) return nil unless workflow.started_at ending = if workflow.completed_at workflow.completed_at elsif workflow.running? Time.current elsif workflow.failed? || workflow.stalled? workflow.updated_at end return nil unless ending secs = (ending - workflow.started_at).to_i # A negative elapsed time (ending before start — clock skew or bad data) # is meaningless; report it as unknown rather than a fake 0s or a crash. secs.negative? ? nil : secs end |
#cf_secs(secs) ⇒ Object
Human duration from a number of seconds, scaled to the two most-significant units (e.g. "45s", "1m 04s", "3h 12m", "2d 21h"); "—" if nil.
115 116 117 118 119 120 121 122 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 115 def cf_secs(secs) return "—" if secs.nil? secs = secs.to_i return "#{secs}s" if secs < 60 return "#{secs / 60}m #{(secs % 60).to_s.rjust(2, "0")}s" if secs < 3600 return "#{secs / 3600}h #{(secs % 3600 / 60).to_s.rjust(2, "0")}m" if secs < 86400 "#{secs / 86400}d #{(secs % 86400 / 3600).to_s.rjust(2, "0")}h" end |
#cf_slow_step?(seconds) ⇒ Boolean
A step slow enough to emphasize (a minute or more) vs. ordinary sub-minute work — drives the amber meter/label in the timeline.
237 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 237 def cf_slow_step?(seconds) = seconds.to_i >= 60 |
#cf_state_badge(workflow, wait = nil) ⇒ Object
State badge, upgraded to "scheduled" for an idle workflow parked on a wait whose wake time is still in the future — so genuinely-scheduled work doesn't read as "stuck idle".
26 27 28 29 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 26 def cf_state_badge(workflow, wait = nil) return cf_badge("scheduled") if workflow.idle? && wait&.scheduled? cf_badge(workflow.state) end |
#cf_state_order(keys) ⇒ Object
8 9 10 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 8 def cf_state_order(keys) (STATE_ORDER & keys) + (keys - STATE_ORDER) end |
#cf_status_color(status) ⇒ Object
Text color for an execution-log status (pending/completed/failed).
177 178 179 180 181 182 183 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 177 def cf_status_color(status) case status when "completed" then "text-emerald-600" when "failed" then "text-rose-600" else "text-zinc-500" end end |
#cf_stranded?(workflow) ⇒ Boolean
A workflow stranded in :running by a hard-killed worker: still running, but its lock hasn't been refreshed within reap_stale_after, so nothing is driving it. This is the only "stuck" signal that holds — a healthy workflow may legitimately run for years, so elapsed runtime says nothing.
194 195 196 197 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 194 def cf_stranded?(workflow) workflow.running? && workflow.locked_at.present? && workflow.locked_at < cf_reap_stale_after.ago end |
#cf_time(t) ⇒ Object
52 53 54 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 52 def cf_time(t) t&.iso8601 || "—" end |
#cf_trend_arrow(points) ⇒ Object
270 271 272 273 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 270 def cf_trend_arrow(points) return "—" if points.zero? (points > 0) ? "▲" : "▼" end |
#cf_trend_points(points) ⇒ Object
275 276 277 278 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 275 def cf_trend_points(points) return "flat" if points.zero? "#{(points > 0) ? "+" : "−"}#{points.abs.round(1)}" end |
#cf_when(t) ⇒ Object
Like cf_ago but direction-aware: future times read "in 3 minutes", past times "3 minutes ago" — for things like a poller's next scheduled check.
99 100 101 102 103 104 105 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 99 def cf_when(t) return "—" unless t rel = t.future? ? "in #{time_ago_in_words(t)}" : "#{time_ago_in_words(t)} ago" abs = t.iso8601 shown, hover = cf_absolute_time? ? [abs, rel] : [rel, abs] tag.span(shown, title: hover, class: "cursor-help") end |
#cf_window_trend(buckets) ⇒ Object
Trend across the window: the newer half's completion/failure rate minus the older half's, in percentage points — so a stat card can say whether things are getting better or worse. Derived from the buckets already loaded (no query); nil when there's too little to compare.
284 285 286 287 288 289 290 291 292 293 294 295 296 |
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 284 def cf_window_trend(buckets) return nil if buckets.size < 2 mid = buckets.size / 2 rate = lambda do |bs| t = bs.sum(&:terminal) t.zero? ? nil : bs.sum(&:completed).to_f / t end older = rate.call(buckets[0...mid]) newer = rate.call(buckets[mid..]) return nil if older.nil? || newer.nil? pts = ((newer - older) * 100).round(1) {completion: pts, failure: -pts} end |