Skip to content

dashboard: Turbo-driven refresh + triage-first redesign + reap action#14

Open
thedumbtechguy wants to merge 17 commits into
mainfrom
turbo-dynamic-loading
Open

dashboard: Turbo-driven refresh + triage-first redesign + reap action#14
thedumbtechguy wants to merge 17 commits into
mainfrom
turbo-dynamic-loading

Conversation

@thedumbtechguy

Copy link
Copy Markdown
Contributor

Bundles the dashboard redesign work on this branch into one PR.

Frontend / Turbo

  • Fix the duplicate hide_branches=0&hide_branches=1 query param on toggle.
  • Vendored Turbo 8 (94 KB, no external host): the polling refresh is now a morph stream (idiomorph mutates in place, so filter text, caret, focus and horizontal scroll survive a tick), and Turbo Drive handles all navigation and form submits. Action POSTs redirect 303 See Other; the definition graph opts out of both (its Cytoscape canvas can't survive a morph).
  • Bulk retry ("Retry blocked") is fanned out by a background job so the request stays fast with a large backlog, and the button disables/relabels and survives the poll 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):

  • Timeline: summary banner names where a blocked run stopped; proportional duration meters; attempts in words per step kind; informative meter/attempts tooltips; long-running runs flagged.
  • Analytics: window trend on the stat cards; per-day completion-rate pill + failure-spike row flag on the throughput chart.
  • Waiting: banner for stalled continue_if event waits; event/poll pills; per-row age meters (rose over-threshold event vs amber long poll).
  • Repetitions: status pills, duration/lateness meters, attempts-in-words.
  • Workflow list / branch children: per-row duration meter, rose when overdue.
  • Configurable 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 so acquire_lock steals 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

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant