mirror of
https://github.com/tale/headplane.git
synced 2026-08-24 19:46:29 +00:00
Merge pull request #507 from siemenvdn/feature/extend-machine-filtering
Add additional Machines list filters
This commit is contained in:
@@ -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.name, 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
|
import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
|
|
||||||
import Code from "~/components/code";
|
import Code from "~/components/code";
|
||||||
import Input from "~/components/input";
|
import Input from "~/components/input";
|
||||||
@@ -9,11 +10,13 @@ import Tooltip from "~/components/tooltip";
|
|||||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import cn from "~/utils/cn";
|
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 type { Route } from "./+types/overview";
|
||||||
|
import { MachineFilters } from "./components/machine-filters";
|
||||||
import MachineRow from "./components/machine-row";
|
import MachineRow from "./components/machine-row";
|
||||||
import NewMachine from "./dialogs/new";
|
import NewMachine from "./dialogs/new";
|
||||||
|
import { useMachineFilterParams } from "./hooks/use-machine-filter-params";
|
||||||
import { machineAction } from "./machine-actions";
|
import { machineAction } from "./machine-actions";
|
||||||
|
|
||||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
@@ -74,26 +77,60 @@ export const action = machineAction;
|
|||||||
|
|
||||||
type SortField = "name" | "ip" | "version" | "lastSeen";
|
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) {
|
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [sortField, setSortField] = useState<SortField>("name");
|
const [sortField, setSortField] = useState<SortField>("name");
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
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 filteredAndSortedNodes = useMemo(() => {
|
||||||
const query = searchQuery.toLowerCase().trim();
|
const query = searchQuery.toLowerCase().trim();
|
||||||
|
|
||||||
let nodes = loaderData.populatedNodes.filter((node) => {
|
let nodes = loaderData.populatedNodes.filter(
|
||||||
if (!query) {
|
(node) =>
|
||||||
return true;
|
(!query ||
|
||||||
}
|
node.givenName.toLowerCase().includes(query) ||
|
||||||
if (node.givenName.toLowerCase().includes(query)) {
|
node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) &&
|
||||||
return true;
|
(filterUser === null ||
|
||||||
}
|
(filterUser === "tag-owned" ? !node.user : node.user?.name === filterUser)) &&
|
||||||
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) {
|
(filterTag === null || (node.tags?.includes(filterTag) ?? false)) &&
|
||||||
return true;
|
(filterStatus === null || STATUS_MATCH[filterStatus](node)) &&
|
||||||
}
|
(filterRoute === null || ROUTE_MATCH[filterRoute](node)),
|
||||||
return false;
|
);
|
||||||
});
|
|
||||||
|
|
||||||
nodes = [...nodes].toSorted((a, b) => {
|
nodes = [...nodes].toSorted((a, b) => {
|
||||||
let comparison = 0;
|
let comparison = 0;
|
||||||
@@ -154,7 +191,16 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
|
}, [
|
||||||
|
loaderData.populatedNodes,
|
||||||
|
searchQuery,
|
||||||
|
filterUser,
|
||||||
|
filterTag,
|
||||||
|
filterStatus,
|
||||||
|
filterRoute,
|
||||||
|
sortField,
|
||||||
|
sortDirection,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSort = (field: SortField) => {
|
const handleSort = (field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
@@ -184,13 +230,13 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
users={loaderData.users}
|
users={loaderData.users}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="relative w-64">
|
||||||
<Input
|
<Input
|
||||||
label="Search machines"
|
label="Search machines"
|
||||||
labelHidden
|
labelHidden
|
||||||
maxLength={100}
|
maxLength={100}
|
||||||
onChange={(value) => setSearchQuery(value.slice(0, 100))}
|
onChange={setSearchQuery}
|
||||||
placeholder="Search by name or IP address..."
|
placeholder="Search by name or IP address..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
/>
|
/>
|
||||||
@@ -204,15 +250,16 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
"dark:text-mist-500 dark:hover:text-mist-300",
|
"dark:text-mist-500 dark:hover:text-mist-300",
|
||||||
"hover:bg-mist-100 dark:hover:bg-mist-800",
|
"hover:bg-mist-100 dark:hover:bg-mist-800",
|
||||||
)}
|
)}
|
||||||
onClick={() => setSearchQuery("")}
|
onClick={clearSearch}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm whitespace-nowrap text-mist-500">
|
<MachineFilters users={loaderData.users} populatedNodes={loaderData.populatedNodes} />
|
||||||
{searchQuery
|
<span className="ml-auto text-sm whitespace-nowrap text-mist-500">
|
||||||
|
{searchQuery || hasActiveFilters
|
||||||
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
|
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
|
||||||
: `${loaderData.populatedNodes.length} machines`}
|
: `${loaderData.populatedNodes.length} machines`}
|
||||||
</span>
|
</span>
|
||||||
@@ -371,7 +418,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
|||||||
className="py-8 text-center text-mist-500"
|
className="py-8 text-center text-mist-500"
|
||||||
colSpan={loaderData.agent !== undefined ? 6 : 5}
|
colSpan={loaderData.agent !== undefined ? 6 : 5}
|
||||||
>
|
>
|
||||||
No machines found matching "{searchQuery}"
|
No machines match the current filters
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user