fix(ui): tell the user when a page fails to load instead of showing "nothing here"

A backend that was down rendered as a pristine empty account: every dashboard
chart said "No data for this period" and every metric card showed 0, while the
templates and knowledges lists showed their "nothing here yet" empty states —
all indistinguishable from a genuinely empty account, with no toast, no banner
and no other hint that anything had failed.

None of the three pages read `error` from their queries. The templates and
knowledges providers destructured only `{data, loading}` and never exposed it,
and every dashboard query dropped it on the floor. A failed query leaves `data`
undefined, which the empty-state branch cannot tell apart from an empty result.

Expose `error` from the two providers and render a load-error state distinct
from the empty state: a full-page alert on the lists, a banner above the cards
on both dashboard tabs. The settings pages already did exactly this, so the
shared `DataLoadError` gives those call sites one shape instead of four copies.

Templates also gained the loading state it never had — its empty state used to
show while the very first fetch was still in flight.

Verified by failing each query at the network layer and re-running the check
that caught it: templates and knowledges now surface "Error loading ..." rather
than their empty state, and the dashboard shows the banner on both tabs against
a cold cache. Happy path unchanged; 993 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-17 18:09:52 +07:00
co-authored by Claude Opus 4.8
parent 9952f7d6b0
commit cada8ffbe8
7 changed files with 187 additions and 29 deletions
@@ -0,0 +1,18 @@
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface DataLoadErrorProps {
message?: string;
title: string;
}
export function DataLoadError({ message, title }: DataLoadErrorProps) {
return (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>{title}</AlertTitle>
{message ? <AlertDescription>{message}</AlertDescription> : null}
</Alert>
);
}
@@ -8,6 +8,7 @@ import type { FlowFragmentFragment, UsageStatsPeriod } from '@/graphql/types';
import { ChartCard, ChartTooltip } from '@/components/dashboard';
import { FlowStatusBadge } from '@/components/icons/flow-status-badge';
import { DataLoadError } from '@/components/shared/data-load-error';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
@@ -61,22 +62,38 @@ type FlowExecution = {
};
export function DashboardAnalytics({ period }: { period: UsageStatsPeriod }) {
const { data: usageByPeriodData, loading: usageByPeriodLoading } = useQuery(UsageStatsByPeriodDocument, {
const {
data: usageByPeriodData,
error: usageByPeriodError,
loading: usageByPeriodLoading,
} = useQuery(UsageStatsByPeriodDocument, {
variables: { period },
});
const { data: toolcallsByPeriodData, loading: toolcallsByPeriodLoading } = useQuery(
ToolcallsStatsByPeriodDocument,
{
variables: { period },
},
);
const { data: flowsByPeriodData, loading: flowsByPeriodLoading } = useQuery(FlowsStatsByPeriodDocument, {
const {
data: toolcallsByPeriodData,
error: toolcallsByPeriodError,
loading: toolcallsByPeriodLoading,
} = useQuery(ToolcallsStatsByPeriodDocument, {
variables: { period },
});
const { data: executionStatsData, loading: executionStatsLoading } = useQuery(FlowsExecutionStatsByPeriodDocument, {
const {
data: flowsByPeriodData,
error: flowsByPeriodError,
loading: flowsByPeriodLoading,
} = useQuery(FlowsStatsByPeriodDocument, {
variables: { period },
});
const { data: flowsData } = useQuery(FlowsDocument);
const {
data: executionStatsData,
error: executionStatsError,
loading: executionStatsLoading,
} = useQuery(FlowsExecutionStatsByPeriodDocument, {
variables: { period },
});
const { data: flowsData, error: flowsError } = useQuery(FlowsDocument);
const loadError =
usageByPeriodError ?? toolcallsByPeriodError ?? flowsByPeriodError ?? executionStatsError ?? flowsError;
const flowsTooltip = useChartTooltipAnimation();
const toolcallsTooltip = useChartTooltipAnimation();
@@ -137,6 +154,12 @@ export function DashboardAnalytics({ period }: { period: UsageStatsPeriod }) {
return (
<div className="flex flex-col gap-6">
{loadError ? (
<DataLoadError
message={loadError.message}
title="Error loading dashboard data"
/>
) : null}
<ChartCard
description="Flows, tasks, and subtasks created per day"
empty={!flowsByPeriodLoading && flowsChartData.length === 0}
@@ -4,6 +4,7 @@ import { Activity, CircleDollarSign, Cpu, GitFork } from 'lucide-react';
import type { UsageStatsFragmentFragment } from '@/graphql/types';
import { MetricCard } from '@/components/dashboard';
import { DataLoadError } from '@/components/shared/data-load-error';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
@@ -20,15 +21,50 @@ import {
import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/lib/utils/format';
export function DashboardOverview() {
const { data: usageTotalData, loading: usageTotalLoading } = useQuery(UsageStatsTotalDocument);
const { data: usageByProviderData, loading: usageByProviderLoading } = useQuery(UsageStatsByProviderDocument);
const { data: usageByModelData, loading: usageByModelLoading } = useQuery(UsageStatsByModelDocument);
const { data: usageByAgentTypeData, loading: usageByAgentTypeLoading } = useQuery(UsageStatsByAgentTypeDocument);
const { data: toolcallsTotalData, loading: toolcallsTotalLoading } = useQuery(ToolcallsStatsTotalDocument);
const { data: toolcallsByFunctionData, loading: toolcallsByFunctionLoading } = useQuery(
ToolcallsStatsByFunctionDocument,
);
const { data: flowsTotalData, loading: flowsTotalLoading } = useQuery(FlowsStatsTotalDocument);
const {
data: usageTotalData,
error: usageTotalError,
loading: usageTotalLoading,
} = useQuery(UsageStatsTotalDocument);
const {
data: usageByProviderData,
error: usageByProviderError,
loading: usageByProviderLoading,
} = useQuery(UsageStatsByProviderDocument);
const {
data: usageByModelData,
error: usageByModelError,
loading: usageByModelLoading,
} = useQuery(UsageStatsByModelDocument);
const {
data: usageByAgentTypeData,
error: usageByAgentTypeError,
loading: usageByAgentTypeLoading,
} = useQuery(UsageStatsByAgentTypeDocument);
const {
data: toolcallsTotalData,
error: toolcallsTotalError,
loading: toolcallsTotalLoading,
} = useQuery(ToolcallsStatsTotalDocument);
const {
data: toolcallsByFunctionData,
error: toolcallsByFunctionError,
loading: toolcallsByFunctionLoading,
} = useQuery(ToolcallsStatsByFunctionDocument);
const {
data: flowsTotalData,
error: flowsTotalError,
loading: flowsTotalLoading,
} = useQuery(FlowsStatsTotalDocument);
const loadError =
usageTotalError ??
usageByProviderError ??
usageByModelError ??
usageByAgentTypeError ??
toolcallsTotalError ??
toolcallsByFunctionError ??
flowsTotalError;
const usageTotal = usageTotalData?.usageStatsTotal;
const toolcallsTotal = toolcallsTotalData?.toolcallsStatsTotal;
@@ -56,6 +92,12 @@ export function DashboardOverview() {
return (
<div className="flex flex-col gap-6">
{loadError ? (
<DataLoadError
message={loadError.message}
title="Error loading dashboard data"
/>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<MetricCard
description={`Tasks: ${flowsTotal?.totalTasksCount ?? 0} · Subtasks: ${flowsTotal?.totalSubtasksCount ?? 0} · Assistants: ${flowsTotal?.totalAssistantsCount ?? 0}`}
+16 -1
View File
@@ -15,6 +15,7 @@ import {
AppHeaderTitle,
} from '@/components/layouts/app/app-header';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { DataLoadError } from '@/components/shared/data-load-error';
import { InlineEditInput } from '@/components/shared/inline-edit';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -61,7 +62,7 @@ const docTypeSubtype = (k: Knowledge): null | string => {
function Knowledges() {
const navigate = useNavigate();
const location = useLocation();
const { deleteKnowledge, isLoading, knowledges, renameKnowledge } = useKnowledges();
const { deleteKnowledge, error, isLoading, knowledges, renameKnowledge } = useKnowledges();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingKnowledge, setDeletingKnowledge] = useState<Knowledge | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
@@ -422,6 +423,20 @@ function Knowledges() {
);
}
if (error) {
return (
<>
{pageHeader}
<div className="flex flex-1 flex-col gap-4 p-4">
<DataLoadError
message={error.message}
title="Error loading knowledge documents"
/>
</div>
</>
);
}
if (!knowledges.length) {
return (
<>
+38 -1
View File
@@ -13,6 +13,7 @@ import {
AppHeaderTitle,
} from '@/components/layouts/app/app-header';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { DataLoadError } from '@/components/shared/data-load-error';
import { InlineEditInput } from '@/components/shared/inline-edit';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
@@ -34,7 +35,7 @@ import { type Template, useTemplates } from '@/providers/templates-provider';
function Templates() {
const navigate = useNavigate();
const location = useLocation();
const { deleteTemplate, templates, updateTemplate } = useTemplates();
const { deleteTemplate, error, isLoading, templates, updateTemplate } = useTemplates();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingTemplate, setDeletingTemplate] = useState<null | Template>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
@@ -264,6 +265,42 @@ function Templates() {
</AppHeader>
);
if (isLoading && !templates.length) {
return (
<>
{pageHeader}
<div className="flex flex-1 flex-col gap-4 p-4">
<Empty>
<EmptyHeader>
<EmptyMedia>
<Spinner
className="text-muted-foreground size-10"
variant="circle"
/>
</EmptyMedia>
<EmptyTitle>Loading templates...</EmptyTitle>
<EmptyDescription>Please wait while we fetch your flow templates</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
</>
);
}
if (error) {
return (
<>
{pageHeader}
<div className="flex flex-1 flex-col gap-4 p-4">
<DataLoadError
message={error.message}
title="Error loading templates"
/>
</div>
</>
);
}
if (!templates.length) {
return (
<>
+23 -3
View File
@@ -31,6 +31,7 @@ export type Knowledge = KnowledgeDocumentFragmentFragment;
interface KnowledgesContextValue {
createKnowledge: (input: CreateKnowledgeDocumentInput) => Promise<Knowledge | undefined>;
deleteKnowledge: (id: string) => Promise<void>;
error?: Error;
getKnowledge: (id: string) => Knowledge | undefined;
isLoading: boolean;
knowledges: Knowledge[];
@@ -71,7 +72,11 @@ export function KnowledgesProvider({ children }: KnowledgesProviderProps) {
// That keeps Apollo's cache warm for the inactive branch — when the user
// toggles `?qs=` on/off, the previous result is shown immediately while
// the network refetches in the background.
const { data: listData, loading: isListLoading } = useQuery(KnowledgeDocumentsDocument, {
const {
data: listData,
error: listError,
loading: isListLoading,
} = useQuery(KnowledgeDocumentsDocument, {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-and-network',
skip: !shouldFetch || inSearchMode,
@@ -80,7 +85,11 @@ export function KnowledgesProvider({ children }: KnowledgesProviderProps) {
// `searchKnowledge` ignores `withContent` — the backend always returns the full
// chunk text plus a relevance score we currently drop.
const { data: searchData, loading: isSearchLoading } = useQuery(SearchKnowledgeDocument, {
const {
data: searchData,
error: searchError,
loading: isSearchLoading,
} = useQuery(SearchKnowledgeDocument, {
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-and-network',
skip: !shouldFetch || !inSearchMode,
@@ -126,6 +135,7 @@ export function KnowledgesProvider({ children }: KnowledgesProviderProps) {
}, [inSearchMode, listData?.knowledgeDocuments, searchData?.searchKnowledge]);
const isLoading = inSearchMode ? isSearchLoading : isListLoading;
const error = inSearchMode ? searchError : listError;
const getKnowledge = useCallback(
(id: string): Knowledge | undefined => knowledges.find((k) => k.id === id),
@@ -198,13 +208,23 @@ export function KnowledgesProvider({ children }: KnowledgesProviderProps) {
() => ({
createKnowledge,
deleteKnowledge,
error,
getKnowledge,
isLoading,
knowledges,
renameKnowledge,
updateKnowledge,
}),
[createKnowledge, deleteKnowledge, getKnowledge, isLoading, knowledges, renameKnowledge, updateKnowledge],
[
createKnowledge,
deleteKnowledge,
error,
getKnowledge,
isLoading,
knowledges,
renameKnowledge,
updateKnowledge,
],
);
return <KnowledgesContext.Provider value={value}>{children}</KnowledgesContext.Provider>;
@@ -26,6 +26,7 @@ export interface Template {
interface TemplatesContextValue {
createTemplate: (title: string, text: string) => Promise<void>;
deleteTemplate: (id: string) => Promise<void>;
error?: Error;
getTemplate: (id: string) => Template | undefined;
isLoading: boolean;
templates: Template[];
@@ -43,10 +44,11 @@ export function TemplatesProvider({ children }: TemplatesProviderProps) {
const shouldFetchTemplates = Boolean(authInfo && authInfo.type !== 'guest' && isAuthenticated());
const { data: templatesData, loading: isLoadingTemplates } = useQuery(
FlowTemplatesDocument,
shouldFetchTemplates ? { fetchPolicy: 'cache-and-network' } : skipToken,
);
const {
data: templatesData,
error: templatesError,
loading: isLoadingTemplates,
} = useQuery(FlowTemplatesDocument, shouldFetchTemplates ? { fetchPolicy: 'cache-and-network' } : skipToken);
const [createTemplateMutation] = useMutation(CreateFlowTemplateDocument);
const [updateTemplateMutation] = useMutation(UpdateFlowTemplateDocument);
@@ -155,12 +157,13 @@ export function TemplatesProvider({ children }: TemplatesProviderProps) {
() => ({
createTemplate,
deleteTemplate,
error: templatesError,
getTemplate,
isLoading: isLoadingTemplates,
templates,
updateTemplate,
}),
[createTemplate, deleteTemplate, getTemplate, isLoadingTemplates, templates, updateTemplate],
[createTemplate, deleteTemplate, templatesError, getTemplate, isLoadingTemplates, templates, updateTemplate],
);
return <TemplatesContext.Provider value={value}>{children}</TemplatesContext.Provider>;