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]}
+
+ {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.
+
+ )}
diff --git a/app/routes/acls/overview.tsx b/app/routes/acls/overview.tsx
index 0579dd5..9f363ee 100644
--- a/app/routes/acls/overview.tsx
+++ b/app/routes/acls/overview.tsx
@@ -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 ;
}
diff --git a/app/routes/dns/overview.tsx b/app/routes/dns/overview.tsx
index 6d60501..8d4b361 100644
--- a/app/routes/dns/overview.tsx
+++ b/app/routes/dns/overview.tsx
@@ -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() {
);
}
+
+export function ErrorBoundary({ error }: { error: unknown }) {
+ return ;
+}
diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx
index 5024a58..01fc91e 100644
--- a/app/routes/machines/overview.tsx
+++ b/app/routes/machines/overview.tsx
@@ -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 ;
+}
diff --git a/app/routes/settings/overview.tsx b/app/routes/settings/overview.tsx
index 77901ff..56f7267 100644
--- a/app/routes/settings/overview.tsx
+++ b/app/routes/settings/overview.tsx
@@ -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
);
}
+
+export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
+ return ;
+}
diff --git a/app/routes/users/overview.tsx b/app/routes/users/overview.tsx
index 1053468..cb7388b 100644
--- a/app/routes/users/overview.tsx
+++ b/app/routes/users/overview.tsx
@@ -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 ;
+}