Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions app/routes/machines/components/machine-filters.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<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",
)}
>
{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[];
}

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) })),
];
Comment on lines +95 to +98
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see what you're doing here, but I think it's slightly unexpected behavior where the tooltip/user select dropdown shows names, but the URL shows IDs. They should both be the usernames.


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>
)}
</>
);
}
51 changes: 51 additions & 0 deletions app/routes/machines/hooks/use-machine-filter-params.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
89 changes: 68 additions & 21 deletions app/routes/machines/overview.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -67,26 +70,60 @@ 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, 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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -177,13 +223,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}
/>
Expand All @@ -197,15 +243,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>
Expand Down Expand Up @@ -361,7 +408,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>
) : (
Expand Down
Loading