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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +147 -0
  5. data/app/assets/chrono_forge/dashboard/dashboard.css +2 -0
  6. data/app/assets/chrono_forge/dashboard/dashboard.js +89 -0
  7. data/app/assets/chrono_forge/dashboard/tailwind.css +69 -0
  8. data/app/controllers/chrono_forge/dashboard/actions_controller.rb +58 -0
  9. data/app/controllers/chrono_forge/dashboard/analytics_controller.rb +23 -0
  10. data/app/controllers/chrono_forge/dashboard/assets_controller.rb +21 -0
  11. data/app/controllers/chrono_forge/dashboard/base_controller.rb +29 -0
  12. data/app/controllers/chrono_forge/dashboard/branch_children_controller.rb +33 -0
  13. data/app/controllers/chrono_forge/dashboard/repetitions_controller.rb +20 -0
  14. data/app/controllers/chrono_forge/dashboard/wait_states_controller.rb +38 -0
  15. data/app/controllers/chrono_forge/dashboard/workflows_controller.rb +31 -0
  16. data/app/helpers/chrono_forge/dashboard/dashboard_helper.rb +153 -0
  17. data/app/presenters/chrono_forge/dashboard/branch_presenter.rb +64 -0
  18. data/app/presenters/chrono_forge/dashboard/branches_presenter.rb +62 -0
  19. data/app/presenters/chrono_forge/dashboard/context_presenter.rb +17 -0
  20. data/app/presenters/chrono_forge/dashboard/periodic_health_presenter.rb +77 -0
  21. data/app/presenters/chrono_forge/dashboard/timeline_presenter.rb +90 -0
  22. data/app/presenters/chrono_forge/dashboard/wait_state_presenter.rb +64 -0
  23. data/app/queries/chrono_forge/dashboard/analytics_query.rb +133 -0
  24. data/app/queries/chrono_forge/dashboard/repetitions_query.rb +110 -0
  25. data/app/queries/chrono_forge/dashboard/stats_query.rb +30 -0
  26. data/app/queries/chrono_forge/dashboard/workflows_query.rb +90 -0
  27. data/app/views/chrono_forge/dashboard/analytics/index.html.erb +103 -0
  28. data/app/views/chrono_forge/dashboard/branch_children/show.html.erb +58 -0
  29. data/app/views/chrono_forge/dashboard/repetitions/index.html.erb +69 -0
  30. data/app/views/chrono_forge/dashboard/wait_states/index.html.erb +73 -0
  31. data/app/views/chrono_forge/dashboard/workflows/_branches.html.erb +57 -0
  32. data/app/views/chrono_forge/dashboard/workflows/_context_tree.html.erb +16 -0
  33. data/app/views/chrono_forge/dashboard/workflows/_error_card.html.erb +13 -0
  34. data/app/views/chrono_forge/dashboard/workflows/_filters.html.erb +6 -0
  35. data/app/views/chrono_forge/dashboard/workflows/_parent_breadcrumb.html.erb +9 -0
  36. data/app/views/chrono_forge/dashboard/workflows/_periodic.html.erb +24 -0
  37. data/app/views/chrono_forge/dashboard/workflows/_stats.html.erb +14 -0
  38. data/app/views/chrono_forge/dashboard/workflows/_timeline.html.erb +67 -0
  39. data/app/views/chrono_forge/dashboard/workflows/_wait_callout.html.erb +5 -0
  40. data/app/views/chrono_forge/dashboard/workflows/_workflow_row.html.erb +8 -0
  41. data/app/views/chrono_forge/dashboard/workflows/index.html.erb +39 -0
  42. data/app/views/chrono_forge/dashboard/workflows/show.html.erb +79 -0
  43. data/app/views/layouts/chrono_forge/dashboard/application.html.erb +50 -0
  44. data/config/routes.rb +20 -0
  45. data/lib/chrono_forge/dashboard/configuration.rb +32 -0
  46. data/lib/chrono_forge/dashboard/engine.rb +9 -0
  47. data/lib/chrono_forge/dashboard/step_name_parser.rb +32 -0
  48. data/lib/chrono_forge/dashboard/version.rb +5 -0
  49. data/lib/chrono_forge/dashboard.rb +30 -0
  50. metadata +237 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bbe30b442414b1aa18a74c822c26e579c9c6295cd115de58479dcdb408922b1a
