Skip to content

Workflow health: Ability to view grouped triaged work orders in history page - #5151

Open
lmac-1 wants to merge 14 commits into
frank/con-106from
workflow-health-view-button
Open

Workflow health: Ability to view grouped triaged work orders in history page#5151
lmac-1 wants to merge 14 commits into
frank/con-106from
workflow-health-view-button

Conversation

@lmac-1

@lmac-1 lmac-1 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Description

Adds a View button to each row of the workflow health page's triage table. It links to the history page filtered to exactly the work orders behind that row, where the existing "retry all" can act on the group.

image

Underneath the button:

  • A shared signature query, so the health page and the history filter agree on what counts as "the same failure."
  • Triage rows key on job_id now, not the resolved job name, so a rename or adaptor bump mid-window merges into one row instead of two.
  • Three new filter keys on the history page (exit_reason, error_type, job_id), which also scope bulk retry, bulk cancel, and CSV export since they share one query.
  • A filter chip, so an active signature filter is visible and clearable, not just applied silently.

Also fixes a bug where an empty error type split one failure into two identical-looking triage rows.

Builds on #5108 (still open); this branch rebases onto it once it merges.

Closes CON-113

Validation steps

  1. On a workflow with failed work orders, open its health page and click View on a triage row.
  2. Confirm history opens filtered to the workflow, the picked date range, and that row's signature. Confirm the filter chip is showing too.
  3. Confirm the work orders listed match the row's count, and that "retry all" only retries that group.
  4. Type a term into the search box and confirm it narrows that list rather than emptying it.
  5. Clear the chip and confirm the filter drops back to just the date range.
  6. Click View on a rejected row and confirm it filters on filters[rejected]=true rather than a signature.

Additional notes for the reviewer

  • Rolling deploy risk on CSV export. It runs as a background job that any node can pick up. If an old node (pre-this-PR) runs it, it silently drops the three new filter keys and exports more rows than the user asked for. Self-resolves once the deploy finishes.
  • A bug in the shared query would widen retry/cancel/export, not just search. SearchParams.new/1 raising on a malformed field limits how far that can reach.
  • The adaptor no longer shows its version, since a merged row can span more than one. Showing versions again later needs us to record which version actually ran a work order, which we don't today.
  • The UI needs a design pass (follow-on PR). Right now the View button sits too far from its row.
  • The View link builds its query string in TypeScript, not through SearchParams.to_uri_params/1 like every server-rendered link. So it has to carry history's four search-field flags itself, or the search box on the page it lands on matches nothing. Any filter key added later needs the same treatment.
  • It's normal for the count to drift by one or two right after clicking View. Health numbers are cached for up to 30 seconds.

AI Usage

Please disclose whether you've used AI anywhere in this PR (it's cool, we just
want to know!):

  • I have used Claude Code
  • I have used another model
  • I have not used AI

You can read more details in our
Responsible AI Policy

Pre-submission checklist

  • I have performed an AI review of my code (we recommend using /review
    with Claude Code)
  • I have implemented and tested all related authorization policies.
    (e.g., :owner, :admin, :editor, :viewer)
  • I have updated the changelog.
  • I have ticked a box in "AI usage" in this PR

Step error type coalesces to the run's with `||`, and `"" || x` returns
`""` since empty string is truthy in Elixir. A step reporting an empty
error type produced its own signature instead of falling through,
splitting one failure into two rows with identical labels and split
counts. The run's error type can be empty the same way, and is read
straight into the signature, so it splits the run-level rows too.

Normalise "" to nil on both sides in to_signature/2.
Extracts the failing-step predicate, the two exit_reason/error_type
coalesces, and the latest-run tiebreak from Stats into
Lightning.Invocation.Query, alongside the failure-state list it already
needs to share with the upcoming history filter. Stats composes them
instead of inlining them; latest_runs/2 keeps its DISTINCT ON.

Triage rows now merge on job_id rather than on every display field, so a
job renamed (or adaptor-bumped) mid-window is one row, not two. The label
is taken from the group's most recent failing snapshot, using the
snapshot's lock_version as the tiebreak since the grouped rows carry no
timestamp to compare by. job_id joins the signature map and the health
JSON.
Adds exit_reason, error_type, and job_id to SearchParams and the
LiveView filter types, and filter_by_signature/2 to scope any work
order query to exactly the work orders behind one triage row.

A present job_id reads as a step-level row, correlated against the
latest run; an absent one reads as a run-level failure (lost, crashed,
no step), matched against the inverted Run.state_reasons/0 map and
failing closed on an unrecognised reason rather than dropping the
filter, which would widen a bulk retry.

