chrono_forge-dashboard 0.1.0 → 0.2.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +43 -24
  4. data/app/assets/chrono_forge/dashboard/cytoscape-dagre.js +397 -0
  5. data/app/assets/chrono_forge/dashboard/cytoscape.min.js +32 -0
  6. data/app/assets/chrono_forge/dashboard/dagre.min.js +3809 -0
  7. data/app/assets/chrono_forge/dashboard/dashboard.css +1 -1
  8. data/app/assets/chrono_forge/dashboard/dashboard.js +37 -0
  9. data/app/assets/chrono_forge/dashboard/definition_graph.js +161 -0
  10. data/app/controllers/chrono_forge/dashboard/assets_controller.rb +8 -1
  11. data/app/controllers/chrono_forge/dashboard/definitions_controller.rb +35 -0
  12. data/app/controllers/chrono_forge/dashboard/workflows_controller.rb +6 -2
  13. data/app/helpers/chrono_forge/dashboard/dashboard_helper.rb +33 -0
  14. data/app/presenters/chrono_forge/dashboard/branch_presenter.rb +38 -1
  15. data/app/presenters/chrono_forge/dashboard/branches_presenter.rb +59 -4
  16. data/app/presenters/chrono_forge/dashboard/cytoscape_graph.rb +48 -0
  17. data/app/presenters/chrono_forge/dashboard/definition_overlay.rb +128 -0
  18. data/app/queries/chrono_forge/dashboard/workflows_query.rb +10 -1
  19. data/app/views/chrono_forge/dashboard/branch_children/show.html.erb +56 -11
  20. data/app/views/chrono_forge/dashboard/definitions/show.html.erb +42 -0
  21. data/app/views/chrono_forge/dashboard/workflows/_branches.html.erb +14 -4
  22. data/app/views/chrono_forge/dashboard/workflows/_filters.html.erb +12 -1
  23. data/app/views/chrono_forge/dashboard/workflows/_stats.html.erb +5 -9
  24. data/app/views/chrono_forge/dashboard/workflows/index.html.erb +3 -3
  25. data/app/views/chrono_forge/dashboard/workflows/show.html.erb +1 -0
  26. data/app/views/layouts/chrono_forge/dashboard/application.html.erb +18 -10
  27. data/config/routes.rb +6 -1
  28. data/lib/chrono_forge/dashboard/configuration.rb +2 -2
  29. data/lib/chrono_forge/dashboard/version.rb +1 -1
  30. metadata +10 -2
