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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +43 -24
- data/app/assets/chrono_forge/dashboard/cytoscape-dagre.js +397 -0
- data/app/assets/chrono_forge/dashboard/cytoscape.min.js +32 -0
- data/app/assets/chrono_forge/dashboard/dagre.min.js +3809 -0
- data/app/assets/chrono_forge/dashboard/dashboard.css +1 -1
- data/app/assets/chrono_forge/dashboard/dashboard.js +37 -0
- data/app/assets/chrono_forge/dashboard/definition_graph.js +161 -0
- data/app/controllers/chrono_forge/dashboard/assets_controller.rb +8 -1
- data/app/controllers/chrono_forge/dashboard/definitions_controller.rb +35 -0
- data/app/controllers/chrono_forge/dashboard/workflows_controller.rb +6 -2
- data/app/helpers/chrono_forge/dashboard/dashboard_helper.rb +33 -0
- data/app/presenters/chrono_forge/dashboard/branch_presenter.rb +38 -1
- data/app/presenters/chrono_forge/dashboard/branches_presenter.rb +59 -4
- data/app/presenters/chrono_forge/dashboard/cytoscape_graph.rb +48 -0
- data/app/presenters/chrono_forge/dashboard/definition_overlay.rb +128 -0
- data/app/queries/chrono_forge/dashboard/workflows_query.rb +10 -1
- data/app/views/chrono_forge/dashboard/branch_children/show.html.erb +56 -11
- data/app/views/chrono_forge/dashboard/definitions/show.html.erb +42 -0
- data/app/views/chrono_forge/dashboard/workflows/_branches.html.erb +14 -4
- data/app/views/chrono_forge/dashboard/workflows/_filters.html.erb +12 -1
- data/app/views/chrono_forge/dashboard/workflows/_stats.html.erb +5 -9
- data/app/views/chrono_forge/dashboard/workflows/index.html.erb +3 -3
- data/app/views/chrono_forge/dashboard/workflows/show.html.erb +1 -0
- data/app/views/layouts/chrono_forge/dashboard/application.html.erb +18 -10
- data/config/routes.rb +6 -1
- data/lib/chrono_forge/dashboard/configuration.rb +2 -2
- data/lib/chrono_forge/dashboard/version.rb +1 -1
- 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
|
-
|
|
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
|
-
<%
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
<%=
|
|
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">
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
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.
|
|
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-
|
|
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
|