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/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. 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) => ( ))}