@@ -0,0 +1,48 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ # Builds Cytoscape.js "elements" (nodes + edges) from overlay node hashes and
4
+ # Definition edges. Unlike a text-DSL renderer, this emits STRUCTURED data the
5
+ # client consumes directly (via JSON.parse), so node labels and guard text need
6
+ # no escaping — the whole class of Mermaid string-grammar bugs disappears.
7
+ # Rendering-only: no DB, no analysis.
8
+ class CytoscapeGraph
9
+ def initialize(nodes, edges)
10
+ @nodes = nodes
11
+ @edges = edges
12
+ end
13
+
14
+ def to_h
15
+ {nodes: node_elements, edges: edge_elements}
16
+ end
17
+
18
+ private
19
+
20
+ def node_elements
21
+ real_ids = @nodes.map { |n| n[:id] }.to_set
22
+ real = @nodes.map do |n|
23
+ data = {id: n[:id], label: n[:label].to_s, step_name: n[:step_name]}
24
+ # Run aggregates the overlay computed for this node: a repeat's execution
25
+ # count and a branch fan-out's per-state child tally. Forwarded so the
26
+ # client can label them (nil/absent for other kinds).
27
+ data[:repetitions] = n[:repetitions] if n[:repetitions]
28
+ data[:counts] = n[:counts] if n[:counts]&.any?
29
+ {data: data, classes: "kind-#{n[:kind]} status-#{n[:status]}"}
30
+ end
31
+ # start/halt (and any other virtual endpoint) are edge targets but not in
32
+ # the node list; Cytoscape rejects edges to missing nodes, so synthesize
33
+ # them as endpoint nodes.
34
+ endpoints = @edges.flat_map { |e| [e.from, e.to] }.uniq.reject { |id| real_ids.include?(id) }
35
+ real + endpoints.map { |id| {data: {id: id, label: id}, classes: "kind-endpoint"} }
36
+ end
37
+
38
+ def edge_elements
39
+ @edges.each_with_index.map do |e, i|
40
+ {
41
+ data: {id: "e#{i}", source: e.from, target: e.to, label: e.guard.to_s},
42
+ classes: "kind-#{e.kind}"
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,128 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ # Overlays a workflow run's execution_logs onto a static Definition, producing
4
+ # per-node hashes with a runtime :status (and fan-out/repeat aggregates).
5
+ # Read-only. The Definition is the static map; logs are the source of truth
6
+ # for a specific run.
7
+ class DefinitionOverlay
8
+ # ExecutionLog's state enum is only pending/completed/failed. "pending" means
9
+ # reached-but-unfinished (in progress), so it maps to :active; the fetch
10
+ # default below catches any state added later.
11
+ LOG_STATUS = {"completed" => :done, "failed" => :failed, "pending" => :active}.freeze
12
+
13
+ def initialize(definition, workflow)
14
+ @definition = definition
15
+ @workflow = workflow
16
+ end
17
+
18
+ def nodes
19
+ mapped = @definition.nodes.map { |n| overlay(n) }
20
+ mapped + unmapped_nodes(mapped)
21
+ end
22
+
23
+ def warnings = @definition.warnings
24
+
25
+ private
26
+
27
+ def overlay(node)
28
+ base = node.to_h.merge(status: :not_reached)
29
+ case node.kind
30
+ when :branch then base.merge(fanout_status(node))
31
+ when :repeat then base.merge(repeat_status(node))
32
+ when :dynamic then base.merge(dynamic_status(node))
33
+ else
34
+ log = logs_by_name[node.step_name]
35
+ log ? base.merge(status: LOG_STATUS.fetch(log_state(log), :active)) : base
36
+ end
37
+ end
38
+
39
+ # A dynamic node has no exact step_name (its name is computed at runtime).
40
+ # Bind it to the next log whose step_name matches its prefix pattern, in
41
+ # creation order for a stable binding. Skip logs already owned by an exact
42
+ # static node (reserved) or by an earlier dynamic node (consumed) so the same
43
+ # run log never surfaces on two nodes — that double-report was the gap.
44
+ def dynamic_status(node)
45
+ pattern = node.step_name_pattern
46
+ return {status: :not_reached} unless pattern
47
+ log = candidate_logs.find do |l|
48
+ l.step_name.start_with?(pattern) && !reserved.include?(l.step_name) &&
49
+ !consumed.include?(l.step_name) && !framework_log?(l)
50
+ end
51
+ return {status: :not_reached} unless log
52
+ consumed << log.step_name
53
+ {status: LOG_STATUS.fetch(log_state(log), :active), step_name: log.step_name}
54
+ end
55
+
56
+ # Exact step_names claimed by static nodes — a dynamic prefix node must not
57
+ # rebind these (its prefix, e.g. "durably_execute$", matches them all).
58
+ def reserved = @reserved ||= @definition.nodes.filter_map(&:step_name).to_set
59
+
60
+ # Loaded logs in a stable (creation) order, so dynamic prefix binding and
61
+ # `consumed` tracking are deterministic rather than DB-load-order dependent.
62
+ def candidate_logs = @candidate_logs ||= @workflow.execution_logs.sort_by(&:id)
63
+
64
+ def fanout_status(node)
65
+ log = logs_by_name[node.step_name]
66
+ return {status: :not_reached} unless log
67
+ counts = ChronoForge::Workflow
68
+ .where(parent_execution_log_id: log.id)
69
+ .group(:state).count
70
+ .transform_keys { |k| ChronoForge::Workflow.states.key(k) || k.to_s }
71
+ status = if counts["failed"].to_i.positive?
72
+ :failed
73
+ elsif counts.except("completed").values.sum.positive?
74
+ :active
75
+ elsif counts.any?
76
+ :done
77
+ else
78
+ :not_reached
79
+ end
80
+ {status: status, counts: counts}
81
+ end
82
+
83
+ def repeat_status(node)
84
+ coord = logs_by_name[node.step_name]
85
+ return {status: :not_reached} unless coord
86
+ # Repetition logs are "<coord.step_name>$<tick>". Count them in Ruby over
87
+ # already-loaded logs via an exact string prefix — a SQL LIKE would treat
88
+ # the "_" in names like "durably_repeat$reconcile_ledger" as wildcards and
89
+ # over-count rows from unrelated steps.
90
+ prefix = "#{node.step_name}$"
91
+ reps = candidate_logs.count { |l| l.step_name.start_with?(prefix) }
92
+ {status: (coord_done?(coord) ? :done : :active), repetitions: reps}
93
+ end
94
+
95
+ def consumed = @consumed ||= Set.new
96
+
97
+ def unmapped_nodes(mapped)
98
+ known = mapped.filter_map { |n| n[:step_name] }.to_set
99
+ @workflow.execution_logs
100
+ .select { |l| l.completed? }
101
+ .reject { |l| known.include?(l.step_name) || consumed.include?(l.step_name) || framework_log?(l) }
102
+ .map do |l|
103
+ {id: "log-#{l.id}", kind: :dynamic, label: l.step_name, step_name: l.step_name,
104
+ status: :unmapped, warnings: ["no matching static node"]}
105
+ end
106
+ end
107
+
108
+ # Skip framework-internal and fan-out child/rep logs (aggregated elsewhere).
109
+ def framework_log?(log)
110
+ log.step_name.start_with?("$") || log.step_name.count("$") >= 2
111
+ end
112
+
113
+ def logs_by_name
114
+ @logs_by_name ||= @workflow.execution_logs.index_by(&:step_name)
115
+ end
116
+
117
+ # ExecutionLog#state is a Rails enum with exactly three values
118
+ # (pending/completed/failed). A pending log is a step that has been reached
119
+ # but hasn't finished, so it reads as :active. Guard the Integer case too.
120
+ def log_state(log)
121
+ state = log.state
122
+ state.is_a?(String) ? state : ChronoForge::ExecutionLog.states.key(state).to_s
123
+ end
124
+
125
+ def coord_done?(log) = log_state(log) == "completed"
126
+ end
127
+ end
128
+ end
@@ -10,7 +10,8 @@ module ChronoForge
10
10
  MAX_PER = 200
11
11
 
12
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)
13
+ created_from: nil, created_to: nil, before: nil, after: nil, per: DEFAULT_PER,
14
+ exclude_branched: false)
14
15
  @base = base
