feat: add machine filtering functionality with dropdowns for user, tag, status, and route

This commit is contained in:
Siemen Van den Neste
2026-03-21 01:43:54 +01:00
parent dfda629b12
commit 3ec00e17d8
2 changed files with 249 additions and 20 deletions
@@ -0,0 +1,177 @@
import { ChevronDown, X } from "lucide-react";
import type { JSX } from "react";
import { useSearchParams } from "react-router";
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
import type { User } from "~/types/User";
import cn from "~/utils/cn";
import type { PopulatedNode } from "~/utils/node-info";
import { getUserDisplayName } from "~/utils/user";
const STATUS_OPTIONS = [
{ value: "online", label: "Online" },
{ value: "offline", label: "Offline" },
{ value: "expired", label: "Expired" },
] as const;
const ROUTE_OPTIONS = [
{ value: "exit-node", label: "Exit node" },
{ value: "subnet", label: "Subnet router" },
] as const;
function FilterDropdown({
label,
value,
options,
onChange,
}: {
label: string;
value: string | null;
options: readonly { value: string; label: string }[];
onChange: (value: string | null) => void;
}): JSX.Element {
const activeOption = value !== null ? options.find((o) => o.value === value) : null;
const isActive = activeOption !== null;
return (
<Menu>
<MenuTrigger
className={cn(
"px-3 py-1.5 rounded-full text-sm font-medium",
"border transition-colors",
"flex items-center gap-1.5",
isActive
? "border-indigo-300 bg-indigo-50 text-indigo-700 dark:border-indigo-700 dark:bg-indigo-950/50 dark:text-indigo-300"
: "border-mist-200 dark:border-mist-700 text-mist-700 dark:text-mist-300 hover:border-mist-300 dark:hover:border-mist-600",
)}
>
{isActive ? activeOption!.label : label}
<ChevronDown className="h-3.5 w-3.5" />
</MenuTrigger>
<MenuContent>
{options.map((option) => (
<MenuItem
key={option.value}
onClick={() => onChange(value === option.value ? null : option.value)}
>
{option.value === value ? (
<span className="font-medium text-indigo-600 dark:text-indigo-400">
{option.label}
</span>
) : (
option.label
)}
</MenuItem>
))}
{isActive && (
<>
<MenuSeparator />
<MenuItem onClick={() => onChange(null)}>Clear filter</MenuItem>
</>
)}
</MenuContent>
</Menu>
);
}
interface MachineFiltersProps {
users: User[];
populatedNodes: PopulatedNode[];
}
/**
* Renders the filter dropdowns (User, Tag, Status, Route) and a "Clear filters"
* button as a React Fragment so they slot directly into the parent flex row.
* Filter state is stored in URL search params for bookmarkability.
*/
export function MachineFilters({ users, populatedNodes }: MachineFiltersProps): JSX.Element {
const [searchParams, setSearchParams] = useSearchParams();
const filterUser = searchParams.get("user");
const filterTag = searchParams.get("tag");
const filterStatus = searchParams.get("status");
const filterRoute = searchParams.get("route");
const hasActiveFilters =
filterUser !== null || filterTag !== null || filterStatus !== null || filterRoute !== null;
const setParam = (key: string, value: string | null) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (value === null) {
next.delete(key);
} else {
next.set(key, value);
}
return next;
});
};
const clearFilters = () => {
setSearchParams((prev) => {
const next = new URLSearchParams();
const q = prev.get("q");
if (q) next.set("q", q);
return next;
});
};
const tagOwnedExists = populatedNodes.some((n) => !n.user);
const userOptions = [
...(tagOwnedExists ? [{ value: "tag-owned", label: "Tag-owned" }] : []),
...users.map((u) => ({ value: u.id, label: getUserDisplayName(u) })),
];
const tagOptions = Array.from(new Set(populatedNodes.flatMap((n) => n.tags)))
.filter(Boolean)
.sort()
.map((tag) => ({ value: tag, label: tag }));
return (
<>
{userOptions.length > 0 && (
<FilterDropdown
label="User"
onChange={(v) => setParam("user", v)}
options={userOptions}
value={filterUser}
/>
)}
{tagOptions.length > 0 && (
<FilterDropdown
label="Tag"
onChange={(v) => setParam("tag", v)}
options={tagOptions}
value={filterTag}
/>
)}
<FilterDropdown
label="Status"
onChange={(v) => setParam("status", v)}
options={STATUS_OPTIONS}
value={filterStatus}
/>
<FilterDropdown
label="Route"
onChange={(v) => setParam("route", v)}
options={ROUTE_OPTIONS}
value={filterRoute}
/>
{hasActiveFilters && (
<button
className={cn(
"flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium",
"border border-mist-200 dark:border-mist-700",
"text-mist-600 dark:text-mist-400",
"hover:border-mist-300 dark:hover:border-mist-600",
)}
onClick={clearFilters}
type="button"
>
Clear filters
<X className="h-3.5 w-3.5" />
</button>
)}
</>
);
}
+72 -20
View File
@@ -1,5 +1,6 @@
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";
@@ -10,8 +11,10 @@ import { nodesResource, usersResource } from "~/server/headscale/live-store";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
import 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 { machineAction } from "./machine-actions";
@@ -67,26 +70,65 @@ 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 [searchQuery, setSearchQuery] = useState("");
const [searchParams, setSearchParams] = useSearchParams();
const [sortField, setSortField] = useState<SortField>("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const searchQuery = searchParams.get("q") ?? "";
const filterUser = searchParams.get("user");
const filterTag = searchParams.get("tag");
const filterStatus = searchParams.get("status") as "online" | "offline" | "expired" | null;
const filterRoute = searchParams.get("route") as "exit-node" | "subnet" | null;
const hasActiveFilters =
filterUser !== null || filterTag !== null || filterStatus !== null || filterRoute !== null;
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) => {
if (!query) {
return true;
}
if (node.givenName.toLowerCase().includes(query)) {
return true;
}
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) {
return true;
}
return false;
});
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?.id === 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;
@@ -147,7 +189,16 @@ export default function Page({ loaderData }: Route.ComponentProps) {
});
return nodes;
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
}, [
loaderData.populatedNodes,
searchQuery,
filterUser,
filterTag,
filterStatus,
filterRoute,
sortField,
sortDirection,
]);
const handleSort = (field: SortField) => {
if (sortField === field) {
@@ -177,13 +228,13 @@ export default function Page({ loaderData }: Route.ComponentProps) {
users={loaderData.users}
/>
</div>
<div className="mb-4 flex items-center gap-4">
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="relative w-64">
<Input
label="Search machines"
labelHidden
maxLength={100}
onChange={(value) => setSearchQuery(value.slice(0, 100))}
onChange={setSearchQuery}
placeholder="Search by name or IP address..."
value={searchQuery}
/>
@@ -197,15 +248,16 @@ export default function Page({ loaderData }: Route.ComponentProps) {
"dark:text-mist-500 dark:hover:text-mist-300",
"hover:bg-mist-100 dark:hover:bg-mist-800",
)}
onClick={() => setSearchQuery("")}
onClick={clearSearch}
type="button"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<span className="text-sm whitespace-nowrap text-mist-500">
{searchQuery
<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>
@@ -361,7 +413,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
className="py-8 text-center text-mist-500"
colSpan={loaderData.agent !== undefined ? 6 : 5}
>
No machines found matching "{searchQuery}"
No machines match the current filters
</td>
</tr>
) : (