mirror of
https://github.com/tale/headplane.git
synced 2026-08-27 15:37:04 +00:00
feat: correctly use error boundaries per-page
This commit is contained in:
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Outlet, redirect } from "react-router";
|
import { Outlet, redirect } from "react-router";
|
||||||
|
|
||||||
import { ErrorBanner } from "~/components/error-banner";
|
import { ErrorBanner } from "~/components/error-banner";
|
||||||
|
import StatusBanner from "~/components/status-banner";
|
||||||
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
||||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
@@ -94,6 +95,17 @@ export default function AppLayout({ loaderData }: Route.ComponentProps) {
|
|||||||
user={loaderData.user}
|
user={loaderData.user}
|
||||||
/>
|
/>
|
||||||
<main className="container mt-4 mb-24 overscroll-contain">
|
<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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<Footer isDebug={loaderData.isDebug} baseUrl={loaderData.baseUrl} />
|
<Footer isDebug={loaderData.isDebug} baseUrl={loaderData.baseUrl} />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Card from "~/components/Card";
|
|||||||
import Code from "~/components/Code";
|
import Code from "~/components/Code";
|
||||||
import Link from "~/components/link";
|
import Link from "~/components/link";
|
||||||
import Notice from "~/components/Notice";
|
import Notice from "~/components/Notice";
|
||||||
|
import PageError from "~/components/page-error";
|
||||||
import Tabs from "~/components/Tabs";
|
import Tabs from "~/components/Tabs";
|
||||||
import { isApiError } from "~/server/headscale/api/error-client";
|
import { isApiError } from "~/server/headscale/api/error-client";
|
||||||
import toast from "~/utils/toast";
|
import toast from "~/utils/toast";
|
||||||
@@ -185,5 +186,5 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
return <PageError error={error} page="Access Control" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLoaderData } from "react-router";
|
|||||||
|
|
||||||
import Code from "~/components/Code";
|
import Code from "~/components/Code";
|
||||||
import Notice from "~/components/Notice";
|
import Notice from "~/components/Notice";
|
||||||
|
import PageError from "~/components/page-error";
|
||||||
import type { LoadContext } from "~/server";
|
import type { LoadContext } from "~/server";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
|
|
||||||
@@ -101,3 +102,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary({ error }: { error: unknown }) {
|
||||||
|
return <PageError error={error} page="DNS" />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
|||||||
import Code from "~/components/Code";
|
import Code from "~/components/Code";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import Link from "~/components/link";
|
import Link from "~/components/link";
|
||||||
|
import PageError from "~/components/page-error";
|
||||||
import Tooltip from "~/components/Tooltip";
|
import Tooltip from "~/components/Tooltip";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import cn from "~/utils/cn";
|
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" />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
|
|
||||||
import Link from "~/components/link";
|
import Link from "~/components/link";
|
||||||
|
import PageError from "~/components/page-error";
|
||||||
|
|
||||||
import type { Route } from "./+types/overview";
|
import type { Route } from "./+types/overview";
|
||||||
|
|
||||||
@@ -64,3 +65,7 @@ export default function Page({ loaderData: { config, isOidcEnabled } }: Route.Co
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
return <PageError error={error} page="Settings" />;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import PageError from "~/components/page-error";
|
||||||
import { users as usersTable } from "~/server/db/schema";
|
import { users as usersTable } from "~/server/db/schema";
|
||||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
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" />;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user