dashboard: Turbo-driven refresh + triage-first redesign + reap action#14
Open
thedumbtechguy wants to merge 17 commits into
Open
dashboard: Turbo-driven refresh + triage-first redesign + reap action#14thedumbtechguy wants to merge 17 commits into
thedumbtechguy wants to merge 17 commits into
Conversation
The Hide branches checkbox is paired with a same-name hidden field (the Rails unchecked-submits-0 trick), so a checked box put both values in the GET query string: hide_branches=0&hide_branches=1. Disable the hidden twin while the box is checked so the URL carries a single value.
The auto-refresh previously swapped the list region's innerHTML, then hand- restored horizontal scroll, every filter field's value, and the focused field's caret/focus — ~45 lines of fragile DOM bookkeeping, because a blunt innerHTML swap destroys all of that. Vendor Turbo 8 (self-contained UMD, ~94KB, served like the other vendored libs) and refresh the region with a morph stream instead (<turbo-stream action="update" method="morph">). idiomorph mutates the existing nodes in place, so scroll and focus survive for free. The filter text inputs carry data-cf-poll-preserve; a turbo:before-morph-element listener skips them so in-progress typing (value + caret) is never reset to the last-submitted server value. Turbo Drive is disabled (data-turbo="false" on <body>): Turbo is loaded solely as a morphing engine, and all navigation and action POSTs stay ordinary full-page requests — no 303-redirect requirement, no other page behavior changes. Docs and the assets structural test updated accordingly.
Turn Turbo from a morph-only engine (Drive was disabled) into the driver for the whole dashboard. Removing data-turbo="false" lets Turbo Drive turn every link, the filter form, pagination, row clicks, and the action POSTs into in-app visits — no full reloads, history-aware, with a progress bar. - Turbo moves to <head> so Drive keeps it across body swaps instead of re-initialising it on every visit (a body <script> re-runs each render). - Action POSTs redirect with 303 See Other (turbo-rails isn't a dependency, so Rails won't auto-303) so Turbo follows them with GET. Tests assert it. - Per-page setup (poll timer, flash auto-dismiss) moves into a turbo:load handler so it re-runs after every visit, not just first script execution. - Row click and the cookie-backed time/interval controls use Turbo.visit instead of window.location, keeping everything an in-app visit. - Fix the polling opt-out gate: it checked the #cf-poll-region id, which is always on <main>, instead of the conditional data-poll-region attribute — so the definition graph (which opts out) would have armed polling and morphed away its live Cytoscape canvas. Gate on the attribute again, and guard both sides with tests. The polling refresh stays a region-scoped morph stream (not a whole-page refresh) so the header and flash toasts are untouched.
Bulk retry looped over every failed/stalled workflow in the request thread, enqueuing a retry job each — slow (and the button sat there re-clickable) when thousands were blocked. Worse, the button lives in the polled region, so a refresh tick could reconcile it back to enabled mid-request. - Move the fan-out into ChronoForge::Dashboard::BulkRetryJob. The controller now counts (cheap), enqueues one job, and redirects immediately — the request is a COUNT + one enqueue regardless of backlog size. The job owns the retryable scope (all, or one branch's children) and does the per- workflow retry_later. Flash reports the up-front count; empty case says so. - Harden both bulk buttons: data-turbo-submits-with disables and relabels them to "Retrying…" on submit, and data-cf-poll-preserve on the form makes the polling morph skip it so the in-flight state survives a tick. Verified in a browser: the form's disabled/relabeled state survives poll morphs while list cells reconcile; a click returns in ~120ms with "Retrying N blocked workflow(s) in the background."
…running
The timeline rendered every step in the same flat grey: a bare ×N attempts
marker (meaningless, and shown even for ×1), durations as identical tiny
numbers, no signal when a step or run was dragging, and the failure buried
among the rest. Rework it to rank what matters:
- Attempts read in words, per step kind, only when there's something to say:
a retried execution is "3 attempts" (red on failure), a polled wait is
"checked 3×"; a single attempt shows nothing.
- Durations get a proportional meter (sqrt-scaled, reusing the CSP-safe
cf-bar-{n} width classes) so a 7-minute wait stands out from a 2-second
step; minute-plus steps are emphasized amber.
- Pending/running steps show a live clock and turn rose once past the
workflow's threshold — a stuck step is visible without digging.
- A summary banner leads the flow view: it names where a blocked run stopped
and the last error, or that a run is dragging past its limit.
Long-running detection is configurable: long_run_threshold (default 1h) with
per-class long_run_thresholds overrides (nil opts out). A running workflow
past its threshold is flagged on the list row (rose badge + tooltip) and the
flow view (banner + escalated Duration). Status is now a pill and timestamps
are trimmed to one per row.
Verified in a browser against the seeded runs (stalled, running-overdue,
completed); helper/config/request tests cover the wording, thresholds, and
banners.
…page
The flow-view timeline was rebuilt to lead with the problem and encode
severity in form. This applies the same information design to the other
pages, all from data each already loads (no new queries):
- Timeline: the duration-meter tooltip now states the step's share of the
run's longest step and its absolute span; the attempts chip explains the
count in words per kind ("Ran 3 times — 2 retries…" / "Polled 3 times…").
- Analytics: stat cards gain a window trend (newer half vs older half), and
the daily-throughput bars carry a per-day completion-rate pill and flag a
row amber when its failure rate spikes — health, not just volume.
- Waiting: leads with a banner for event waits past the threshold (the
timeout-less continue_if that stalls silently), event/poll pills, and a
per-row age meter that escalates rose (over-threshold event) vs amber.
- Repetitions: status pills (done / failed / tombstone / caught-up),
duration + lateness meters, and the shared attempts-in-words chip.
- Workflow list (and branch children): a per-row duration meter, rose when
a run is overdue.
Extracts cf_meter and adds cf_day_rate/cf_day_flagged?/cf_window_trend/
cf_row_duration_secs, with unit and controller tests.
Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
An overdue running workflow may be stranded by a hard-killed worker: the lock is never released and nothing is left to wake it. Force unlock only idles the row; retry/resume don't apply to a running workflow. Reap is the missing recovery — the single-workflow form of Workflow.reap_stalled: it re-enqueues the run so acquire_lock steals the stale lock and completed steps replay as no-ops. The action is offered for any running workflow (in the header cluster) and surfaced right in the long-running banner where the stall is flagged, with an idempotency warning in the confirm. Only running workflows are reapable; others get a flash. Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
Regenerate the workflow-list, analytics, waiting, repetitions, workflow- detail and branch-children screenshots for the new triage treatment, add a long-running/reap example, and update the feature list and captions. Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
…h the card The error card's backtrace <pre> scrolled horizontally but grew unbounded vertically, so a deep trace pushed everything below it far down the page. Cap it at max-h-64 and scroll (both axes) within the box. Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
…d ones A stalled workflow ran and then halted after exhausting a step's retries — it has a real run span, exactly like a failed one — but cf_row_duration_secs only handled failed?, so stalled rows showed "—" in the duration column. Include stalled? (its updated_at is when it halted). Parked idle/scheduled rows stay blank by design: their elapsed time is wait time, not run time. Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
The completion-rate card put the trend line directly under the number while the failure-rate card pushed it below "N failed", so the two "vs first half" deltas sat at different heights. Make the cards flex columns and pin the trend with mt-auto so they line up across the row. Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
…cible The branches screenshot showed a large fan-out with a still-draining merge (throughput + ETA), but that fixture was never committed to the seed — so the shot couldn't be regenerated and had gone stale against the timeline redesign (old "×0" markers instead of pills/meters). Add a fanout-100k workflow whose branch/merge counts come entirely from the poller's cached metadata (no child rows), and refresh branches.png (now showing the redesigned timeline) and workflows.png (the new idle row). Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
…add a Stranded page Elapsed runtime was the wrong signal for "stuck": a ChronoForge workflow may legitimately run for months (parked on a continue_if, a long durably_repeat), so long_run_threshold flagged healthy work and — because it read started_at, not the lock — missed the case it was meant for. The only signal that means "broken and recoverable" is a stale lock: still running, but locked_at older than reap_stale_after, so no worker is on it. That's exactly what the reaper (Workflow.reap_stalled) acts on. - Replace cf_run_overdue?/cf_overdue? and the long_run_threshold(s) config with cf_stranded? (running + locked_at < ChronoForge.config.reap_stale_after.ago), reading the gem's own threshold so dashboard and reaper never disagree. - Retire the runtime-based badge/banner/duration-rose and the per-step "stuck" timeline clock. The list badge and flow-view banner now key off the stale lock; the banner names the dead worker. - New Stranded page (peer of Waiting): lists running workflows with a stale lock, with lock-age meters, the worker named, per-row Reap, and a "Reap all stranded" background sweep (BulkReapJob → Workflow.reap_stalled). Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
…ed a stranded fixture Update the config table (drop long_run_threshold/thresholds; explain stranded reads the gem's reap_stale_after), features, and captions; add a Stranded page screenshot and switch the reap example to a genuinely stranded workflow. Seed a batch-import-42 fixture (running, stale lock) so the Stranded page and reap shot are reproducible — alongside newsletter-9, which runs for days with a fresh lock and is correctly NOT flagged. Re-shot every page (the nav gained a Stranded tab). Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
cf_duration_bar scales the meter with Math.sqrt(seconds / max) but only guarded nil and max <= 0 — never a negative seconds. A step whose completed_at predates its started_at (clock skew, or bad data) yields a negative duration, and Math.sqrt of a negative raises "Numerical argument is out of domain", 500-ing the workflow detail (and any list row with a backwards run). Clamp non-positive durations to no-bar in cf_duration_bar, return nil (unknown, renders "—") from cf_row_duration_secs, and drop backwards steps in the timeline so they neither skew the shared scale nor reach the sqrt. Regression tests for both helpers. Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
A fleet-wide summary answering "how much has run through, and by what": total processed / in-flight / blocked across every class, then one row per workflow class sorted by volume with a share-of-processed bar. Every count drills into the matching filtered list; each class name opens its per-class Analytics. Blocked keeps the rose triage flag. The index renders only a shell — each card and the per-class table load in their own turbo-frame (target="_top" so inner links break out), so the page paints instantly and the heavy GROUP BY never blocks the cheap card counts. Frame responses carry a short private HTTP cache. Adds an in_flight virtual filter (idle + running) to the workflow list so the in-flight count has somewhere to drill. Workflows stays the landing page; Overview is the second nav tab. The dev seed gains a backdated bulk backfill so the totals read like a real fleet, and the scheduled-payment fixture's timestamps are corrected to run forward. Screenshots + README updated. Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
The stat chips above the filter row already filter by state on one click, with live counts, an active-state highlight, and the blocked virtual filter the dropdown couldn't express — so the "All states" select was a strictly weaker duplicate. Remove it; the filter row is now just job class + key. A hidden state field carries a chip-selected state through a job-class/key submit so the search doesn't clear it. Claude-Session: https://claude.ai/code/session_01GRx8a5mmTDuP8abV4WWpFa
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bundles the dashboard redesign work on this branch into one PR.
Frontend / Turbo
hide_branches=0&hide_branches=1query param on toggle.303 See Other; the definition graph opts out of both (its Cytoscape canvas can't survive a morph).Triage-first information design
The flow-view timeline was rebuilt to lead with the problem and encode severity in form, then the same treatment was carried to every other page — all from data each page already loads (no new queries):
continue_ifevent waits; event/poll pills; per-row age meters (rose over-threshold event vs amber long poll).long_run_threshold/long_run_thresholds(global + per-class).Reap action
An overdue running workflow stranded by a hard-killed worker can now be reaped from the flow view — the single-workflow form of
Workflow.reap_stalled: it re-enqueues the run soacquire_locksteals the stale lock and completed steps replay as no-ops. Offered in the header cluster and inline in the long-running banner.Tests
156 dashboard tests, 0 failures; Standard clean. Screenshots refreshed.
🤖 Generated with Claude Code