Files
headplane/app/routes/machines/overview.tsx
T
Aarnav Tale 0512565f8e feat: replace openapi hashing system with /version
Apparently I didn't use my brain cells and rely on the /version
endpoint that Headscale has exposed since 0.26 (our lowest supported
version). Switching to that significantly simplifies the API surface.
2026-05-25 11:51:02 -04:00

455 lines
16 KiB
TypeScript

import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
import { useMemo, useState } from "react";
import { useSearchParams } from "react-router";
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 { nodesResource, usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortNodeTags, type PopulatedNode } from "~/utils/node-info";
import type { Route } from "./+types/overview";
import { MachineFilters } from "./components/machine-filters";
import MachineRow from "./components/machine-row";
import NewMachine from "./dialogs/new";
import { useMachineFilterParams } from "./hooks/use-machine-filter-params";
import { machineAction } from "./machine-actions";
export async function loader({ request, context }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
if (!context.auth.can(principal, Capabilities.read_machines)) {
throw new Error(
"You do not have permission to view this page. Please contact your administrator.",
);
}
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
const { api } = await context.apiForRequest(request);
const [nodesSnap, usersSnap] = await Promise.all([
context.hsLive.get(nodesResource, api),
context.hsLive.get(usersResource, api),
]);
const nodes = nodesSnap.data;
const users = usersSnap.data;
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
const stats = await agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
const agentSync = agents?.lastSync();
return {
agent: agentSync
? {
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
nodeCount: agentSync.nodeCount,
nodeKey: agents?.agentNodeKey(),
}
: undefined,
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
magic,
nodes,
populatedNodes,
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
publicServer: context.config.headscale.public_url,
server: context.config.headscale.url,
supportsNodeOwnerChange: supportsNodeOwnerChange,
users,
writable: writablePermission,
};
}
export const action = machineAction;
type SortField = "name" | "ip" | "version" | "lastSeen";
const STATUS_MATCH: Record<string, (n: PopulatedNode) => boolean> = {
online: (n) => n.online && !n.expired,
offline: (n) => !n.online && !n.expired,
expired: (n) => n.expired,
};
const ROUTE_MATCH: Record<string, (n: PopulatedNode) => boolean> = {
"exit-node": (n) => n.customRouting.exitRoutes.length > 0,
subnet: (n) =>
n.customRouting.subnetApprovedRoutes.length > 0 ||
n.customRouting.subnetWaitingRoutes.length > 0,
};
export default function Page({ loaderData }: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams();
const [sortField, setSortField] = useState<SortField>("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const searchQuery = searchParams.get("q") ?? "";
const { filterUser, filterTag, filterStatus, filterRoute, hasActiveFilters } =
useMachineFilterParams();
const setSearchQuery = (value: string) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
const v = value.slice(0, 100);
if (v) next.set("q", v);
else next.delete("q");
return next;
});
};
const clearSearch = () => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("q");
return next;
});
};
const filteredAndSortedNodes = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
let nodes = loaderData.populatedNodes.filter(
(node) =>
(!query ||
node.givenName.toLowerCase().includes(query) ||
node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) &&
(filterUser === null ||
(filterUser === "tag-owned" ? !node.user : node.user?.name === filterUser)) &&
(filterTag === null || (node.tags?.includes(filterTag) ?? false)) &&
(filterStatus === null || STATUS_MATCH[filterStatus](node)) &&
(filterRoute === null || ROUTE_MATCH[filterRoute](node)),
);
nodes = [...nodes].toSorted((a, b) => {
let comparison = 0;
switch (sortField) {
case "name": {
comparison = a.givenName.localeCompare(b.givenName);
break;
}
case "ip": {
const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(":")) || addresses[0] || "";
const ipA = getIPv4(a.ipAddresses);
const ipB = getIPv4(b.ipAddresses);
if (!ipA.includes(":") && !ipB.includes(":")) {
const octetsA = ipA.split(".").map(Number);
const octetsB = ipB.split(".").map(Number);
for (let i = 0; i < 4; i++) {
if (octetsA[i] !== octetsB[i]) {
comparison = octetsA[i] - octetsB[i];
break;
}
}
} else {
comparison = ipA.localeCompare(ipB);
}
break;
}
case "version": {
const versionA = a.hostInfo?.IPNVersion?.split("-")[0] || "0";
const versionB = b.hostInfo?.IPNVersion?.split("-")[0] || "0";
const partsA = versionA.split(".").map(Number);
const partsB = versionB.split(".").map(Number);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const segA = partsA[i] || 0;
const segB = partsB[i] || 0;
if (segA !== segB) {
comparison = segA - segB;
break;
}
}
break;
}
case "lastSeen": {
if (a.online !== b.online) {
comparison = a.online ? 1 : -1;
break;
}
comparison = new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
break;
}
}
return sortDirection === "asc" ? comparison : -comparison;
});
return nodes;
}, [
loaderData.populatedNodes,
searchQuery,
filterUser,
filterTag,
filterStatus,
filterRoute,
sortField,
sortDirection,
]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDirection("asc");
}
};
return (
<>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col">
<h1 className="mb-2 text-2xl font-medium">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{" "}
<Link external styled to="https://tailscale.com/kb/1372/manage-devices">
Learn more
</Link>
</p>
</div>
<NewMachine
disabledKeys={loaderData.preAuth ? [] : ["pre-auth"]}
isDisabled={!loaderData.writable}
server={loaderData.publicServer ?? loaderData.server}
users={loaderData.users}
/>
</div>
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="relative w-64">
<Input
label="Search machines"
labelHidden
maxLength={100}
onChange={setSearchQuery}
placeholder="Search by name or IP address..."
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2",
"p-1 rounded-full",
"text-mist-400 hover:text-mist-600",
"dark:text-mist-500 dark:hover:text-mist-300",
"hover:bg-mist-100 dark:hover:bg-mist-800",
)}
onClick={clearSearch}
type="button"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<MachineFilters users={loaderData.users} populatedNodes={loaderData.populatedNodes} />
<span className="ml-auto text-sm whitespace-nowrap text-mist-500">
{searchQuery || hasActiveFilters
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
: `${loaderData.populatedNodes.length} machines`}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-160 table-auto rounded-lg">
<thead className="text-mist-600 dark:text-mist-300">
<tr className="px-0.5 text-left">
<th
aria-sort={
sortField === "name"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by name"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-mist-900 dark:hover:text-mist-100",
)}
onClick={() => handleSort("name")}
type="button"
>
Name
{sortField === "name" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
<th
aria-sort={
sortField === "ip"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="w-1/4 pb-2"
>
<div className="flex items-center gap-x-1">
<button
aria-label="Sort by IP address"
className={cn(
"flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold",
"hover:text-mist-900 dark:hover:text-mist-100",
)}
onClick={() => handleSort("ip")}
type="button"
>
Addresses
{sortField === "ip" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
{loaderData.magic ? (
<Tooltip
content={
<span className="font-normal">
Since MagicDNS is enabled, you can access devices based on their name and
also at{" "}
<Code>
[name].
{loaderData.magic}
</Code>
</span>
}
>
<Info className="h-4 w-4" />
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th
aria-sort={
sortField === "version"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by version"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-mist-900 dark:hover:text-mist-100",
)}
onClick={() => handleSort("version")}
type="button"
>
Version
{sortField === "version" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
) : undefined}
<th
aria-sort={
sortField === "lastSeen"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by last seen"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-mist-900 dark:hover:text-mist-100",
)}
onClick={() => handleSort("lastSeen")}
type="button"
>
Last Seen
{sortField === "lastSeen" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
<th className="w-12 pb-2">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody
className={cn(
"divide-y divide-mist-100 dark:divide-mist-800 align-top",
"border-t border-mist-100 dark:border-mist-800",
)}
>
{filteredAndSortedNodes.length === 0 ? (
<tr>
<td
className="py-8 text-center text-mist-500"
colSpan={loaderData.agent !== undefined ? 6 : 5}
>
No machines match the current filters
</td>
</tr>
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={
loaderData.agent !== undefined
? node.nodeKey === loaderData.agent.nodeKey
: undefined
}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user?.id !== loaderData.headscaleUserId
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
/>
))
)}
</tbody>
</table>
</div>
</>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return <PageError error={error} page="Machines" />;
}