15
16
  @state = state.presence
16
17
  @job_class = job_class.presence
@@ -20,6 +21,11 @@ module ChronoForge
20
21
  @before = before.presence&.to_i
21
22
  @after = after.presence&.to_i
22
23
  @per = per.to_i.clamp(1, MAX_PER)
24
+ # Off by default: the branch-children view drives this same query with a
25
+ # base scoped to a branch's spawned_workflows (all of which ARE branch
26
+ # children), so excluding them there would empty the list. Only the main
27
+ # index opts in, to keep a large fan-out's children out of the top level.
28
+ @exclude_branched = exclude_branched
23
29
  end
24
30
 
25
31
  def records
@@ -83,6 +89,9 @@ module ChronoForge
83
89
  s = s.where("key LIKE ?", "#{ChronoForge::Workflow.sanitize_sql_like(@key)}%") if @key
84
90
  s = s.where(created_at: @created_from..) if @created_from
85
91
  s = s.where(created_at: ..@created_to) if @created_to
92
+ # Top-level workflows only: a spawned branch child carries a non-null
93
+ # parent_execution_log_id (its branch coordination log).
94
+ s = s.where(parent_execution_log_id: nil) if @exclude_branched
86
95
  s
87
96
  end
88
97
  end
@@ -9,22 +9,67 @@
9
9
  form: {data: {confirm: "Re-enqueue every failed/stalled child of this branch?"}}, class: "cf-btn cf-btn-primary shrink-0" %>
10
10
  </div>
11
11
 
