diff --git a/changelog/161.fix.md b/changelog/161.fix.md
new file mode 100644
index 00000000..307b6d17
--- /dev/null
+++ b/changelog/161.fix.md
@@ -0,0 +1 @@
+Loading more passing groups on the failures page no longer hides the groups already loaded when it fails, and a failure to load the model list is now reported with a retry.
diff --git a/changelog/161.improvement.md b/changelog/161.improvement.md
new file mode 100644
index 00000000..4ffec298
--- /dev/null
+++ b/changelog/161.improvement.md
@@ -0,0 +1 @@
+The diagnostic dots on the failures page now turn grey and gently swell while the counts for new filters load.
diff --git a/frontend/src/components/failures/health.test.tsx b/frontend/src/components/failures/health.test.tsx
new file mode 100644
index 00000000..b78f0d1e
--- /dev/null
+++ b/frontend/src/components/failures/health.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { HEALTH, HealthDot } from "./health";
+
+describe("HealthDot", () => {
+ it("shows the health colour", () => {
+ render( );
+ const dot = screen.getByRole("img", { name: "All failing" });
+ expect(dot).toHaveClass(HEALTH.failed.colour);
+ expect(dot).not.toHaveClass("motion-safe:animate-swell");
+ });
+
+ it("goes grey and swells while pending, hiding the stale health", () => {
+ render( );
+ const dot = screen.getByRole("img", { name: "Updating" });
+ expect(dot).toHaveClass(HEALTH.not_run.colour, "motion-safe:animate-swell");
+ expect(dot).not.toHaveClass(HEALTH.failed.colour);
+ });
+});
diff --git a/frontend/src/components/failures/health.tsx b/frontend/src/components/failures/health.tsx
index 1d35f8a3..e1ccf14d 100644
--- a/frontend/src/components/failures/health.tsx
+++ b/frontend/src/components/failures/health.tsx
@@ -1,3 +1,4 @@
+import type React from "react";
import type { DiagnosticHealth } from "@/lib/failures";
import { cn } from "@/lib/utils";
@@ -28,20 +29,28 @@ export const HEALTH = {
{ label: string; description: string; colour: string }
>;
+/** A diagnostic's health, or a grey swelling dot while `pending` new counts */
export function HealthDot({
health,
+ pending = false,
className,
+ style,
}: {
health: DiagnosticHealth;
+ pending?: boolean;
className?: string;
+ style?: React.CSSProperties;
}) {
return (
diff --git a/frontend/src/components/failures/passingGroups.tsx b/frontend/src/components/failures/passingGroups.tsx
index 5ba6ada4..866c8586 100644
--- a/frontend/src/components/failures/passingGroups.tsx
+++ b/frontend/src/components/failures/passingGroups.tsx
@@ -53,24 +53,37 @@ function PassingGroupList({
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),
- });
+ const {
+ data,
+ error,
+ refetch,
+ 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 && error) {
+ return (
+
+
{apiErrorMessage(error)}
+
refetch()}>
+ Try again
+
+
+ );
}
if (!data) {
return (
@@ -90,6 +103,9 @@ function PassingGroupList({
))}
+ {error && (
+ {apiErrorMessage(error)}
+ )}
{hasNextPage && (
;
+ const dot = (
+
+ );
// A failing diagnostic jumps to its section below, the others open the diagnostic
return (
@@ -69,12 +86,18 @@ function DiagnosticDot({
{diagnostic.name}
-
- {failures.total === 0
- ? "No execution groups"
- : failureSummary(failures, filters)}
-
- {HEALTH[failures.health].label}
+ {updating ? (
+ Updating…
+ ) : (
+ <>
+
+ {failures.total === 0
+ ? "No execution groups"
+ : failureSummary(failures, filters)}
+
+ {HEALTH[failures.health].label}
+ >
+ )}
);
@@ -84,10 +107,13 @@ function DiagnosticDot({
export function ProviderHealthOverview({
diagnostics,
filters,
+ updating = false,
}: {
diagnostics: DiagnosticFailures[];
/** What the failures were narrowed to, named in each dot's summary */
filters?: FailureFilters;
+ /** The counts are from the previous filters while new ones load, so the dots go grey and swell */
+ updating?: boolean;
}) {
return (
@@ -110,11 +136,13 @@ export function ProviderHealthOverview({
- {diagnostics.map((failures) => (
+ {diagnostics.map((failures, index) => (
))}
diff --git a/frontend/src/components/failures/useFailures.ts b/frontend/src/components/failures/useFailures.ts
index 13dd9ca1..19e2a24c 100644
--- a/frontend/src/components/failures/useFailures.ts
+++ b/frontend/src/components/failures/useFailures.ts
@@ -64,17 +64,21 @@ export function useFailures(mipEra: MipEra, model?: string) {
});
// A failed query that is being retried counts as loading again
- const failed = [catalog, unsuccessful].filter(
- (query) => query.isError && !query.isFetching,
- );
+ const failedQuery = (query: { isError: boolean; isFetching: boolean }) =>
+ query.isError && !query.isFetching;
+ const failed = [catalog, unsuccessful, models].filter(failedQuery);
const error = failed.length > 0 ? failed[0].error : null;
const loaded = catalog.data !== undefined && unsuccessful.data !== undefined;
+ // The models only fill in the model picker, so their failure is reported without hiding the page
+ const pageFailed = [catalog, unsuccessful].some(failedQuery);
return {
catalog: catalog.data?.data,
unsuccessful: unsuccessful.data,
models: models.data?.data.map(({ source_id }) => source_id),
- isLoading: !loaded && error === null,
+ isLoading: !loaded && !pageFailed,
+ /** The catalog still holds the previous filters' counts while the new ones load */
+ isUpdating: catalog.isPlaceholderData,
error,
retry: () => {
for (const query of failed) {
diff --git a/frontend/src/routes/_app/executions.failures.tsx b/frontend/src/routes/_app/executions.failures.tsx
index 11321c1c..d75c81af 100644
--- a/frontend/src/routes/_app/executions.failures.tsx
+++ b/frontend/src/routes/_app/executions.failures.tsx
@@ -69,7 +69,7 @@ function FailuresPage() {
const search = Route.useSearch();
const { model, reason, from, to, passing = false } = search;
const { mipEra } = useMipEra(search.mip_era);
- const { catalog, unsuccessful, models, isLoading, error, retry } =
+ const { catalog, unsuccessful, models, isLoading, isUpdating, error, retry } =
useFailures(mipEra, model);
const navigate = useNavigate({ from: Route.fullPath });
const updateSearch = (patch: Partial) =>
@@ -215,6 +215,7 @@ function FailuresPage() {
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index 041955cd..b5e22006 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -109,6 +109,19 @@
--color-cmip-blue: #000546;
--color-cmip-mustard: #f2b30d;
--color-cmip-cyan: #22bfb7;
+
+ --animate-swell: swell 1.6s ease-in-out infinite;
+ @keyframes swell {
+ 0%,
+ 100% {
+ transform: scale(1);
+ opacity: 0.6;
+ }
+ 50% {
+ transform: scale(1.3);
+ opacity: 1;
+ }
+ }
}
@layer base {