Skip to content

Add a workflow health page - #5108

Draft
midigofrank wants to merge 6 commits into
mainfrom
frank/con-106
Draft

Add a workflow health page#5108
midigofrank wants to merge 6 commits into
mainfrom
frank/con-106

Conversation

@midigofrank

@midigofrank midigofrank commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Description

A per-workflow page at /w/:id/health showing a 30-day breakdown of its work orders. Stats come over a channel rather than the LiveView so each chart can be requested on its own — one slow query shouldn't hold up the rest of the page.

Lightning.Workflows.Stats is kept separate from DashboardStats: the list view batches across many workflows to dodge an N+1, this queries one, and the windows diverge as soon as the page grows a range picker.

The channel re-checks project membership on join. The LiveView's on_mount guards don't cover it, since a client can join any topic on the socket.

Closes _

Validation steps

  1. (How can a reviewer validate your work?)

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

A per-workflow page at /w/:id/health showing a 30-day breakdown of its
work orders. Stats come over a channel rather than the LiveView so each
chart can be requested on its own — one slow query shouldn't hold up the
rest of the page.

Lightning.Workflows.Stats is kept separate from DashboardStats: the list
view batches across many workflows to dodge an N+1, this queries one, and
the windows diverge as soon as the page grows a range picker.

The channel re-checks project membership on join. The LiveView's on_mount
guards don't cover it, since a client can join any topic on the socket.
@github-project-automation github-project-automation Bot moved this to New Issues in Core Aug 31, 2026
@midigofrank midigofrank self-assigned this Aug 31, 2026
@midigofrank
midigofrank requested a review from lmac-1 August 31, 2026 18:27
@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.6%. Comparing base (fb98b9e) to head (26bf574).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##            main   #5108     +/-   ##
=======================================
- Coverage   90.6%   90.6%   -0.0%     
=======================================
  Files        422     425      +3     
  Lines      20043   20078     +35     
=======================================
+ Hits       18160   18188     +28     
- Misses      1883    1890      +7     

☔ 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.

lmac-1 added 3 commits August 31, 2026 17:25
The health page only handled channel-level failures, so a socket that never
connected left it sitting on "Loading…" indefinitely. Surface connectionError
instead, but only before the first load — a dropped connection recovers on its
own and shouldn't blank a page that already has numbers on it.
The whole page was replaced by a single line while a request was in flight or
after it failed, so a broken chart took the workflow name and the card heading
with it. Each card now handles its own loading and failure, which is the shape
the remaining charts need anyway.

A rejected join also printed the channel guard's own word — "unauthorized" — as
the page body. Log that and show something the reader can act on instead.

@lmac-1 lmac-1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Frank, nice work! I have directly committed some updates to help improve the React side:

  1. A socket that never connects now shows an error instead of "Loading…" forever. The hook only handled channel errors, so if the socket itself never came up the page just sat there. It only shows before the first load, so a brief drop doesn't blank a page that already has numbers on it.

  2. Loading and error states moved into the card. Loading and error states were early returns in HealthContent, so a failed request took the workflow name and card heading with it. It's also set things up for the next cards, since each one loads separately.

  3. A rejected join no longer prints "unauthorized" as the page body. That's the channel guard talking, and non-members get redirected before the page renders anyway. It goes to the console now. I left the catch alone — those messages are already written for people.

I have left some inline comments on other areas that I found. I know you asked for a review focusing on the React architecture, but you got some Elixir bits for free 🤭

I also have some design questions:

  1. Should stats update live, or only on page refresh? CON-107 covers refreshing when you switch range. I'm not sure whether they should also update while you're sat on the page. (Guessing no, but wanted to confirm.)

  2. Are we counting work orders or runs? CON-106 says the subtitle should read "# runs" but this counts work orders, and CON-110 is a failed run breakdown. Different numbers on the same page will confuse people.

Comment thread assets/js/workflow-health/WorkflowHealth.tsx Outdated
) do
with %_{} = user <- socket.assigns[:current_user],
%_{} = project <- Projects.get_project(project_id),
%_{} <- Projects.get_project_user(project, user),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Support users have no project_users row, so this refuses them where other channels admit them. Worth going through Permissions.can(ProjectUsers, :access_project, ...) instead, it also covers projects with scheduled_deletion set.

socket
) do
with %_{} = user <- socket.assigns[:current_user],
%_{} = project <- Projects.get_project(project_id),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A malformed project_id crashes this instead of erroring cleanly. Repo.get raises Ecto.Query.CastError on a bad UUID, it only returns nil for a valid one that doesn't exist.

Other lookups guard with valid_uuid?/Ecto.UUID.cast first (job.ex, channel_proxy_plug.ex, workflow_channel.ex), should do the same here.

with %_{} = user <- socket.assigns[:current_user],
%_{} = project <- Projects.get_project(project_id),
%_{} <- Projects.get_project_user(project, user),
true <- Workflows.workflow_exists_in_project?(project.id, workflow_id) do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that this join does quite a few separate DB lookups, including one to check if the workflow exists and then a separate one to fetch it. Is there a reason those aren't combined into a single query? Also the project fetch pulls in :parent, which doesn't look like it's used here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch

select: {wo.state, count(wo.id)}
)
|> Repo.all()
|> Enum.reduce(%{success: 0, failed: 0, pending: 0}, fn

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same rule as DashboardStats.count_workorders/1 (dashboard_stats.ex:239-248). CON-112 is considering changing this - cancelled will not be counted as failed. If that happens, the rule would need updating in both places, and there is nothing here to tell you the other copy exists.

Would it be worth putting the rule in one place, in Lightning.WorkOrder next to active_states/0?

def outcome(:success), do: :success
def outcome(state) when state in @active_states, do: :pending
def outcome(_state), do: :failed

Then both reducers would just be Map.update!(acc, WorkOrder.outcome(state), &(&1 + count)).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to completely isolate it. I din't want it sharing anything with the DashboardStats so that we can modify it as we like

@midigofrank

Copy link
Copy Markdown
Contributor Author

Thanks @lmac-1 . Looks like you forgot to push your changes 👀

The join checked for a project_users row, which support users don't have, so
it refused them where the rest of the app admits them. Going through
Permissions.can/4 also rejects projects with scheduled_deletion set.

A malformed project_id crashed the join rather than erroring: Repo.get only
returns nil for a well-formed id that doesn't exist, and raises
Ecto.Query.CastError otherwise. Both ids now go through a cast first.

Workflows.get_workflow_for_project/3 replaces the separate existence check and
fetch, taking the join from four queries to two and dropping the unused
:parent preload that Projects.get_project/1 carries.
The header summed all three buckets while the donut draws success and failed,
so the two numbers on screen didn't add up.
@midigofrank

Copy link
Copy Markdown
Contributor Author

Thanks @lmac-1 , I have implemented the changes. Regarding your two questions:

  1. Runs vs WorkOrders: The workflow index page tracks workorders and it kinda made sense to stick with that. You can retry runs multiple times within a work order until it succeeds. Once the run turns green, that workorder is marked as successful
  2. Range switching wasn't part of the scope here. This was to set this up with the utmost minimum which can kick off the other issues

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.

2 participants