Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ and this project adheres to

### Changed

- Runs on Erlang/OTP 28 and Elixir 1.18.4. OTP 27 only finishes normalising
the first character of a string, which breaks names in many languages.
Lightning does not normalise anything today, but #4577 adds it on every name,
so the runtime moves first.
- Runs on Erlang/OTP 28 and Elixir 1.18.4. OTP 27 only finishes normalising the
first character of a string, which breaks names in many languages. Lightning
does not normalise anything today, but #4577 adds it on every name, so the
runtime moves first.

### Added

Expand All @@ -43,7 +43,9 @@ and this project adheres to
- A workflow health page at `/projects/:project_id/w/:workflow_id/health`,
summarising one workflow over a selectable window (last 24 hours, 7 days, or
30 days): a donut of work order outcomes, a breakdown of the failing ones, and
a triage table grouping failures by error signature, heaviest first. The page
a triage table grouping failures by error signature, heaviest first. Each row
has a View button linking to the history page filtered to just the work orders
behind it, where the existing "retry all" can retry the group. The page
refreshes itself as that workflow's work orders settle, at most once every 30
seconds. Reachable from the workflows list via a "Health" link in each row's
Actions column.
Expand Down Expand Up @@ -73,17 +75,17 @@ and this project adheres to
- AI assistant code blocks share the surface the workflow diffs use, so a reply
and the diff below it no longer read as two different products.
[#5118](https://github.com/OpenFn/lightning/issues/5118)
- A global assistant reply whose changes could not be applied now says so on
the reply itself, beside the diffs that did not land, and offers to try
again. It used to fall back to a raw YAML panel.
- A global assistant reply whose changes could not be applied now says so on the
reply itself, beside the diffs that did not land, and offers to try again. It
used to fall back to a raw YAML panel.
[#5118](https://github.com/OpenFn/lightning/issues/5118)
- A failed apply is now remembered, so reloading no longer turns it back into
a success. The reply kept its diff blocks and offered to undo changes that
had never landed. A retry that works clears the record.
- A failed apply is now remembered, so reloading no longer turns it back into a
success. The reply kept its diff blocks and offered to undo changes that had
never landed. A retry that works clears the record.
[#5118](https://github.com/OpenFn/lightning/issues/5118)
- Editing an open step with the global assistant no longer puts a diff in the
code editor. The change is already applied, so the diff read as a proposal
to accept or reject when the only control was a close button, and reloading
code editor. The change is already applied, so the diff read as a proposal to
accept or reject when the only control was a close button, and reloading
revealed the change had been written all along.
[#5118](https://github.com/OpenFn/lightning/issues/5118)

Expand Down
7 changes: 5 additions & 2 deletions assets/js/health/WorkflowHealth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { FailureBreakdownDonut } from './charts/FailureBreakdownDonut';
import { OutcomesDonut } from './charts/OutcomesDonut';
import { TriageTable } from './charts/TriageTable';
import { DEFAULT_DAYS, RangePicker } from './RangePicker';
import type { FailureSignatures, Outcomes } from './types';
import type { ErrorSignatures, Outcomes } from './types';
import { FAILURE_STATES } from './types';
import type { Query } from './useHealthQuery';
import { healthBase, useHealthQuery } from './useHealthQuery';
Expand Down Expand Up @@ -58,7 +58,7 @@ export const HealthContent = ({
const base = healthBase(projectId, workflowId);

const outcomes = useHealthQuery<Outcomes>(`${base}/outcomes?days=${days}`);
const signatures = useHealthQuery<FailureSignatures>(
const signatures = useHealthQuery<ErrorSignatures>(
`${base}/failures?days=${days}`
);

Expand Down Expand Up @@ -125,6 +125,9 @@ export const HealthContent = ({
<TriageTable
signatures={signatures}
emptyMessage={emptyMessage(window, 'failures')}
projectId={projectId}
workflowId={workflowId}
from={window.from}
/>
)}
</Panel>
Expand Down
139 changes: 117 additions & 22 deletions assets/js/health/charts/TriageTable.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { FailureSignature } from '../types';
import type { ErrorSignature } from '../types';

/**
* Failed work orders grouped by error signature, heaviest first.
*
* Purely informational — there is nothing to act on here, so no row is a link
* or a control. The signature grammar is:
* `exitReason:errorType [@ stepName [adaptor@version]]`.
* Failed work orders grouped by error signature, heaviest first. Each row
* links to the history page filtered to the work orders it counts, where the
* existing "retry all" can act on the group. The signature grammar is:
* `exitReason:errorType [@ stepName [adaptor]]` — the adaptor renders without
* its version, since a merged row can span more than one (see `job_id` on
* `ErrorSignature`).
*/

// One sentence per error type the worker can report, written to hold
Expand Down Expand Up @@ -59,54 +60,82 @@ const TIPS: Record<string, string> = {
};

interface TriageTableProps {
signatures: FailureSignature[];
signatures: ErrorSignature[];
emptyMessage: string;
projectId: string;
workflowId: string;
/** `window.from` off the same response — the picked range's start. */
from: string;
}

export const TriageTable = ({ signatures, emptyMessage }: TriageTableProps) => {
export const TriageTable = ({
signatures,
emptyMessage,
projectId,
workflowId,
from,
}: TriageTableProps) => {
if (signatures.length === 0) {
return <p className="text-sm text-gray-500">{emptyMessage}</p>;
}

return (
// A rename forks a job's history into a signature per name, so a long-lived
// workflow can list far more rows than it has ways of breaking. Capped in
// height rather than in rows: the tail is still worth reading, just not
// worth pushing the rest of the page down for. `max-h` over a row count so
// a short list keeps the card short.
<div className="max-h-96 overflow-y-auto">
// Capped in height rather than in rows: the tail is still worth reading,
// just not worth pushing the rest of the page down for. `max-h` over a
// row count so a short list keeps the card short.
//
// `-mr-6 pr-4` bleeds the scroll region out to the card's own edge (the
// card is `p-6`), so the scrollbar sits flush against it instead of
// floating in the middle of the card's padding.
<div className="-mr-6 max-h-96 overflow-y-auto pr-4">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-white">
<thead className="sticky top-0 z-10 bg-white">
<tr className="border-b border-gray-200 text-xs uppercase tracking-wide text-gray-500">
<th scope="col" className="w-28 py-2 pr-4 font-medium">
Work orders
</th>
<th scope="col" className="py-2 font-medium">
Signature
</th>
<th scope="col" className="w-24 py-2 pl-4 font-medium">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{signatures.map(signature => (
// job_id joins the key: a job deleted and recreated with the same
// name reads as two identical-looking signatures otherwise.
<tr
key={[
signature.exit_reason,
signature.error_type,
signature.step_name,
signature.adaptor,
signature.job_id,
].join('|')}
className="border-b border-gray-100 align-top last:border-0"
className="border-b border-gray-100 last:border-0"
>
<td className="py-3 pr-4 tabular-nums text-gray-900">
{signature.count.toLocaleString()}
</td>
<td className="py-3">
<td className="py-3 align-top">
<Signature signature={signature} />
<p className="mt-1">
<span className="font-medium text-gray-500">Tip: </span>
<span className="text-gray-600">{tipFor(signature)}</span>
</p>
</td>
{/* Nothing to link on a row whose `exit_reason` never resolved:
that leaves neither a step nor a mappable run state to filter
history on. */}
<td className="py-3 pl-4 text-right">
{signature.exit_reason && (
<ViewButton
href={historyUrl(projectId, workflowId, from, signature)}
/>
)}
</td>
</tr>
))}
</tbody>
Expand All @@ -115,25 +144,91 @@ export const TriageTable = ({ signatures, emptyMessage }: TriageTableProps) => {
);
};

/**
* Lands on history filtered to exactly the work orders this row counts, where
* the existing "retry all" can act on the group. Not labelled with the row's
* count — the filter re-derives the count on every load, so the number moves.
*/
const ViewButton = ({ href }: { href: string }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-x-1 whitespace-nowrap rounded-full bg-primary-50 px-2.5 py-1 text-xs font-semibold text-primary-700 hover:bg-primary-100"
>
View
<span className="hero-arrow-right-micro h-3 w-3" />
</a>
);

// A rejected work order never got a run, so the signature filter would fail
// closed on it server-side — history's existing `rejected` status filter is
// what actually matches these. `to_signature/2` gives every rejected row the
// same literal `exit_reason: "rejected"`, so that is the signal to switch.
//
// No status is ticked for the other rows: the signature filter carries
// `wo.state in failure_states()` itself, so the group is already exactly the
// row's, and a status the reason names would only subtract from it — a `fail:`
// row counts every work order whose latest run holds a step that failed,
// whatever state the run itself ended in.
const historyUrl = (
projectId: string,
workflowId: string,
from: string,
signature: ErrorSignature
) => {
const params = new URLSearchParams({
'filters[workflow_id]': workflowId,
'filters[date_after]': from,
});

if (signature.exit_reason === 'rejected') {
params.set('filters[rejected]', 'true');
} else {
params.set('filters[error_signature_exit_reason]', signature.exit_reason);
if (signature.error_type) {
params.set('filters[error_signature_error_type]', signature.error_type);
}
if (signature.job_id) {
params.set('filters[error_signature_job_id]', signature.job_id);
}
}

return `/projects/${projectId}/history?${params.toString()}`;
};

// The parts are styled apart rather than concatenated server-side: the error
// type is the bit worth scanning down the column for.
const Signature = ({ signature }: { signature: FailureSignature }) => (
const Signature = ({ signature }: { signature: ErrorSignature }) => (
<p className="font-mono text-gray-900">
<span className="text-gray-500">{signature.exit_reason}:</span>
<span className="font-semibold">{errorTypeOf(signature)}</span>
{signature.step_name && <span> @ {signature.step_name}</span>}
{signature.adaptor && (
<span className="text-gray-500"> [{signature.adaptor}]</span>
<span className="text-gray-500">
{' '}
[{packageNameOf(signature.adaptor)}]
</span>
)}
</p>
);

// A row is keyed and labelled by `job_id`, not by (job_id, adaptor), so a row
// spanning an adaptor bump mid-window is labelled from its newest failing
// snapshot. Rendering that snapshot's version would head older failures with a
// version that isn't theirs, so only the package name renders. Strips
// everything from the last '@' that isn't the scope's leading one, so a scoped
// package's own '@' survives.
const packageNameOf = (adaptor: string) => {
const lastAt = adaptor.lastIndexOf('@');
return lastAt > 0 ? adaptor.slice(0, lastAt) : adaptor;
};

// A step can finish without reporting a type, and a worker can report one as an
// empty string. The signature still has to say something, and `default` is the
// tip written for exactly that case — hence `||`, which catches '' as well as
// null, where `??` would render a bare `fail:` and a tip with no sentence.
const errorTypeOf = ({ error_type }: FailureSignature) =>
error_type || 'unknown';
const errorTypeOf = ({ error_type }: ErrorSignature) => error_type || 'unknown';

const tipFor = ({ error_type }: FailureSignature) =>
const tipFor = ({ error_type }: ErrorSignature) =>
(error_type && TIPS[error_type]) || TIPS['default'];
13 changes: 9 additions & 4 deletions assets/js/health/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,23 @@ export interface Outcomes {
* One row of the triage table: the parts of an error signature and
* the number of work orders that carry it. `step_name` and `adaptor` are null
* for a work order whose run failed before reaching a step, or never ran at
* all; `error_type` is null when nothing reported one.
* all; `error_type` is null when nothing reported one. `job_id` is the same
* story as `step_name`/`adaptor` — null for a run-level row and for a
* rejected one — but it is the key the history filter matches on, since
* matching on the resolved name would need a snapshot lookup the filter
* doesn't do.
*/
export interface FailureSignature {
export interface ErrorSignature {
count: number;
exit_reason: string;
error_type: string | null;
job_id: string | null;
step_name: string | null;
adaptor: string | null;
}

/** The `failures` response, heaviest signature first. */
export interface FailureSignatures {
export interface ErrorSignatures {
window: { from: string; to: string };
signatures: FailureSignature[];
signatures: ErrorSignature[];
}
11 changes: 6 additions & 5 deletions assets/test/health/WorkflowHealth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,21 @@ const outcomes = {
},
};

const failureSignatures = {
const errorSignatures = {
window: outcomes.window,
signatures: [
{
count: 98,
exit_reason: 'fail',
error_type: 'RuntimeError',
job_id: 'a1b2c3d4-0000-0000-0000-000000000000',
step_name: 'Map-beneficiary',
adaptor: '@openfn/language-common@2.0.0',
},
],
};

const both = { outcomes, failures: failureSignatures };
const both = { outcomes, failures: errorSignatures };

const ERROR = 'Could not load workflow stats. Refresh to try again.';

Expand Down Expand Up @@ -231,7 +232,7 @@ describe('WorkflowHealth', () => {
window: { from: '2026-08-30T10:00:00Z', to: '2026-08-31T10:00:00Z' },
};

mount({ outcomes: dayWide, failures: failureSignatures });
mount({ outcomes: dayWide, failures: errorSignatures });

expect(
await screen.findByText('Last 24 hours · 1,287 work orders')
Expand Down Expand Up @@ -259,7 +260,7 @@ describe('WorkflowHealth', () => {
expect(screen.queryByText('cancelled')).not.toBeInTheDocument();
});

test('lists the failure signatures in the triage table', async () => {
test('lists the error signatures in the triage table', async () => {
mount(both);

expect(
Expand All @@ -282,7 +283,7 @@ describe('WorkflowHealth', () => {
});

test('degrades both donuts when the outcomes request fails', async () => {
mount({ outcomes: 500, failures: failureSignatures });
mount({ outcomes: 500, failures: errorSignatures });

// Both donuts read the same response, so both degrade.
expect(await screen.findAllByText(ERROR)).toHaveLength(2);
Expand Down
Loading