diff --git a/.changeset/happy-cameras-occur.md b/.changeset/happy-cameras-occur.md new file mode 100644 index 0000000000..19f0adc59e --- /dev/null +++ b/.changeset/happy-cameras-occur.md @@ -0,0 +1,9 @@ +--- +"@cloudflare/local-explorer-ui": patch +--- + +Improves local explorer invalid route error handling. + +Visiting a route either as a 404 or 500 error now has dedicated components to handle as such, rather than the generic TanStack error UI. + +Additionally, it also fixes route loaders to correctly throw a 404 error if a resource is not found, rather than showing a generic error. diff --git a/packages/local-explorer-ui/src/components/NotFound.tsx b/packages/local-explorer-ui/src/components/NotFound.tsx new file mode 100644 index 0000000000..2dea78d853 --- /dev/null +++ b/packages/local-explorer-ui/src/components/NotFound.tsx @@ -0,0 +1,19 @@ +import { Button } from "@cloudflare/kumo"; +import { Link, type NotFoundRouteProps } from "@tanstack/react-router"; + +export function NotFound(_props: NotFoundRouteProps): JSX.Element { + return ( +
+

Page not found

+ +

+ The resource you're looking for doesn't exist or has been + removed. +

+ + + + +
+ ); +} diff --git a/packages/local-explorer-ui/src/components/ResourceError.tsx b/packages/local-explorer-ui/src/components/ResourceError.tsx new file mode 100644 index 0000000000..84ea8113a4 --- /dev/null +++ b/packages/local-explorer-ui/src/components/ResourceError.tsx @@ -0,0 +1,29 @@ +import { Button } from "@cloudflare/kumo"; +import { WarningIcon } from "@phosphor-icons/react"; +import { Link, type ErrorComponentProps } from "@tanstack/react-router"; +import type { WorkersApiResponseCommonFailure } from "../api"; + +const DEFAULT_ERROR_DESCRIPTION = + "An unknown error occured. Please report this issue to Cloudflare."; + +export function ResourceError({ + error, +}: ErrorComponentProps): JSX.Element { + const details = + ("errors" in error ? error.errors?.[0]?.message : error.message) ?? + DEFAULT_ERROR_DESCRIPTION; + + return ( +
+ + +

Something went wrong

+ +

{details}

+ + + + +
+ ); +} diff --git a/packages/local-explorer-ui/src/routes/__root.tsx b/packages/local-explorer-ui/src/routes/__root.tsx index bd4ef5ada8..37a274e9d5 100644 --- a/packages/local-explorer-ui/src/routes/__root.tsx +++ b/packages/local-explorer-ui/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { localExplorerListWorkers } from "../api"; +import { NotFound } from "../components/NotFound"; import { Sidebar } from "../components/Sidebar"; import { filterVisibleWorkers, @@ -15,6 +16,7 @@ import { export const Route = createRootRoute({ component: RootLayout, + notFoundComponent: NotFound, loader: async () => { const workersResponse = await localExplorerListWorkers(); const workers = workersResponse.data?.result ?? []; diff --git a/packages/local-explorer-ui/src/routes/d1/$databaseId.tsx b/packages/local-explorer-ui/src/routes/d1/$databaseId.tsx index 2a22fe7099..f32e6dfb4c 100644 --- a/packages/local-explorer-ui/src/routes/d1/$databaseId.tsx +++ b/packages/local-explorer-ui/src/routes/d1/$databaseId.tsx @@ -14,6 +14,7 @@ import { import { useCallback, useMemo, useRef, useState } from "react"; import D1Icon from "../../assets/icons/d1.svg?react"; import { Breadcrumbs } from "../../components/Breadcrumbs"; +import { ResourceError } from "../../components/ResourceError"; import { Studio } from "../../components/studio"; import { DropTableConfirmationModal } from "../../components/studio/Modal/DropTableConfirmation"; import { StudioTableActionsDropdown } from "../../components/studio/Table/ActionsDropdown"; @@ -25,6 +26,7 @@ import type { StudioResource } from "../../types/studio"; export const Route = createFileRoute("/d1/$databaseId")({ component: DatabaseView, + errorComponent: ResourceError, loader: async (ctx) => { const driver = new LocalD1Driver(ctx.params.databaseId); const schemas = await driver.schemas(); diff --git a/packages/local-explorer-ui/src/routes/do/$className.tsx b/packages/local-explorer-ui/src/routes/do/$className.tsx index 2482492fb4..914b2b4cbf 100644 --- a/packages/local-explorer-ui/src/routes/do/$className.tsx +++ b/packages/local-explorer-ui/src/routes/do/$className.tsx @@ -1,8 +1,11 @@ -import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { createFileRoute, notFound, Outlet } from "@tanstack/react-router"; import { durableObjectsNamespaceListNamespaces } from "../../api"; +import { NotFound } from "../../components/NotFound"; +import { ResourceError } from "../../components/ResourceError"; export const Route = createFileRoute("/do/$className")({ component: () => , + errorComponent: ResourceError, loader: async ({ params }) => { const response = await durableObjectsNamespaceListNamespaces(); const namespaces = response.data?.result ?? []; @@ -15,7 +18,7 @@ export const Route = createFileRoute("/do/$className")({ ns.id === params.className ); if (!namespace?.id) { - throw new Error(`Durable Object class "${params.className}" not found`); + throw notFound(); } return { @@ -23,4 +26,5 @@ export const Route = createFileRoute("/do/$className")({ namespaceId: namespace.id, }; }, + notFoundComponent: NotFound, }); diff --git a/packages/local-explorer-ui/src/routes/do/$className/$objectId.tsx b/packages/local-explorer-ui/src/routes/do/$className/$objectId.tsx index a1b499bb8b..93326ed888 100644 --- a/packages/local-explorer-ui/src/routes/do/$className/$objectId.tsx +++ b/packages/local-explorer-ui/src/routes/do/$className/$objectId.tsx @@ -7,6 +7,7 @@ import { import { createFileRoute, Link, + notFound, useNavigate, useRouter, } from "@tanstack/react-router"; @@ -14,6 +15,8 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { durableObjectsNamespaceListNamespaces } from "../../../api"; import DOIcon from "../../../assets/icons/durable-objects.svg?react"; import { Breadcrumbs } from "../../../components/Breadcrumbs"; +import { NotFound } from "../../../components/NotFound"; +import { ResourceError } from "../../../components/ResourceError"; import { Studio } from "../../../components/studio"; import { DropTableConfirmationModal } from "../../../components/studio/Modal/DropTableConfirmation"; import { StudioTableActionsDropdown } from "../../../components/studio/Table/ActionsDropdown"; @@ -31,6 +34,7 @@ function isHexId(str: string): boolean { export const Route = createFileRoute("/do/$className/$objectId")({ component: ObjectView, + errorComponent: ResourceError, loader: async ({ params }) => { // Resolve className to a namespace ID const response = await durableObjectsNamespaceListNamespaces(); @@ -42,7 +46,7 @@ export const Route = createFileRoute("/do/$className/$objectId")({ ns.id === params.className ); if (!namespace?.id) { - throw new Error(`Durable Object class "${params.className}" not found`); + throw notFound(); } // Determine if the param is a hex ID or a name @@ -67,6 +71,7 @@ export const Route = createFileRoute("/do/$className/$objectId")({ tables, }; }, + notFoundComponent: NotFound, validateSearch: (search) => ({ table: typeof search.table === "string" ? search.table : undefined, }), diff --git a/packages/local-explorer-ui/src/routes/do/$className/index.tsx b/packages/local-explorer-ui/src/routes/do/$className/index.tsx index 3f77fe7b1a..b9909cd4ec 100644 --- a/packages/local-explorer-ui/src/routes/do/$className/index.tsx +++ b/packages/local-explorer-ui/src/routes/do/$className/index.tsx @@ -1,5 +1,10 @@ import { Button, Label, Link as KumoLink, Table } from "@cloudflare/kumo"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { + createFileRoute, + Link, + notFound, + useNavigate, +} from "@tanstack/react-router"; import { useCallback, useEffect, useState } from "react"; import { durableObjectsNamespaceListNamespaces, @@ -7,10 +12,13 @@ import { } from "../../../api"; import DOIcon from "../../../assets/icons/durable-objects.svg?react"; import { Breadcrumbs } from "../../../components/Breadcrumbs"; +import { NotFound } from "../../../components/NotFound"; +import { ResourceError } from "../../../components/ResourceError"; import type { WorkersObject } from "../../../api"; export const Route = createFileRoute("/do/$className/")({ component: NamespaceView, + errorComponent: ResourceError, loader: async ({ params }) => { const response = await durableObjectsNamespaceListNamespaces(); const namespaces = response.data?.result ?? []; @@ -21,7 +29,7 @@ export const Route = createFileRoute("/do/$className/")({ ns.id === params.className ); if (!namespace?.id) { - throw new Error(`Durable Object class "${params.className}" not found`); + throw notFound(); } const objectsResponse = await durableObjectsNamespaceListObjects({ @@ -43,6 +51,7 @@ export const Route = createFileRoute("/do/$className/")({ objects, }; }, + notFoundComponent: NotFound, }); function NamespaceView() { diff --git a/packages/local-explorer-ui/src/routes/kv/$namespaceId.tsx b/packages/local-explorer-ui/src/routes/kv/$namespaceId.tsx index 3250b38adc..0fdc38702b 100644 --- a/packages/local-explorer-ui/src/routes/kv/$namespaceId.tsx +++ b/packages/local-explorer-ui/src/routes/kv/$namespaceId.tsx @@ -16,12 +16,14 @@ import KVIcon from "../../assets/icons/kv.svg?react"; import { AddKVForm } from "../../components/AddKVForm"; import { Breadcrumbs } from "../../components/Breadcrumbs"; import { KVTable } from "../../components/KVTable"; +import { ResourceError } from "../../components/ResourceError"; import { SearchForm } from "../../components/SearchForm"; import { getSelectedWorker } from "../../components/WorkerSelector"; import type { KVEntry } from "../../api"; export const Route = createFileRoute("/kv/$namespaceId")({ component: NamespaceView, + errorComponent: ResourceError, loader: async ({ params }) => { const keysResponse = await workersKvNamespaceListANamespace_SKeys({ path: { namespace_id: params.namespaceId }, diff --git a/packages/local-explorer-ui/src/routes/r2/$bucketName.tsx b/packages/local-explorer-ui/src/routes/r2/$bucketName.tsx index 72c633d063..561a18a08c 100644 --- a/packages/local-explorer-ui/src/routes/r2/$bucketName.tsx +++ b/packages/local-explorer-ui/src/routes/r2/$bucketName.tsx @@ -1,5 +1,7 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { ResourceError } from "../../components/ResourceError"; export const Route = createFileRoute("/r2/$bucketName")({ component: () => , + errorComponent: ResourceError, }); diff --git a/packages/local-explorer-ui/src/routes/r2/$bucketName/index.tsx b/packages/local-explorer-ui/src/routes/r2/$bucketName/index.tsx index 6ab831dd27..c6db74a136 100644 --- a/packages/local-explorer-ui/src/routes/r2/$bucketName/index.tsx +++ b/packages/local-explorer-ui/src/routes/r2/$bucketName/index.tsx @@ -13,7 +13,12 @@ import { ListIcon, UploadIcon, } from "@phosphor-icons/react"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { + createFileRoute, + Link, + notFound, + useNavigate, +} from "@tanstack/react-router"; import { useCallback, useEffect, useState } from "react"; import { r2BucketDeleteObjects, @@ -22,8 +27,10 @@ import { } from "../../../api"; import R2Icon from "../../../assets/icons/r2.svg?react"; import { Breadcrumbs } from "../../../components/Breadcrumbs"; +import { NotFound } from "../../../components/NotFound"; import { R2ObjectTable } from "../../../components/R2ObjectTable"; import { R2UploadDialog } from "../../../components/R2UploadDialog"; +import { ResourceError } from "../../../components/ResourceError"; import { withMinimumDelay } from "../../../utils/async"; import type { R2Object } from "../../../api"; @@ -34,10 +41,7 @@ export interface R2BucketSearch { export const Route = createFileRoute("/r2/$bucketName/")({ component: BucketView, - validateSearch: (search: Record): R2BucketSearch => ({ - prefix: typeof search.prefix === "string" ? search.prefix : undefined, - delimiter: search.delimiter === false ? false : true, - }), + errorComponent: ResourceError, loaderDeps: ({ search }) => ({ prefix: search.prefix, delimiter: search.delimiter, @@ -52,7 +56,17 @@ export const Route = createFileRoute("/r2/$bucketName/")({ per_page: 100, prefix: deps.prefix || undefined, }, + throwOnError: false, }); + if (response.response?.status === 404) { + throw notFound(); + } + + if (response.error) { + throw new Error( + `Failed to list objects in bucket "${params.bucketName}"` + ); + } return { objects: response.data?.result ?? [], @@ -62,6 +76,11 @@ export const Route = createFileRoute("/r2/$bucketName/")({ delimiterEnabled: deps.delimiter !== false, }; }, + notFoundComponent: NotFound, + validateSearch: (search: Record): R2BucketSearch => ({ + prefix: typeof search.prefix === "string" ? search.prefix : undefined, + delimiter: search.delimiter === false ? false : true, + }), }); function BucketView(): JSX.Element { diff --git a/packages/local-explorer-ui/src/routes/r2/$bucketName/object.$.tsx b/packages/local-explorer-ui/src/routes/r2/$bucketName/object.$.tsx index a2e37a5dd7..9cc96ddae7 100644 --- a/packages/local-explorer-ui/src/routes/r2/$bucketName/object.$.tsx +++ b/packages/local-explorer-ui/src/routes/r2/$bucketName/object.$.tsx @@ -1,16 +1,24 @@ import { Button, Dialog } from "@cloudflare/kumo"; import { DownloadIcon, TrashIcon } from "@phosphor-icons/react"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { + createFileRoute, + Link, + notFound, + useNavigate, +} from "@tanstack/react-router"; import { useState } from "react"; import { r2BucketDeleteObjects, r2BucketGetObject } from "../../../api"; import R2Icon from "../../../assets/icons/r2.svg?react"; import { Breadcrumbs } from "../../../components/Breadcrumbs"; import { CopyButton } from "../../../components/CopyButton"; +import { NotFound } from "../../../components/NotFound"; +import { ResourceError } from "../../../components/ResourceError"; import { formatDate, formatSize } from "../../../utils/format"; import type { R2HeadObjectResult } from "../../../api"; export const Route = createFileRoute("/r2/$bucketName/object/$")({ component: ObjectDetailView, + errorComponent: ResourceError, loader: async ({ params }) => { const objectKey = params._splat; if (!objectKey) { @@ -25,11 +33,19 @@ export const Route = createFileRoute("/r2/$bucketName/object/$")({ headers: { "cf-metadata-only": "true", }, + throwOnError: false, }); + if (response.response?.status === 404) { + throw notFound(); + } + + if (response.error) { + throw new Error(`Failed to fetch object "${objectKey}"`); + } const result = response.data?.result; if (!result) { - throw new Error(`Object "${objectKey}" not found`); + throw notFound(); } return { @@ -37,6 +53,7 @@ export const Route = createFileRoute("/r2/$bucketName/object/$")({ objectKey, }; }, + notFoundComponent: NotFound, }); interface ObjectDetailsCardProps { diff --git a/packages/local-explorer-ui/src/routes/workflows/$workflowName.tsx b/packages/local-explorer-ui/src/routes/workflows/$workflowName.tsx index f22d66f298..165a38aa93 100644 --- a/packages/local-explorer-ui/src/routes/workflows/$workflowName.tsx +++ b/packages/local-explorer-ui/src/routes/workflows/$workflowName.tsx @@ -1,7 +1,9 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { ResourceError } from "../../components/ResourceError"; export const Route = createFileRoute("/workflows/$workflowName")({ component: () => , + errorComponent: ResourceError, loader: async ({ params }) => { return { workflowName: params.workflowName, diff --git a/packages/local-explorer-ui/src/routes/workflows/$workflowName/$instanceId.tsx b/packages/local-explorer-ui/src/routes/workflows/$workflowName/$instanceId.tsx index 5ce42b9546..e734bab11e 100644 --- a/packages/local-explorer-ui/src/routes/workflows/$workflowName/$instanceId.tsx +++ b/packages/local-explorer-ui/src/routes/workflows/$workflowName/$instanceId.tsx @@ -20,7 +20,12 @@ import { StopIcon, TrashIcon, } from "@phosphor-icons/react"; -import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; +import { + createFileRoute, + Link, + notFound, + useNavigate, +} from "@tanstack/react-router"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { workflowsChangeInstanceStatus, @@ -30,6 +35,8 @@ import { } from "../../../api"; import WorkflowsIcon from "../../../assets/icons/workflows.svg?react"; import { Breadcrumbs } from "../../../components/Breadcrumbs"; +import { NotFound } from "../../../components/NotFound"; +import { ResourceError } from "../../../components/ResourceError"; import { CopyButton } from "../../../components/workflows/CopyButton"; import { formatDuration, @@ -50,21 +57,33 @@ import type { export const Route = createFileRoute("/workflows/$workflowName/$instanceId")({ component: InstanceDetailView, + errorComponent: ResourceError, loader: async ({ params }) => { const response = await workflowsGetInstanceDetails({ path: { instance_id: params.instanceId, workflow_name: params.workflowName, }, + throwOnError: false, }); + if (response.response?.status === 404) { + throw notFound(); + } + + if (response.error) { + throw new Error( + `Failed to fetch workflow instance "${params.instanceId}"` + ); + } const details = response.data?.result as InstanceDetails | undefined; if (!details) { - throw new Error(`Workflow instance "${params.instanceId}" not found.`); + throw notFound(); } return { details }; }, + notFoundComponent: NotFound, }); // --------------------------------------------------------------------------- diff --git a/packages/local-explorer-ui/src/routes/workflows/$workflowName/index.tsx b/packages/local-explorer-ui/src/routes/workflows/$workflowName/index.tsx index 870dae6b82..4df4f4946f 100644 --- a/packages/local-explorer-ui/src/routes/workflows/$workflowName/index.tsx +++ b/packages/local-explorer-ui/src/routes/workflows/$workflowName/index.tsx @@ -21,7 +21,7 @@ import { StopIcon, TrashIcon, } from "@phosphor-icons/react"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, notFound, useNavigate } from "@tanstack/react-router"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { workflowsChangeInstanceStatus, @@ -32,6 +32,8 @@ import { } from "../../../api"; import WorkflowsIcon from "../../../assets/icons/workflows.svg?react"; import { Breadcrumbs } from "../../../components/Breadcrumbs"; +import { NotFound } from "../../../components/NotFound"; +import { ResourceError } from "../../../components/ResourceError"; import { CreateWorkflowInstanceDialog } from "../../../components/workflows/CreateInstanceDialog"; import { timeAgo } from "../../../components/workflows/helpers"; import { WorkflowStatusBadge } from "../../../components/workflows/StatusBadge"; @@ -41,11 +43,22 @@ import type { Action } from "../../../components/workflows/types"; export const Route = createFileRoute("/workflows/$workflowName/")({ component: WorkflowInstancesView, + errorComponent: ResourceError, loader: async ({ params }) => { const response = await workflowsListInstances({ path: { workflow_name: params.workflowName }, query: { page: 1, per_page: 25 }, + throwOnError: false, }); + if (response.response?.status === 404) { + throw notFound(); + } + + if (response.error) { + throw new Error( + `Failed to list instances for workflow "${params.workflowName}"` + ); + } return { instances: response.data?.result ?? [], @@ -57,6 +70,7 @@ export const Route = createFileRoute("/workflows/$workflowName/")({ }, }; }, + notFoundComponent: NotFound, }); // ---------------------------------------------------------------------------