Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 17 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,23 @@ and this project adheres to
[#5188](https://github.com/OpenFn/lightning/pull/5188)
- A "Steps with failures" card on the workflow health page, ranking the jobs the
window's failures landed on, heaviest first.

### Changed
- A band of a bar in the runs volume chart on the workflow health page now opens
the work orders it counted, filtered to that bar's slot and the run states the
band stacked. [#5191](https://github.com/OpenFn/lightning/pull/5191)
- History can now filter on when a run was created and on the state a run
finished in, each as its own chip, so a failure later retried to success is
still reachable from the band that counted it.
[#5191](https://github.com/OpenFn/lightning/pull/5191)

### Changed

- The runs volume chart on the workflow health page is now bucketed on your own
timezone rather than UTC, so a daily bar covers your day, the week view's bars
split at your noon, a day the clocks change is still one bar, and the card
names the clock it was drawn on. A browser that sends no timezone, or says it
does not know one, still gets UTC; one that sends a zone the tz database does
not know now gets an error.
[#5191](https://github.com/OpenFn/lightning/pull/5191)

### Fixed

Expand Down
8 changes: 6 additions & 2 deletions assets/js/health/WorkflowHealth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,17 @@ export const WorkflowHealth = ({
<Card
title="Runs over time"
className="lg:col-span-6"
meta={volume.data && bucketMeta(volume.data.buckets)}
meta={volume.data && bucketMeta(volume.data)}
>
<Panel data={volume.data} error={volume.error}>
{({ buckets, window }) => (
{({ buckets, window, timezone, bucket_hours }) => (
<VolumeBars
buckets={buckets}
timezone={timezone}
hours={bucket_hours}
emptyMessage={emptyMessage(window, 'runs')}
projectId={projectId}
workflowId={workflowId}
/>
)}
</Panel>
Expand Down
190 changes: 134 additions & 56 deletions assets/js/health/charts/VolumeBars.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
YAxis,
} from 'recharts';

import { historyUrl } from '../historyUrl';
import type { FailureState } from '../types';
import { FAILURE_STATES } from '../types';

Expand All @@ -20,16 +21,17 @@ import { CANCELLED, FAILED, SUCCESS } from './OutcomesDonut';
*
* Counts runs where the donuts beside it count work orders, so the two differ
* on purpose and the card carries no total. Buckets arrive already counted and
* zero-filled from `Stats.runs/2` — one row per bar, one key per run state,
* which is the shape Recharts takes as `data`.
* zero-filled from `Stats.runs/3` — one row per bar, one key per run state,
* which is the shape Recharts takes as `data` — on a grid cut to the reader's
* calendar, so every label here follows `timezone` rather than the browser.
*/

// A run has no `rejected` state — a work order rejected on arrival never
// produced one. Narrowed off the donut's list so a state added there reaches
// both.
type RunFailureState = Exclude<FailureState, 'rejected'>;

const RUN_FAILURE_STATES = FAILURE_STATES.filter(
export const RUN_FAILURE_STATES = FAILURE_STATES.filter(
(state): state is RunFailureState => state !== 'rejected'
);

Expand All @@ -47,23 +49,47 @@ export interface RunBucket {

export interface RunVolume {
window: { from: string; to: string };
/** The clock the server cut the grid on. Every label follows it. */
timezone: string;
/** Bar width, in wall-clock hours on that clock: 2, 12 or 24. */
bucket_hours: number;
buckets: RunBucket[];
}

// Top to bottom, as the legend and the spoken totals read. The bars draw from
// the reverse of it, since Recharts puts the first `Bar` on the axis.
//
// `states` is what the band actually stacked, and is what its history link
// filters on — the red one folds five, and it is the only place those five can
// be named.
const SERIES = [
{ key: 'success', label: 'Success', color: SUCCESS },
{ key: 'cancelled', label: 'Cancelled', color: CANCELLED },
{ key: 'failed', label: 'Failed', color: FAILED },
{ key: 'success', label: 'Success', color: SUCCESS, states: ['success'] },
{
key: 'cancelled',
label: 'Cancelled',
color: CANCELLED,
states: ['cancelled'],
},
{ key: 'failed', label: 'Failed', color: FAILED, states: RUN_FAILURE_STATES },
] as const;

interface VolumeBarsProps {
buckets: RunBucket[];
timezone: string;
hours: number;
emptyMessage: string;
projectId: string;
workflowId: string;
}

export const VolumeBars = ({ buckets, emptyMessage }: VolumeBarsProps) => {
export const VolumeBars = ({
buckets,
timezone,
hours,
emptyMessage,
projectId,
workflowId,
}: VolumeBarsProps) => {
const rows = buckets.map(bucket => ({
at: bucket.at,
success: bucket.success,
Expand All @@ -84,8 +110,6 @@ export const VolumeBars = ({ buckets, emptyMessage }: VolumeBarsProps) => {
return <p className={EMPTY}>{emptyMessage}</p>;
}

const hours = bucketHours(buckets);

return (
<>
{/* The donut's `FRAME` is a fixed box and gains nothing from extra
Expand All @@ -104,7 +128,7 @@ export const VolumeBars = ({ buckets, emptyMessage }: VolumeBarsProps) => {
to thin the axis itself Recharts drops named bars too. */}
<XAxis
dataKey="at"
tickFormatter={at => tickLabel(at as string, hours)}
tickFormatter={at => tickLabel(at as string, hours, timezone)}
tickLine={false}
axisLine={false}
interval={hours >= 12 && hours < 24 ? 0 : 'preserveEnd'}
Expand Down Expand Up @@ -132,22 +156,42 @@ export const VolumeBars = ({ buckets, emptyMessage }: VolumeBarsProps) => {
content={
<ChartTooltip
reverse
formatLabel={at => rangeLabel(at, hours)}
formatLabel={at => rangeLabel(at, hours, timezone)}
/>
}
/>
{/* Reversed, so failures land on the axis and can be read against
a fixed baseline day to day rather than judged by thickness. */}
{[...totals].reverse().map(({ key, label: name, color }) => (
<Bar
key={key}
dataKey={key}
name={name}
stackId="runs"
maxBarSize={64}
fill={color}
/>
))}
a fixed baseline day to day rather than judged by thickness.

Each band links to its own states rather than the chart
carrying one link for the whole column: the red band is the
answer most readers came for, and a column-wide link would
make them filter the failures out again on arrival. Clicking
is a mouse affordance only — the frame is `aria-hidden`, and
thirty bars times three bands is not a link list. */}
{[...totals]
.reverse()
.map(({ key, label: name, color, states }) => (
<Bar
key={key}
dataKey={key}
name={name}
stackId="runs"
maxBarSize={64}
fill={color}
className="cursor-pointer"
onClick={(_bar, index) => {
const href = bucketUrl(
projectId,
workflowId,
buckets,
index,
states
);
if (href) window.open(href, '_blank');
}}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
Expand Down Expand Up @@ -183,69 +227,103 @@ export const VolumeBars = ({ buckets, emptyMessage }: VolumeBarsProps) => {
);
};

// Read off the data rather than passed in, so nothing naming the bucket width
// can disagree with the bars under it. A window too short to measure reads as
// daily, which only ever costs a coarser label.
const bucketHours = (buckets: RunBucket[]) => {
const [first, second] = buckets;

return first && second
? (Date.parse(second.at) - Date.parse(first.at)) / 3_600_000
: 24;
};
/**
* The card's meta line: the bar width this range draws, and the clock it is
* drawn on.
*
* Names the timezone because the grid is cut on the reader's own clock rather
* than UTC, and a bar labelled `Mar 3` is only unambiguous once the reader
* knows whose `Mar 3` it is.
*/
export const bucketMeta = ({ timezone, bucket_hours: hours }: RunVolume) =>
`${hours >= 24 ? 'daily' : `${hours}-hour`} buckets · ${timezone}`;

/**
* The card's meta line, measured off the same buckets the chart draws.
* The history link for one band of one bar: the work orders with a run
* created inside the bar's slot that settled in one of the band's states.
*
* Names the zone as well as the width: the axis and the tooltip are in UTC, so
* a reader whose own day starts hours either side of it can tell which day a
* bar is counting.
* Half-open, and the slot's end is the *next* bar's start rather than its own
* plus a width: no timezone arithmetic happens here, and a bar spanning a
* clock change is 11 or 13 real hours wide. The newest bar has no upper bound
* — it is still filling.
*/
export const bucketMeta = (buckets: RunBucket[]) => {
const hours = bucketHours(buckets);
export const bucketUrl = (
projectId: string,
workflowId: string,
buckets: RunBucket[],
index: number,
states: readonly string[]
) => {
const bucket = buckets[index];

if (!bucket) return null;

return `${hours >= 24 ? 'daily' : `${hours}-hour`} buckets · UTC`;
return historyUrl(projectId, workflowId, {
run_date_after: bucket.at,
run_date_before: buckets[index + 1]?.at,
run_status: states,
});
};

const TICK_FILL = '#6b7280';

// Rendered in UTC because that is where the buckets are: the server lays the
// grid on the raw epoch, so a day starts at UTC midnight — 03:00 in Nairobi,
// 05:30 in Delhi. The tooltip says UTC for the same reason.
const dayLabel = (date: Date) =>
// In the timezone the server cut the grid on, not the browser's own: a bar
// starts at a local whole hour there, so a Delhi bar opening at UTC 18:30
// labels as 00:00 and no label ever needs minutes.
const dayLabel = (date: Date, timezone: string) =>
date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
timeZone: timezone,
});

const hourLabel = (date: Date) => `${date.getUTCHours()}:00`;
// `hourCycle` rather than `hour12: false` because it is the explicit way to
// ask for 00-23 and does not depend on the locale's own preference. Every
// boundary is a local whole hour, so no label ever needs minutes.
const localHour = (date: Date, timezone: string) =>
Number(
date.toLocaleString('en-GB', {
hour: '2-digit',
hourCycle: 'h23',
timeZone: timezone,
})
);

const hourLabel = (hour: number) => `${String(hour).padStart(2, '0')}:00`;

export const tickLabel = (at: string, hours: number) => {
export const tickLabel = (at: string, hours: number, timezone: string) => {
const date = new Date(at);

if (hours >= 24) return dayLabel(date);
if (hours >= 24) return dayLabel(date, timezone);

// Two bars to a day at this width, so the date goes on the morning bar,
// where the day starts, and the afternoon is left blank. A window opening
// mid-day leaves that first bar unlabelled, which is honest — its morning
// is outside the window — and keeps every date two bars from the last.
if (hours >= 12) return date.getUTCHours() < 12 ? dayLabel(date) : '';
if (hours >= 12) {
return localHour(date, timezone) < 12 ? dayLabel(date, timezone) : '';
}

return hourLabel(date);
return hourLabel(localHour(date, timezone));
};

// The axis is thinned and its labels name only where a bucket starts, so the
// tooltip names the whole slot rather than leaving the reader to add the width
// to the start.
export const rangeLabel = (at: string, hours: number) => {
// to the start. No timezone suffix: the card's meta line names it once, and
// repeating it on every hover is noise.
export const rangeLabel = (at: string, hours: number, timezone: string) => {
const start = new Date(at);

// A daily bar is a whole UTC day, and `00:00 – 00:00` invites the question
// of whether the closing midnight is inside the bar or the next one.
if (hours >= 24) return `${dayLabel(start)} UTC`;
// A daily bar is a whole local day, and `00:00 – 00:00` invites the question
// of whether the closing midnight is inside the bar or the next one. It is
// also the branch that keeps a 25-hour clock-change day honest, by printing
// no end at all.
if (hours >= 24) return dayLabel(start, timezone);

const end = new Date(start.getTime() + hours * 3_600_000);
// The end hour is the start's plus the width on the wall clock, not
// `start + hours` in real time: a bar spanning a clock change is 11 or 13
// real hours, and only the wall clock closes it where the next bar opens.
const from = localHour(start, timezone);

return `${dayLabel(start)}, ${hourLabel(start)} – ${hourLabel(end)} UTC`;
return `${dayLabel(start, timezone)}, ${hourLabel(from)} – ${hourLabel((from + hours) % 24)}`;
};
12 changes: 9 additions & 3 deletions assets/js/health/historyUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,23 @@ import type { WorkOrderStateCounts } from './types';
export const historyUrl = (
projectId: string,
workflowId: string,
filters: Record<string, string | null | undefined>
filters: Record<string, string | readonly string[] | null | undefined>
) => {
const params = new URLSearchParams({
'filters[workflow_id]': workflowId,
'filters[log]': 'true',
});

// Absent parts of a filter are skipped, so a caller can hand over an
// optional field without guarding it.
// optional field without guarding it. A list goes over as `key[]` repeated,
// which is what Plug decodes back into a list for a `{:array, _}` field —
// one joined string would fail to cast.
for (const [key, value] of Object.entries(filters)) {
if (value) params.set(`filters[${key}]`, value);
if (Array.isArray(value)) {
for (const item of value) params.append(`filters[${key}][]`, item);
} else if (typeof value === 'string' && value) {
params.set(`filters[${key}]`, value);
}
}

return `/projects/${projectId}/history?${params.toString()}`;
Expand Down
12 changes: 11 additions & 1 deletion assets/js/health/useHealthQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,17 @@ export function useHealthQuery<T>(url: string): Query<T> {

setInFlight(true);

fetch(url, { credentials: 'same-origin', signal: controller.signal })
// Read inside the effect, so a reader whose machine changes timezone
// mid-session picks it up on the next poll. Sent to all three endpoints;
// only `runs` reads it, which is cheaper than threading a per-endpoint
// flag through a hook whose job is to be indifferent to the endpoint.
fetch(url, {
credentials: 'same-origin',
signal: controller.signal,
headers: {
'x-timezone': Intl.DateTimeFormat().resolvedOptions().timeZone,
},
})
.then(response => {
if (!response.ok) throw new Error('Could not load workflow stats');

Expand Down
Loading