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()} + /> ) : (