fix(admin): replace hand-rolled read-error boxes with the shared primitive

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-09 04:32:54 +02:00
1 parent f6a0ee4623
commit ba42354733
9 files changed
+94 -72

No files matched your search

+7 -7
View File
@@ -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 `<AdminShell>`
now (its own grid row, so the nav lines up with the first card). */}
{settingsError && (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.adminSettingsLoadError")}
</div>
<QueryErrorRow
message={t("admin.adminSettingsLoadError")}
onRetry={() => void refetchSettings()}
/>
)}
<VersionTileSection />
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const cases: Array<[string, React.ReactNode]> = [
["RecentAuditPreview", <RecentAuditPreview />],
["CoachFeedbackSection", <CoachFeedbackSection />],
["AiQualitySection", <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");
});
});
+5 -6
View File
@@ -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() {
</span>
</div>
) : query.isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border p-3 text-sm"
>
{t("admin.aiQuality.loadError")}
</div>
<QueryErrorRow
message={t("admin.aiQuality.loadError")}
onRetry={() => void query.refetch()}
/>
) : !hasData ? (
<p className="text-muted-foreground text-sm">
{t("admin.aiQuality.empty")}
+6 -7
View File
@@ -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<BackupsList>("/api/admin/backups");
@@ -596,12 +597,10 @@ export function BackupsSection() {
</span>
</div>
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.section.backups.loadError")}
</div>
<QueryErrorRow
message={t("admin.section.backups.loadError")}
onRetry={() => 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
@@ -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() {
</span>
</div>
) : query.isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border p-3 text-sm"
>
{t("admin.coachFeedback.loadError")}
</div>
<QueryErrorRow
message={t("admin.coachFeedback.loadError")}
onRetry={() => void query.refetch()}
/>
) : !hasData ? (
<p className="text-muted-foreground text-sm">
{t("admin.coachFeedback.empty")}
+6 -7
View File
@@ -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<HostMetricsApiResponse>("/api/admin/host-metrics?since=2h");
@@ -195,12 +196,10 @@ export function HostMetricsChart() {
</span>
}
/>
<p
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-xs"
>
{t("admin.hostMetrics.loadError")}
</p>
<QueryErrorRow
message={t("admin.hostMetrics.loadError")}
onRetry={() => void refetch()}
/>
</SettingsCard>
);
}
@@ -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<AuditLogResponse>(
@@ -82,12 +83,10 @@ export function RecentAuditPreview() {
</span>
</div>
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.overview.auditLoadError")}
</div>
<QueryErrorRow
message={t("admin.overview.auditLoadError")}
onRetry={() => 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
+8 -25
View File
@@ -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.
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("admin.systemStatusLoadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
disabled={isFetching}
onClick={() => void refetch()}
className="min-h-9"
data-testid="system-status-retry"
>
{isFetching ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{t("admin.systemStatusRetry")}
</Button>
</div>
<QueryErrorRow
message={t("admin.systemStatusLoadError")}
onRetry={() => void refetch()}
retryLabel={t("admin.systemStatusRetry")}
retrySlot="system-status-retry"
/>
) : (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
@@ -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() {
)}
</div>
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.overview.snapshotLoadError")}
</div>
<QueryErrorRow
message={t("admin.overview.snapshotLoadError")}
onRetry={() => void refetch()}
/>
) : (
<div className="flex items-center gap-2">
<Loader2