Carries wo.state in failure_states() itself: a successful work order
can hold a failing step under an on_job_failure handler that ran
fine, so without this the filter would match work orders the triage
row never counted.
Adds a chip to the history page's filter bar, alongside the existing
work order ID chip, that renders when exit_reason is set and clears
all three signature keys at once.

search_workorders_query/2 is shared with bulk retry, bulk cancel and
CSV export, so a signature filter with no visible indicator would be a
way to silently act on the wrong group of work orders.
Each row now links to the history page filtered to exactly the work
orders it counts, where the existing "retry all" can act on the
group. job_id joins the signature so a job deleted and recreated with
the same name doesn't collide with the row it replaced, and the
adaptor renders without its version since a merged row can span more
than one.

A rejected row links through history's existing rejected status
filter instead of the signature keys, since a rejected work order
never got a run for the signature filter to match against server-side.
View is now a small filled pill with an arrow instead of a plain text
link, easier to spot as an action. Work orders and View are middle-
aligned in the row; only the two-line signature/tip cell stays pinned
to the top. The scroll region bleeds out to the card's own edge so
the scrollbar sits flush against it instead of floating in the middle
of the card's padding, and the sticky header gets a z-index so a
row's content can't paint above it while scrolling.
@github-project-automation github-project-automation Bot moved this to New Issues in Core Sep 8, 2026
The workflow health page is still unreleased, so a Changed entry
describing a fix to it is noise, not history: nobody saw the split-
row bug this PR corrects. The Added entry for the page already
describes the fixed behaviour on its own.
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Security Review ✅

  • S0 (project scoping): The API's authorize_workflow plug (lib/lightning_web/controllers/api/workflow_health_controller.ex:57) casts project_id, looks up the workflow via Workflows.get_workflow_for_project/3 (which filters through Query.workflows_for(project)lib/lightning/workflows/query.ex:34), and every Stats query in lib/lightning/workflows/stats.ex is bound by that already-scoped workflow_id; the LiveView route is guarded by :project_scope + :ensure_workflow_belongs_to_project (lib/lightning_web/live/workflow_live/health.ex:12-13).
  • S1 (authorization): The API plug calls Permissions.can(:project_users, :access_project, user, project) (workflow_health_controller.ex:68) — read-only viewer-level access is appropriate for a summary view — and the LiveView inherits :project_scope's membership + MFA check; test coverage in test/lightning_web/controllers/api/workflow_health_controller_test.exs:36-207 exercises anonymous, non-member, cross-project, MFA-blocked, deleted-project, deleted-workflow, and malformed-id refusals.
  • S2 (audit trail): N/A — the PR is read-only (aggregations for the health page) with no config-resource writes.

@codecov

codecov Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.9%. Comparing base (8c20f3f) to head (3a42587).

Additional details and impacted files
@@               Coverage Diff               @@
##           frank/con-106   #5151     +/-   ##
===============================================
+ Coverage           90.8%   90.9%   +0.1%     
===============================================
  Files                422     422             
  Lines              20846   20871     +25     
===============================================
+ Hits               18928   18963     +35     
+ Misses              1918    1908     -10     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Three fixture helpers replace the hand-built work order and run in
every test. Drops the duplicate query in the no-op test: an absent key
and a nil value cast to the same field, so both runs asked the same
question.
exit_reason/2 and error_type/2 only ever ran for the health page's
triage rows, so they move back into Stats as defp. The three that the
history filter also calls stay in Query, two of them with a comment
instead of a doc block.
The triage row nils an empty error_type before grouping, and so does the
step-level branch of the signature filter. The run-level branch compared
the raw column, so a crashed run with error_type "" was counted in the row
but never matched by its own View button.
History derives search_fields from the query string and put_new's the
result, so a link carrying none of the four keys lands with an empty list
rather than the schema default, and the search box then matches nothing.
Every server-built link fills these in via to_uri_params/1.
ViewButton took four props to build one URL, all of them already at the
call site. It takes the href now, and the row keeps the guard: no exit
reason, no link. Also drops a comment's pointer to a plan file the
reader of this file cannot open.
The comment contrasted the key with "the whole map", which only means
something to someone who saw the previous implementation.
@lmac-1
lmac-1 requested a review from midigofrank September 8, 2026 22:04
from_uri/1 always set search_fields, so a link naming none of the four
flags landed with an empty list and every search term then matched
nothing. Each unticked box still arrives marked false, so "none present"
can only mean a link that never mentioned them.

Reverts the flags the triage View link carried to work around this. It
was the first link built in the browser rather than through
to_uri_params/1, so it was the first to hit this.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New Issues

Development

Successfully merging this pull request may close these issues.

1 participant