12
- <div data-poll-region>
12
+ <%# Live poll stats from the branch log's metadata (throughput/ETA while draining,
13
+ plus the never-started count and dropped-child recovery the poller records). %>
14
+ <% if @branch.polled? %>
15
+ <dl class="mb-5 grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-4">
16
+ <% if @branch.throughput? %>
17
+ <div>
18
+ <dt class="text-xs uppercase tracking-wide text-zinc-400">Throughput</dt>
19
+ <dd class="font-mono text-zinc-700"><%= @branch.rate < 1 ? @branch.rate.round(1) : number_with_delimiter(@branch.rate.round) %>/s</dd>
20
+ </div>
21
+ <% if @branch.eta_seconds %>
22
+ <div>
23
+ <dt class="text-xs uppercase tracking-wide text-zinc-400">ETA</dt>
24
+ <dd class="font-mono text-zinc-700"><%= cf_secs(@branch.eta_seconds) %></dd>
25
+ </div>
26
+ <% end %>
27
+ <% end %>
28
+ <% if @branch.exact_spawned %>
29
+ <div>
30
+ <dt class="text-xs uppercase tracking-wide text-zinc-400">Spawned</dt>
31
+ <dd class="font-mono text-zinc-700"><%= number_with_delimiter(@branch.exact_spawned) %></dd>
32
+ </div>
33
+ <% end %>
34
+ <% if @branch.exact_pending %>
35
+ <div>
36
+ <dt class="text-xs uppercase tracking-wide text-zinc-400">Pending</dt>
37
+ <dd class="font-mono text-zinc-700"><%= number_with_delimiter(@branch.exact_pending) %></dd>
38
+ </div>
39
+ <% end %>
40
+ <div>
41
+ <dt class="text-xs uppercase tracking-wide text-zinc-400">Never started</dt>
42
+ <dd class="font-mono text-zinc-700"><%= @branch.exact_never_started ? number_with_delimiter(@branch.exact_never_started) : cf_capped(@branch.never_started, @branch.cap) %></dd>
43
+ </div>
44
+ <div>
45
+ <dt class="text-xs uppercase tracking-wide text-zinc-400">Recovered</dt>
46
+ <dd class="font-mono text-zinc-700"><%= @branch.rekicks %><% if @branch.last_rekick_at %> <span class="text-zinc-400">· last <%= cf_ago(@branch.last_rekick_at) %></span><% end %></dd>
47
+ </div>
48
+ <div>
49
+ <dt class="text-xs uppercase tracking-wide text-zinc-400">Last check</dt>
50
+ <dd class="font-mono text-zinc-700"><%= cf_ago(@branch.last_polled_at) %> · <%= pluralize(@branch.polls, "poll") %></dd>
51
+ </div>
52
+ <% if @branch.next_poll_at %>
53
+ <div>
54
+ <dt class="text-xs uppercase tracking-wide text-zinc-400">Next check</dt>
55
+ <dd class="font-mono <%= @branch.poll_overdue? ? "text-rose-600" : "text-zinc-700" %>"><%= cf_when(@branch.next_poll_at) %></dd>
56
+ </div>
57
+ <% end %>
58
+ </dl>
59
+ <% end %>
60
+
61
+ <div>
13
62
  <% current = params.key?(:state) ? params[:state].to_s : "blocked" %>
14
63
  <% 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
- } %>
64
+ <% branch_chip_href = ->(state) { workflow_branch_path(@workflow, @branch_log, state: state) } %>
22
65
  <div class="mb-4 flex flex-wrap gap-2">
23
- <%= chip.call("blocked", "blocked", blocked, current == "blocked") %>
24
- <%= chip.call("all", "", nil, current == "") %>
66
+ <%# "blocked" (failed+stalled) gets a red dot; "all" has no single state, so no
67
+ dot, and sits last as the catch-all/reset. %>
68
+ <%= cf_filter_chip(branch_chip_href.call("blocked"), label: "blocked", count: blocked, cap: @stats_cap, active: current == "blocked", dot: "failed") %>
25
69
  <% ChronoForge::Workflow.states.keys.each do |s| %>
26
- <%= chip.call(s, s, @stats[s], current == s) %>
70
+ <%= cf_filter_chip(branch_chip_href.call(s), label: s, count: @stats[s], cap: @stats_cap, active: current == s, dot: s) %>
27
71
  <% end %>
72
+ <%= cf_filter_chip(branch_chip_href.call(""), label: "all", active: current == "") %>
28
73
  </div>
29
74
 
30
75
  <div class="cf-card overflow-hidden">
@@ -0,0 +1,42 @@
1
+ <%= link_to "‹ Back to workflow", workflow_path(@workflow), class: cf_chip("mb-2") %>
2
+
3
+ <div class="mb-4">
4
+ <h1 class="text-lg font-semibold text-zinc-800">Definition graph</h1>
5
+ <p class="text-xs text-zinc-500"><%= @workflow.job_class %> — <%= @workflow.key %></p>
6
+ </div>
7
+
8
+ <% if @warnings.any? %>
9
+ <div class="mb-4 rounded border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
10
+ <p class="font-medium mb-1">Static analysis notes</p>
11
+ <ul class="list-disc pl-4 space-y-0.5">
12
+ <% @warnings.each do |w| %><li><%= w %></li><% end %>
13
+ </ul>
14
+ </div>
15
+ <% end %>
16
+
17
+ <div class="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm">
18
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-1.5 border-b border-zinc-100 px-4 py-2.5 text-[11px] text-zinc-500">
19
+ <% {done: "done", active: "in progress", pending: "pending", not_reached: "not yet reached", failed: "failed", unmapped: "unmapped"}.each do |status, label| %>
20
+ <span class="inline-flex items-center gap-1.5">
21
+ <span class="h-2.5 w-2.5 rounded-sm border cf-legend-<%= status %>"></span><%= label %>
22
+ </span>
23
+ <% end %>
24
+ </div>
25
+ <%# The graph is passed as JSON in a data attribute — ERB-escaped, so labels and
26
+ guard text (which contain (), <, &&) round-trip through the DOM untouched. %>
27
+ <div id="cf-graph" data-graph="<%= @graph.to_json %>" class="h-[72vh] w-full bg-zinc-50"></div>
28
+ <div id="cf-graph-detail" class="min-h-[3rem] border-t border-zinc-100 bg-white px-4 py-3 text-xs"></div>
29
+ </div>
30
+
31
+ <style>
32
+ .cf-legend-done{background:#ecfdf5;border-color:#10b981}
33
+ .cf-legend-active{background:#eff6ff;border-color:#3b82f6}
34
+ .cf-legend-pending{background:#fafafa;border-color:#d4d4d8}
35
+ .cf-legend-not_reached{background:#fff;border-color:#e4e4e7}
36
+ .cf-legend-failed{background:#fef2f2;border-color:#ef4444}
37
+ .cf-legend-unmapped{background:#fafaf9;border-color:#d6d3d1}
38
+ </style>
39
+
40
+ <% %w[cytoscape.min.js dagre.min.js cytoscape-dagre.js definition_graph.js].each do |asset| %>
41
+ <script src="<%= "#{request.script_name}/assets/#{asset}?v=#{ChronoForge::Dashboard.asset_digest(asset)}" %>"></script>
42
+ <% end %>
@@ -6,8 +6,9 @@
6
6
  <tr class="border-b border-zinc-200 text-left text-xs uppercase tracking-wide text-zinc-500">
7
7
  <th class="py-2 pr-4 font-medium">Branch</th>
8
8
  <th class="py-2 pr-4 font-medium">Status</th>
9
- <th class="py-2 pr-4 text-right font-medium">Dispatched</th>
9
+ <th class="py-2 pr-4 text-right font-medium">Spawned</th>
10
10
  <th class="py-2 pr-4 text-right font-medium">Pending</th>
11
+ <th class="py-2 pr-4 text-right font-medium">Never started</th>
11
12
  <th class="py-2 pr-4 text-right font-medium">Blocked</th>
12
13
  <th class="py-2 font-medium"></th>
13
14
  </tr>
@@ -29,8 +30,9 @@
29
30
  · <span class="text-zinc-400" title="<%= b.polls %> polls · next <%= b.next_poll_at&.iso8601 || "—" %>">polling</span>
30
31
  <% end %>
31
32
  </td>
32
- <td class="py-2 pr-4 text-right font-mono tabular-nums text-zinc-600"><%= cf_capped(b.dispatched, b.cap) %></td>
33
- <td class="py-2 pr-4 text-right font-mono tabular-nums text-zinc-600"><%= cf_capped(b.pending, b.cap) %></td>
33
+ <td class="py-2 pr-4 text-right font-mono tabular-nums text-zinc-600"><%= b.exact_spawned ? number_with_delimiter(b.exact_spawned) : cf_capped(b.spawned, b.cap) %></td>
34
+ <td class="py-2 pr-4 text-right font-mono tabular-nums text-zinc-600"><%= b.exact_pending ? number_with_delimiter(b.exact_pending) : cf_capped(b.pending, b.cap) %></td>
35
+ <td class="py-2 pr-4 text-right font-mono tabular-nums text-zinc-600"><%= b.exact_never_started ? number_with_delimiter(b.exact_never_started) : cf_capped(b.never_started, b.cap) %></td>
34
36
  <td class="py-2 pr-4 text-right font-mono tabular-nums <%= "font-medium text-rose-600" if b.blocked > 0 %>"><%= cf_capped(b.blocked, b.cap) %></td>
35
37
  <td class="py-2 text-right"><%= link_to "details →", workflow_branch_path(workflow, b.log), class: cf_chip %></td>
36
38
  </tr>
@@ -48,8 +50,16 @@
48
50
  <div class="min-w-0">
49
51
  <span class="break-all font-mono text-zinc-700"><%= m.names.join(" + ") %></span>
50
52
  <span class="ml-1.5 text-xs <%= m.merging? ? "text-amber-600" : "text-zinc-400" %>"><%= m.state %></span>
53
+ <% if m.poll_overdue? %><span class="ml-1.5 text-xs font-medium text-rose-600" title="next check is past due — the poller may have been dropped">poll overdue</span><% end %>
54
+ </div>
55
+ <div class="flex shrink-0 flex-wrap items-baseline gap-x-3 text-xs text-zinc-400">
56
+ <% if m.last_polled_at %><span>last check <%= cf_ago(m.last_polled_at) %></span><% end %>
57
+ <% if m.merging? && m.next_poll_at %><span class="<%= "font-medium text-rose-600" if m.poll_overdue? %>">next check <%= cf_when(m.next_poll_at) %></span><% end %>
58
+ <% if m.throughput? %><span title="measured over the last poll interval"><%= m.rate < 1 ? m.rate.round(1) : number_with_delimiter(m.rate.round) %>/s</span><% end %>
59
+ <% if m.throughput? && m.eta_seconds %><span>ETA <%= cf_secs(m.eta_seconds) %></span><% end %>
60
+ <% if m.polls&.positive? %><span><%= pluralize(m.polls, "poll") %></span><% end %>
61
+ <% if m.started_at %><span>started <%= cf_ago(m.started_at) %></span><% end %>
51
62
  </div>
52
- <% if m.started_at %><span class="shrink-0 text-xs text-zinc-400">started <%= cf_ago(m.started_at) %></span><% end %>
53
63
  </div>
54
64
  <% end %>
55
65
  </div>
@@ -1,6 +1,17 @@
1
- <%= form_with url: workflows_path, method: :get, class: "mb-4 flex flex-wrap items-center gap-2" do |f| %>
1
+ <%# enforce_utf8: false keeps the legacy `utf8=✓` IE hack out of the GET query string. %>
2
+ <%= form_with url: workflows_path, method: :get, enforce_utf8: false, class: "mb-4 flex flex-wrap items-center gap-2" do |f| %>
2
3
  <%= f.select :state, options_for_select([["All states", ""], *ChronoForge::Workflow.states.keys], params[:state]), {}, class: "rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm", data: {autosubmit: true} %>
3
4
  <%= f.text_field :job_class, value: params[:job_class], placeholder: "Job class", class: "rounded-md border border-zinc-300 px-2.5 py-1.5 text-sm placeholder:text-zinc-400" %>
4
5
  <%= f.text_field :key, value: params[:key], placeholder: "Key", class: "rounded-md border border-zinc-300 px-2.5 py-1.5 text-sm placeholder:text-zinc-400" %>
5
6
  <%= f.submit "Filter", class: "cf-btn" %>
7
+
8
+ <%# Far right: hide spawned branch children (on by default) so a large fan-out
9
+ doesn't flood the top-level list. Hidden field makes "off" submit "0" so the
10
+ controller can default an absent param to on. Auto-applies on toggle. %>
11
+ <label class="ml-auto flex cursor-pointer items-center gap-1.5 text-sm text-zinc-600">
12
+ <%= hidden_field_tag :hide_branches, "0", id: nil %>
13
+ <%= check_box_tag :hide_branches, "1", params[:hide_branches] != "0",
14
+ data: {autosubmit: true}, class: "h-4 w-4 rounded border-zinc-300" %>
15
+ Hide branches
16
+ </label>
6
17
  <% end %>
@@ -1,14 +1,10 @@
1
1
  <div class="mb-4 flex flex-wrap gap-2">
2
2
  <% base = request.query_parameters.except("before", "after") %>
3
+ <% chip_href = ->(state) { workflows_path(params[:state].to_s == state ? base.except("state") : base.merge(state: state)) } %>
4
+ <%# "blocked" = failed + stalled — the actionable subset, one click to triage. %>
5
+ <% blocked_count = stats["failed"].to_i + stats["stalled"].to_i %>
6
+ <%= cf_filter_chip(chip_href.call("blocked"), label: "blocked", count: blocked_count, cap: cap, active: params[:state].to_s == "blocked", dot: "failed") %>
3
7
  <% cf_state_order(stats.keys).each do |state| %>
4
- <% count = stats[state] %>
5
- <% active = params[:state].to_s == state %>
6
- <% target = active ? base.except("state") : base.merge(state: state) %>
7
- <%= link_to workflows_path(target),
8
- class: "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"}" do %>
9
- <%= cf_dot(state) %>
10
- <span class="text-zinc-500"><%= state %></span>
11
- <span class="font-mono font-medium tabular-nums"><%= cf_capped(count, cap) %></span>
12
- <% end %>
8
+ <%= cf_filter_chip(chip_href.call(state), label: state, count: stats[state], cap: cap, active: params[:state].to_s == state, dot: state) %>
13
9
  <% end %>
14
10
  </div>
@@ -1,10 +1,10 @@
1
1
  <div class="mb-5 flex items-center justify-between">
2
2
  <h1 class="text-lg font-semibold tracking-tight">Workflows</h1>
3
- <%= button_to "Retry failed & stalled", bulk_retry_workflows_path, method: :post,
4
- form: {data: {confirm: "Re-enqueue every failed and stalled workflow?"}}, class: "cf-btn" %>
3
+ <%= button_to "Retry blocked", bulk_retry_workflows_path, method: :post,
4
+ form: {data: {confirm: "Re-enqueue every blocked (failed and stalled) workflow?"}}, class: "cf-btn" %>
5
5
  </div>
6
6
 
7
- <div data-poll-region>
7
+ <div>
8
8
  <%= render "stats", stats: @stats, cap: @stats_cap %>
9
9
  <%= render "filters", query: @query %>
10
10
 
@@ -9,6 +9,7 @@
9
9
  <p class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-zinc-500">
10
10
  <span class="break-all font-mono"><%= @workflow.job_class %></span>
11
11
  <%= link_to "metrics →", analytics_path(class: @workflow.job_class), class: cf_chip %>
12
+ <%= link_to "Definition graph", definition_workflow_path(@workflow), class: cf_chip %>
12
13
  </p>
13
14
  </div>
14
15
  <% if @workflow.retryable? || @workflow.running? || @workflow.idle? %>
@@ -26,15 +26,19 @@
26
26
  <a href="<%= analytics_path %>" class="<%= controller_name == "analytics" ? "font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900" %>">Analytics</a>
27
27
  </nav>
28
28
  <div class="ml-auto flex flex-wrap items-center gap-2">
29
- <% current_poll = cf_poll_interval %>
30
- <label class="flex items-center gap-1 text-xs text-zinc-400" title="Auto-refresh">
31
- <span>refresh</span>
32
- <select data-poll-select class="rounded-md border border-zinc-200 bg-white px-1.5 py-1 text-xs text-zinc-700">
33
- <% (cf_poll_options | [current_poll]).sort.each do |secs| %>
34
- <option value="<%= secs %>" <%= "selected" if secs == current_poll %>><%= cf_poll_label(secs) %></option>
35
- <% end %>
36
- </select>
37
- </label>
29
+ <%# Hidden on pages that opt out of auto-refresh (cf_poll_region? false), so
30
+ a control that does nothing there doesn't confuse. %>
31
+ <% if cf_poll_region? %>
32
+ <% current_poll = cf_poll_interval %>
33
+ <label class="flex items-center gap-1 text-xs text-zinc-400" title="Auto-refresh">
34
+ <span>refresh</span>
35
+ <select data-poll-select class="rounded-md border border-zinc-200 bg-white px-1.5 py-1 text-xs text-zinc-700">
36
+ <% (cf_poll_options | [current_poll]).sort.each do |secs| %>
37
+ <option value="<%= secs %>" <%= "selected" if secs == current_poll %>><%= cf_poll_label(secs) %></option>
38
+ <% end %>
39
+ </select>
40
+ </label>
41
+ <% end %>
38
42
  <div class="flex items-center gap-0.5 rounded-md border border-zinc-200 p-0.5 text-xs" title="Timestamp display">
39
43
  <button type="button" data-time-set="relative" class="rounded px-2 py-0.5 <%= cf_absolute_time? ? "text-zinc-500 hover:text-zinc-900" : "bg-zinc-900 text-white" %>">relative</button>
40
44
  <button type="button" data-time-set="absolute" class="rounded px-2 py-0.5 <%= cf_absolute_time? ? "bg-zinc-900 text-white" : "text-zinc-500 hover:text-zinc-900" %>">absolute</button>
@@ -42,7 +46,11 @@
42
46
  </div>
43
47
  </div>
44
48
  </header>
45
- <main class="mx-auto max-w-6xl px-4 py-6">
49
+ <%# data-poll-region: the JS auto-refreshes this region in place (preserving
50
+ filter text, focus, and scroll). Marking <main> makes pages refresh — the
51
+ nav and the refresh/time controls sit in <header>, outside the swap. A page
52
+ can opt out (cf_poll_region? false) when an in-place swap would break it. %>
53
+ <main class="mx-auto max-w-6xl px-4 py-6" <%= "data-poll-region" if cf_poll_region? %>>
46
54
  <%= yield %>
47
55
  </main>
48
56
  <script src="<%= "#{request.script_name}/assets/dashboard.js?v=#{ChronoForge::Dashboard.asset_digest("dashboard.js")}" %>"></script>
data/config/routes.rb CHANGED
@@ -6,6 +6,7 @@ ChronoForge::Dashboard::Engine.routes.draw do
6
6
  post :resume, to: "actions#resume"
7
7
  post :unlock, to: "actions#unlock"
8
8
  get :repetitions, to: "repetitions#index"
9
+ get :definition, to: "definitions#show"
9
10
  end
10
11
  collection do
11
12
  post :bulk_retry, to: "actions#bulk_retry"
@@ -16,5 +17,9 @@ ChronoForge::Dashboard::Engine.routes.draw do
16
17
  end
17
18
  resources :wait_states, only: :index
18
19
  get "analytics", to: "analytics#index", as: :analytics
19
- get "assets/:file", to: "assets#show", constraints: {file: /dashboard\.(css|js)/}
20
+ # Explicit allowlist (mirrors AssetsController::TYPES) so unknown assets 404 at
21
+ # the routing layer rather than reaching the controller.
22
+ get "assets/:file", to: "assets#show", constraints: {
23
+ file: /(dashboard\.(css|js)|cytoscape\.min\.js|dagre\.min\.js|cytoscape-dagre\.js|definition_graph\.js)/
24
+ }
20
25
  end
@@ -19,9 +19,9 @@ module ChronoForge
19
19
  @http_basic = nil
20
20
  @authentication = nil
21
21
  @auth_hook = nil
22
- @polling_interval = 5
22
+ @polling_interval = 15
23
23
  # Selectable auto-refresh intervals (seconds; 0 = off) for the nav control.
24
- @polling_interval_options = [0, 5, 10, 30, 60, 300]
24
+ @polling_interval_options = [0, 5, 10, 15, 30, 60, 300]
25
25
  @page_size = 50
26
26
  @long_wait_threshold = 3600
27
27
  end
@@ -1,5 +1,5 @@
1
1
  module ChronoForge
2
2
  module Dashboard
3
- VERSION = "0.1.0"
3
+ VERSION = "0.2.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chrono_forge-dashboard
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stefan Froelich
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-27 00:00:00.000000000 Z
11
+ date: 2026-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: chrono_forge
@@ -162,14 +162,19 @@ files:
162
162
  - CHANGELOG.md
163
163
  - MIT-LICENSE
164
164
  - README.md
165
+ - app/assets/chrono_forge/dashboard/cytoscape-dagre.js
166
+ - app/assets/chrono_forge/dashboard/cytoscape.min.js
167
+ - app/assets/chrono_forge/dashboard/dagre.min.js
165
168
  - app/assets/chrono_forge/dashboard/dashboard.css
166
169
  - app/assets/chrono_forge/dashboard/dashboard.js
170
+ - app/assets/chrono_forge/dashboard/definition_graph.js
167
171
  - app/assets/chrono_forge/dashboard/tailwind.css
168
172
  - app/controllers/chrono_forge/dashboard/actions_controller.rb
169
173
  - app/controllers/chrono_forge/dashboard/analytics_controller.rb
170
174
  - app/controllers/chrono_forge/dashboard/assets_controller.rb
171
175
  - app/controllers/chrono_forge/dashboard/base_controller.rb
172
176
  - app/controllers/chrono_forge/dashboard/branch_children_controller.rb
177
+ - app/controllers/chrono_forge/dashboard/definitions_controller.rb
173
178
  - app/controllers/chrono_forge/dashboard/repetitions_controller.rb
174
179
  - app/controllers/chrono_forge/dashboard/wait_states_controller.rb
175
180
  - app/controllers/chrono_forge/dashboard/workflows_controller.rb
@@ -177,6 +182,8 @@ files:
177
182
  - app/presenters/chrono_forge/dashboard/branch_presenter.rb
178
183
  - app/presenters/chrono_forge/dashboard/branches_presenter.rb
179
184
  - app/presenters/chrono_forge/dashboard/context_presenter.rb
185
+ - app/presenters/chrono_forge/dashboard/cytoscape_graph.rb
186
+ - app/presenters/chrono_forge/dashboard/definition_overlay.rb
180
187
  - app/presenters/chrono_forge/dashboard/periodic_health_presenter.rb
181
188
  - app/presenters/chrono_forge/dashboard/timeline_presenter.rb
182
189
  - app/presenters/chrono_forge/dashboard/wait_state_presenter.rb
@@ -186,6 +193,7 @@ files:
186
193
  - app/queries/chrono_forge/dashboard/workflows_query.rb
187
194
  - app/views/chrono_forge/dashboard/analytics/index.html.erb
188
195
  - app/views/chrono_forge/dashboard/branch_children/show.html.erb
196
+ - app/views/chrono_forge/dashboard/definitions/show.html.erb
189
197
  - app/views/chrono_forge/dashboard/repetitions/index.html.erb
190
198
  - app/views/chrono_forge/dashboard/wait_states/index.html.erb
191
199
  - app/views/chrono_forge/dashboard/workflows/_branches.html.erb