From 3ec00e17d829e7eef95a6b97973d6d1c3dd88383 Mon Sep 17 00:00:00 2001 From: Siemen Van den Neste Date: Sat, 21 Mar 2026 01:43:54 +0100 Subject: [PATCH 1/2] feat: add machine filtering functionality with dropdowns for user, tag, status, and route --- .../machines/components/machine-filters.tsx | 177 ++++++++++++++++++ app/routes/machines/overview.tsx | 92 +++++++-- 2 files changed, 249 insertions(+), 20 deletions(-) create mode 100644 app/routes/machines/components/machine-filters.tsx diff --git a/app/routes/machines/components/machine-filters.tsx b/app/routes/machines/components/machine-filters.tsx new file mode 100644 index 00000000..b008a930 --- /dev/null +++ b/app/routes/machines/components/machine-filters.tsx @@ -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 ( + + + {isActive ? activeOption!.label : label} + + + + {options.map((option) => ( + onChange(value === option.value ? null : option.value)} + > + {option.value === value ? ( + + {option.label} + + ) : ( + option.label + )} + + ))} + {isActive && ( + <> + + onChange(null)}>Clear filter + + )} + + + ); +} + +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 && ( + setParam("user", v)} + options={userOptions} + value={filterUser} + /> + )} + {tagOptions.length > 0 && ( + setParam("tag", v)} + options={tagOptions} + value={filterTag} + /> + )} + setParam("status", v)} + options={STATUS_OPTIONS} + value={filterStatus} + /> + setParam("route", v)} + options={ROUTE_OPTIONS} + value={filterRoute} + /> + {hasActiveFilters && ( + + )} + + ); +} diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index fc782bd6..35378ac9 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -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 boolean> = { + online: (n) => n.online && !n.expired, + offline: (n) => !n.online && !n.expired, + expired: (n) => n.expired, +}; + +const ROUTE_MATCH: Record 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("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} /> -
+
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" > )}
- - {searchQuery + + + {searchQuery || hasActiveFilters ? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines` : `${loaderData.populatedNodes.length} machines`} @@ -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 ) : ( From 4e94e82a01620ed6c47adcbcf3d9ff8a69ece2a6 Mon Sep 17 00:00:00 2001 From: Siemen Van den Neste Date: Sat, 21 Mar 2026 01:46:27 +0100 Subject: [PATCH 2/2] feat: implement custom hook for machine filter parameters management --- .../machines/components/machine-filters.tsx | 51 +++++-------------- .../hooks/use-machine-filter-params.ts | 51 +++++++++++++++++++ app/routes/machines/overview.tsx | 13 ++--- 3 files changed, 68 insertions(+), 47 deletions(-) create mode 100644 app/routes/machines/hooks/use-machine-filter-params.ts diff --git a/app/routes/machines/components/machine-filters.tsx b/app/routes/machines/components/machine-filters.tsx index b008a930..f88b84a3 100644 --- a/app/routes/machines/components/machine-filters.tsx +++ b/app/routes/machines/components/machine-filters.tsx @@ -1,6 +1,5 @@ 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"; @@ -8,6 +7,8 @@ import cn from "~/utils/cn"; import type { PopulatedNode } from "~/utils/node-info"; import { getUserDisplayName } from "~/utils/user"; +import { useMachineFilterParams } from "../hooks/use-machine-filter-params"; + const STATUS_OPTIONS = [ { value: "online", label: "Online" }, { value: "offline", label: "Offline" }, @@ -30,7 +31,7 @@ function FilterDropdown({ 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 activeOption = options.find((o) => o.value === value) ?? null; const isActive = activeOption !== null; return ( @@ -45,7 +46,7 @@ function FilterDropdown({ : "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} + {activeOption?.label ?? label} @@ -79,42 +80,16 @@ interface MachineFiltersProps { 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 { + filterUser, + filterTag, + filterStatus, + filterRoute, + hasActiveFilters, + setParam, + clearFilters, + } = useMachineFilterParams(); const tagOwnedExists = populatedNodes.some((n) => !n.user); const userOptions = [ diff --git a/app/routes/machines/hooks/use-machine-filter-params.ts b/app/routes/machines/hooks/use-machine-filter-params.ts new file mode 100644 index 00000000..0f16a6b7 --- /dev/null +++ b/app/routes/machines/hooks/use-machine-filter-params.ts @@ -0,0 +1,51 @@ +import { useSearchParams } from "react-router"; + +export interface MachineFilterParams { + filterUser: string | null; + filterTag: string | null; + filterStatus: "online" | "offline" | "expired" | null; + filterRoute: "exit-node" | "subnet" | null; + hasActiveFilters: boolean; + setParam: (key: string, value: string | null) => void; + clearFilters: () => void; +} + +export function useMachineFilterParams(): MachineFilterParams { + const [searchParams, setSearchParams] = useSearchParams(); + + const filterUser = searchParams.get("user"); + const filterTag = searchParams.get("tag"); + const filterStatus = searchParams.get("status") as MachineFilterParams["filterStatus"]; + const filterRoute = searchParams.get("route") as MachineFilterParams["filterRoute"]; + + 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; + }); + }; + + return { + filterUser, + filterTag, + filterStatus, + filterRoute, + hasActiveFilters, + setParam, + clearFilters, + }; +} diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 35378ac9..adb92bba 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -10,13 +10,13 @@ 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 } from "~/utils/node-info"; -import type { PopulatedNode } from "~/utils/node-info"; +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) { @@ -89,13 +89,8 @@ export default function Page({ loaderData }: Route.ComponentProps) { 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 { filterUser, filterTag, filterStatus, filterRoute, hasActiveFilters } = + useMachineFilterParams(); const setSearchQuery = (value: string) => { setSearchParams((prev) => {