4
+ data.tar.gz: 0b03195d459b7e9919a38ca2bf58e026a0a8c05e595bcd86f6ca8c2a227e9db8
5
+ SHA512:
6
+ metadata.gz: '052338fbc1da61418b261d93db5555c6413c8ebbe5e72253fe31acc3a9c8db6925399656dafc09f5a78c5c89b070413628e85db737bff464fc6c445c588b76df'
7
+ data.tar.gz: dee584ae0d82ea86aec8a12f57dd18df3f32c6176db80bee4401aeeb26c195c9d3a5f094f41abf8ebd250eaaf1640e5af6204a35a4324c30889897b23205e76b
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to `chrono_forge-dashboard` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/).
4
+
5
+ ## [0.1.0] - 2026-06-27
6
+
7
+ Initial release — a free, mountable, server-rendered dashboard for ChronoForge workflows. Requires `chrono_forge >= 0.10.0` (for the branches feature and the `durably_repeat` fast-forward catch-up it surfaces). Adds no migrations of its own, no host asset pipeline, and no new indexes on hot tables (branch views read the core's `parent_execution_log_id` index).
8
+
9
+ ### Added
10
+
11
+ - **Mountable engine** with **fail-closed authentication** — HTTP Basic, a custom hook, or explicit `:none`; mounting without configuring any raises `AuthenticationNotConfigured`.
12
+ - **Workflow list** — state badges, keyset (cursor) pagination, capped index-only state counts, and filtering by state / job class (prefix) / key (prefix) / date. Idle workflows parked on a future-dated wait render as **scheduled** rather than "idle".
13
+ - **Workflow detail** — a step-replay timeline decoded from execution logs (kind, status, attempts, duration, relative times) with **error logs inlined on the step that produced them** (class, attempt, message, expandable backtrace); periodic-task health; a typed context inspector; and arguments.
14
+ - **Recovery actions** — retry (`retry_later`, guarded), force-unlock (with a duplicate-execution warning), bulk retry of failed + stalled, **Resume** (re-enqueue an idle/parked workflow — recovers a dropped wait or merge poll), a **resume poller** action on an overdue branch merge, and per-branch bulk retry of blocked children. CSRF- and auth-protected, with a floating auto-dismissing toast.
15
+ - **Branch views** — for workflows that fan out into concurrent sub-workflows (`spawn` / `spawn_each` / `merge_branches`): a per-branch panel on the parent (dispatched / pending / blocked counts, merge state, and **dropped-poller detection** that flags a branch whose `BranchMergeJob` poll is overdue), a parent breadcrumb, a keyset-paginated children drill-down defaulting to the **blocked** (failed + stalled) triage subset, and an in-flight merges list (the durable `BranchMergeJob` records).
16
+ - **`durably_repeat` repetitions** — iteration runs are collapsed in the timeline into a summary ("N iterations · M catch-up ticks skipped · last run …") that links to a dedicated, keyset-paginated repetitions page. Expired per-tick catch-up runs are labeled "tombstone"; a fast-forward catch-up summary row (`metadata["fast_forwarded"]`) renders as "caught up ×N" — counted as its N skipped ticks (in the roll-up and the periodic "Missed" count), not as a spurious failure — alongside a "Late by" column (scheduled vs actual start).
17
+ - **Waiting page** — leads with the **oldest unresolved `continue_if` (event) wait per class**, the silent stall a webhook-that-never-arrives causes; bounded scan over the oldest idle workflows.
18
+ - **Analytics** (own pages, off the hot path) — workflow-level completion rate, failure rate, average duration, and daily throughput over a 24h/7d/30d window; top error classes; and per-class current queue-health counts. Linked per-class from the workflow detail.
19
+ - **Display & refresh controls** — a nav toggle for relative vs absolute timestamps (the other form on hover) and an auto-refresh control (pause + interval), both cookie-persisted per viewer; a configurable `polling_interval_options` set; durations scaled to the two most-significant units (seconds → days); and a **Next run** column showing a scheduled workflow's next wake. Live polling preserves horizontal table-scroll position across refreshes.
20
+ - **Scale-aware data access** — keyset pagination (no `OFFSET`/`COUNT(*)`), capped counts, index-friendly windowed aggregation, and bounded scans with visible "showing N" notes. Workflow-level failure rates never count `durably_repeat` catch-up runs, with UI notes making the distinction explicit.
21
+ - **Frontend** — server-rendered ERB plus one engine-served CSS (Tailwind, precompiled via the standalone CLI) and one vanilla JS file; CSP-friendly (no inline scripts, class-based bar widths), with content-digest asset cache-busting.
data/MIT-LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Stefan Froelich
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # ChronoForge::Dashboard
2
+
3
+ A mountable Rails engine that provides visibility and operational controls for ChronoForge workflows.
4
+
5
+ Version: `0.1.0` (early release). The UI and config API may change before `1.0`.
6
+
7
+ ## Screenshots
8
+
9
+ | Workflow list | Analytics |
10
+ | --- | --- |
11
+ | [![Workflow list](docs/screenshots/workflows.png)](docs/screenshots/workflows.png) | [![Analytics](docs/screenshots/analytics.png)](docs/screenshots/analytics.png) |
12
+ | Filter by state/class/key, keyset pagination, capped state counts. | Completion/failure rate, throughput, top errors, queue health — per class. |
13
+
14
+ | Waiting | Repetitions |
15
+ | --- | --- |
16
+ | [![Waiting workflows](docs/screenshots/waiting.png)](docs/screenshots/waiting.png) | [![Repetitions](docs/screenshots/repetitions.png)](docs/screenshots/repetitions.png) |
17
+ | Oldest unresolved `continue_if` (event) wait per class — the silent stall. | A `durably_repeat` step's per-iteration runs, with tombstones and lateness. |
18
+
19
+ | Branches | Branch children |
20
+ | --- | --- |
21
+ | [![Branches panel](docs/screenshots/branches.png)](docs/screenshots/branches.png) | [![Branch children](docs/screenshots/branch-children.png)](docs/screenshots/branch-children.png) |
22
+ | Fan-out branches with capped dispatched/pending/blocked counts and merge state. | One branch's children — blocked-first triage, capped filters, retry per child. |
23
+
24
+ **Workflow detail** — step-replay timeline with errors inlined on the step that failed, periodic-task health, and arguments/context:
25
+
26
+ [![Workflow detail](docs/screenshots/workflow-detail.png)](docs/screenshots/workflow-detail.png)
27
+
28
+ ## Installation
29
+
30
+ Add to your application's Gemfile (requires `chrono_forge`):
31
+
32
+ ```ruby
33
+ gem "chrono_forge-dashboard"
34
+ ```
35
+
36
+ Then run:
37
+
38
+ ```bash
39
+ bundle install
40
+ ```
41
+
42
+ ## Mounting
43
+
44
+ Add to `config/routes.rb`:
45
+
46
+ ```ruby
47
+ mount ChronoForge::Dashboard::Engine, at: "/chrono_forge"
48
+ ```
49
+
50
+ ## Authentication
51
+
52
+ The dashboard is fail-closed. If you mount it without configuring authentication, hitting any dashboard URL raises `ChronoForge::Dashboard::AuthenticationNotConfigured`. Configure one of the following in an initializer (e.g. `config/initializers/chrono_forge_dashboard.rb`).
53
+
54
+ Resolution order: custom hook, then HTTP Basic, then `:none`, else raise.
55
+
56
+ ### HTTP Basic Auth
57
+
58
+ ```ruby
59
+ ChronoForge::Dashboard.configure do |c|
60
+ c.http_basic = { username: ENV["CF_USER"], password: ENV["CF_PASS"] }
61
+ end
62
+ ```
63
+
64
+ ### Custom Hook
65
+
66
+ ```ruby
67
+ ChronoForge::Dashboard.configure do |c|
68
+ c.authenticate { |controller| controller.head(:forbidden) unless controller.current_user&.admin? }
69
+ end
70
+ ```
71
+
72
+ The block receives the current controller instance. Call `head(:forbidden)` or `redirect_to` to deny access; return normally to allow it.
73
+
74
+ ### Disable (use routing constraints instead)
75
+
76
+ Set authentication to `:none` and guard the mount point yourself:
77
+
78
+ ```ruby
79
+ ChronoForge::Dashboard.configure do |c|
80
+ c.authentication = :none
81
+ end
82
+ ```
83
+
84
+ ```ruby
85
+ # config/routes.rb
86
+ authenticate :user, ->(u) { u.admin? } do
87
+ mount ChronoForge::Dashboard::Engine, at: "/chrono_forge"
88
+ end
89
+ ```
90
+
91
+ ## Configuration
92
+
93
+ All options go in the same `configure` block as auth:
94
+
95
+ ```ruby
96
+ ChronoForge::Dashboard.configure do |c|
97
+ c.polling_interval = 5 # seconds; the default auto-refresh interval. 0 to disable.
98
+ c.polling_interval_options = [0, 5, 10, 30, 60, 300] # selectable intervals in the nav "refresh" control
99
+ c.page_size = 50 # workflows per page
100
+ c.long_wait_threshold = 3600 # seconds; wait-state ages above this are flagged
101
+ end
102
+ ```
103
+
104
+ | Option | Default | Notes |
105
+ | --- | --- | --- |
106
+ | `polling_interval` | `5` | Seconds between auto-refreshes (the default). A viewer can override it with the nav "refresh" control (stored in a cookie). `0` disables. |
107
+ | `polling_interval_options` | `[0, 5, 10, 30, 60, 300]` | Intervals (seconds; `0` = off) offered by the nav refresh control. |
108
+ | `page_size` | `50` | Workflows per page on the index. |
109
+ | `long_wait_threshold` | `3600` | Wait-state age in seconds above which a warning is shown. |
110
+
111
+ ## Features
112
+
113
+ - **Workflow list**: state badges, filter by state/job class/workflow key, stats header showing counts by state
114
+ - **Workflow detail**: step replay timeline showing every `durably_execute`, `wait`, `continue_if`, and `durably_repeat` run; repetitions from `durably_repeat` appear nested under their coordination step
115
+ - **Context inspector**: JSON tree view of the workflow's persistent context
116
+ - **Per-step error logs**: errors attributed to the step and attempt that raised them
117
+ - **Periodic-task health**: summary of each `durably_repeat` task (last run, next run, missed executions)
118
+ - **Wait-states view**: lists workflows in a wait state, with age flagged if above `long_wait_threshold`
119
+ - **Recovery actions**: retry a stalled or failed workflow, force-unlock a stuck running workflow (with a duplicate-execution warning), bulk retry all failed workflows
120
+
121
+ ## Frontend
122
+
123
+ The dashboard is server-rendered. It serves one CSS file and one JS file directly from the engine. **The host needs no npm, no build step, and no asset-pipeline configuration** — the compiled stylesheet ships with the gem. The JS is dependency-free vanilla. CSP-compatible (no external hosts or inline handlers).
124
+
125
+ Styles are written with [Tailwind CSS](https://tailwindcss.com) and precompiled into the shipped `dashboard.css`. Contributors editing views or styles rebuild it with the standalone compiler (no Node required):
126
+
127
+ ```bash
128
+ bundle exec rake tailwind:build
129
+ ```
130
+
131
+ Assets are cache-busted by a content digest, so a gem upgrade is picked up without a hard refresh.
132
+
133
+ ## Development
134
+
135
+ Run a seeded preview locally (compiles the stylesheet, then boots a demo app on `http://localhost:9876/chrono_forge`):
136
+
137
+ ```bash
138
+ bin/dev # PORT=9877 bin/dev to change the port
139
+ ```
140
+
141
+ To release: bump `lib/chrono_forge/dashboard/version.rb`, commit, then run:
142
+
143
+ ```bash
144
+ bin/release
145
+ ```
146
+
147
+ It compiles the stylesheet, refuses to continue on a dirty tree, then runs `rake release` (tests, linter, build, git tag, and push to RubyGems). `rake build` always recompiles `dashboard.css` first, so a release never ships a stale stylesheet. Use `bundle exec rake prepare` on its own to run assets + tests + linter without releasing.
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.1 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--font-mono:ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-900:oklch(41.4% .112 45.904);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-700:oklch(50% .134 242.749);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-700:oklch(49.1% .27 292.581);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-300:oklch(81% .117 11.638);--color-rose-400:oklch(71.2% .194 13.428);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-600:oklch(58.6% .253 17.585);--color-rose-700:oklch(51.4% .222 16.935);--color-rose-800:oklch(45.5% .188 13.697);--color-zinc-50:oklch(98.5% 0 0);--color-zinc-100:oklch(96.7% .001 286.375);--color-zinc-200:oklch(92% .004 286.32);--color-zinc-300:oklch(87.1% .006 286.286);--color-zinc-400:oklch(70.5% .015 286.067);--color-zinc-500:oklch(55.2% .016 285.938);--color-zinc-600:oklch(44.2% .017 285.786);--color-zinc-700:oklch(37% .013 285.805);--color-zinc-800:oklch(27.4% .006 286.033);--color-zinc-900:oklch(21% .006 285.885);--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.cf-pill{align-items:center;gap:calc(var(--spacing) * 1.5);border-style:var(--tw-border-style);padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);font-family:var(--font-mono);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);border-width:1px;border-radius:3.40282e38px;display:inline-flex}.cf-pill-idle{border-color:var(--color-zinc-200);background-color:var(--color-zinc-100);color:var(--color-zinc-600)}.cf-pill-running{border-color:var(--color-sky-200);background-color:var(--color-sky-50);color:var(--color-sky-700)}.cf-pill-completed{border-color:var(--color-emerald-200);background-color:var(--color-emerald-50);color:var(--color-emerald-700)}.cf-pill-failed{border-color:var(--color-rose-200);background-color:var(--color-rose-50);color:var(--color-rose-700)}.cf-pill-stalled{border-color:var(--color-amber-200);background-color:var(--color-amber-50);color:var(--color-amber-700)}.cf-pill-scheduled{border-color:var(--color-violet-200);background-color:var(--color-violet-50);color:var(--color-violet-700)}.cf-dot{height:calc(var(--spacing) * 2);width:calc(var(--spacing) * 2);border-radius:3.40282e38px;display:inline-block}.cf-dot-idle{background-color:var(--color-zinc-400)}.cf-dot-running{background-color:var(--color-sky-500)}.cf-dot-completed{background-color:var(--color-emerald-500)}.cf-dot-failed{background-color:var(--color-rose-500)}.cf-dot-stalled{background-color:var(--color-amber-500)}.cf-dot-scheduled{background-color:var(--color-violet-500)}.cf-dot-pending{background-color:var(--color-zinc-300)}.cf-btn{align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-zinc-300);background-color:var(--color-white);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-zinc-800);--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration));display:inline-flex}@media (hover:hover){.cf-btn:hover{background-color:var(--color-zinc-50)}}.cf-btn:focus{--tw-outline-style:none;outline-style:none}.cf-btn:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:#18181b26}@supports (color:color-mix(in lab, red, red)){.cf-btn:focus-visible{--tw-ring-color:color-mix(in oklab, var(--color-zinc-900) 15%, transparent)}}.cf-btn-primary{border-color:var(--color-zinc-900);background-color:var(--color-zinc-900);color:var(--color-white)}@media (hover:hover){.cf-btn-primary:hover{background-color:var(--color-zinc-700)}}.cf-btn-danger{border-color:var(--color-rose-300);color:var(--color-rose-700)}@media (hover:hover){.cf-btn-danger:hover{background-color:var(--color-rose-50)}}.cf-card{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-zinc-200);background-color:var(--color-white);--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.cf-bar{height:100%}.cf-bar-0{width:0%}.cf-bar-5{width:5%}.cf-bar-10{width:10%}.cf-bar-15{width:15%}.cf-bar-20{width:20%}.cf-bar-25{width:25%}.cf-bar-30{width:30%}.cf-bar-35{width:35%}.cf-bar-40{width:40%}.cf-bar-45{width:45%}.cf-bar-50{width:50%}.cf-bar-55{width:55%}.cf-bar-60{width:60%}.cf-bar-65{width:65%}.cf-bar-70{width:70%}.cf-bar-75{width:75%}.cf-bar-80{width:80%}.cf-bar-85{width:85%}.cf-bar-90{width:90%}.cf-bar-95{width:95%}.cf-bar-100{width:100%}}@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.top-1\.5{top:calc(var(--spacing) * 1.5)}.top-4{top:calc(var(--spacing) * 4)}.right-4{right:calc(var(--spacing) * 4)}.-left-\[5px\]{left:-5px}.z-50{z-index:50}.order-99{order:99}.order-1000{order:1000}.order-1001{order:1001}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:var(--spacing)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mb-1{margin-bottom:var(--spacing)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.-ml-2{margin-left:calc(var(--spacing) * -2)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.flex{display:flex}.grid{display:grid}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.min-h-screen{min-height:100vh}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-16{width:calc(var(--spacing) * 16)}.w-24{width:calc(var(--spacing) * 24)}.w-32{width:calc(var(--spacing) * 32)}.w-64{width:calc(var(--spacing) * 64)}.w-80{width:calc(var(--spacing) * 80)}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.max-w-\[16rem\]{max-width:16rem}.max-w-\[calc\(100vw-2rem\)\]{max-width:calc(100vw - 2rem)}.max-w-full{max-width:100%}.min-w-0{min-width:0}.min-w-\[36rem\]{min-width:36rem}.min-w-\[40rem\]{min-width:40rem}.min-w-\[44rem\]{min-width:44rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:var(--spacing)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-2{column-gap:calc(var(--spacing) * 2)}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-6{column-gap:calc(var(--spacing) * 6)}.gap-y-0\.5{row-gap:calc(var(--spacing) * .5)}.gap-y-1{row-gap:var(--spacing)}.gap-y-2{row-gap:calc(var(--spacing) * 2)}.gap-y-3{row-gap:calc(var(--spacing) * 3)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-amber-100>:not(:last-child)){border-color:var(--color-amber-100)}:where(.divide-zinc-100>:not(:last-child)){border-color:var(--color-zinc-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-amber-200{border-color:var(--color-amber-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-rose-200{border-color:var(--color-rose-200)}.border-zinc-100{border-color:var(--color-zinc-100)}.border-zinc-200{border-color:var(--color-zinc-200)}.border-zinc-300{border-color:var(--color-zinc-300)}.border-zinc-900{border-color:var(--color-zinc-900)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-50\/40{background-color:#fffbeb66}@supports (color:color-mix(in lab, red, red)){.bg-amber-50\/40{background-color:color-mix(in oklab, var(--color-amber-50) 40%, transparent)}}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-300{background-color:var(--color-rose-300)}.bg-rose-400{background-color:var(--color-rose-400)}.bg-white{background-color:var(--color-white)}.bg-zinc-50{background-color:var(--color-zinc-50)}.bg-zinc-100{background-color:var(--color-zinc-100)}.bg-zinc-900{background-color:var(--color-zinc-900)}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-2{padding:calc(var(--spacing) * 2)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.pl-6{padding-left:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[11px\]{font-size:11px}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-900{color:var(--color-amber-900)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-800{color:var(--color-emerald-800)}.text-rose-500{color:var(--color-rose-500)}.text-rose-600{color:var(--color-rose-600)}.text-rose-700{color:var(--color-rose-700)}.text-rose-800{color:var(--color-rose-800)}.text-white{color:var(--color-white)}.text-zinc-300{color:var(--color-zinc-300)}.text-zinc-400{color:var(--color-zinc-400)}.text-zinc-500{color:var(--color-zinc-500)}.text-zinc-600{color:var(--color-zinc-600)}.text-zinc-700{color:var(--color-zinc-700)}.text-zinc-800{color:var(--color-zinc-800)}.text-zinc-900{color:var(--color-zinc-900)}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.opacity-0{opacity:0}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-4{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-200{--tw-ring-color:var(--color-amber-200)}.ring-white{--tw-ring-color:var(--color-white)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-300{--tw-duration:.3s;transition-duration:.3s}.placeholder\:text-zinc-400::placeholder{color:var(--color-zinc-400)}.last\:border-l-transparent:last-child{border-left-color:#0000}.last\:pb-0:last-child{padding-bottom:0}@media (hover:hover){.hover\:bg-zinc-50:hover{background-color:var(--color-zinc-50)}.hover\:text-zinc-900:hover{color:var(--color-zinc-900)}.hover\:underline:hover{text-decoration-line:underline}}@media (min-width:40rem){.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width:48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}
@@ -0,0 +1,89 @@
1
+ (function () {
2
+ "use strict";
3
+
4
+ // Interactive behaviors are delegated to `document` and attached ONCE. Per-row
5
+ // listeners are lost whenever the DOM is replaced — the back/forward cache,
6
+ // a Hotwire/Turbo body swap, or the polling innerHTML refresh — which is why
7
+ // row-click stopped working after navigating back. A single document-level
8
+ // listener (document is never swapped) keeps matching current and future rows.
9
+ if (!window.__chronoForgeDashboard) {
10
+ window.__chronoForgeDashboard = true;
11
+
12
+ document.addEventListener("click", function (e) {
13
+ // Timestamp display toggle (relative vs absolute), persisted in a cookie.
14
+ var timeSet = e.target.closest("[data-time-set]");
15
+ if (timeSet) {
16
+ document.cookie = "cf_time_format=" + timeSet.getAttribute("data-time-set") +
17
+ ";path=/;max-age=31536000;samesite=lax";
18
+ window.location.reload();
19
+ return;
20
+ }
21
+ // Collapsible context tree
22
+ var key = e.target.closest(".cf-context__key");
23
+ if (key && key.closest("[data-collapsible]")) {
24
+ var li = key.closest("li");
25
+ if (li) li.classList.toggle("cf-collapsed");
26
+ return;
27
+ }
28
+ // Whole-row navigation, except when the click landed on an interactive child.
29
+ var row = e.target.closest("tr[data-href]");
30
+ if (row && !e.target.closest("a, button, input, form, summary")) {
31
+ window.location = row.getAttribute("data-href");
32
+ }
33
+ });
34
+
35
+ // Auto-submit a filter control (e.g. the state select) on change.
36
+ document.addEventListener("change", function (e) {
37
+ // Auto-refresh interval control: persist and reload so the server re-renders
38
+ // the body's data-poll-interval.
39
+ var poll = e.target.closest("[data-poll-select]");
40
+ if (poll) {
41
+ document.cookie = "cf_poll_interval=" + poll.value + ";path=/;max-age=31536000;samesite=lax";
42
+ window.location.reload();
43
+ return;
44
+ }
45
+ var el = e.target.closest("[data-autosubmit]");
46
+ if (el && el.form) el.form.requestSubmit();
47
+ });
48
+
49
+ // Confirm destructive actions: any form with data-confirm.
50
+ document.addEventListener("submit", function (e) {
51
+ var form = e.target.closest("form[data-confirm]");
52
+ if (form && !window.confirm(form.getAttribute("data-confirm"))) e.preventDefault();
53
+ });
54
+ }
55
+
56
+ // Auto-dismiss floating flash toasts after a few seconds (fade, then remove).
57
+ document.querySelectorAll("[data-flash]").forEach(function (el, i) {
58
+ setTimeout(function () {
59
+ el.classList.add("opacity-0");
60
+ setTimeout(function () { el.remove(); }, 300);
61
+ }, 4000 + i * 150);
62
+ });
63
+
64
+ // Polling refresh of the list/stats region. Keep a single timer and re-resolve
65
+ // the region each tick so it survives a swapped body.
66
+ if (window.__chronoForgePoll) clearInterval(window.__chronoForgePoll);
67
+ var body = document.body, interval = parseInt(body.getAttribute("data-poll-interval") || "0", 10) * 1000;
68
+ if (interval > 0 && document.querySelector("[data-poll-region]") && !body.hasAttribute("data-poll-paused")) {
69
+ window.__chronoForgePoll = setInterval(function () {
70
+ var region = document.querySelector("[data-poll-region]");
71
+ if (!region) return;
72
+ fetch(window.location.href, { headers: { "X-Requested-With": "XMLHttpRequest" } })
73
+ .then(function (r) { return r.text(); })
74
+ .then(function (html) {
75
+ var doc = new DOMParser().parseFromString(html, "text/html");
76
+ var fresh = doc.querySelector("[data-poll-region]");
77
+ if (!fresh) return;
78
+ // Preserve horizontal scroll of any scroll containers across the swap,
79
+ // so polling doesn't yank a table back while it's being scrolled.
80
+ var scrolls = Array.prototype.map.call(
81
+ region.querySelectorAll(".overflow-x-auto"), function (el) { return el.scrollLeft; });
82
+ region.innerHTML = fresh.innerHTML;
83
+ region.querySelectorAll(".overflow-x-auto").forEach(function (el, i) {
84
+ if (scrolls[i]) el.scrollLeft = scrolls[i];
85
+ });
86
+ }).catch(function () {});
87
+ }, interval);
88
+ }
89
+ })();
@@ -0,0 +1,69 @@
1
+ @import "tailwindcss";
2
+
3
+ /*
4
+ * ChronoForge dashboard styles. Source for the compiled dashboard.css.
5
+ * Rebuild after editing this file or any view: `bundle exec rake tailwind:build`.
6
+ *
7
+ * Design rule: color means workflow state; all other chrome stays ink/zinc.
8
+ * System fonts only (no external fonts, CSP-friendly).
9
+ */
10
+
11
+ @theme {
12
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
13
+ --font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
14
+ }
15
+
16
+ @layer components {
17
+ /* State pills (the one place color is allowed) */
18
+ .cf-pill { @apply inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 font-mono text-xs font-medium; }
19
+ .cf-pill-idle { @apply border-zinc-200 bg-zinc-100 text-zinc-600; }
20
+ .cf-pill-running { @apply border-sky-200 bg-sky-50 text-sky-700; }
21
+ .cf-pill-completed { @apply border-emerald-200 bg-emerald-50 text-emerald-700; }
22
+ .cf-pill-failed { @apply border-rose-200 bg-rose-50 text-rose-700; }
23
+ .cf-pill-stalled { @apply border-amber-200 bg-amber-50 text-amber-700; }
24
+ .cf-pill-scheduled { @apply border-violet-200 bg-violet-50 text-violet-700; }
25
+
26
+ /* Pill-shaped state dot used in the stats strip + timeline */
27
+ .cf-dot { @apply inline-block h-2 w-2 rounded-full; }
28
+ .cf-dot-idle { @apply bg-zinc-400; }
29
+ .cf-dot-running { @apply bg-sky-500; }
30
+ .cf-dot-completed { @apply bg-emerald-500; }
31
+ .cf-dot-failed { @apply bg-rose-500; }
32
+ .cf-dot-stalled { @apply bg-amber-500; }
33
+ .cf-dot-scheduled { @apply bg-violet-500; }
34
+ .cf-dot-pending { @apply bg-zinc-300; }
35
+
36
+ /* Buttons: ink chrome, not brand color */
37
+ .cf-btn { @apply inline-flex items-center gap-1.5 rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-zinc-900/15; }
38
+ .cf-btn-primary { @apply border-zinc-900 bg-zinc-900 text-white hover:bg-zinc-700; }
39
+ .cf-btn-danger { @apply border-rose-300 text-rose-700 hover:bg-rose-50; }
40
+
41
+ /* Surfaces */
42
+ .cf-card { @apply rounded-lg border border-zinc-200 bg-white shadow-sm; }
43
+
44
+ /* Stacked-bar segments for the analytics throughput chart. Widths are
45
+ quantized to 5% steps and applied as classes so the chart needs no inline
46
+ style (keeps a strict style-src CSP intact). */
47
+ .cf-bar { @apply h-full; }
48
+ .cf-bar-0 { width: 0%; }
49
+ .cf-bar-5 { width: 5%; }
50
+ .cf-bar-10 { width: 10%; }
51
+ .cf-bar-15 { width: 15%; }
52
+ .cf-bar-20 { width: 20%; }
53
+ .cf-bar-25 { width: 25%; }
54
+ .cf-bar-30 { width: 30%; }
55
+ .cf-bar-35 { width: 35%; }
56
+ .cf-bar-40 { width: 40%; }
57
+ .cf-bar-45 { width: 45%; }
58
+ .cf-bar-50 { width: 50%; }
59
+ .cf-bar-55 { width: 55%; }
60
+ .cf-bar-60 { width: 60%; }
61
+ .cf-bar-65 { width: 65%; }
62
+ .cf-bar-70 { width: 70%; }
63
+ .cf-bar-75 { width: 75%; }
64
+ .cf-bar-80 { width: 80%; }
65
+ .cf-bar-85 { width: 85%; }
66
+ .cf-bar-90 { width: 90%; }
67
+ .cf-bar-95 { width: 95%; }
68
+ .cf-bar-100 { width: 100%; }
69
+ }
@@ -0,0 +1,58 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ class ActionsController < BaseController
4
+ rescue_from ChronoForge::Executor::WorkflowNotRetryableError do |e|
5
+ redirect_to workflow_path(params[:id]), alert: e.message
6
+ end
7
+
8
+ def retry
9
+ workflow.retry_later
10
+ redirect_to workflow_path(workflow), notice: "Re-enqueued #{workflow.key}."
11
+ end
12
+
13
+ def unlock
14
+ workflow.update!(locked_at: nil, locked_by: nil, state: :idle)
15
+ redirect_to workflow_path(workflow), notice: "Unlocked #{workflow.key}."
16
+ end
17
+
18
+ # Re-enqueue an idle (parked) workflow so the executor picks it up again.
19
+ # This is the recovery for a dropped poll/wake — a wait_until or merge whose
20
+ # poller job was lost, or a continue_if whose event has since arrived: the
21
+ # replay re-checks the condition and re-arms the poll if still unmet.
22
+ def resume
23
+ return redirect_to(workflow_path(workflow), alert: "Only idle workflows can be resumed.") unless workflow.idle?
24
+ workflow.job_klass.perform_later(workflow.key)
25
+ redirect_to workflow_path(workflow), notice: "Re-enqueued #{workflow.key}."
26
+ end
27
+
28
+ # Both failed and stalled workflows are retryable, so bulk retry covers
29
+ # both (matching the per-workflow Retry, which uses `retryable?`).
30
+ RETRYABLE_STATES = %i[failed stalled].map { |s| ChronoForge::Workflow.states[s] }.freeze
31
+
32
+ def bulk_retry
33
+ n = 0
34
+ ChronoForge::Workflow.where(state: RETRYABLE_STATES).find_each do |wf|
35
+ wf.retry_later
36
+ n += 1
37
+ end
38
+ redirect_to workflows_path, notice: "Re-enqueued #{n} workflow(s)."
39
+ end
40
+
41
+ # Retry every blocked (failed/stalled) child of one branch.
42
+ def bulk_retry_branch
43
+ parent = ChronoForge::Workflow.find(params[:workflow_id])
44
+ branch_log = parent.execution_logs.find(params[:id])
45
+ n = 0
46
+ branch_log.spawned_workflows.where(state: RETRYABLE_STATES).find_each do |wf|
47
+ wf.retry_later
48
+ n += 1
49
+ end
50
+ redirect_to workflow_branch_path(parent, branch_log), notice: "Re-enqueued #{n} child workflow(s)."
51
+ end
52
+
53
+ private
54
+
55
+ def workflow = @workflow ||= ChronoForge::Workflow.find(params[:id])
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,23 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ # Analytics live on their own page so the workflow list stays fast (no
4
+ # aggregation on the hot path). With a `class` param the same view is scoped
5
+ # to a single workflow class, linked from the workflow detail.
6
+ class AnalyticsController < BaseController
7
+ def index
8
+ @query = AnalyticsQuery.new(window: params[:window], job_class: params[:class])
9
+ @job_class = @query.job_class
10
+ @buckets = @query.buckets
11
+ @totals = @query.totals
12
+ @top_errors = @query.top_errors
13
+
14
+ # Current queue health for the class (capped, all-time) complements the
15
+ # windowed throughput above. Only shown per-class; the workflow list's
16
+ # stats strip already covers the global breakdown.
17
+ if @job_class
18
+ @queue = StatsQuery.new(base: ChronoForge::Workflow.where(job_class: @job_class))
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ class AssetsController < BaseController
4
+ skip_before_action :authenticate!
5
+ skip_forgery_protection
6
+
7
+ TYPES = {"dashboard.css" => "text/css", "dashboard.js" => "application/javascript"}.freeze
8
+ ROOT = ChronoForge::Dashboard::Engine.root.join("app/assets/chrono_forge/dashboard")
9
+
10
+ def show
11
+ file = params[:file]
12
+ type = TYPES[file] or return head(:not_found)
13
+ path = ROOT.join(file)
14
+ return head(:not_found) unless path.file?
15
+
16
+ response.set_header("Cache-Control", "public, max-age=31536000, immutable")
17
+ send_file path, type: type, disposition: "inline"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,29 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ class BaseController < ActionController::Base
4
+ layout "chrono_forge/dashboard/application"
5
+
6
+ protect_from_forgery with: :exception
7
+ before_action :authenticate!
8
+
9
+ private
10
+
11
+ def authenticate!
12
+ config = ChronoForge::Dashboard.config
13
+ if config.auth_hook
14
+ instance_exec(self, &config.auth_hook)
15
+ elsif config.http_basic
16
+ creds = config.http_basic
17
+ authenticate_or_request_with_http_basic("ChronoForge") do |u, p|
18
+ ActiveSupport::SecurityUtils.secure_compare(u, creds[:username]) &
19
+ ActiveSupport::SecurityUtils.secure_compare(p, creds[:password])
20
+ end
21
+ elsif config.authentication == :none
22
+ true
23
+ else
24
+ raise AuthenticationNotConfigured
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,33 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ # The children of one branch — a keyset-paginated, filterable list scoped to a
4
+ # single branch$ log's spawned_workflows. A branch can hold hundreds of
5
+ # thousands of children, so we never render more than a page and default the
6
+ # filter to "blocked" (failed + stalled) — the triage view that matters.
7
+ class BranchChildrenController < BaseController
8
+ def show
9
+ @workflow = ChronoForge::Workflow.find(params[:workflow_id])
10
+ @branch_log = @workflow.execution_logs.find(params[:id]) # scope to this workflow
11
+ @branch = BranchPresenter.new(@branch_log)
12
+
13
+ base = @branch_log.spawned_workflows
14
+ @query = WorkflowsQuery.new(base: base, **list_params)
15
+ @children = @query.records
16
+ @waits = WaitStatePresenter.active_map(@children)
17
+
18
+ stats = StatsQuery.new(base: base)
19
+ @stats = stats.counts
20
+ @stats_cap = stats.cap
21
+ end
22
+
23
+ private
24
+
25
+ def list_params
26
+ permitted = params.permit(:state, :job_class, :key, :before, :after).to_h.symbolize_keys
27
+ # Land on the blockers, not page 1 of 500k, unless a filter is chosen.
28
+ permitted[:state] = "blocked" unless params.key?(:state)
29
+ permitted.merge(per: ChronoForge::Dashboard.config.page_size)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ # The full, keyset-paginated list of a durably_repeat step's per-iteration
4
+ # runs — kept off the workflow timeline so a deep repetition history neither
5
+ # buries the timeline nor loads unbounded.
6
+ class RepetitionsController < BaseController
7
+ def index
8
+ @workflow = ChronoForge::Workflow.find(params[:id])
9
+ @step = params.require(:step)
10
+ @query = RepetitionsQuery.new(
11
+ workflow: @workflow, step: @step,
12
+ before: params[:before], after: params[:after],
13
+ per: ChronoForge::Dashboard.config.page_size
14
+ )
15
+ @runs = @query.records
16
+ @summary = @query.summary
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,38 @@
1
+ module ChronoForge
2
+ module Dashboard
3
+ class WaitStatesController < BaseController
4
+ # Bound the scan: at scale there can be tens of thousands of idle
5
+ # workflows, so we examine at most CAP (oldest first) and resolve their
6
+ # waits in a single batch query rather than one query per row.
7
+ CAP = 500
8
+
9
+ def index
10
+ idle = ChronoForge::Workflow
11
+ .where(state: ChronoForge::Workflow.states[:idle])
12
+ .order(id: :asc)
13
+ .limit(CAP + 1)
14
+ .to_a
15
+ @capped = idle.size > CAP
16
+ idle = idle.first(CAP)
17
+
18
+ waits = WaitStatePresenter.active_map(idle)
19
+ @waits = idle.filter_map { |wf| {workflow: wf, wait: waits[wf.id]} if waits[wf.id] }
20
+ .sort_by { |h| h[:wait].waiting_since || Time.current }
21
+
22
+ # The headline signal: the oldest unresolved continue_if (event) wait per
23
+ # class. These never time out or self-resume — a webhook that never
24
+ # arrives sits here forever — so the oldest one per class is what an
25
+ # operator must chase down.
26
+ @oldest_event_by_class = @waits
27
+ .select { |h| h[:wait].event_wait? }
28
+ .group_by { |h| h[:workflow].job_class }
29
+ .transform_values { |rows| rows.min_by { |h| h[:wait].waiting_since || Time.current } }
30
+ .values
31
+ .sort_by { |h| h[:wait].waiting_since || Time.current }
32
+
33
+ @threshold = ChronoForge::Dashboard.config.long_wait_threshold
34
+ @cap = CAP
35
+ end
36
+ end
37
+ end
38
+ end