From 5581c5fe140389797def5e2e675452bbbde0a745 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Sun, 25 Oct 2026 22:06:23 +1100 Subject: [PATCH 1/2] feat(failures): show passing groups and an empty state Adds a "Show passing groups" toggle that lists each diagnostic's passing execution groups, loaded on expand. Models without failures now appear in the model picker, so picking one shows an empty state with how many of its groups passed. The executions list gains exact provider_slug and diagnostic_slug filters for this. --- .../src/ref_backend/api/routes/executions.py | 5 + .../test_api/test_routes/test_executions.py | 18 +++ .../src/client/@tanstack/react-query.gen.ts | 6 +- frontend/src/client/sdk.gen.ts | 3 +- frontend/src/client/types.gen.ts | 12 ++ .../failures/diagnosticFailuresCard.tsx | 31 ++-- .../components/failures/failureFilters.tsx | 21 ++- .../failures/failuresEmptyState.tsx | 35 +++++ .../src/components/failures/openGroupLink.tsx | 17 +++ .../src/components/failures/passingGroups.tsx | 134 ++++++++++++++++++ .../src/components/failures/useFailures.ts | 11 +- frontend/src/lib/failures.test.ts | 26 ++++ frontend/src/lib/failures.ts | 27 ++++ .../src/routes/_app/executions.failures.tsx | 61 +++++--- 14 files changed, 374 insertions(+), 33 deletions(-) create mode 100644 frontend/src/components/failures/failuresEmptyState.tsx create mode 100644 frontend/src/components/failures/openGroupLink.tsx create mode 100644 frontend/src/components/failures/passingGroups.tsx diff --git a/backend/src/ref_backend/api/routes/executions.py b/backend/src/ref_backend/api/routes/executions.py index 050a24a1..5c9e80e1 100644 --- a/backend/src/ref_backend/api/routes/executions.py +++ b/backend/src/ref_backend/api/routes/executions.py @@ -87,6 +87,8 @@ async def list_recent_execution_groups( # noqa: PLR0913, PLR0917 offset: int = Query(0, ge=0), diagnostic_name_contains: str | None = None, provider_name_contains: str | None = None, + provider_slug: str | None = Query(None, description="Keep only the groups of this provider"), + diagnostic_slug: str | None = Query(None, description="Keep only the groups of this diagnostic"), dirty: bool | None = None, successful: bool | None = None, failure_reason: ExecutionFailureReason | None = None, @@ -107,6 +109,7 @@ async def list_recent_execution_groups( # noqa: PLR0913, PLR0917 Supports filtering by: - diagnostic_name_contains (case-insensitive substring of the diagnostic slug) - provider_name_contains (case-insensitive substring of the provider slug) + - provider_slug and diagnostic_slug (exact slugs) - dirty - successful (filters by latest execution success) - failure_reason (filters by why the latest execution failed, e.g. `system_error`) @@ -119,6 +122,8 @@ async def list_recent_execution_groups( # noqa: PLR0913, PLR0917 filters = ExecutionGroupFilter( diagnostic_contains=diagnostic_name_contains or None, provider_contains=provider_name_contains or None, + provider_slug=provider_slug, + diagnostic_slug=diagnostic_slug, include_superseded=False, include_retracted=False, dirty=dirty, diff --git a/backend/tests/test_api/test_routes/test_executions.py b/backend/tests/test_api/test_routes/test_executions.py index 1bad4923..175b353e 100644 --- a/backend/tests/test_api/test_routes/test_executions.py +++ b/backend/tests/test_api/test_routes/test_executions.py @@ -436,6 +436,24 @@ def test_execution_statistics_match_list(client: TestClient, settings) -> None: assert unsuccessful["total_count"] == listed["total_count"] - successful["total_count"] +def test_execution_list_filters_by_exact_slugs(client: TestClient, settings) -> None: + """The slug filters match whole slugs, unlike the `_contains` filters.""" + group = client.get(f"{settings.API_V1_STR}/executions?limit=1").json()["data"][0] + provider, diagnostic = group["provider_slug"], group["diagnostic_slug"] + + matched = client.get( + f"{settings.API_V1_STR}/executions", + params={"provider_slug": provider, "diagnostic_slug": diagnostic, "limit": 1000}, + ).json() + assert matched["total_count"] > 0 + assert {(g["provider_slug"], g["diagnostic_slug"]) for g in matched["data"]} == {(provider, diagnostic)} + + partial = client.get( + f"{settings.API_V1_STR}/executions", params={"diagnostic_slug": diagnostic[:-1], "limit": 1} + ).json() + assert all(g["diagnostic_slug"] == diagnostic[:-1] for g in partial["data"]) + + def test_execution_list_pagination(client: TestClient, settings) -> None: page1 = client.get(f"{settings.API_V1_STR}/executions?limit=2&offset=0").json() page2 = client.get(f"{settings.API_V1_STR}/executions?limit=2&offset=2").json() diff --git a/frontend/src/client/@tanstack/react-query.gen.ts b/frontend/src/client/@tanstack/react-query.gen.ts index ff1466c7..ffe764a5 100644 --- a/frontend/src/client/@tanstack/react-query.gen.ts +++ b/frontend/src/client/@tanstack/react-query.gen.ts @@ -482,6 +482,7 @@ export const executionsListRecentExecutionGroupsQueryKey = (options?: Options format(new Date(date), "d MMM yyyy"); @@ -91,15 +94,7 @@ function FailedGroupItem({ group }: { group: ExecutionGroup }) { {format(new Date(execution.updated_at), "yyyy-MM-dd HH:mm")} - - - + @@ -109,14 +104,20 @@ function FailedGroupItem({ group }: { group: ExecutionGroup }) { ); } -/** A diagnostic with failing execution groups, listing each with what its log says about the failure. */ +/** + * A diagnostic's failing execution groups, listing each with what its log says about the failure + * + * Given `passingEra`, it also lists the diagnostic's passing groups of that era. + */ export function DiagnosticFailuresCard({ failures, filters = {}, + passingEra, }: { failures: DiagnosticFailures; /** What the failures were narrowed to, named in the summary */ filters?: FailureFilters; + passingEra?: MipEra; }) { const [showAll, setShowAll] = useState(false); const { diagnostic } = failures; @@ -179,6 +180,14 @@ export function DiagnosticFailuresCard({ : `Show all ${groups.length} failed execution groups`} )} + {passingEra && failures.successful > 0 && ( + + )} ); diff --git a/frontend/src/components/failures/failureFilters.tsx b/frontend/src/components/failures/failureFilters.tsx index 6fd3cd6a..6ddc8a12 100644 --- a/frontend/src/components/failures/failureFilters.tsx +++ b/frontend/src/components/failures/failureFilters.tsx @@ -1,6 +1,7 @@ import { Check, ChevronsUpDown, X } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Command, CommandEmpty, @@ -9,6 +10,7 @@ import { CommandItem, CommandList, } from "@/components/ui/command"; +import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, @@ -21,7 +23,9 @@ import { cn } from "@/lib/utils"; import { type DayRange, DayRangePicker } from "./dayRangePicker"; const countText = ({ groups, diagnostics }: FailureCount) => - `${groups} failed in ${diagnostics} ${diagnostics === 1 ? "diagnostic" : "diagnostics"}`; + groups === 0 + ? "no failures" + : `${groups} failed in ${diagnostics} ${diagnostics === 1 ? "diagnostic" : "diagnostics"}`; /** * A searchable dropdown picking one value to filter failures by, or all of them @@ -144,6 +148,8 @@ export function FailureFilterBar({ allDays, failureDays, onDays, + passing, + onPassing, onClear, summary, disabled, @@ -165,6 +171,8 @@ export function FailureFilterBar({ /** Days with failures matching the other filters, marked in the calendar */ failureDays?: Set; onDays: (days: DayRange) => void; + passing: boolean; + onPassing: (passing: boolean) => void; onClear?: () => void; summary?: string; disabled?: boolean; @@ -206,6 +214,17 @@ export function FailureFilterBar({ onChange={onDays} disabled={disabled || !allDays} /> +
+ onPassing(checked === true)} + disabled={disabled} + /> + +
{onClear && ( + )} + + ); +} diff --git a/frontend/src/components/failures/openGroupLink.tsx b/frontend/src/components/failures/openGroupLink.tsx new file mode 100644 index 00000000..7dfeb88f --- /dev/null +++ b/frontend/src/components/failures/openGroupLink.tsx @@ -0,0 +1,17 @@ +import { Link } from "@tanstack/react-router"; +import { SquareArrowOutUpRight } from "lucide-react"; + +/** Opens an execution group's logs */ +export function OpenGroupLink({ groupId }: { groupId: number }) { + return ( + + + + ); +} diff --git a/frontend/src/components/failures/passingGroups.tsx b/frontend/src/components/failures/passingGroups.tsx new file mode 100644 index 00000000..5ba6ada4 --- /dev/null +++ b/frontend/src/components/failures/passingGroups.tsx @@ -0,0 +1,134 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { ChevronRight } from "lucide-react"; +import type { DiagnosticCatalogEntry, ExecutionGroup } from "@/client"; +import { executionsListRecentExecutionGroupsInfiniteOptions } from "@/client/@tanstack/react-query.gen"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { Skeleton } from "@/components/ui/skeleton"; +import { apiErrorMessage } from "@/lib/apiError"; +import { groupFacets } from "@/lib/failures"; +import type { MipEra } from "@/lib/mipEras"; +import { nextOffset } from "@/lib/pagination"; +import { OpenGroupLink } from "./openGroupLink"; + +/** Passing groups fetched at a time, because a diagnostic can have thousands */ +const PAGE_SIZE = 25; + +function PassingGroupItem({ group }: { group: ExecutionGroup }) { + const execution = group.executions.at(-1); + const { model, experiment, member } = groupFacets(group); + return ( +
  • + + + {group.key} + + + {model && {model}} + {experiment && {experiment}} + {member && {member}} + + + {execution && ( + + {format(new Date(execution.updated_at), "yyyy-MM-dd HH:mm")} + + )} + +
  • + ); +} + +function PassingGroupList({ + diagnostic, + mipEra, + model, +}: { + diagnostic: DiagnosticCatalogEntry; + mipEra: MipEra; + model?: string; +}) { + const { data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery({ + ...executionsListRecentExecutionGroupsInfiniteOptions({ + query: { + successful: true, + provider_slug: diagnostic.provider.slug, + diagnostic_slug: diagnostic.slug, + mip_era: mipEra, + source_id: model, + limit: PAGE_SIZE, + }, + }), + initialPageParam: 0, + getNextPageParam: (_lastPage, allPages) => nextOffset(allPages), + }); + + if (error) { + return

    {apiErrorMessage(error)}

    ; + } + if (!data) { + return ( +
    + Loading passing execution groups + + +
    + ); + } + + const groups = data.pages.flatMap((page) => page.data); + return ( +
    +
      + {groups.map((group) => ( + + ))} +
    + {hasNextPage && ( + + )} +
    + ); +} + +/** A diagnostic's passing execution groups, fetched only once expanded */ +export function PassingGroups({ + diagnostic, + count, + mipEra, + model, +}: { + diagnostic: DiagnosticCatalogEntry; + count: number; + mipEra: MipEra; + model?: string; +}) { + return ( + + + + {count} passing execution {count === 1 ? "group" : "groups"} + + + + + + ); +} diff --git a/frontend/src/components/failures/useFailures.ts b/frontend/src/components/failures/useFailures.ts index 7819c890..13dd9ca1 100644 --- a/frontend/src/components/failures/useFailures.ts +++ b/frontend/src/components/failures/useFailures.ts @@ -1,6 +1,9 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { ExecutionGroup } from "@/client"; -import { diagnosticsCatalogOptions } from "@/client/@tanstack/react-query.gen"; +import { + diagnosticsCatalogOptions, + modelsListOptions, +} from "@/client/@tanstack/react-query.gen"; import { executionsListRecentExecutionGroups } from "@/client/sdk.gen"; import type { MipEra } from "@/lib/mipEras"; @@ -54,6 +57,11 @@ export function useFailures(mipEra: MipEra, model?: string) { queryKey: ["executions", "unsuccessful", mipEra], queryFn: () => fetchUnsuccessfulGroups(mipEra), }); + // Only lists the models without failures in the model picker, so it never holds up the page + const models = useQuery({ + ...modelsListOptions({ query: { mip_era: mipEra } }), + staleTime: 5 * 60 * 1000, + }); // A failed query that is being retried counts as loading again const failed = [catalog, unsuccessful].filter( @@ -65,6 +73,7 @@ export function useFailures(mipEra: MipEra, model?: string) { return { catalog: catalog.data?.data, unsuccessful: unsuccessful.data, + models: models.data?.data.map(({ source_id }) => source_id), isLoading: !loaded && error === null, error, retry: () => { diff --git a/frontend/src/lib/failures.test.ts b/frontend/src/lib/failures.test.ts index 76d3effe..019756d2 100644 --- a/frontend/src/lib/failures.test.ts +++ b/frontend/src/lib/failures.test.ts @@ -14,9 +14,11 @@ import { failuresByReason, filterOptions, groupFacets, + groupTotals, lastSucceededAt, reasonBreakdown, summariseFailures, + withModelsWithoutFailures, } from "./failures"; const diagnostic = ( @@ -400,3 +402,27 @@ describe("failureSummary", () => { ).toBe("1 of 4 CanESM5 execution groups failed (System error)"); }); }); + +describe("withModelsWithoutFailures", () => { + it("lists the models without failures after the failing ones, by name", () => { + const failing = [{ key: "CESM2", groups: 2, diagnostics: 1 }]; + expect( + withModelsWithoutFailures(failing, ["MIROC6", "CESM2", "ACCESS-ESM1-6"]), + ).toEqual([ + { key: "CESM2", groups: 2, diagnostics: 1 }, + { key: "ACCESS-ESM1-6", groups: 0, diagnostics: 0 }, + { key: "MIROC6", groups: 0, diagnostics: 0 }, + ]); + }); +}); + +describe("groupTotals", () => { + it("adds up the groups and successes of every diagnostic", () => { + const summaries = summariseFailures( + [diagnostic("pmp", "enso", 5, 4), diagnostic("ilamb", "gpp", 3, 3)], + [], + ); + expect(groupTotals(summaries)).toEqual({ total: 8, successful: 7 }); + expect(groupTotals([])).toEqual({ total: 0, successful: 0 }); + }); +}); diff --git a/frontend/src/lib/failures.ts b/frontend/src/lib/failures.ts index 448e115c..d26b1f09 100644 --- a/frontend/src/lib/failures.ts +++ b/frontend/src/lib/failures.ts @@ -305,3 +305,30 @@ export function failureDays( ? { from: days[0], to: days[days.length - 1] } : undefined; } + +/** Add the models without failures to the failure counts, after the failing ones, so each can be picked */ +export function withModelsWithoutFailures( + counts: FailureCount[], + models: string[], +): FailureCount[] { + const failing = new Set(counts.map(({ key }) => key)); + const passing = models + .filter((model) => !failing.has(model)) + .sort((a, b) => a.localeCompare(b)) + .map((key) => ({ key, groups: 0, diagnostics: 0 })); + return [...counts, ...passing]; +} + +/** How many execution groups the diagnostics have and how many of them succeeded */ +export function groupTotals(diagnostics: DiagnosticFailures[]): { + total: number; + successful: number; +} { + return diagnostics.reduce( + (sum, failures) => ({ + total: sum.total + failures.total, + successful: sum.successful + failures.successful, + }), + { total: 0, successful: 0 }, + ); +} diff --git a/frontend/src/routes/_app/executions.failures.tsx b/frontend/src/routes/_app/executions.failures.tsx index 0f93f413..11321c1c 100644 --- a/frontend/src/routes/_app/executions.failures.tsx +++ b/frontend/src/routes/_app/executions.failures.tsx @@ -9,6 +9,7 @@ import { DiagnosticFailuresCardSkeleton, } from "@/components/failures/diagnosticFailuresCard"; import { FailureFilterBar } from "@/components/failures/failureFilters"; +import { FailuresEmptyState } from "@/components/failures/failuresEmptyState"; import { ProviderHealthOverview, ProviderHealthOverviewSkeleton, @@ -32,7 +33,9 @@ import { failedGroupsSummary, failureDays, filterOptions, + groupTotals, summariseFailures, + withModelsWithoutFailures, } from "@/lib/failures"; import { type MipEra, mipEraSearchFields } from "@/lib/mipEras"; @@ -43,6 +46,7 @@ const FailuresSearchSchema = z.object({ reason: z.string().optional(), from: day.optional(), to: day.optional(), + passing: z.boolean().optional(), ...mipEraSearchFields, }); @@ -63,12 +67,10 @@ const NO_OPTIONS: FilterOptions = { reasons: [], models: [], days: new Set() }; function FailuresPage() { const search = Route.useSearch(); - const { model, reason, from, to } = search; + const { model, reason, from, to, passing = false } = search; const { mipEra } = useMipEra(search.mip_era); - const { catalog, unsuccessful, isLoading, error, retry } = useFailures( - mipEra, - model, - ); + const { catalog, unsuccessful, models, isLoading, error, retry } = + useFailures(mipEra, model); const navigate = useNavigate({ from: Route.fullPath }); const updateSearch = (patch: Partial) => navigate({ search: (prev) => ({ ...prev, ...patch }) }); @@ -104,12 +106,26 @@ function FailuresPage() { [catalog, unsuccessful, filters], ); + const modelCounts = useMemo( + () => withModelsWithoutFailures(options.models, models ?? []), + [options.models, models], + ); + const failing = (diagnostics ?? []).filter( (failures) => failures.failedGroups.length > 0, ); - const filtered = [model, reason, from, to].some( - (value) => value !== undefined, - ); + const shown = passing + ? (diagnostics ?? []).filter((failures) => failures.total > 0) + : failing; + const totals = groupTotals(diagnostics ?? []); + const narrowsFailures = [reason, from, to].some((v) => v !== undefined); + const filtered = model !== undefined || narrowsFailures; + const emptyHeading = + model !== undefined && !narrowsFailures + ? `No failing ${model} execution groups in ${mipEra}` + : filtered + ? "No failing execution groups match these filters" + : `No ${mipEra} execution groups are failing`; return (
    @@ -127,13 +143,17 @@ function FailuresPage() { onReason={(selected) => updateSearch({ reason: selected })} reasonLabel={reasonLabel} reasonDescription={reasonDescription} - modelCounts={options.models} + modelCounts={modelCounts} model={model} onModel={(selected) => updateSearch({ model: selected })} days={{ from, to }} allDays={allDays} failureDays={options.days} onDays={selectDays} + passing={passing} + onPassing={(checked) => + updateSearch({ passing: checked ? true : undefined }) + } onClear={ filtered ? () => @@ -199,19 +219,26 @@ function FailuresPage() { - {failing.length === 0 ? ( -

    - {filtered - ? "No failing execution groups match these filters." - : `No ${mipEra} execution groups are failing.`} -

    - ) : ( + {failing.length === 0 && ( + 0 + ? () => updateSearch({ passing: true }) + : undefined + } + /> + )} + + {shown.length > 0 && (
    - {failing.map((failures) => ( + {shown.map((failures) => ( ))}
    From 827546814345e613d914da6912f24d503c393ef8 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Sun, 25 Oct 2026 22:06:38 +1100 Subject: [PATCH 2/2] docs(changelog): add 159 fragment --- changelog/159.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog/159.feature.md diff --git a/changelog/159.feature.md b/changelog/159.feature.md new file mode 100644 index 00000000..2fa52ef3 --- /dev/null +++ b/changelog/159.feature.md @@ -0,0 +1 @@ +The failures page can now show passing execution groups, and lists models without failures with an empty state saying how many of their groups passed.