feat: correctly use error boundaries per-page

This commit is contained in:
Aarnav Tale
2026-03-14 13:52:08 -04:00
parent 38cf93e5ae
commit 225a52ded6
8 changed files with 157 additions and 1 deletions
+43
View File
@@ -0,0 +1,43 @@
import { RefreshCw, ServerOff } from "lucide-react";
import { isRouteErrorResponse, useRevalidator } from "react-router";
import { isConnectionError } from "~/server/headscale/api/error-client";
import cn from "~/utils/cn";
import Button from "./Button";
import { ErrorBanner } from "./error-banner";
interface PageErrorProps {
error: unknown;
page: string;
}
export default function PageError({ error, page }: PageErrorProps) {
const { revalidate, state } = useRevalidator();
if (isRouteErrorResponse(error) && isConnectionError(error.data)) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<ServerOff className={cn("h-12 w-12", "text-mist-400 dark:text-mist-500")} />
<h2 className="mt-4 text-lg font-semibold">{page} Unavailable</h2>
<p className="mt-1 max-w-sm text-sm text-mist-500 dark:text-mist-400">
This page could not be loaded because the Headscale server is unreachable. It will be
available once the connection is restored.
</p>
<Button
className="mt-6"
variant="light"
onPress={() => revalidate()}
isDisabled={state === "loading"}
>
<RefreshCw
className={cn("mr-2 inline-block h-4 w-4", state === "loading" && "animate-spin")}
/>
Retry
</Button>
</div>
);
}
return <ErrorBanner className="max-w-2xl" error={error} />;
}
+80
View File
@@ -0,0 +1,80 @@
import { AlertCircle, CloudOff, Info, TriangleAlert, X } from "lucide-react";
import { useState } from "react";
import cn from "~/utils/cn";
type Variant = "info" | "warning" | "error" | "critical";
interface StatusBannerProps {
variant: Variant;
title: string;
children: React.ReactNode;
dismissable?: boolean;
className?: string;
}
const icons: Record<Variant, React.ReactNode> = {
info: <Info className="h-5 w-5 shrink-0" />,
warning: <TriangleAlert className="h-5 w-5 shrink-0" />,
error: <AlertCircle className="h-5 w-5 shrink-0" />,
critical: <CloudOff className="h-5 w-5 shrink-0" />,
};
export default function StatusBanner({
variant,
title,
children,
dismissable = true,
className,
}: StatusBannerProps) {
const [dismissed, setDismissed] = useState(false);
if (dismissed) {
return null;
}
return (
<div
role="alert"
className={cn(
"flex items-start gap-3 rounded-lg border px-4 py-3 text-sm",
variant === "info" && [
"border-indigo-200 bg-indigo-50 text-indigo-900",
"dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200",
],
variant === "warning" && [
"border-yellow-300 bg-yellow-50 text-yellow-900",
"dark:border-yellow-500/30 dark:bg-yellow-500/10 dark:text-yellow-200",
],
variant === "error" && [
"border-red-300 bg-red-50 text-red-900",
"dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200",
],
variant === "critical" && [
"border-red-400 bg-red-100 text-red-900",
"dark:border-red-500/40 dark:bg-red-500/20 dark:text-red-100",
],
className,
)}
>
{icons[variant]}
<div className="flex-1">
<p className="font-medium">{title}</p>
<div className="mt-0.5 text-current/80">{children}</div>
</div>
{dismissable && (
<button
type="button"
aria-label="Dismiss"
onClick={() => setDismissed(true)}
className={cn(
"shrink-0 rounded p-0.5 transition-colors",
"hover:bg-black/10 dark:hover:bg-white/10",
)}
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
}
+12
View File
@@ -1,6 +1,7 @@
import { Outlet, redirect } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import StatusBanner from "~/components/status-banner";
import { pruneEphemeralNodes } from "~/server/db/pruner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
@@ -94,6 +95,17 @@ export default function AppLayout({ loaderData }: Route.ComponentProps) {
user={loaderData.user}
/>
<main className="container mt-4 mb-24 overscroll-contain">
{!loaderData.isHealthy && (
<StatusBanner
className="mb-4"
dismissable={false}
title="Headscale Unreachable"
variant="critical"
>
Unable to connect to the Headscale server. Data shown may be stale and changes cannot be
saved until the connection is restored.
</StatusBanner>
)}
<Outlet />
</main>
<Footer isDebug={loaderData.isDebug} baseUrl={loaderData.baseUrl} />
+2 -1
View File
@@ -7,6 +7,7 @@ import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/link";
import Notice from "~/components/Notice";
import PageError from "~/components/page-error";
import Tabs from "~/components/Tabs";
import { isApiError } from "~/server/headscale/api/error-client";
import toast from "~/utils/toast";
@@ -185,5 +186,5 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
);
}
throw error;
return <PageError error={error} page="Access Control" />;
}
+5
View File
@@ -3,6 +3,7 @@ import { useLoaderData } from "react-router";
import Code from "~/components/Code";
import Notice from "~/components/Notice";
import PageError from "~/components/page-error";
import type { LoadContext } from "~/server";
import { Capabilities } from "~/server/web/roles";
@@ -101,3 +102,7 @@ export default function Page() {
</div>
);
}
export function ErrorBoundary({ error }: { error: unknown }) {
return <PageError error={error} page="DNS" />;
}
+5
View File
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import Code from "~/components/Code";
import Input from "~/components/Input";
import Link from "~/components/link";
import PageError from "~/components/page-error";
import Tooltip from "~/components/Tooltip";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
@@ -381,3 +382,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return <PageError error={error} page="Machines" />;
}
+5
View File
@@ -1,6 +1,7 @@
import { ArrowRight } from "lucide-react";
import Link from "~/components/link";
import PageError from "~/components/page-error";
import type { Route } from "./+types/overview";
@@ -64,3 +65,7 @@ export default function Page({ loaderData: { config, isOidcEnabled } }: Route.Co
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return <PageError error={error} page="Settings" />;
}
+5
View File
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
import { eq } from "drizzle-orm";
import { useEffect, useState } from "react";
import PageError from "~/components/page-error";
import { users as usersTable } from "~/server/db/schema";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
@@ -168,3 +169,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return <PageError error={error} page="Users" />;
}