From ba42354733c202b443976f4cb5eb988f430b5b01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 9 Aug 2026 04:32:54 +0200 Subject: [PATCH] fix(admin): replace hand-rolled read-error boxes with the shared primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight admin surfaces rendered a failed read as their own destructive- tinted box, and most offered no way to recover. Sweep them onto the shared QueryErrorRow with retry so the console uses one error form (§13). The statically renderable sections were watched fail first against the hand-rolled boxes. --- src/app/admin/page.tsx | 14 +++--- .../admin-read-error-family.test.tsx | 45 +++++++++++++++++++ src/components/admin/ai-quality-section.tsx | 11 +++-- src/components/admin/backups-section.tsx | 13 +++--- .../admin/coach-feedback-section.tsx | 11 +++-- src/components/admin/host-metrics-chart.tsx | 13 +++--- src/components/admin/recent-audit-preview.tsx | 13 +++--- .../admin/system-status-section.tsx | 33 ++++---------- .../admin/system-status-summary.tsx | 13 +++--- 9 files changed, 94 insertions(+), 72 deletions(-) create mode 100644 src/components/admin/__tests__/admin-read-error-family.test.tsx diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 9d956cc88..5b6e2f7f1 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,6 +2,7 @@ import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { AdminShell } from "@/components/admin/admin-shell"; import { SystemStatusSummary } from "@/components/admin/system-status-summary"; import { VersionTileSection } from "@/components/admin/version-tile-section"; @@ -23,7 +24,8 @@ export default function AdminOverviewPage() { // Many sub-sections share `useAdminSettings()` and render defaults // silently on error — the banner makes the failure visible so the // admin knows the toggles below aren't reflecting real state. - const { isError: settingsError } = useAdminSettings(); + const { isError: settingsError, refetch: refetchSettings } = + useAdminSettings(); if (!user || user.role !== "ADMIN") return null; @@ -33,12 +35,10 @@ export default function AdminOverviewPage() { {/* v1.18.6.1 — the console title + subtitle render in `` now (its own grid row, so the nav lines up with the first card). */} {settingsError && ( -
- {t("admin.adminSettingsLoadError")} -
+ void refetchSettings()} + /> )} diff --git a/src/components/admin/__tests__/admin-read-error-family.test.tsx b/src/components/admin/__tests__/admin-read-error-family.test.tsx new file mode 100644 index 000000000..cece02147 --- /dev/null +++ b/src/components/admin/__tests__/admin-read-error-family.test.tsx @@ -0,0 +1,45 @@ +/** + * The admin error-box family rendered a failed read as a hand-rolled + * destructive-tinted box (§13 drift). Each self-contained admin section is + * forced into its query error state and must now render the shared + * `query-error-row` primitive with retry. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: undefined, + isLoading: false, + isError: true, + refetch: vi.fn(), + }), + useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { RecentAuditPreview } from "../recent-audit-preview"; +import { CoachFeedbackSection } from "../coach-feedback-section"; +import { AiQualitySection } from "../ai-quality-section"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +const cases: Array<[string, React.ReactNode]> = [ + ["RecentAuditPreview", ], + ["CoachFeedbackSection", ], + ["AiQualitySection", ], +]; + +describe("admin read-error family — a read failure is foreground and recoverable", () => { + it.each(cases)("%s renders a query-error row with retry", (_name, node) => { + const html = render(node); + expect(html).toContain('data-slot="query-error-row"'); + expect(html).toContain('data-slot="query-error-row-retry"'); + expect(html).not.toContain("bg-destructive/10"); + }); +}); diff --git a/src/components/admin/ai-quality-section.tsx b/src/components/admin/ai-quality-section.tsx index 57cb8adf4..0183fb900 100644 --- a/src/components/admin/ai-quality-section.tsx +++ b/src/components/admin/ai-quality-section.tsx @@ -23,6 +23,7 @@ import { useQuery } from "@tanstack/react-query"; import { Sparkles, Loader2 } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -102,12 +103,10 @@ export function AiQualitySection() { ) : query.isError ? ( -
- {t("admin.aiQuality.loadError")} -
+ void query.refetch()} + /> ) : !hasData ? (

{t("admin.aiQuality.empty")} diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 9b80465ea..7950bc323 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -36,6 +36,7 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -286,7 +287,7 @@ export function BackupsSection() { const fmt = useFormatters(); const queryClient = useQueryClient(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.adminBackups(), queryFn: async () => { return apiGet("/api/admin/backups"); @@ -596,12 +597,10 @@ export function BackupsSection() { ) : isError ? ( -

- {t("admin.section.backups.loadError")} -
+ void refetch()} + /> ) : rows.length === 0 ? ( // v1.4.15 phase-C5: replace bare text with the EmptyState // primitive. The header already exposes "Backup now" but a diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 245bcee60..9780cdcf2 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -23,6 +23,7 @@ import { Loader2, Sparkles } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -97,12 +98,10 @@ export function CoachFeedbackSection() { ) : query.isError ? ( -
- {t("admin.coachFeedback.loadError")} -
+ void query.refetch()} + /> ) : !hasData ? (

{t("admin.coachFeedback.empty")} diff --git a/src/components/admin/host-metrics-chart.tsx b/src/components/admin/host-metrics-chart.tsx index 3422a8372..8c7f5a742 100644 --- a/src/components/admin/host-metrics-chart.tsx +++ b/src/components/admin/host-metrics-chart.tsx @@ -39,6 +39,7 @@ import { useMemo } from "react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Activity } from "lucide-react"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -142,7 +143,7 @@ export function HostMetricsChart() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.adminHostMetrics("2h"), queryFn: async () => { return apiGet("/api/admin/host-metrics?since=2h"); @@ -195,12 +196,10 @@ export function HostMetricsChart() { } /> -

- {t("admin.hostMetrics.loadError")} -

+ void refetch()} + /> ); } diff --git a/src/components/admin/recent-audit-preview.tsx b/src/components/admin/recent-audit-preview.tsx index 7a07b4a9a..fef83bcc1 100644 --- a/src/components/admin/recent-audit-preview.tsx +++ b/src/components/admin/recent-audit-preview.tsx @@ -19,6 +19,7 @@ import { XCircle, } from "lucide-react"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { formatDateTime } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -37,7 +38,7 @@ interface AuditLogResponse { export function RecentAuditPreview() { const { t } = useTranslations(); - const { data, isLoading, isError } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.adminAuditOverview(), queryFn: async () => { return apiGet( @@ -82,12 +83,10 @@ export function RecentAuditPreview() { ) : isError ? ( -
- {t("admin.overview.auditLoadError")} -
+ void refetch()} + /> ) : entries.length === 0 ? ( // v1.4.15 phase-C5: compact + plain so it nests inside the // overview card without doubling the dashed border. Reuses diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index daecfa25a..0747a8aff 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -13,11 +13,10 @@ import { Key, Loader2, Map, - RefreshCw, Server, Users, } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { formatDateTime } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; @@ -128,7 +127,7 @@ export function FailingJobsCard({ export function SystemStatusSection() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data: status, isError, refetch, isFetching } = useSystemStatus(); + const { data: status, isError, refetch } = useSystemStatus(); const { data: version } = usePublicVersion(); return ( @@ -273,28 +272,12 @@ export function SystemStatusSection() { // v1.4.16 Wave-C MED — pair the alert with a Retry button so a // transient 500 (rolling deploy, DB blip) doesn't require a // full page reload to recover. -
- {t("admin.systemStatusLoadError")} - -
+ void refetch()} + retryLabel={t("admin.systemStatusRetry")} + retrySlot="system-status-retry" + /> ) : (
diff --git a/src/components/admin/system-status-summary.tsx b/src/components/admin/system-status-summary.tsx index 6960eac3d..051ba9f0e 100644 --- a/src/components/admin/system-status-summary.tsx +++ b/src/components/admin/system-status-summary.tsx @@ -16,12 +16,13 @@ import { Clock, Cog, Database, Globe, Loader2, Server } from "lucide-react"; import { formatDateTime } from "@/lib/format"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { useTranslations } from "@/lib/i18n/context"; import { StatusItem, usePublicVersion, useSystemStatus } from "./_shared"; export function SystemStatusSummary() { const { t } = useTranslations(); - const { data: status, isError } = useSystemStatus(); + const { data: status, isError, refetch } = useSystemStatus(); const { data: version } = usePublicVersion(); return ( @@ -90,12 +91,10 @@ export function SystemStatusSummary() { )}
) : isError ? ( -
- {t("admin.overview.snapshotLoadError")} -
+ void refetch()} + /> ) : (