This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit ba06c3adf6be7ba997c3237b3dd32e998677b57a Author: Yang Luo <[email protected]> AuthorDate: Wed Aug 26 00:36:57 2026 +0800 fix(web): stop rendering a failed load as an empty listing A listing that failed showed an error toast for a few seconds and then the empty state, so "you have no providers yet" and "the providers could not be read" looked the same. On a tool that holds your API keys the first one is alarming, and it was the wrong thing to say. The home page told anyone whose scan failed to install an agent they already have. Split the two: DataTable takes an error and renders it with a retry instead of the empty state, and the pages that draw their own empty state gate it on the load having succeeded. Data already on screen stays there behind an alert, since a failed refresh leaves it stale, not gone. Five listings called .then() with no .catch(), so a request that threw never cleared the loading flag and left the table on its skeleton. --- web/src/components/shared/data-table.tsx | 14 +++++++++-- web/src/components/shared/empty-state.tsx | 40 ++++++++++++++++++++++++++++++- web/src/locales/en/data.json | 2 ++ web/src/locales/zh/data.json | 2 ++ web/src/pages/CertListPage.tsx | 28 +++++++++++++--------- web/src/pages/HomePage.tsx | 40 +++++++++++++++++++++++++++---- web/src/pages/NodeListPage.tsx | 26 ++++++++++++-------- web/src/pages/ProviderListPage.tsx | 24 ++++++++++++++----- web/src/pages/RecordListPage.tsx | 28 +++++++++++++--------- web/src/pages/RuleListPage.tsx | 28 +++++++++++++--------- web/src/pages/SiteListPage.tsx | 22 ++++++++++------- 11 files changed, 189 insertions(+), 65 deletions(-) diff --git a/web/src/components/shared/data-table.tsx b/web/src/components/shared/data-table.tsx index 1822cbf..50e32a1 100644 --- a/web/src/components/shared/data-table.tsx +++ b/web/src/components/shared/data-table.tsx @@ -31,7 +31,7 @@ import {Button} from "@/components/ui/button"; import {Input} from "@/components/ui/input"; import {Skeleton} from "@/components/ui/skeleton"; import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"; -import {EmptyState} from "@/components/shared/empty-state"; +import {EmptyState, ErrorState} from "@/components/shared/empty-state"; export type SortOrder = "ascend" | "descend" | undefined; @@ -109,6 +109,10 @@ export interface DataTableProps<T> { onSort?: (field: string, order: SortOrder) => void; emptyText?: React.ReactNode; emptyIcon?: React.ComponentType<{className?: string}>; + /** Why the listing is empty when it is empty because the load failed. */ + error?: string; + /** Reloads the listing from the error state. */ + onRetry?: () => void; onRowClick?: (record: T) => void; expandable?: { rowExpandable?: (record: T) => boolean; @@ -140,6 +144,8 @@ export function DataTable<T>({ onSort, emptyText, emptyIcon, + error, + onRetry, onRowClick, expandable, dense = false, @@ -324,7 +330,11 @@ export function DataTable<T>({ ) : rows.length === 0 ? ( <TableRow className="hover:bg-transparent"> <TableCell colSpan={columns.length + (expandable ? 1 : 0)} className="p-0"> - <EmptyState icon={emptyIcon} title={emptyText ?? i18next.t("general:No data")} /> + {error ? ( + <ErrorState error={error} onRetry={onRetry} /> + ) : ( + <EmptyState icon={emptyIcon} title={emptyText ?? i18next.t("general:No data")} /> + )} </TableCell> </TableRow> ) : ( diff --git a/web/src/components/shared/empty-state.tsx b/web/src/components/shared/empty-state.tsx index d556764..478b41f 100644 --- a/web/src/components/shared/empty-state.tsx +++ b/web/src/components/shared/empty-state.tsx @@ -13,8 +13,10 @@ // limitations under the License. import * as React from "react"; -import {Inbox} from "lucide-react"; +import {Inbox, RefreshCw, TriangleAlert} from "lucide-react"; +import i18next from "i18next"; +import {Button} from "@/components/ui/button"; import {cn} from "@/lib/utils"; export function EmptyState({ @@ -42,3 +44,39 @@ export function EmptyState({ </div> ); } + +/** + * What a listing shows when it could not be read. A load that failed must never + * fall through to EmptyState: "you have no providers" and "the providers could + * not be read" look the same there, and the first one is alarming to be told + * about an account that holds your API keys. + */ +export function ErrorState({ + title, + error, + onRetry, + className, +}: { + title?: React.ReactNode; + error?: React.ReactNode; + onRetry?: () => void; + className?: string; +}) { + return ( + <div className={cn("flex flex-col items-center justify-center gap-2 px-6 py-14 text-center", className)}> + <div className="bg-destructive/10 text-destructive flex size-10 items-center justify-center rounded-full"> + <TriangleAlert className="size-5" /> + </div> + <p className="text-sm font-medium">{title ?? i18next.t("general:Could not load this")}</p> + {error ? <p className="text-muted-foreground max-w-sm text-xs break-words">{error}</p> : null} + {onRetry ? ( + <div className="mt-2"> + <Button variant="outline" size="sm" onClick={onRetry}> + <RefreshCw /> + {i18next.t("general:Retry")} + </Button> + </div> + ) : null} + </div> + ); +} diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index 900f9f4..bba9d67 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -321,6 +321,7 @@ "Copied to clipboard": "Copied to clipboard", "Copy": "Copy", "Could not check for updates": "Could not check for updates", + "Could not load this": "Could not load this", "Count": "Count", "Create time": "Create time", "Created time": "Created time", @@ -378,6 +379,7 @@ "Request Count Over Time": "Request Count Over Time", "Restarting into the new version": "Restarting into the new version", "Restore": "Restore", + "Retry": "Retry", "Rules": "Rules", "Save": "Save", "Search": "Search", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index a1459c2..0693f81 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -321,6 +321,7 @@ "Copied to clipboard": "已复制到剪贴板", "Copy": "复制", "Could not check for updates": "检查更新失败", + "Could not load this": "加载失败", "Count": "计数", "Create time": "创建时间", "Created time": "创建时间", @@ -378,6 +379,7 @@ "Request Count Over Time": "请求数趋势", "Restarting into the new version": "正在重启到新版本", "Restore": "复原", + "Retry": "重试", "Rules": "规则", "Save": "保存", "Search": "搜索", diff --git a/web/src/pages/CertListPage.tsx b/web/src/pages/CertListPage.tsx index fd4a98a..698a5ee 100644 --- a/web/src/pages/CertListPage.tsx +++ b/web/src/pages/CertListPage.tsx @@ -60,6 +60,7 @@ export default function CertListPage({account}: {account: Account}) { const [data, setData] = React.useState<Cert[]>([]); const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(""); const [page, setPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(20); const [addOpen, setAddOpen] = React.useState(false); @@ -70,17 +71,20 @@ export default function CertListPage({account}: {account: Account}) { const fetchCerts = React.useCallback( (nextPage = page, nextPageSize = pageSize) => { setLoading(true); - CertBackend.getCerts(account.name, nextPage, nextPageSize).then(res => { - setLoading(false); - if (res.status === "ok") { - setData(res.data ?? []); - setTotal(res.data2 ?? 0); - setPage(nextPage); - setPageSize(nextPageSize); - } else { - Setting.showMessage("error", `${i18next.t("general:Failed to get data")}: ${res.msg}`); - } - }); + CertBackend.getCerts(account.name, nextPage, nextPageSize) + .then(res => { + if (res.status === "ok") { + setData(res.data ?? []); + setTotal(res.data2 ?? 0); + setPage(nextPage); + setPageSize(nextPageSize); + setError(""); + } else { + setError(res.msg || i18next.t("general:Failed to get data")); + } + }) + .catch(err => setError(err.message || String(err))) + .then(() => setLoading(false)); }, [account.name, page, pageSize], ); @@ -285,6 +289,8 @@ export default function CertListPage({account}: {account: Account}) { dataSource={data} rowKey={record => `${record.owner}/${record.name}`} loading={loading} + error={error} + onRetry={() => fetchCerts()} serverPagination={{ page: page, pageSize: pageSize, diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx index e5882e7..cdf4790 100644 --- a/web/src/pages/HomePage.tsx +++ b/web/src/pages/HomePage.tsx @@ -21,7 +21,7 @@ import * as LlmRecordBackend from "@/backend/LlmRecordBackend"; import * as ProviderBackend from "@/backend/ProviderBackend"; import * as Setting from "@/Setting"; import {AgentGridCard} from "@/components/AgentGridCard"; -import {EmptyState} from "@/components/shared/empty-state"; +import {EmptyState, ErrorState} from "@/components/shared/empty-state"; import {Loading} from "@/components/shared/loading"; import {UnauthorizedResult} from "@/components/shared/misc"; import {PageContainer, PageHeader} from "@/components/shared/page-header"; @@ -67,6 +67,10 @@ export default function HomePage({account}: {account: Account}) { const [recording, setRecording] = React.useState(true); // False until the first listing lands, so "no providers" never flashes. const [loaded, setLoaded] = React.useState(false); + // A listing that failed must not read as "you have no providers": that says + // the accounts holding your API keys are gone when the request simply + // dropped. + const [providerError, setProviderError] = React.useState(""); // The sessions feed is only worth polling once something is being watched. const watching = agents.some(agent => agent.patched); const {activity} = useAgentSessions(isAdmin && watching, "", refreshMs); @@ -76,8 +80,15 @@ export default function HomePage({account}: {account: Account}) { return; } ProviderBackend.getProviders(account.name) - .then(res => setProviders(res.status === "ok" ? (res.data ?? []) : [])) - .catch(() => setProviders([])) + .then(res => { + if (res.status === "ok") { + setProviders(res.data ?? []); + setProviderError(""); + } else { + setProviderError(res.msg || i18next.t("provider:Failed to get providers")); + } + }) + .catch(failure => setProviderError(failure.message || String(failure))) .then(() => setLoaded(true)); // What is already known paints at once; the refresh then asks only the @@ -160,9 +171,22 @@ export default function HomePage({account}: {account: Account}) { } /> - {error ? <MessageAlert title={error} /> : null} + {/* With no agents to show, the failure is the whole page and is rendered + below instead; here it sits above the cards that are still on screen. */} + {error !== "" && agents.length > 0 ? <MessageAlert title={error} /> : null} - {loaded && providers.length === 0 ? ( + {providerError !== "" ? ( + <MessageAlert + title={i18next.t("provider:Failed to get providers")} + description={providerError} + action={ + <Button variant="outline" size="sm" onClick={loadProviders}> + <RefreshCw /> + {i18next.t("general:Retry")} + </Button> + } + /> + ) : loaded && providers.length === 0 ? ( <MessageAlert variant="info" title={i18next.t("provider:No providers yet")} @@ -180,6 +204,12 @@ export default function HomePage({account}: {account: Account}) { {!scanned ? ( <Loading tip={i18next.t("agent:Scan")} /> + ) : error !== "" && agents.length === 0 ? ( + // A scan that failed is not a machine without agents, and telling + // someone to install one they already have is the wrong instruction. + <Card className="py-0"> + <ErrorState title={i18next.t("agent:Failed to scan agents")} error={error} onRetry={refresh} /> + </Card> ) : agents.length === 0 ? ( <Card className="py-0"> <EmptyState diff --git a/web/src/pages/NodeListPage.tsx b/web/src/pages/NodeListPage.tsx index 4b819c0..e75a1ea 100644 --- a/web/src/pages/NodeListPage.tsx +++ b/web/src/pages/NodeListPage.tsx @@ -45,6 +45,7 @@ export default function NodeListPage({account}: {account: Account}) { const navigate = useNavigate(); const [data, setData] = React.useState<Node[]>([]); const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(""); const [authorized, setAuthorized] = React.useState(true); const [addOpen, setAddOpen] = React.useState(false); const [adding, setAdding] = React.useState(false); @@ -53,16 +54,19 @@ export default function NodeListPage({account}: {account: Account}) { const fetchNodes = React.useCallback(() => { setLoading(true); - NodeBackend.getNodes(account.name).then(res => { - setLoading(false); - if (res.status === "ok") { - setData(res.data ?? []); - } else if (Setting.isResponseDenied(res)) { - setAuthorized(false); - } else { - Setting.showMessage("error", res.msg); - } - }); + NodeBackend.getNodes(account.name) + .then(res => { + if (res.status === "ok") { + setData(res.data ?? []); + setError(""); + } else if (Setting.isResponseDenied(res)) { + setAuthorized(false); + } else { + setError(res.msg || i18next.t("general:Failed to get data")); + } + }) + .catch(err => setError(err.message || String(err))) + .then(() => setLoading(false)); }, [account.name]); React.useEffect(() => { @@ -210,6 +214,8 @@ export default function NodeListPage({account}: {account: Account}) { dataSource={data} rowKey={record => `${record.owner}/${record.name}`} loading={loading} + error={error} + onRetry={() => fetchNodes()} pageSize={20} searchable title={i18next.t("general:Nodes")} diff --git a/web/src/pages/ProviderListPage.tsx b/web/src/pages/ProviderListPage.tsx index 7639dab..43698af 100644 --- a/web/src/pages/ProviderListPage.tsx +++ b/web/src/pages/ProviderListPage.tsx @@ -33,10 +33,12 @@ import {ProviderModelsField} from "@/components/ProviderModelsField"; import {ProviderSourcePicker, sourceTitle} from "@/components/ProviderSourcePicker"; import {ProviderTestField, useProviderTest} from "@/components/ProviderTestField"; import {type SortOrder} from "@/components/shared/data-table"; +import {ErrorState} from "@/components/shared/empty-state"; import {Field, FormDialog} from "@/components/shared/form-dialog"; import {PageContainer, PageHeader} from "@/components/shared/page-header"; import {PasswordInput} from "@/components/shared/password-input"; import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select"; +import {MessageAlert} from "@/components/ui/alert"; import {Badge} from "@/components/ui/badge"; import {Button} from "@/components/ui/button"; import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"; @@ -110,6 +112,10 @@ export default function ProviderListPage({account}: {account: Account}) { const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(false); const [loaded, setLoaded] = React.useState(false); + // Empty because there is nothing, or empty because the listing failed? The + // two must never render the same: being told that an account which holds your + // API keys has no providers is alarming when it is really a dropped request. + const [error, setError] = React.useState(""); const [page, setPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(10); const [sort, setSort] = React.useState<{field: string; order: SortOrder}>({ @@ -140,22 +146,21 @@ export default function ProviderListPage({account}: {account: Account}) { nextSort.order ?? "", ) .then(res => { - setLoading(false); - setLoaded(true); if (res.status === "ok") { setData(res.data ?? []); setTotal(res.data2 ?? 0); setPage(nextPage); setPageSize(nextPageSize); setSort(nextSort); + setError(""); } else { - Setting.showMessage("error", `${i18next.t("provider:Failed to get providers")}: ${res.msg}`); + setError(res.msg || i18next.t("provider:Failed to get providers")); } }) - .catch(error => { + .catch(failure => setError(failure.message || String(failure))) + .then(() => { setLoading(false); setLoaded(true); - Setting.showMessage("error", `${i18next.t("provider:Failed to get providers")}: ${error}`); }); }, [account.name, page, pageSize, sort], @@ -331,7 +336,11 @@ export default function ProviderListPage({account}: {account: Account}) { } /> - {loaded && data.length === 0 ? ( + {error !== "" && data.length === 0 ? ( + <Card> + <ErrorState error={error} onRetry={() => fetchProviders()} /> + </Card> + ) : loaded && error === "" && data.length === 0 ? ( <Card> <CardHeader> <CardTitle className="flex items-center gap-2"> @@ -346,6 +355,9 @@ export default function ProviderListPage({account}: {account: Account}) { </Card> ) : ( <div className="space-y-3"> + {/* A refresh that failed leaves the last good listing on screen: the + providers below are stale, not gone. */} + {error !== "" ? <MessageAlert title={i18next.t("provider:Failed to get providers")} description={error} /> : null} <div className="flex flex-wrap items-center justify-between gap-2"> <p className="text-muted-foreground text-sm"> {`${total} ${i18next.t("provider:Providers")}`} diff --git a/web/src/pages/RecordListPage.tsx b/web/src/pages/RecordListPage.tsx index 0e1738e..0d37f23 100644 --- a/web/src/pages/RecordListPage.tsx +++ b/web/src/pages/RecordListPage.tsx @@ -54,6 +54,7 @@ export default function RecordListPage({account}: {account: Account}) { const [data, setData] = React.useState<GatewayRecord[]>([]); const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(""); const [page, setPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(10); const [addOpen, setAddOpen] = React.useState(false); @@ -63,17 +64,20 @@ export default function RecordListPage({account}: {account: Account}) { const fetchRecords = React.useCallback( (nextPage = page, nextPageSize = pageSize) => { setLoading(true); - RecordBackend.getRecords(account.name, nextPage, nextPageSize).then(res => { - setLoading(false); - if (res.status === "ok") { - setData(res.data ?? []); - setTotal(res.data2 ?? 0); - setPage(nextPage); - setPageSize(nextPageSize); - } else { - Setting.showMessage("error", `${i18next.t("general:Failed to get data")}: ${res.msg}`); - } - }); + RecordBackend.getRecords(account.name, nextPage, nextPageSize) + .then(res => { + if (res.status === "ok") { + setData(res.data ?? []); + setTotal(res.data2 ?? 0); + setPage(nextPage); + setPageSize(nextPageSize); + setError(""); + } else { + setError(res.msg || i18next.t("general:Failed to get data")); + } + }) + .catch(err => setError(err.message || String(err))) + .then(() => setLoading(false)); }, [account.name, page, pageSize], ); @@ -218,6 +222,8 @@ export default function RecordListPage({account}: {account: Account}) { dataSource={data} rowKey={record => String(record.id)} loading={loading} + error={error} + onRetry={() => fetchRecords()} serverPagination={{ page: page, pageSize: pageSize, diff --git a/web/src/pages/RuleListPage.tsx b/web/src/pages/RuleListPage.tsx index aa5db1c..6833e60 100644 --- a/web/src/pages/RuleListPage.tsx +++ b/web/src/pages/RuleListPage.tsx @@ -57,6 +57,7 @@ export default function RuleListPage({account}: {account: Account}) { const [data, setData] = React.useState<Rule[]>([]); const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(""); const [page, setPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(10); const [addOpen, setAddOpen] = React.useState(false); @@ -67,17 +68,20 @@ export default function RuleListPage({account}: {account: Account}) { const fetchRules = React.useCallback( (nextPage = page, nextPageSize = pageSize) => { setLoading(true); - RuleBackend.getRules(account.name, nextPage, nextPageSize).then(res => { - setLoading(false); - if (res.status === "ok") { - setData(res.data ?? []); - setTotal(res.data2 ?? 0); - setPage(nextPage); - setPageSize(nextPageSize); - } else { - Setting.showMessage("error", `${i18next.t("general:Failed to get data")}: ${res.msg}`); - } - }); + RuleBackend.getRules(account.name, nextPage, nextPageSize) + .then(res => { + if (res.status === "ok") { + setData(res.data ?? []); + setTotal(res.data2 ?? 0); + setPage(nextPage); + setPageSize(nextPageSize); + setError(""); + } else { + setError(res.msg || i18next.t("general:Failed to get data")); + } + }) + .catch(err => setError(err.message || String(err))) + .then(() => setLoading(false)); }, [account.name, page, pageSize], ); @@ -244,6 +248,8 @@ export default function RuleListPage({account}: {account: Account}) { dataSource={data} rowKey={record => `${record.owner}/${record.name}`} loading={loading} + error={error} + onRetry={() => fetchRules()} serverPagination={{ page: page, pageSize: pageSize, diff --git a/web/src/pages/SiteListPage.tsx b/web/src/pages/SiteListPage.tsx index 5f4dca2..245f8cc 100644 --- a/web/src/pages/SiteListPage.tsx +++ b/web/src/pages/SiteListPage.tsx @@ -70,6 +70,7 @@ export default function SiteListPage({account}: {account: Account}) { const navigate = useNavigate(); const [data, setData] = React.useState<Site[]>([]); const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(""); const [gatewayStatus, setGatewayStatus] = React.useState<GatewayStatus | null>(null); const [switchingGateway, setSwitchingGateway] = React.useState(false); const [addOpen, setAddOpen] = React.useState(false); @@ -79,14 +80,17 @@ export default function SiteListPage({account}: {account: Account}) { const fetchSites = React.useCallback(() => { setLoading(true); - SiteBackend.getSites(account.name).then(res => { - setLoading(false); - if (res.status === "ok") { - setData(res.data ?? []); - } else { - Setting.showMessage("error", `${i18next.t("general:Failed to get data")}: ${res.msg}`); - } - }); + SiteBackend.getSites(account.name) + .then(res => { + if (res.status === "ok") { + setData(res.data ?? []); + setError(""); + } else { + setError(res.msg || i18next.t("general:Failed to get data")); + } + }) + .catch(err => setError(err.message || String(err))) + .then(() => setLoading(false)); }, [account.name]); const loadGatewayStatus = React.useCallback(() => { @@ -412,6 +416,8 @@ export default function SiteListPage({account}: {account: Account}) { dataSource={data} rowKey={record => `${record.owner}/${record.name}`} loading={loading} + error={error} + onRetry={() => fetchSites()} pageSize={20} searchable title={i18next.t("general:Sites")} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
