fix(web): unify load error states and retry actions

This commit is contained in:
Hyu
2026-10-09 21:00:27 +08:00
parent cdc4086dea
commit ce3a9020af
19 changed files with 141 additions and 130 deletions
@@ -21,6 +21,7 @@ import { Button } from '@/components/ui/button';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import LoadErrorState from '@/components/LoadErrorState';
import { ScrollArea } from '@/components/ui/scroll-area';
import ProcessorDetailWorkbench from '@/app/home/components/processor-detail/ProcessorDetailWorkbench';
import EntityTitleEditButton from '@/app/home/components/entity-basic-info/EntityTitleEditButton';
@@ -363,11 +364,7 @@ export default function PluginProcessorDetailContent({
const logsContent = (
<div className="flex h-full min-h-0 flex-col gap-4 p-4">
{failed && (
<Alert variant="destructive">
<AlertDescription>
{t('agents.eventProcessor.loadError')}
</AlertDescription>
</Alert>
<LoadErrorState compact title={t('agents.eventProcessor.loadError')} onRetry={refreshLatestRun} />
)}
<div className="flex shrink-0 items-center justify-between gap-2">
<span className="text-sm font-medium">
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import LoadErrorState from '@/components/LoadErrorState';
import {
Collapsible,
CollapsibleTrigger,
@@ -263,9 +264,7 @@ export default function AgentMonitoringTab({
</Button>
</div>
{listError && (
<Alert variant="destructive">
<AlertDescription>{t('monitoring.loadError')}</AlertDescription>
</Alert>
<LoadErrorState compact title={t('monitoring.loadError')} onRetry={refresh} />
)}
<div className="grid min-h-0 flex-1 gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]">
<section
@@ -494,17 +493,7 @@ export default function AgentMonitoringTab({
)
)}
{traceError && (
<Alert variant="destructive">
<AlertDescription>
{t('monitoring.loadError')}
<Button
variant="link"
onClick={() => setTraceRevision((value) => value + 1)}
>
{t('common.retry')}
</Button>
</AlertDescription>
</Alert>
<LoadErrorState title={t('monitoring.loadError')} onRetry={() => setTraceRevision((value) => value + 1)} />
)}
</section>
</div>
@@ -1,3 +1,4 @@
import LoadErrorState from '@/components/LoadErrorState';
import React, {
useState,
useEffect,
@@ -738,19 +739,7 @@ const BotSessionMonitor = forwardRef<
{t('bots.sessionMonitor.loading')}
</div>
) : sessionError ? (
<div
role="alert"
className="p-3 space-y-2 text-sm text-destructive"
>
<p>{t('monitoring.loadError')}</p>
<button
type="button"
onClick={loadSessions}
className="rounded border px-2 py-1 text-foreground"
>
{t('common.retry')}
</button>
</div>
<LoadErrorState compact title={t('monitoring.loadError')} onRetry={loadSessions} />
) : sessions.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noSessions')}
@@ -922,45 +911,14 @@ const BotSessionMonitor = forwardRef<
>
<div className="space-y-4">
{analysisError && !loadingMessages && (
<div
role="alert"
className="text-sm text-destructive space-y-2"
>
<p>
{t('monitoring.toolCalls.title')}:{' '}
{t('monitoring.loadError')}
</p>
<button
type="button"
onClick={() =>
loadMessages(selectedSessionId, messagePage)
}
className="rounded border px-2 py-1 text-foreground"
>
{t('common.retry')}
</button>
</div>
<LoadErrorState compact title={`${t('monitoring.toolCalls.title')}: ${t('monitoring.loadError')}`} onRetry={() => loadMessages(selectedSessionId, messagePage)} />
)}
{loadingMessages ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.loading')}
</div>
) : messageError ? (
<div
role="alert"
className="text-sm text-destructive space-y-2"
>
<p>{t('monitoring.loadError')}</p>
<button
type="button"
onClick={() =>
loadMessages(selectedSessionId, messagePage)
}
className="rounded border px-2 py-1 text-foreground"
>
{t('common.retry')}
</button>
</div>
<LoadErrorState title={t('monitoring.loadError')} onRetry={() => loadMessages(selectedSessionId, messagePage)} />
) : timelineItems.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')}
@@ -1,3 +1,4 @@
import LoadErrorState from '@/components/LoadErrorState';
import { useEffect, useState, useRef } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
@@ -270,9 +271,7 @@ export default function ProviderForm({
<LoadingSpinner text={t('common.loading')} />
</div>
) : (
<p role="alert" className="py-8 text-sm text-destructive">
{t('models.loadError')}
</p>
<LoadErrorState compact title={t('models.loadError')} />
)}
<DialogFooter>
{loadState === 'error' && (
@@ -30,7 +30,7 @@ import {
} from '@/components/ui/table';
import { Card } from '@/components/ui/card';
import { MetricCard } from './overview-cards/MetricCard';
import { Alert, AlertTitle } from '@/components/ui/alert';
import LoadErrorState from '@/components/LoadErrorState';
import { TabState } from './TabState';
import { httpClient } from '@/app/infra/http/HttpClient';
import { getErrorMessage } from '../utils';
@@ -174,9 +174,7 @@ export default function TokenMonitoring({
if (error) {
return (
<Alert variant="destructive">
<AlertTitle>{t('monitoring.tokens.loadError', { error })}</AlertTitle>
</Alert>
<LoadErrorState title={t('monitoring.loadError')} description={error} onRetry={fetchStats} busy={loading} />
);
}
@@ -1,3 +1,4 @@
import LoadErrorState from '@/components/LoadErrorState';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
@@ -297,9 +298,7 @@ export default function ExecutionDetailSheet({
<div key={`${item.id}-${i}`}>{renderItem(section, item)}</div>
))}
{sectionError === section && (
<p role="alert" className="text-sm text-destructive">
{t('monitoring.execution.detail.loadError')}
</p>
<LoadErrorState compact title={t('monitoring.execution.detail.loadError')} onRetry={() => loadMore(section)} />
)}
{page.has_more && (
<Button
@@ -375,9 +374,7 @@ export default function ExecutionDetailSheet({
</div>
{loading && <Skeleton className="h-36 w-full" />}
{error && (
<p role="alert" className="text-destructive">
{t('monitoring.execution.detail.loadError')}
</p>
<LoadErrorState title={t('monitoring.execution.detail.loadError')} onRetry={() => setRefresh((value) => value + 1)} />
)}
{record && (
<>
+3 -23
View File
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import LoadErrorState from '@/components/LoadErrorState';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
@@ -266,14 +266,7 @@ function MonitoringPageContent() {
{/* Content Area */}
{error ? (
<Alert variant="destructive">
<AlertTitle>{t('monitoring.loadError')}</AlertTitle>
<AlertDescription className="mt-2">
<Button variant="outline" size="sm" onClick={handleRefresh}>
{t('common.retry')}
</Button>
</AlertDescription>
</Alert>
<LoadErrorState title={t('monitoring.loadError')} onRetry={handleRefresh} busy={refreshing} />
) : (
<div className="relative z-0 flex flex-col gap-6 pb-4 pt-3">
{/* Overview Section: the execution metrics and the runtime status share
@@ -365,20 +358,7 @@ function MonitoringPageContent() {
{executionError ? (
<div className="px-3 pt-4 sm:px-6">
<Alert variant="destructive">
<AlertTitle>
{t('monitoring.execution.detail.loadError')}
</AlertTitle>
<AlertDescription className="mt-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
>
{t('common.retry')}
</Button>
</AlertDescription>
</Alert>
<LoadErrorState title={t('monitoring.execution.detail.loadError')} onRetry={handleRefresh} busy={refreshing} />
</div>
) : (
<>
+2 -3
View File
@@ -1,4 +1,5 @@
import EntityLoadState from '@/components/EntityLoadState';
import LoadErrorState from '@/components/LoadErrorState';
import { useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useEffect, useRef, useState, useCallback } from 'react';
@@ -202,9 +203,7 @@ function PluginPageIframe({
return (
<div className="flex flex-col h-full w-full">
{assetError ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t('plugins.loadFailed')}
</div>
<LoadErrorState title={t('plugins.loadFailed')} className="flex-1" onRetry={() => window.location.reload()} />
) : loading || !assetUrl ? (
<EntityLoadState />
) : null}
+17 -8
View File
@@ -17,6 +17,7 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import LoadErrorState from '@/components/LoadErrorState';
import { Skeleton } from '@/components/ui/skeleton';
import { Input } from '@/components/ui/input';
import React, { useState, useRef, useEffect } from 'react';
@@ -54,6 +55,8 @@ function PluginListView() {
const [pluginSystemStatus, setPluginSystemStatus] =
useState<ApiRespPluginSystemStatus | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const [statusError, setStatusError] = useState(false);
const [statusRevision, setStatusRevision] = useState(0);
const [debugInfo, setDebugInfo] = useState<{
debug_url: string;
plugin_debug_key: string;
@@ -70,10 +73,12 @@ function PluginListView() {
const fetchPluginSystemStatus = async () => {
try {
setStatusLoading(true);
setStatusError(false);
const status = await httpClient.getPluginSystemStatus();
setPluginSystemStatus(status);
} catch (error) {
console.error('Failed to fetch plugin system status:', error);
setStatusError(true);
toast.error(t('plugins.failedToGetStatus'));
} finally {
setStatusLoading(false);
@@ -81,7 +86,7 @@ function PluginListView() {
};
void fetchPluginSystemStatus();
}, [t]);
}, [t, statusRevision]);
useEffect(() => {
const onComplete = (
@@ -141,13 +146,13 @@ function PluginListView() {
);
const renderPluginConnectionErrorState = () => (
<div className="flex justify-center pt-[10vh] px-4">
<Alert variant="destructive" className="max-w-md">
<Unlink />
<AlertTitle>{t('plugins.connectionError')}</AlertTitle>
<AlertDescription>{t('plugins.connectionErrorDesc')}</AlertDescription>
</Alert>
</div>
<LoadErrorState
className="pt-[10vh]"
icon={<Unlink className="size-5" aria-hidden="true" />}
title={t('plugins.connectionError')}
description={t('plugins.connectionErrorDesc')}
onRetry={() => setStatusRevision((value) => value + 1)}
/>
);
const renderLoadingState = () => (
@@ -162,6 +167,10 @@ function PluginListView() {
return renderLoadingState();
}
if (statusError) {
return <LoadErrorState title={t('plugins.failedToGetStatus')} onRetry={() => setStatusRevision((value) => value + 1)} />;
}
if (!pluginSystemStatus?.is_enable) {
return renderPluginDisabledState();
}
+6 -18
View File
@@ -1,6 +1,6 @@
import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import LoadErrorState from '@/components/LoadErrorState';
export default function EntityLoadState({
error = false,
@@ -10,27 +10,15 @@ export default function EntityLoadState({
onRetry?: () => void;
}) {
const { t } = useTranslation();
if (error) return <LoadErrorState onRetry={onRetry} className="flex-1" />;
return (
<div
role={error ? 'alert' : 'status'}
aria-busy={!error}
role="status"
aria-busy="true"
className="flex min-h-40 flex-1 flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground"
>
{error ? (
<>
<p>{t('common.loadFailed')}</p>
{onRetry && (
<Button type="button" variant="outline" onClick={onRetry}>
{t('common.retry')}
</Button>
)}
</>
) : (
<>
<Loader2 aria-hidden="true" className="size-5 animate-spin" />
<p>{t('common.loading')}</p>
</>
)}
<Loader2 aria-hidden="true" className="size-5 animate-spin" />
<p>{t('common.loading')}</p>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { useState, type ReactNode } from 'react';
import { FileWarning, RefreshCw } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export default function LoadErrorState({
title,
description,
icon,
onRetry,
busy = false,
compact = false,
className,
}: {
title?: string;
description?: string;
icon?: ReactNode;
onRetry?: () => unknown;
busy?: boolean;
compact?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const [retrying, setRetrying] = useState(false);
async function retry() {
if (!onRetry || retrying || busy) return;
setRetrying(true);
try {
await onRetry();
} catch {
// The owning view retains its error state if the request fails again.
} finally {
setRetrying(false);
}
}
return (
<div
role="alert"
aria-busy={retrying || busy}
className={cn(
'flex w-full items-center justify-center px-4',
compact ? 'py-4' : 'min-h-64 py-10',
className,
)}
>
<div className="flex w-full max-w-md items-start gap-4 rounded-xl border bg-card p-5 text-card-foreground shadow-sm sm:p-6">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-amber-500/10 text-amber-600 dark:text-amber-400">
{icon ?? <FileWarning className="size-5" aria-hidden="true" />}
</div>
<div className="min-w-0 flex-1 space-y-2">
<p className="break-words text-sm font-medium leading-6">
{title ?? t('common.loadFailedTitle')}
</p>
<p className="break-words text-sm leading-6 text-muted-foreground">
{description ?? t('common.loadFailedHint')}
</p>
{onRetry && (
<div className="pt-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={retrying || busy}
onClick={() => void retry()}
>
<RefreshCw
aria-hidden="true"
className={cn('size-3.5', (retrying || busy) && 'animate-spin motion-reduce:animate-none')}
/>
{retrying || busy ? t('common.loading') : t('common.retry')}
</Button>
</div>
)}
</div>
</div>
</div>
);
}
+2
View File
@@ -340,6 +340,8 @@ const enUS = {
editionCloud: 'Cloud',
},
common: {
loadFailedTitle: 'Unable to load this content',
loadFailedHint: 'Please try again in a moment. If this continues, check your connection and service status.',
customValue: 'Custom',
loadFailed: 'Failed to load. Please try again.',
search: 'Search',
+2
View File
@@ -343,6 +343,8 @@ const esES = {
editionCloud: 'Cloud',
},
common: {
loadFailedTitle: 'No se pudo cargar el contenido',
loadFailedHint: 'Inténtalo de nuevo. Si el problema persiste, comprueba la conexión y el estado del servicio.',
customValue: 'Personalizado',
loadFailed: 'No se pudo cargar. Inténtalo de nuevo.',
search: 'Buscar',
+2
View File
@@ -339,6 +339,8 @@ const jaJP = {
editionCloud: 'Cloud',
},
common: {
loadFailedTitle: '読み込めませんでした',
loadFailedHint: 'しばらくしてから再試行してください。解決しない場合は、接続とサービスの状態を確認してください。',
customValue: 'カスタム',
loadFailed: '読み込みに失敗しました。再試行してください。',
search: '検索',
+2
View File
@@ -342,6 +342,8 @@ const ruRU = {
editionCloud: 'Cloud',
},
common: {
loadFailedTitle: 'Не удалось загрузить данные',
loadFailedHint: 'Повторите попытку позже. Если проблема остаётся, проверьте подключение и состояние сервиса.',
customValue: 'Свой вариант',
loadFailed: 'Не удалось загрузить. Попробуйте ещё раз.',
search: 'Поиск',
+2
View File
@@ -335,6 +335,8 @@ const thTH = {
editionCloud: 'Cloud',
},
common: {
loadFailedTitle: 'ไม่สามารถโหลดเนื้อหาได้',
loadFailedHint: 'โปรดลองอีกครั้ง หากยังโหลดไม่ได้ ให้ตรวจสอบการเชื่อมต่อและสถานะบริการ',
customValue: 'กำหนดเอง',
loadFailed: 'โหลดไม่สำเร็จ โปรดลองอีกครั้ง',
search: 'ค้นหา',
+2
View File
@@ -339,6 +339,8 @@ const viVN = {
editionCloud: 'Cloud',
},
common: {
loadFailedTitle: 'Không thể tải nội dung',
loadFailedHint: 'Vui lòng thử lại sau. Nếu vẫn không tải được, hãy kiểm tra kết nối và trạng thái dịch vụ.',
customValue: 'Tùy chỉnh',
loadFailed: 'Không tải được. Vui lòng thử lại.',
search: 'Tìm kiếm',
+2
View File
@@ -319,6 +319,8 @@ const zhHans = {
editionCloud: 'Cloud',
},
common: {
loadFailedTitle: '暂时无法加载',
loadFailedHint: '请稍后重试。如果仍无法加载,请检查网络连接与服务状态。',
customValue: '自定义',
loadFailed: '加载失败,请重试。',
search: '搜索',
+2
View File
@@ -316,6 +316,8 @@ const zhHant = {
editionCloud: 'Cloud',
},
common: {
loadFailedTitle: '暫時無法載入',
loadFailedHint: '請稍後重試。如果仍無法載入,請檢查網路連線與服務狀態。',
customValue: '自訂',
loadFailed: '載入失敗,請重試。',
search: '搜尋',