feat: refactor several components and clean up ui

This commit is contained in:
Aarnav Tale
2026-03-09 22:09:58 -04:00
parent a53fd31d27
commit 6470f5a821
49 changed files with 1522 additions and 1355 deletions
+106
View File
@@ -0,0 +1,106 @@
import { Outlet, redirect } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import { pruneEphemeralNodes } from "~/server/db/pruner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
import log from "~/utils/log";
import type { Route } from "./+types/app";
import Footer from "./footer";
import Header from "./header";
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
try {
const principal = await context.auth.require(request);
if (
typeof context.oidc === "object" &&
principal.kind === "oidc" &&
!principal.user.onboarded &&
!request.url.endsWith("/onboarding")
) {
return redirect("/onboarding");
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const user =
principal.kind === "oidc"
? {
email: principal.profile.email,
name: principal.profile.name,
picture: principal.profile.picture,
subject: principal.user.subject,
username: principal.profile.username,
}
: { name: principal.displayName, subject: "api_key" };
// MARK: The session should stay valid if Headscale isn't healthy
const isHealthy = await api.isHealthy();
if (isHealthy) {
try {
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
log.warn("auth", "Logging out %s due to expired API key", displayName);
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
}
}
return {
access: {
dns: context.auth.can(principal, Capabilities.read_network),
machines: context.auth.can(principal, Capabilities.read_machines),
policy: context.auth.can(principal, Capabilities.read_policy),
settings: context.auth.can(principal, Capabilities.read_feature),
ui: context.auth.can(principal, Capabilities.ui_access),
users: context.auth.can(principal, Capabilities.read_users),
},
baseUrl: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
isDebug: context.config.debug,
isHealthy,
user,
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
}
export default function AppLayout({ loaderData }: Route.ComponentProps) {
return (
<>
<Header
access={loaderData.access}
configAvailable={loaderData.configAvailable}
user={loaderData.user}
/>
<main className="container mt-4 mb-24 overscroll-contain">
<Outlet />
</main>
<Footer isDebug={loaderData.isDebug} baseUrl={loaderData.baseUrl} />
</>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="mx-auto my-24 w-fit overscroll-contain">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { Eye, EyeOff } from "lucide-react";
import { useState } from "react";
import Link from "~/components/link";
import cn from "~/utils/cn";
export interface FooterProps {
isDebug: boolean;
baseUrl: string;
}
export default function Footer({ isDebug, baseUrl }: FooterProps) {
const [urlVisible, setUrlVisible] = useState(false);
return (
<footer
className={cn(
"fixed w-full bottom-0 left-0 z-50",
"bg-mist-50 dark:bg-mist-950",
"dark:border-t dark:border-mist-800",
)}
>
<div className="container flex items-center justify-between py-2">
<p className="text-xs">
Headplane is free and open-source. Please consider{" "}
<Link external styled to="https://tale.me/sponsor">
sponsoring
</Link>{" "}
to support development.
</p>
<div className="flex items-center gap-2 text-xs">
{isDebug && (
<span
className={cn(
"rounded-full px-2 py-0.5 font-medium",
"bg-amber-100 text-amber-800",
"dark:bg-amber-900/50 dark:text-amber-300",
)}
>
Debug
</span>
)}
<p className="text-mist-500 dark:text-mist-400">
{__VERSION__} &middot;{" "}
{urlVisible ? (
<code>{baseUrl}</code>
) : (
<span aria-hidden="true">&bull;&bull;&bull;&bull;&bull;</span>
)}
<button
type="button"
aria-label={urlVisible ? "Hide server URL" : "Show server URL"}
className={cn(
"ml-1 inline-flex align-middle rounded-xs p-0.5",
"text-mist-400 hover:text-mist-600",
"dark:text-mist-500 dark:hover:text-mist-300",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
)}
onClick={() => setUrlVisible((v) => !v)}
>
{urlVisible ? <EyeOff size={12} /> : <Eye size={12} />}
</button>
</p>
</div>
</div>
</footer>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { CircleQuestionMark, CircleUser, Globe, Lock, Server, Settings, Users } from "lucide-react";
import { NavLink, useLocation, useSubmit } from "react-router";
import Link from "~/components/link";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import logoBg from "~/logo/dark-bg.svg";
import logoDark from "~/logo/dark.svg";
import logoLight from "~/logo/light.svg";
import cn from "~/utils/cn";
export interface HeaderProps {
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
configAvailable: boolean;
}
const tabs = [
{ to: "/machines", icon: Server, label: "Machines", key: "machines" },
{ to: "/users", icon: Users, label: "Users", key: "users" },
{ to: "/acls", icon: Lock, label: "Access Control", key: "policy" },
{ to: "/dns", icon: Globe, label: "DNS", key: "dns" },
{ to: "/settings", icon: Settings, label: "Settings", key: "settings" },
] as const;
export default function Header({ user, access, configAvailable }: HeaderProps) {
const submit = useSubmit();
const location = useLocation();
const isOnboarding = location.pathname.startsWith("/onboarding");
const showTabs = access.ui && !isOnboarding;
return (
<header
className={cn(
"bg-mist-200 dark:bg-mist-950 text-mist-800 dark:text-mist-200",
"dark:border-b dark:border-mist-800 shadow-inner",
)}
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-8">
<div className="flex items-center gap-x-2">
<picture>
<source srcSet={logoLight} media="(prefers-color-scheme: dark)" />
<source srcSet={logoDark} media="(prefers-color-scheme: light)" />
<img src={logoBg} alt="Headplane logo" />
</picture>
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
{showTabs && (
<nav className="hidden items-center gap-x-2 text-sm font-medium md:flex">
{tabs.map((tab) => {
if (!access[tab.key]) return null;
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
return (
<NavLink
key={tab.to}
className={({ isActive }) =>
cn(
"px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
isActive
? "bg-mist-300/70 dark:bg-mist-800 text-mist-900 dark:text-mist-50"
: "text-mist-600 dark:text-mist-300",
)
}
prefetch="intent"
to={tab.to}
>
<tab.icon className="w-4" />
{tab.label}
</NavLink>
);
})}
</nav>
)}
</div>
<div className="grid grid-cols-2 gap-x-4">
<Menu>
<MenuTrigger className="size-8 rounded-full p-1">
<CircleQuestionMark className="w-5" />
</MenuTrigger>
<MenuContent align="end">
<MenuItem>
<Link external to="https://headplane.net">
Docs
</Link>
</MenuItem>
<MenuItem>
<Link external to="https://headscale.net">
Headscale
</Link>
</MenuItem>
<MenuItem>
<Link external to="https://tailscale.com/download">
Download
</Link>
</MenuItem>
</MenuContent>
</Menu>
<Menu>
<MenuTrigger className="size-8 overflow-hidden rounded-full">
{user.picture ? (
<img alt={user.name} className="size-8" src={user.picture} />
) : (
<CircleUser className="size-8" />
)}
</MenuTrigger>
<MenuContent align="end">
<MenuItem disabled>
<div className="text-mist-900 dark:text-mist-50">
{user.subject === "api_key" ? (
<>
<p className="font-bold">API Key</p>
<p>{user.name}</p>
</>
) : (
<>
<p className="font-bold">{user.name}</p>
{user.email && <p>{user.email}</p>}
</>
)}
</div>
</MenuItem>
<MenuSeparator />
<MenuItem
variant="danger"
onClick={() => submit({}, { action: "/logout", method: "POST" })}
>
Logout
</MenuItem>
</MenuContent>
</Menu>
</div>
</div>
</header>
);
}