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

Instance Method Summary collapse

Instance Method Details

#cf_absolute_time?Boolean

Whether the viewer prefers absolute timestamps (cookie-persisted nav toggle).

Returns:

  • (Boolean)


62
63
64
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 62

def cf_absolute_time?
  cookies[: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_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 cf_bar_width(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_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_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_meta_pairs(metadata) ⇒ Object



169
170
171
172
173
174
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 169

def cf_meta_pairs()
  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_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_intervalObject



70
71
72
73
74
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 70

def cf_poll_interval
  raw = cookies[: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_optionsObject

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 cf_poll_options = ChronoForge::Dashboard.config.polling_interval_options

#cf_poll_region?Boolean

Whether the main region opts into auto-refresh. A page sets Cytoscape canvas can't survive the poll's innerHTML region swap). Without a [data-poll-region] the JS never starts a poll timer for the page.

Returns:

  • (Boolean)


85
# File 'app/helpers/chrono_forge/dashboard/dashboard_helper.rb', line 85

def cf_poll_region? = !@cf_disable_polling

#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_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_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_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