mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
0512565f8e
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.
455 lines
16 KiB
TypeScript
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" />;
|
|
}
|