diff --git a/app/routes/machines/components/machine-filters.tsx b/app/routes/machines/components/machine-filters.tsx
new file mode 100644
index 00000000..f88b84a3
--- /dev/null
+++ b/app/routes/machines/components/machine-filters.tsx
@@ -0,0 +1,152 @@
+import { ChevronDown, X } from "lucide-react";
+import type { JSX } from "react";
+
+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";
+
+import { useMachineFilterParams } from "../hooks/use-machine-filter-params";
+
+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 = options.find((o) => o.value === value) ?? null;
+ const isActive = activeOption !== null;
+
+ return (
+
+ );
+}
+
+interface MachineFiltersProps {
+ users: User[];
+ populatedNodes: PopulatedNode[];
+}
+
+export function MachineFilters({ users, populatedNodes }: MachineFiltersProps): JSX.Element {
+ const {
+ filterUser,
+ filterTag,
+ filterStatus,
+ filterRoute,
+ hasActiveFilters,
+ setParam,
+ clearFilters,
+ } = useMachineFilterParams();
+
+ 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/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 fc782bd6..adb92bba 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";
@@ -9,11 +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 { 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) {
@@ -67,26 +70,60 @@ 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, 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) => {
- 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 +184,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 +223,13 @@ export default function Page({ loaderData }: Route.ComponentProps) {
users={loaderData.users}
/>
-