mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-25 20:46:31 +00:00
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:
co-authored by
Claude Opus 4.8
parent
9952f7d6b0
commit
cada8ffbe8
@@ -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}`}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user