fix(ui): draw every failed read through a read-failure primitive

Around thirty surfaces drew a failed read their own way: a bare
destructive paragraph with no retry, a muted line saying the data was
still being learned, a skeleton that never ended, an error card nested
in a tile, or nothing at all. None of them told an ended session from a
refusal. They now use QueryErrorRow or QueryErrorCard with the query's
error and a retry, inset where a card already frames them, and keep
their card and title where the page would otherwise jump. Hydration and
caffeine now fail the same way.

The recovery page no longer points to Integrations while Oura's first
resilience level is simply on its way.

A guard reads every read hook's isError branch and refuses hand-rolled
markup there, and refuses a framed error row inside a card.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 10:10:49 +02:00
1 parent a1b56f0b75
commit 923249dbf6
60 files changed
+579 -175

No files matched your search

@@ -104,6 +104,7 @@ export function AiQualitySection() {
</div>
) : query.isError ? (
<QueryErrorRow
inset
error={query.error}
message={t("admin.aiQuality.loadError")}
onRetry={() => void query.refetch()}
+1
View File
@@ -1243,6 +1243,7 @@ export function BackupsSection() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.section.backups.loadError")}
onRetry={() => void refetch()}
@@ -99,6 +99,7 @@ export function CoachFeedbackSection() {
</div>
) : query.isError ? (
<QueryErrorRow
inset
error={query.error}
message={t("admin.coachFeedback.loadError")}
onRetry={() => void query.refetch()}
+10 -3
View File
@@ -52,6 +52,7 @@ import {
} from "@/components/settings/security-section/use-recent-proof";
import { KeyBackupCard } from "./key-backup-card";
import { useKeyBackupStatus } from "./use-key-backup-status";
import { QueryErrorRow } from "@/components/ui/query-error-row";
interface ColumnScan {
model: string;
@@ -182,9 +183,15 @@ export function EncryptionSection() {
{t("admin.section.encryption.coverageDetail")}
</p>
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
{statusQuery.isError ? (
<QueryErrorRow
inset
error={statusQuery.error}
slot="encryption-status-error"
message={t("admin.section.encryption.loadError")}
onRetry={() => void statusQuery.refetch()}
/>
) : null}
</SettingsCard>
</div>
);
@@ -198,6 +198,7 @@ export function HostMetricsChart() {
}
/>
<QueryErrorRow
inset
error={error}
message={t("admin.hostMetrics.loadError")}
onRetry={() => void refetch()}
+14 -4
View File
@@ -75,6 +75,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { QueryErrorRow } from "@/components/ui/query-error-row";
interface InviteRedemptionEntry {
id: string;
@@ -192,7 +193,12 @@ export function InviteTokensSection() {
const [copied, setCopied] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<AdminInvite | null>(null);
const { data: invites, isError } = useQuery({
const {
data: invites,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.adminInvites(),
queryFn: async () => {
return apiGet<AdminInvite[]>("/api/admin/invites");
@@ -313,9 +319,13 @@ export function InviteTokensSection() {
/>
{isError && (
<p className="text-destructive text-sm">
{t("admin.invites.loadError")}
</p>
<QueryErrorRow
inset
error={error}
slot="admin-invites-error"
message={t("admin.invites.loadError")}
onRetry={() => void refetch()}
/>
)}
{invites && invites.length === 0 && (
+1
View File
@@ -155,6 +155,7 @@ export function KeyBackupCard() {
/>
{status.isError ? (
<QueryErrorRow
inset
error={status.error}
message={t("admin.keyBackup.loadError")}
onRetry={() => void status.refetch()}
@@ -170,6 +170,7 @@ export function OffhostBackupsSection() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.section.backups.offhost.loadError")}
onRetry={() => void refetch()}
@@ -10,6 +10,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* v1.37.31 — the delivery-health readout the `provider_health` ledger never
@@ -66,7 +67,7 @@ export function ProviderHealthSection() {
const groupDigits = (value: number): string =>
new Intl.NumberFormat(locale).format(value);
const { data, isPending, isError } = useQuery({
const { data, isPending, isError, error, refetch } = useQuery({
queryKey: queryKeys.adminProviderHealth(),
queryFn: () =>
apiGet<{
@@ -134,9 +135,13 @@ export function ProviderHealthSection() {
{t("admin.providerHealth.loading")}
</p>
) : isError ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.loadError")}
</p>
<QueryErrorRow
inset
error={error}
slot="provider-health-error"
message={t("admin.providerHealth.loadError")}
onRetry={() => void refetch()}
/>
) : providers.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.empty")}
@@ -84,6 +84,7 @@ export function RecentAuditPreview() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.overview.auditLoadError")}
onRetry={() => void refetch()}
@@ -277,6 +277,7 @@ export function SystemStatusSection() {
// transient 500 (rolling deploy, DB blip) doesn't require a
// full page reload to recover.
<QueryErrorRow
inset
error={error}
message={t("admin.systemStatusLoadError")}
onRetry={() => void refetch()}
@@ -110,6 +110,7 @@ export function SystemStatusSummary() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.overview.snapshotLoadError")}
onRetry={() => void refetch()}