chrono_forge-dashboard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/MIT-LICENSE +21 -0
- data/README.md +147 -0
- data/app/assets/chrono_forge/dashboard/dashboard.css +2 -0
- data/app/assets/chrono_forge/dashboard/dashboard.js +89 -0
- data/app/assets/chrono_forge/dashboard/tailwind.css +69 -0
- data/app/controllers/chrono_forge/dashboard/actions_controller.rb +58 -0
- data/app/controllers/chrono_forge/dashboard/analytics_controller.rb +23 -0
- data/app/controllers/chrono_forge/dashboard/assets_controller.rb +21 -0
- data/app/controllers/chrono_forge/dashboard/base_controller.rb +29 -0
- data/app/controllers/chrono_forge/dashboard/branch_children_controller.rb +33 -0
- data/app/controllers/chrono_forge/dashboard/repetitions_controller.rb +20 -0
- data/app/controllers/chrono_forge/dashboard/wait_states_controller.rb +38 -0
- data/app/controllers/chrono_forge/dashboard/workflows_controller.rb +31 -0
- data/app/helpers/chrono_forge/dashboard/dashboard_helper.rb +153 -0
- data/app/presenters/chrono_forge/dashboard/branch_presenter.rb +64 -0
- data/app/presenters/chrono_forge/dashboard/branches_presenter.rb +62 -0
- data/app/presenters/chrono_forge/dashboard/context_presenter.rb +17 -0
- data/app/presenters/chrono_forge/dashboard/periodic_health_presenter.rb +77 -0
- data/app/presenters/chrono_forge/dashboard/timeline_presenter.rb +90 -0
- data/app/presenters/chrono_forge/dashboard/wait_state_presenter.rb +64 -0
- data/app/queries/chrono_forge/dashboard/analytics_query.rb +133 -0
- data/app/queries/chrono_forge/dashboard/repetitions_query.rb +110 -0
- data/app/queries/chrono_forge/dashboard/stats_query.rb +30 -0
- data/app/queries/chrono_forge/dashboard/workflows_query.rb +90 -0
- data/app/views/chrono_forge/dashboard/analytics/index.html.erb +103 -0
- data/app/views/chrono_forge/dashboard/branch_children/show.html.erb +58 -0
- data/app/views/chrono_forge/dashboard/repetitions/index.html.erb +69 -0
- data/app/views/chrono_forge/dashboard/wait_states/index.html.erb +73 -0
- data/app/views/chrono_forge/dashboard/workflows/_branches.html.erb +57 -0
- data/app/views/chrono_forge/dashboard/workflows/_context_tree.html.erb +16 -0
- data/app/views/chrono_forge/dashboard/workflows/_error_card.html.erb +13 -0
- data/app/views/chrono_forge/dashboard/workflows/_filters.html.erb +6 -0
- data/app/views/chrono_forge/dashboard/workflows/_parent_breadcrumb.html.erb +9 -0
- data/app/views/chrono_forge/dashboard/workflows/_periodic.html.erb +24 -0
- data/app/views/chrono_forge/dashboard/workflows/_stats.html.erb +14 -0
- data/app/views/chrono_forge/dashboard/workflows/_timeline.html.erb +67 -0
- data/app/views/chrono_forge/dashboard/workflows/_wait_callout.html.erb +5 -0
- data/app/views/chrono_forge/dashboard/workflows/_workflow_row.html.erb +8 -0
- data/app/views/chrono_forge/dashboard/workflows/index.html.erb +39 -0
- data/app/views/chrono_forge/dashboard/workflows/show.html.erb +79 -0
- data/app/views/layouts/chrono_forge/dashboard/application.html.erb +50 -0
- data/config/routes.rb +20 -0
- data/lib/chrono_forge/dashboard/configuration.rb +32 -0
- data/lib/chrono_forge/dashboard/engine.rb +9 -0
- data/lib/chrono_forge/dashboard/step_name_parser.rb +32 -0
- data/lib/chrono_forge/dashboard/version.rb +5 -0
- data/lib/chrono_forge/dashboard.rb +30 -0
- metadata +237 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<h1 class="mb-1 text-lg font-semibold tracking-tight">Waiting workflows</h1>
|
|
2
|
+
<p class="mb-5 text-sm text-zinc-500">
|
|
3
|
+
Idle workflows parked on a condition. <span class="font-mono">wait_until</span> polls and times out;
|
|
4
|
+
<span class="font-mono">continue_if</span> waits on an external event with no timeout — so a signal that never
|
|
5
|
+
arrives sits here indefinitely.
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<% if @oldest_event_by_class.any? %>
|
|
9
|
+
<section class="cf-card mb-5 border-amber-200 bg-amber-50/40 p-5">
|
|
10
|
+
<h2 class="mb-1 text-xs font-medium uppercase tracking-wide text-amber-700">Oldest unresolved event wait, by class</h2>
|
|
11
|
+
<p class="mb-3 text-xs text-zinc-500">A <span class="font-mono">continue_if</span> that never resolves is a silent stall. Oldest per class:</p>
|
|
12
|
+
<div class="divide-y divide-amber-100">
|
|
13
|
+
<% @oldest_event_by_class.each do |h| %>
|
|
14
|
+
<% long = (Time.current - (h[:wait].waiting_since || Time.current)) > @threshold %>
|
|
15
|
+
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
|
16
|
+
<div class="min-w-0">
|
|
17
|
+
<span class="font-mono text-sm text-zinc-900"><%= h[:workflow].job_class %></span>
|
|
18
|
+
<span class="ml-2 font-mono text-xs text-zinc-500"><%= h[:wait].condition %></span>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="flex shrink-0 items-baseline gap-3">
|
|
21
|
+
<span class="text-sm <%= long ? "font-medium text-amber-700" : "text-zinc-600" %>">
|
|
22
|
+
<%= h[:wait].waiting_since ? "#{distance_of_time_in_words(h[:wait].waiting_since, Time.current)} waiting" : "—" %>
|
|
23
|
+
</span>
|
|
24
|
+
<%= link_to "open →", workflow_path(h[:workflow]), class: cf_chip %>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
32
|
+
<div class="cf-card overflow-hidden">
|
|
33
|
+
<div class="overflow-x-auto">
|
|
34
|
+
<table class="w-full min-w-[44rem] text-sm">
|
|
35
|
+
<thead>
|
|
36
|
+
<tr class="border-b border-zinc-200 bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
|
|
37
|
+
<th class="px-4 py-2.5 font-medium">Key</th>
|
|
38
|
+
<th class="px-4 py-2.5 font-medium">Class</th>
|
|
39
|
+
<th class="px-4 py-2.5 font-medium">Kind</th>
|
|
40
|
+
<th class="px-4 py-2.5 font-medium">Condition</th>
|
|
41
|
+
<th class="px-4 py-2.5 font-medium">Waiting</th>
|
|
42
|
+
<th class="px-4 py-2.5 text-right font-medium">Timeout</th>
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody class="divide-y divide-zinc-100">
|
|
46
|
+
<% @waits.each do |h| %>
|
|
47
|
+
<% long = (Time.current - (h[:wait].waiting_since || Time.current)) > @threshold %>
|
|
48
|
+
<tr class="cursor-pointer <%= "cf-wait--long bg-amber-50" if long %> hover:bg-zinc-50" data-href="<%= workflow_path(h[:workflow]) %>">
|
|
49
|
+
<td class="px-4 py-2.5"><%= link_to h[:workflow].key, workflow_path(h[:workflow]), class: "font-mono hover:underline" %></td>
|
|
50
|
+
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600"><%= h[:workflow].job_class %></td>
|
|
51
|
+
<td class="px-4 py-2.5 text-xs">
|
|
52
|
+
<% if h[:wait].event_wait? %>
|
|
53
|
+
<span class="font-medium text-amber-700">event</span>
|
|
54
|
+
<% else %>
|
|
55
|
+
<span class="text-zinc-500">poll</span>
|
|
56
|
+
<% end %>
|
|
57
|
+
</td>
|
|
58
|
+
<td class="px-4 py-2.5 font-mono text-xs text-zinc-600"><%= h[:wait].condition %></td>
|
|
59
|
+
<td class="px-4 py-2.5 <%= long ? "font-medium text-amber-700" : "text-zinc-600" %>"><%= distance_of_time_in_words(h[:wait].waiting_since, Time.current) %></td>
|
|
60
|
+
<td class="px-4 py-2.5 text-right font-mono text-xs text-zinc-500"><%= h[:wait].event_wait? ? "none" : (h[:wait].timeout_at || "—") %></td>
|
|
61
|
+
</tr>
|
|
62
|
+
<% end %>
|
|
63
|
+
</tbody>
|
|
64
|
+
</table>
|
|
65
|
+
</div>
|
|
66
|
+
<% if @waits.none? %>
|
|
67
|
+
<p class="px-4 py-12 text-center text-sm text-zinc-500">No workflows are waiting.</p>
|
|
68
|
+
<% end %>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<% if @capped %>
|
|
72
|
+
<p class="mt-3 text-xs text-zinc-400">Scanned the <%= @cap %> oldest idle workflows. Narrow with filters on the workflow list for more.</p>
|
|
73
|
+
<% end %>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<section class="cf-card mb-5 p-5">
|
|
2
|
+
<h2 class="mb-4 text-xs font-medium uppercase tracking-wide text-zinc-500">Branches</h2>
|
|
3
|
+
<div class="overflow-x-auto">
|
|
4
|
+
<table class="w-full min-w-[40rem] text-sm">
|
|
5
|
+
<thead>
|
|
6
|
+
<tr class="border-b border-zinc-200 text-left text-xs uppercase tracking-wide text-zinc-500">
|
|
7
|
+
<th class="py-2 pr-4 font-medium">Branch</th>
|
|
8
|
+
<th class="py-2 pr-4 font-medium">Status</th>
|
|
9
|
+
<th class="py-2 pr-4 text-right font-medium">Dispatched</th>
|
|
10
|
+
<th class="py-2 pr-4 text-right font-medium">Pending</th>
|
|
11
|
+
<th class="py-2 pr-4 text-right font-medium">Blocked</th>
|
|
12
|
+
<th class="py-2 font-medium"></th>
|
|
13
|
+
</tr>
|
|
14
|
+
</thead>
|
|
15
|
+
<tbody class="divide-y divide-zinc-100">
|
|
16
|
+
<% branches.branches.each do |b| %>
|
|
17
|
+
<tr class="cursor-pointer hover:bg-zinc-50" data-href="<%= workflow_branch_path(workflow, b.log) %>">
|
|
18
|
+
<td class="py-2 pr-4 font-mono"><%= b.name %></td>
|
|
19
|
+
<td class="py-2 pr-4 text-xs text-zinc-600">
|
|
20
|
+
<%= b.sealed? ? "sealed" : "dispatching" %><%
|
|
21
|
+
if b.merge_state %> · <span class="<%= b.merge_state == :merging ? "text-amber-600" : "text-zinc-500" %>"><%= b.merge_state %></span><%
|
|
22
|
+
elsif b.sealed? %> · <span class="text-zinc-400">unmerged</span><% end %>
|
|
23
|
+
<% if b.poll_overdue? %>
|
|
24
|
+
· <span class="font-medium text-rose-600" title="last polled <%= b.last_polled_at&.iso8601 %> · next poll was due <%= b.next_poll_at&.iso8601 %> · <%= b.polls %> polls">poll overdue</span>
|
|
25
|
+
<%= button_to "resume poller", resume_workflow_path(workflow), method: :post,
|
|
26
|
+
form_class: "ml-1 inline-block align-middle",
|
|
27
|
+
class: "inline-flex items-center rounded-md border border-rose-200 px-2 py-0.5 text-xs text-rose-700 hover:bg-rose-50" %>
|
|
28
|
+
<% elsif b.polled? %>
|
|
29
|
+
· <span class="text-zinc-400" title="<%= b.polls %> polls · next <%= b.next_poll_at&.iso8601 || "—" %>">polling</span>
|
|
30
|
+
<% end %>
|
|
31
|
+
</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>
|
|
34
|
+
<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
|
+
<td class="py-2 text-right"><%= link_to "details →", workflow_branch_path(workflow, b.log), class: cf_chip %></td>
|
|
36
|
+
</tr>
|
|
37
|
+
<% end %>
|
|
38
|
+
</tbody>
|
|
39
|
+
</table>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<% if branches.merges.any? %>
|
|
43
|
+
<div class="mt-4 border-t border-zinc-100 pt-3">
|
|
44
|
+
<p class="mb-1 text-[11px] font-medium uppercase tracking-wider text-zinc-400">Merges</p>
|
|
45
|
+
<p class="mb-2 text-xs text-zinc-400">Each is a <code class="font-mono">BranchMergeJob</code> polling until its branches complete.</p>
|
|
46
|
+
<% branches.merges.each do |m| %>
|
|
47
|
+
<div class="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 py-1 text-sm">
|
|
48
|
+
<div class="min-w-0">
|
|
49
|
+
<span class="break-all font-mono text-zinc-700"><%= m.names.join(" + ") %></span>
|
|
50
|
+
<span class="ml-1.5 text-xs <%= m.merging? ? "text-amber-600" : "text-zinc-400" %>"><%= m.state %></span>
|
|
51
|
+
</div>
|
|
52
|
+
<% if m.started_at %><span class="shrink-0 text-xs text-zinc-400">started <%= cf_ago(m.started_at) %></span><% end %>
|
|
53
|
+
</div>
|
|
54
|
+
<% end %>
|
|
55
|
+
</div>
|
|
56
|
+
<% end %>
|
|
57
|
+
</section>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<p class="mb-3 font-mono text-xs text-zinc-400"><%= number_to_human_size(context.byte_size) %></p>
|
|
2
|
+
<% if context.nodes.any? %>
|
|
3
|
+
<dl class="space-y-3">
|
|
4
|
+
<% context.nodes.each do |n| %>
|
|
5
|
+
<div>
|
|
6
|
+
<div class="flex items-baseline justify-between gap-2">
|
|
7
|
+
<dt class="font-mono text-sm text-zinc-900"><%= n[:key] %></dt>
|
|
8
|
+
<span class="shrink-0 font-mono text-[11px] text-zinc-400"><%= n[:type] %></span>
|
|
9
|
+
</div>
|
|
10
|
+
<dd class="mt-0.5 break-words font-mono text-sm text-zinc-600"><%= truncate(n[:value].inspect, length: 200) %></dd>
|
|
11
|
+
</div>
|
|
12
|
+
<% end %>
|
|
13
|
+
</dl>
|
|
14
|
+
<% else %>
|
|
15
|
+
<p class="text-sm text-zinc-500">No context stored.</p>
|
|
16
|
+
<% end %>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<div class="mt-1.5 rounded-md border border-rose-200 bg-rose-50 px-2.5 py-1.5">
|
|
2
|
+
<div class="flex flex-wrap items-baseline gap-x-2 font-mono text-xs">
|
|
3
|
+
<span class="font-medium text-rose-600"><%= err.error_class %></span>
|
|
4
|
+
<% if err.attempt %><span class="text-zinc-400">attempt <%= err.attempt %></span><% end %>
|
|
5
|
+
</div>
|
|
6
|
+
<% if err.error_message.present? %><div class="mt-0.5 text-xs text-rose-700"><%= err.error_message %></div><% end %>
|
|
7
|
+
<% if err.backtrace.present? %>
|
|
8
|
+
<details class="mt-1">
|
|
9
|
+
<summary class="cursor-pointer text-[11px] text-zinc-500">backtrace</summary>
|
|
10
|
+
<pre class="mt-1 overflow-x-auto rounded bg-white p-2 font-mono text-[11px] text-zinc-600"><%= err.backtrace %></pre>
|
|
11
|
+
</details>
|
|
12
|
+
<% end %>
|
|
13
|
+
</div>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<%= form_with url: workflows_path, method: :get, class: "mb-4 flex flex-wrap items-center gap-2" do |f| %>
|
|
2
|
+
<%= 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
|
+
<%= 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
|
+
<%= 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
|
+
<%= f.submit "Filter", class: "cf-btn" %>
|
|
6
|
+
<% end %>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<% parent = parent_log.workflow %>
|
|
2
|
+
<% branch_name = ChronoForge::Dashboard::StepNameParser.parse(parent_log.step_name).name %>
|
|
3
|
+
<div class="mb-2 flex flex-wrap items-center gap-1.5 text-xs text-zinc-500">
|
|
4
|
+
<%= link_to parent.key, workflow_path(parent), class: "max-w-[16rem] truncate font-mono hover:text-zinc-900 hover:underline" %>
|
|
5
|
+
<span class="text-zinc-300">›</span>
|
|
6
|
+
<%= link_to "branch #{branch_name}", workflow_branch_path(parent, parent_log), class: "font-mono hover:text-zinc-900 hover:underline" %>
|
|
7
|
+
<span class="text-zinc-300">›</span>
|
|
8
|
+
<span class="text-zinc-400">this child</span>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<div class="overflow-x-auto">
|
|
2
|
+
<table class="w-full min-w-[36rem] text-sm">
|
|
3
|
+
<thead>
|
|
4
|
+
<tr class="border-b border-zinc-200 text-left text-xs uppercase tracking-wide text-zinc-500">
|
|
5
|
+
<th class="py-2 pr-4 font-medium">Task</th>
|
|
6
|
+
<th class="py-2 pr-4 font-medium">Last run</th>
|
|
7
|
+
<th class="py-2 pr-4 font-medium">Next run</th>
|
|
8
|
+
<th class="py-2 pr-4 font-medium">Missed</th>
|
|
9
|
+
<th class="py-2 font-medium">Latency</th>
|
|
10
|
+
</tr>
|
|
11
|
+
</thead>
|
|
12
|
+
<tbody class="divide-y divide-zinc-100">
|
|
13
|
+
<% tasks.each do |t| %>
|
|
14
|
+
<tr>
|
|
15
|
+
<td class="py-2 pr-4 font-mono"><%= link_to t.name, repetitions_workflow_path(workflow, step: t.name), class: "hover:underline" %></td>
|
|
16
|
+
<td class="py-2 pr-4 text-xs text-zinc-500"><%= cf_ago(t.last_execution_at) %></td>
|
|
17
|
+
<td class="py-2 pr-4 font-mono text-xs text-zinc-500"><%= cf_time(t.next_scheduled_at) %></td>
|
|
18
|
+
<td class="py-2 pr-4 font-mono <%= "cf-periodic--timeout text-rose-600" if t.timed_out_count.positive? %>"><%= t.timed_out_count %></td>
|
|
19
|
+
<td class="py-2 font-mono text-xs text-zinc-600" title="recent runs: <%= t.latencies.map { |l| "#{l}s" }.join(", ") %>"><%= cf_latency_summary(t.latencies) %></td>
|
|
20
|
+
</tr>
|
|
21
|
+
<% end %>
|
|
22
|
+
</tbody>
|
|
23
|
+
</table>
|
|
24
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<div class="mb-4 flex flex-wrap gap-2">
|
|
2
|
+
<% base = request.query_parameters.except("before", "after") %>
|
|
3
|
+
<% 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 %>
|
|
13
|
+
<% end %>
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<ol class="cf-timeline">
|
|
2
|
+
<% entries = timeline.entries %>
|
|
3
|
+
<% entries.each_with_index do |e, i| %>
|
|
4
|
+
<% current = timeline.current_position&.id == e.id %>
|
|
5
|
+
<li class="relative border-l border-zinc-200 pb-5 pl-6 last:border-l-transparent last:pb-0">
|
|
6
|
+
<span class="cf-dot cf-dot-<%= e.status %> absolute -left-[5px] top-1.5 ring-4 ring-white"></span>
|
|
7
|
+
<div class="<%= "-ml-2 rounded-md bg-amber-50 px-2 py-1.5 ring-1 ring-amber-200" if current %>">
|
|
8
|
+
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
|
9
|
+
<span class="font-mono text-[11px] uppercase tracking-wider text-zinc-400"><%= cf_kind_label(e.kind) %></span>
|
|
10
|
+
<span class="font-mono text-sm font-medium text-zinc-900"><%= e.name %></span>
|
|
11
|
+
<span class="text-xs <%= cf_status_color(e.status) %>"><%= e.status %></span>
|
|
12
|
+
<span class="font-mono text-xs text-zinc-400">×<%= e.attempts %></span>
|
|
13
|
+
<% if e.started_at && e.completed_at %>
|
|
14
|
+
<span class="font-mono text-xs text-zinc-400">· <%= cf_duration(e.started_at, e.completed_at) %></span>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<% if e.started_at || e.last_executed_at %>
|
|
18
|
+
<div class="mt-0.5 font-mono text-[11px] text-zinc-400">
|
|
19
|
+
<% if e.started_at %>started <%= cf_ago(e.started_at) %><% end %>
|
|
20
|
+
<% if e.completed_at %> · finished <%= cf_ago(e.completed_at) %><% elsif e.last_executed_at %> · last run <%= cf_ago(e.last_executed_at) %><% end %>
|
|
21
|
+
</div>
|
|
22
|
+
<% end %>
|
|
23
|
+
<% pairs = cf_meta_pairs(e.metadata) %>
|
|
24
|
+
<% if pairs.any? %>
|
|
25
|
+
<dl class="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[11px]">
|
|
26
|
+
<% pairs.each do |label, value| %>
|
|
27
|
+
<div class="min-w-0"><span class="text-zinc-400"><%= label %>:</span> <span class="text-zinc-600"><%= truncate(value, length: 80) %></span></div>
|
|
28
|
+
<% end %>
|
|
29
|
+
</dl>
|
|
30
|
+
<% end %>
|
|
31
|
+
<% if e.missing_error_id %>
|
|
32
|
+
<div class="mt-1.5 rounded-md border border-zinc-200 bg-zinc-50 px-2.5 py-1.5 font-mono text-xs text-zinc-500">
|
|
33
|
+
error log #<%= e.missing_error_id %> is no longer available
|
|
34
|
+
</div>
|
|
35
|
+
<% end %>
|
|
36
|
+
<% if e.errors.any? %>
|
|
37
|
+
<%= render partial: "error_card", collection: e.errors, as: :err %>
|
|
38
|
+
<% elsif e.error_class %>
|
|
39
|
+
<div class="mt-1 font-mono text-xs text-rose-600">
|
|
40
|
+
<%= e.error_class %><% if e.error_message.present? %>: <span class="font-normal text-rose-500"><%= truncate(e.error_message, length: 160) %></span><% end %>
|
|
41
|
+
</div>
|
|
42
|
+
<% end %>
|
|
43
|
+
<% if e.kind == :repeat_coordination && e.iterations.to_i > 0 %>
|
|
44
|
+
<div class="mt-1.5 flex flex-wrap items-baseline gap-x-2 text-xs text-zinc-500">
|
|
45
|
+
<span><%= pluralize(e.iterations, "iteration") %></span>
|
|
46
|
+
<% if e.skipped_ticks.to_i > 0 %>
|
|
47
|
+
<span class="text-amber-600">· <%= e.skipped_ticks %> catch-up tick<%= "s" unless e.skipped_ticks == 1 %> skipped</span>
|
|
48
|
+
<% end %>
|
|
49
|
+
<% if e.last_run_at %>
|
|
50
|
+
<span>· last run <%= cf_ago(e.last_run_at) %></span>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="mt-2">
|
|
54
|
+
<%= link_to "repetitions →", repetitions_workflow_path(timeline.workflow, step: e.name), class: cf_chip %>
|
|
55
|
+
</div>
|
|
56
|
+
<% end %>
|
|
57
|
+
</div>
|
|
58
|
+
</li>
|
|
59
|
+
<% end %>
|
|
60
|
+
</ol>
|
|
61
|
+
|
|
62
|
+
<% if timeline.orphan_errors.any? %>
|
|
63
|
+
<div class="mt-4 border-t border-zinc-100 pt-3">
|
|
64
|
+
<p class="mb-1 text-[11px] font-medium uppercase tracking-wider text-zinc-400">Other errors</p>
|
|
65
|
+
<%= render partial: "error_card", collection: timeline.orphan_errors, as: :err %>
|
|
66
|
+
</div>
|
|
67
|
+
<% end %>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<div class="cf-wait-callout mb-5 rounded-md border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
|
2
|
+
Waiting on <span class="font-mono"><%= wait.condition %></span>
|
|
3
|
+
for <%= distance_of_time_in_words(wait.waiting_since, Time.current) %>
|
|
4
|
+
· timeout <span class="font-mono"><%= wait.timeout_at || "—" %></span>
|
|
5
|
+
</div>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<tr class="cursor-pointer hover:bg-zinc-50" data-href="<%= workflow_path(workflow) %>">
|
|
2
|
+
<td class="px-4 py-2.5 font-mono text-xs text-zinc-500"><%= workflow.job_class %></td>
|
|
3
|
+
<td class="px-4 py-2.5"><%= link_to workflow.key, workflow_path(workflow), class: "font-mono text-zinc-900 hover:underline" %></td>
|
|
4
|
+
<td class="px-4 py-2.5"><%= cf_state_badge(workflow, waits && waits[workflow.id]) %></td>
|
|
5
|
+
<td class="px-4 py-2.5 text-right text-xs text-zinc-500"><%= cf_ago(workflow.started_at) %></td>
|
|
6
|
+
<td class="px-4 py-2.5 text-right text-xs text-zinc-500"><%= cf_ago((waits && waits[workflow.id])&.next_run_at) %></td>
|
|
7
|
+
<td class="px-4 py-2.5 text-right text-xs text-zinc-500"><%= cf_ago(workflow.updated_at) %></td>
|
|
8
|
+
</tr>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<div class="mb-5 flex items-center justify-between">
|
|
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" %>
|
|
5
|
+
</div>
|
|
6
|
+
|
|
7
|
+
<div data-poll-region>
|
|
8
|
+
<%= render "stats", stats: @stats, cap: @stats_cap %>
|
|
9
|
+
<%= render "filters", query: @query %>
|
|
10
|
+
|
|
11
|
+
<div class="cf-card overflow-hidden">
|
|
12
|
+
<div class="overflow-x-auto">
|
|
13
|
+
<table class="w-full min-w-[40rem] text-sm">
|
|
14
|
+
<thead>
|
|
15
|
+
<tr class="border-b border-zinc-200 bg-zinc-50 text-left text-xs uppercase tracking-wide text-zinc-500">
|
|
16
|
+
<th class="px-4 py-2.5 font-medium">Class</th>
|
|
17
|
+
<th class="px-4 py-2.5 font-medium">Key</th>
|
|
18
|
+
<th class="px-4 py-2.5 font-medium">State</th>
|
|
19
|
+
<th class="px-4 py-2.5 text-right font-medium">Started</th>
|
|
20
|
+
<th class="px-4 py-2.5 text-right font-medium">Next run</th>
|
|
21
|
+
<th class="px-4 py-2.5 text-right font-medium">Updated</th>
|
|
22
|
+
</tr>
|
|
23
|
+
</thead>
|
|
24
|
+
<tbody class="divide-y divide-zinc-100">
|
|
25
|
+
<%= render partial: "workflow_row", collection: @workflows, as: :workflow, locals: {waits: @waits} %>
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
</div>
|
|
29
|
+
<% if @workflows.none? %>
|
|
30
|
+
<p class="px-4 py-12 text-center text-sm text-zinc-500">No workflows match these filters.</p>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<% base_params = request.query_parameters.except("before", "after") %>
|
|
35
|
+
<nav class="mt-4 flex items-center justify-between text-sm">
|
|
36
|
+
<% if @query.has_prev? %><%= link_to "‹ Newer", base_params.merge(after: @query.prev_cursor), class: cf_chip %><% else %><span></span><% end %>
|
|
37
|
+
<% if @query.has_next? %><%= link_to "Older ›", base_params.merge(before: @query.next_cursor), class: cf_chip %><% else %><span></span><% end %>
|
|
38
|
+
</nav>
|
|
39
|
+
</div>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<%= link_to "‹ Workflows", workflows_path, class: cf_chip("mb-2") %>
|
|
2
|
+
<%= render "parent_breadcrumb", parent_log: @parent_log if @parent_log %>
|
|
3
|
+
|
|
4
|
+
<div class="mb-6">
|
|
5
|
+
<div class="flex flex-wrap items-start justify-between gap-3">
|
|
6
|
+
<div class="min-w-0">
|
|
7
|
+
<%= cf_state_badge(@workflow, @wait) %>
|
|
8
|
+
<h1 class="mt-1.5 break-all font-mono text-xl font-semibold tracking-tight"><%= @workflow.key %></h1>
|
|
9
|
+
<p class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-zinc-500">
|
|
10
|
+
<span class="break-all font-mono"><%= @workflow.job_class %></span>
|
|
11
|
+
<%= link_to "metrics →", analytics_path(class: @workflow.job_class), class: cf_chip %>
|
|
12
|
+
</p>
|
|
13
|
+
</div>
|
|
14
|
+
<% if @workflow.retryable? || @workflow.running? || @workflow.idle? %>
|
|
15
|
+
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
|
16
|
+
<% if @workflow.idle? %>
|
|
17
|
+
<%= button_to "Resume", resume_workflow_path(@workflow), method: :post, class: "cf-btn cf-btn-primary" %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% if @workflow.retryable? %>
|
|
20
|
+
<%= button_to "Retry", retry_workflow_path(@workflow), method: :post, class: "cf-btn cf-btn-primary" %>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% if @workflow.running? %>
|
|
23
|
+
<%= button_to "Force unlock", unlock_workflow_path(@workflow), method: :post,
|
|
24
|
+
form: {data: {confirm: "Force-unlocking a running workflow can cause duplicate execution. Continue?"}}, class: "cf-btn cf-btn-danger" %>
|
|
25
|
+
<% end %>
|
|
26
|
+
</div>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<dl class="mt-4 grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-4">
|
|
31
|
+
<div><dt class="text-xs uppercase tracking-wide text-zinc-400">Created</dt><dd class="font-mono text-zinc-700"><%= cf_ago(@workflow.created_at) %></dd></div>
|
|
32
|
+
<div><dt class="text-xs uppercase tracking-wide text-zinc-400">Started</dt><dd class="font-mono text-zinc-700"><%= cf_ago(@workflow.started_at) %></dd></div>
|
|
33
|
+
<div><dt class="text-xs uppercase tracking-wide text-zinc-400">Completed</dt><dd class="font-mono text-zinc-700"><%= cf_ago(@workflow.completed_at) %></dd></div>
|
|
34
|
+
<div><dt class="text-xs uppercase tracking-wide text-zinc-400">Duration</dt><dd class="font-mono text-zinc-700"><%= cf_duration(@workflow.started_at, @workflow.completed_at || @workflow.updated_at) %></dd></div>
|
|
35
|
+
<div><dt class="text-xs uppercase tracking-wide text-zinc-400">Locked by</dt><dd class="break-all font-mono text-zinc-700"><%= @workflow.locked_by || "—" %></dd></div>
|
|
36
|
+
<div><dt class="text-xs uppercase tracking-wide text-zinc-400">Locked at</dt><dd class="font-mono text-zinc-700"><%= cf_ago(@workflow.locked_at) %></dd></div>
|
|
37
|
+
</dl>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<%= render "wait_callout", wait: @wait if @wait %>
|
|
41
|
+
|
|
42
|
+
<section class="cf-card mb-5 p-5">
|
|
43
|
+
<h2 class="mb-4 text-xs font-medium uppercase tracking-wide text-zinc-500">Timeline</h2>
|
|
44
|
+
<%= render "timeline", timeline: @timeline %>
|
|
45
|
+
</section>
|
|
46
|
+
|
|
47
|
+
<% if @periodic.any? %>
|
|
48
|
+
<section class="cf-card mb-5 p-5">
|
|
49
|
+
<h2 class="mb-3 text-xs font-medium uppercase tracking-wide text-zinc-500">Periodic tasks</h2>
|
|
50
|
+
<%= render "periodic", tasks: @periodic, workflow: @workflow %>
|
|
51
|
+
</section>
|
|
52
|
+
<% end %>
|
|
53
|
+
|
|
54
|
+
<%= render "branches", branches: @branches, workflow: @workflow if @branches.any? %>
|
|
55
|
+
|
|
56
|
+
<div class="grid gap-5 md:grid-cols-2">
|
|
57
|
+
<section class="cf-card p-5">
|
|
58
|
+
<h2 class="mb-3 text-xs font-medium uppercase tracking-wide text-zinc-500">Arguments</h2>
|
|
59
|
+
<% if @workflow.kwargs.present? %>
|
|
60
|
+
<dl class="space-y-3">
|
|
61
|
+
<% @workflow.kwargs.each do |key, value| %>
|
|
62
|
+
<div>
|
|
63
|
+
<div class="flex items-baseline justify-between gap-2">
|
|
64
|
+
<dt class="font-mono text-sm text-zinc-900"><%= key %></dt>
|
|
65
|
+
<span class="shrink-0 font-mono text-[11px] text-zinc-400"><%= value.class.name %></span>
|
|
66
|
+
</div>
|
|
67
|
+
<dd class="mt-0.5 break-words font-mono text-sm text-zinc-600"><%= truncate(value.inspect, length: 200) %></dd>
|
|
68
|
+
</div>
|
|
69
|
+
<% end %>
|
|
70
|
+
</dl>
|
|
71
|
+
<% else %>
|
|
72
|
+
<p class="text-sm text-zinc-500">No arguments.</p>
|
|
73
|
+
<% end %>
|
|
74
|
+
</section>
|
|
75
|
+
<section class="cf-card p-5">
|
|
76
|
+
<h2 class="mb-3 text-xs font-medium uppercase tracking-wide text-zinc-500">Context</h2>
|
|
77
|
+
<%= render "context_tree", context: @context %>
|
|
78
|
+
</section>
|
|
79
|
+
</div>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>ChronoForge</title>
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<%= csrf_meta_tags %>
|
|
7
|
+
<link rel="stylesheet" href="<%= "#{request.script_name}/assets/dashboard.css?v=#{ChronoForge::Dashboard.asset_digest("dashboard.css")}" %>">
|
|
8
|
+
</head>
|
|
9
|
+
<body class="min-h-screen bg-zinc-50 font-sans text-zinc-900 antialiased" data-poll-interval="<%= cf_poll_interval %>">
|
|
10
|
+
<% if flash.any? %>
|
|
11
|
+
<div class="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-2">
|
|
12
|
+
<% flash.each do |type, msg| %>
|
|
13
|
+
<div data-flash class="pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md transition-opacity duration-300 <%= type.to_s == "alert" ? "border-rose-200 bg-rose-50 text-rose-800" : "border-emerald-200 bg-emerald-50 text-emerald-800" %>"><%= msg %></div>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
<header class="border-b border-zinc-200 bg-white">
|
|
18
|
+
<div class="mx-auto flex max-w-6xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3">
|
|
19
|
+
<a href="<%= root_path %>" class="flex items-center gap-2 font-semibold tracking-tight">
|
|
20
|
+
<span class="inline-block h-3.5 w-3.5 rounded-sm bg-zinc-900"></span>
|
|
21
|
+
ChronoForge
|
|
22
|
+
</a>
|
|
23
|
+
<nav class="flex items-center gap-5 text-sm">
|
|
24
|
+
<a href="<%= root_path %>" class="<%= controller_name == "workflows" ? "font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900" %>">Workflows</a>
|
|
25
|
+
<a href="<%= wait_states_path %>" class="<%= controller_name == "wait_states" ? "font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900" %>">Waiting</a>
|
|
26
|
+
<a href="<%= analytics_path %>" class="<%= controller_name == "analytics" ? "font-medium text-zinc-900" : "text-zinc-500 hover:text-zinc-900" %>">Analytics</a>
|
|
27
|
+
</nav>
|
|
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>
|
|
38
|
+
<div class="flex items-center gap-0.5 rounded-md border border-zinc-200 p-0.5 text-xs" title="Timestamp display">
|
|
39
|
+
<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
|
+
<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>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</header>
|
|
45
|
+
<main class="mx-auto max-w-6xl px-4 py-6">
|
|
46
|
+
<%= yield %>
|
|
47
|
+
</main>
|
|
48
|
+
<script src="<%= "#{request.script_name}/assets/dashboard.js?v=#{ChronoForge::Dashboard.asset_digest("dashboard.js")}" %>"></script>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
ChronoForge::Dashboard::Engine.routes.draw do
|
|
2
|
+
root to: "workflows#index"
|
|
3
|
+
resources :workflows, only: %i[index show] do
|
|
4
|
+
member do
|
|
5
|
+
post :retry, to: "actions#retry"
|
|
6
|
+
post :resume, to: "actions#resume"
|
|
7
|
+
post :unlock, to: "actions#unlock"
|
|
8
|
+
get :repetitions, to: "repetitions#index"
|
|
9
|
+
end
|
|
10
|
+
collection do
|
|
11
|
+
post :bulk_retry, to: "actions#bulk_retry"
|
|
12
|
+
end
|
|
13
|
+
resources :branches, only: :show, controller: "branch_children" do
|
|
14
|
+
member { post :bulk_retry, to: "actions#bulk_retry_branch" }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
resources :wait_states, only: :index
|
|
18
|
+
get "analytics", to: "analytics#index", as: :analytics
|
|
19
|
+
get "assets/:file", to: "assets#show", constraints: {file: /dashboard\.(css|js)/}
|
|
20
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
class AuthenticationNotConfigured < StandardError
|
|
4
|
+
MESSAGE = <<~MSG.freeze
|
|
5
|
+
ChronoForge::Dashboard has no authentication configured. Do one of:
|
|
6
|
+
- ChronoForge::Dashboard.configure { |c| c.http_basic = { username:, password: } }
|
|
7
|
+
- ChronoForge::Dashboard.configure { |c| c.authenticate { |controller| ... } }
|
|
8
|
+
- ChronoForge::Dashboard.configure { |c| c.authentication = :none } # then guard the mount with your own routing constraint
|
|
9
|
+
MSG
|
|
10
|
+
def initialize(msg = MESSAGE) = super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Configuration
|
|
14
|
+
attr_accessor :http_basic, :authentication
|
|
15
|
+
attr_reader :auth_hook
|
|
16
|
+
attr_accessor :polling_interval, :polling_interval_options, :page_size, :long_wait_threshold
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@http_basic = nil
|
|
20
|
+
@authentication = nil
|
|
21
|
+
@auth_hook = nil
|
|
22
|
+
@polling_interval = 5
|
|
23
|
+
# Selectable auto-refresh intervals (seconds; 0 = off) for the nav control.
|
|
24
|
+
@polling_interval_options = [0, 5, 10, 30, 60, 300]
|
|
25
|
+
@page_size = 50
|
|
26
|
+
@long_wait_threshold = 3600
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def authenticate(&block) = @auth_hook = block
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Dashboard
|
|
3
|
+
module StepNameParser
|
|
4
|
+
Parsed = Struct.new(:kind, :name, :timestamp, :raw)
|
|
5
|
+
DELIM = "$"
|
|
6
|
+
|
|
7
|
+
def self.parse(step_name)
|
|
8
|
+
prefix, name, ts = step_name.to_s.split(DELIM, 3)
|
|
9
|
+
case prefix
|
|
10
|
+
when "" # framework lifecycle markers: $workflow_completion$, $workflow_failure$<id>, $workflow_retry$<ts>
|
|
11
|
+
# The trailing segment is the error-log id for failures (a timestamp for retries).
|
|
12
|
+
Parsed.new(kind: :lifecycle, name: name.to_s.delete_prefix("workflow_"),
|
|
13
|
+
timestamp: Integer(ts, exception: false), raw: step_name)
|
|
14
|
+
when "durably_execute" then Parsed.new(kind: :execute, name: name, raw: step_name)
|
|
15
|
+
when "wait" then Parsed.new(kind: :sleep, name: name, raw: step_name)
|
|
16
|
+
when "wait_until" then Parsed.new(kind: :wait, name: name, raw: step_name)
|
|
17
|
+
when "continue_if" then Parsed.new(kind: :continue, name: name, raw: step_name)
|
|
18
|
+
when "branch" then Parsed.new(kind: :branch, name: name, raw: step_name)
|
|
19
|
+
when "merge" then Parsed.new(kind: :merge, name: name, raw: step_name)
|
|
20
|
+
when "durably_repeat"
|
|
21
|
+
if ts
|
|
22
|
+
Parsed.new(kind: :repeat_run, name: name, timestamp: Integer(ts, exception: false), raw: step_name)
|
|
23
|
+
else
|
|
24
|
+
Parsed.new(kind: :repeat_coordination, name: name, raw: step_name)
|
|
25
|
+
end
|
|
26
|
+
else
|
|
27
|
+
Parsed.new(kind: :unknown, name: step_name, raw: step_name)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "chrono_forge"
|
|
2
|
+
require "chrono_forge/dashboard/version"
|
|
3
|
+
require "chrono_forge/dashboard/configuration"
|
|
4
|
+
require "chrono_forge/dashboard/engine"
|
|
5
|
+
require "chrono_forge/dashboard/step_name_parser"
|
|
6
|
+
|
|
7
|
+
module ChronoForge
|
|
8
|
+
module Dashboard
|
|
9
|
+
ASSET_ROOT = "app/assets/chrono_forge/dashboard"
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def config = (@config ||= Configuration.new)
|
|
13
|
+
def configure = yield(config)
|
|
14
|
+
def reset_configuration! = @config = Configuration.new
|
|
15
|
+
|
|
16
|
+
# Short content digest of a shipped asset, used to cache-bust the served
|
|
17
|
+
# CSS/JS so a gem upgrade (or a local rebuild) is picked up despite the
|
|
18
|
+
# long immutable cache header. Memoized; computed once per boot.
|
|
19
|
+
def asset_digest(file)
|
|
20
|
+
@asset_digests ||= {}
|
|
21
|
+
@asset_digests[file] ||= begin
|
|
22
|
+
require "digest"
|
|
23
|
+
Digest::SHA256.file(Engine.root.join(ASSET_ROOT, file)).hexdigest[0, 12]
|
|
24
|
+
rescue
|
|
25
|
+
VERSION
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|