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,
});
// ---------------------------------------------------------------------------