mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
f6a0ee4623
commit
ba42354733
9 files changed
+94
-72
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user