From 225a52ded6348d20c400736eccf6507a244db884 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 14 Mar 2026 13:52:08 -0400 Subject: [PATCH] feat: correctly use error boundaries per-page --- app/components/page-error.tsx | 43 +++++++++++++++++ app/components/status-banner.tsx | 80 ++++++++++++++++++++++++++++++++ app/layout/app.tsx | 12 +++++ app/routes/acls/overview.tsx | 3 +- app/routes/dns/overview.tsx | 5 ++ app/routes/machines/overview.tsx | 5 ++ app/routes/settings/overview.tsx | 5 ++ app/routes/users/overview.tsx | 5 ++ 8 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 app/components/page-error.tsx create mode 100644 app/components/status-banner.tsx diff --git a/app/components/page-error.tsx b/app/components/page-error.tsx new file mode 100644 index 0000000..315b9c6 --- /dev/null +++ b/app/components/page-error.tsx @@ -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 ( +
+ +

{page} Unavailable

+

+ This page could not be loaded because the Headscale server is unreachable. It will be + available once the connection is restored. +

+ +
+ ); + } + + return ; +} diff --git a/app/components/status-banner.tsx b/app/components/status-banner.tsx new file mode 100644 index 0000000..df65bc7 --- /dev/null +++ b/app/components/status-banner.tsx @@ -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 = { + info: , + warning: , + error: , + critical: , +}; + +export default function StatusBanner({ + variant, + title, + children, + dismissable = true, + className, +}: StatusBannerProps) { + const [dismissed, setDismissed] = useState(false); + + if (dismissed) { + return null; + } + + return ( +
+ {icons[variant]} +
+

{title}

+
{children}
+
+ {dismissable && ( + + )} +
+ ); +} diff --git a/app/layout/app.tsx b/app/layout/app.tsx index 06f8b0f..5b49b25 100644 --- a/app/layout/app.tsx +++ b/app/layout/app.tsx @@ -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} />
+ {!loaderData.isHealthy && ( + + Unable to connect to the Headscale server. Data shown may be stale and changes cannot be + saved until the connection is restored. + + )}