diff --git a/messages/en-US.json b/messages/en-US.json
index aa8f902ff..898b015c6 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1164,6 +1164,8 @@
"siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "Labels not found",
"labelSearch": "Search labels",
+ "accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
+ "accessLabelFilterClear": "Clear label filters",
"selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.",
diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts
index 49b7f2b57..a4e6588c6 100644
--- a/server/routers/resource/listResources.ts
+++ b/server/routers/resource/listResources.ts
@@ -71,7 +71,7 @@ const listResourcesSchema = z.object({
}),
query: z.string().optional(),
sort_by: z
- .enum(["name"])
+ .literal("name")
.optional()
.catch(undefined)
.openapi({
diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts
index af6514f02..8b73a984b 100644
--- a/server/routers/site/listSites.ts
+++ b/server/routers/site/listSites.ts
@@ -187,6 +187,26 @@ const listSitesSchema = z.object({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site status"
+ }),
+ labels: z
+ .preprocess((val) => {
+ if (val === undefined || val === null || val === "") {
+ return undefined;
+ }
+ if (Array.isArray(val)) {
+ return val;
+ }
+ // the array is returned as this
+ if (typeof val === "string") {
+ return val.split(",");
+ }
+ return undefined;
+ }, z.array(z.string()))
+ .optional()
+ .catch([])
+ .openapi({
+ type: "array",
+ description: "Filter by site labels"
})
});
@@ -319,8 +339,16 @@ export async function listSites(
tierMatrix.labels
);
- const { pageSize, page, query, sort_by, order, online, status } =
- parsedQuery.data;
+ const {
+ pageSize,
+ page,
+ query,
+ sort_by,
+ order,
+ online,
+ status,
+ labels: labelFilter
+ } = parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
@@ -337,6 +365,23 @@ export async function listSites(
if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status));
}
+
+ if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
+ conditions.push(
+ inArray(
+ sites.siteId,
+ db
+ .select({ id: siteLabels.siteId })
+ .from(siteLabels)
+ .innerJoin(
+ labels,
+ eq(labels.labelId, siteLabels.labelId)
+ )
+ .where(inArray(labels.name, labelFilter))
+ )
+ );
+ }
+
if (query) {
const q = "%" + query.toLowerCase() + "%";
const queryList = [
@@ -366,7 +411,9 @@ export async function listSites(
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(
- querySitesBase().where(and(...conditions)).as("filtered_sites")
+ querySitesBase()
+ .where(and(...conditions))
+ .as("filtered_sites")
);
const siteListQuery = baseQuery
diff --git a/src/components/LabelColumnFilterButton.tsx b/src/components/LabelColumnFilterButton.tsx
new file mode 100644
index 000000000..91b8faa92
--- /dev/null
+++ b/src/components/LabelColumnFilterButton.tsx
@@ -0,0 +1,184 @@
+"use client";
+
+import { Button } from "@app/components/ui/button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList
+} from "@app/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger
+} from "@app/components/ui/popover";
+import { cn } from "@app/lib/cn";
+import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
+import { CheckIcon, Funnel } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo, useState } from "react";
+import { Badge } from "./ui/badge";
+import { orgQueries } from "@app/lib/queries";
+import { useQuery } from "@tanstack/react-query";
+import { useDebounce } from "use-debounce";
+
+type LabelColumnFilterButtonProps = {
+ selectedValues: string[];
+ onSelectedValuesChange: (values: string[]) => void;
+ className?: string;
+ label: string;
+ orgId: string;
+};
+
+export function LabelColumnFilterButton({
+ selectedValues,
+ onSelectedValuesChange,
+ className,
+ label,
+ orgId
+}: LabelColumnFilterButtonProps) {
+ const [open, setOpen] = useState(false);
+ const t = useTranslations();
+
+ const [labelSearchQuery, setlabelsSearchQuery] = useState("");
+ const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
+
+ const { data: labels = [] } = useQuery(
+ orgQueries.labels({
+ orgId,
+ query: debouncedQuery,
+ perPage: 500
+ })
+ );
+
+ const selectedSet = useMemo(
+ () => new Set(selectedValues),
+ [selectedValues]
+ );
+
+ const summary = useMemo(() => {
+ if (selectedValues.length === 0) {
+ return null;
+ }
+ if (selectedValues.length === 1) {
+ const foundLabel = labels.find((o) => o.name === selectedValues[0]);
+
+ if (foundLabel) {
+ return (
+
+ );
+ }
+ return selectedValues[0];
+ }
+ return t("accessLabelFilterCount", {
+ count: selectedValues.length
+ });
+ }, [selectedValues, labels, t]);
+
+ function toggle(value: string) {
+ const next = selectedSet.has(value)
+ ? selectedValues.filter((v) => v !== value)
+ : [...selectedValues, value];
+ onSelectedValuesChange(next);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {t("labelsNotFound")}
+
+ {selectedValues.length > 0 && (
+ {
+ onSelectedValuesChange([]);
+ setOpen(false);
+ }}
+ className="text-muted-foreground"
+ >
+ {t("accessLabelFilterClear")}
+
+ )}
+ {labels.map((label) => (
+ {
+ toggle(label.name);
+ }}
+ className="flex items-center gap-2"
+ >
+
+
+ {label.name}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx
index 4412ecccf..3db076819 100644
--- a/src/components/SitesTable.tsx
+++ b/src/components/SitesTable.tsx
@@ -64,6 +64,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
+import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
export type SiteRow = {
id: number;
@@ -136,14 +137,16 @@ export default function SitesTable({
function handleFilterChange(
column: string,
- value: string | undefined | null
+ value: string | undefined | null | string[]
) {
const sp = new URLSearchParams(searchParams);
sp.delete(column);
sp.delete("page");
- if (value) {
+ if (typeof value === "string") {
sp.set(column, value);
+ } else if (value) {
+ value.forEach((val) => sp.append(column, val));
}
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
}
@@ -183,358 +186,373 @@ export default function SitesTable({
const columns = useMemo[]>(() => {
const cols: ExtendedColumnDef[] = [
- {
- accessorKey: "name",
- enableHiding: false,
- header: () => {
- const nameOrder = getSortDirection("name", searchParams);
- const Icon =
- nameOrder === "asc"
- ? ArrowDown01Icon
- : nameOrder === "desc"
- ? ArrowUp10Icon
- : ChevronsUpDownIcon;
+ {
+ accessorKey: "name",
+ enableHiding: false,
+ header: () => {
+ const nameOrder = getSortDirection("name", searchParams);
+ const Icon =
+ nameOrder === "asc"
+ ? ArrowDown01Icon
+ : nameOrder === "desc"
+ ? ArrowUp10Icon
+ : ChevronsUpDownIcon;
- return (
-
- );
- }
- },
- {
- id: "niceId",
- accessorKey: "nice",
- friendlyName: t("identifier"),
- enableHiding: true,
- header: () => {
- return {t("identifier")};
+ return (
+
+ );
+ }
},
- cell: ({ row }) => {
- return {row.original.nice || "-"};
- }
- },
- {
- accessorKey: "online",
- friendlyName: t("online"),
- header: () => {
- return (
-
- handleFilterChange("online", value)
+ {
+ id: "niceId",
+ accessorKey: "nice",
+ friendlyName: t("identifier"),
+ enableHiding: true,
+ header: () => {
+ return {t("identifier")};
+ },
+ cell: ({ row }) => {
+ return {row.original.nice || "-"};
+ }
+ },
+ {
+ accessorKey: "online",
+ friendlyName: t("online"),
+ header: () => {
+ return (
+
+ handleFilterChange("online", value)
+ }
+ searchPlaceholder={t("searchPlaceholder")}
+ emptyMessage={t("emptySearchOptions")}
+ label={t("online")}
+ className="p-3"
+ />
+ );
+ },
+ cell: ({ row }) => {
+ const originalRow = row.original;
+ if (
+ originalRow.type == "newt" ||
+ originalRow.type == "wireguard"
+ ) {
+ if (originalRow.online) {
+ return (
+
+
+ {t("online")}
+
+ );
+ } else {
+ return (
+
+
+ {t("offline")}
+
+ );
}
- searchPlaceholder={t("searchPlaceholder")}
- emptyMessage={t("emptySearchOptions")}
- label={t("online")}
- className="p-3"
- />
- );
- },
- cell: ({ row }) => {
- const originalRow = row.original;
- if (
- originalRow.type == "newt" ||
- originalRow.type == "wireguard"
- ) {
- if (originalRow.online) {
- return (
-
-
- {t("online")}
-
- );
} else {
+ return -;
+ }
+ }
+ },
+ {
+ id: "uptime",
+ friendlyName: "Uptime",
+ header: () => {t("uptime30d")},
+ cell: ({ row }) => {
+ const originalRow = row.original;
+ if (originalRow.type == "local") {
+ return -;
+ }
+ return ;
+ }
+ },
+ {
+ accessorKey: "mbIn",
+ friendlyName: t("dataIn"),
+ header: () => {
+ const dataInOrder = getSortDirection(
+ "megabytesIn",
+ searchParams
+ );
+ const Icon =
+ dataInOrder === "asc"
+ ? ArrowDown01Icon
+ : dataInOrder === "desc"
+ ? ArrowUp10Icon
+ : ChevronsUpDownIcon;
+ return (
+
+ );
+ }
+ },
+ {
+ accessorKey: "mbOut",
+ friendlyName: t("dataOut"),
+ header: () => {
+ const dataOutOrder = getSortDirection(
+ "megabytesOut",
+ searchParams
+ );
+
+ const Icon =
+ dataOutOrder === "asc"
+ ? ArrowDown01Icon
+ : dataOutOrder === "desc"
+ ? ArrowUp10Icon
+ : ChevronsUpDownIcon;
+ return (
+
+ );
+ }
+ },
+ {
+ accessorKey: "type",
+ friendlyName: t("type"),
+ header: () => {
+ return {t("type")};
+ },
+ cell: ({ row }) => {
+ const originalRow = row.original;
+
+ if (originalRow.type === "newt") {
return (
-
-
- {t("offline")}
-
+
+
+
+ Newt
+ {originalRow.newtVersion && (
+
+ v{originalRow.newtVersion}
+
+ )}
+
+
+ {originalRow.newtUpdateAvailable && (
+
+ )}
+
);
}
- } else {
- return -;
- }
- }
- },
- {
- id: "uptime",
- friendlyName: "Uptime",
- header: () => {t("uptime30d")},
- cell: ({ row }) => {
- const originalRow = row.original;
- if (originalRow.type == "local") {
- return -;
- }
- return ;
- }
- },
- {
- accessorKey: "mbIn",
- friendlyName: t("dataIn"),
- header: () => {
- const dataInOrder = getSortDirection(
- "megabytesIn",
- searchParams
- );
- const Icon =
- dataInOrder === "asc"
- ? ArrowDown01Icon
- : dataInOrder === "desc"
- ? ArrowUp10Icon
- : ChevronsUpDownIcon;
- return (
-
- );
- }
- },
- {
- accessorKey: "mbOut",
- friendlyName: t("dataOut"),
- header: () => {
- const dataOutOrder = getSortDirection(
- "megabytesOut",
- searchParams
- );
- const Icon =
- dataOutOrder === "asc"
- ? ArrowDown01Icon
- : dataOutOrder === "desc"
- ? ArrowUp10Icon
- : ChevronsUpDownIcon;
- return (
-
- );
- }
- },
- {
- accessorKey: "type",
- friendlyName: t("type"),
- header: () => {
- return {t("type")};
+ if (originalRow.type === "wireguard") {
+ return (
+
+ WireGuard
+
+ );
+ }
+
+ if (originalRow.type === "local") {
+ return (
+
+ Local
+
+ );
+ }
+ }
},
- cell: ({ row }) => {
- const originalRow = row.original;
-
- if (originalRow.type === "newt") {
+ {
+ id: "resources",
+ accessorKey: "resourceCount",
+ friendlyName: t("resources"),
+ header: () => {t("resources")},
+ cell: ({ row }) => {
+ const siteRow = row.original;
return (
-
+
+ );
+ }
+ },
+ {
+ accessorKey: "exitNode",
+ friendlyName: t("exitNode"),
+ header: () => {
+ return
{t("exitNode")};
+ },
+ cell: ({ row }) => {
+ const originalRow = row.original;
+ if (!originalRow.exitNodeName) {
+ return "-";
+ }
+
+ const isCloudNode =
+ build == "saas" &&
+ originalRow.exitNodeName &&
+ [
+ "mercury",
+ "venus",
+ "earth",
+ "mars",
+ "jupiter",
+ "saturn",
+ "uranus",
+ "neptune",
+ "pluto"
+ ].includes(originalRow.exitNodeName.toLowerCase());
+
+ if (isCloudNode) {
+ const capitalizedName =
+ originalRow.exitNodeName.charAt(0).toUpperCase() +
+ originalRow.exitNodeName.slice(1).toLowerCase();
+ return (
-
- Newt
- {originalRow.newtVersion && (
- v{originalRow.newtVersion}
- )}
-
+ Pangolin {capitalizedName}
- {originalRow.newtUpdateAvailable && (
-
- )}
-
- );
- }
+ );
+ }
- if (originalRow.type === "wireguard") {
- return (
-
- WireGuard
-
- );
- }
-
- if (originalRow.type === "local") {
- return (
-
- Local
-
- );
- }
- }
- },
- {
- id: "resources",
- accessorKey: "resourceCount",
- friendlyName: t("resources"),
- header: () => {t("resources")},
- cell: ({ row }) => {
- const siteRow = row.original;
- return (
-
- );
- }
- },
- {
- accessorKey: "exitNode",
- friendlyName: t("exitNode"),
- header: () => {
- return {t("exitNode")};
- },
- cell: ({ row }) => {
- const originalRow = row.original;
- if (!originalRow.exitNodeName) {
- return "-";
- }
-
- const isCloudNode =
- build == "saas" &&
- originalRow.exitNodeName &&
- [
- "mercury",
- "venus",
- "earth",
- "mars",
- "jupiter",
- "saturn",
- "uranus",
- "neptune",
- "pluto"
- ].includes(originalRow.exitNodeName.toLowerCase());
-
- if (isCloudNode) {
- const capitalizedName =
- originalRow.exitNodeName.charAt(0).toUpperCase() +
- originalRow.exitNodeName.slice(1).toLowerCase();
- return (
-
- Pangolin {capitalizedName}
-
- );
- }
-
- // Self-hosted node
- if (originalRow.remoteExitNodeId) {
- return (
-
-
-
- );
- }
-
- // Fallback if no remoteExitNodeId
- return {originalRow.exitNodeName};
- }
- },
- {
- accessorKey: "address",
- header: () => {
- return {t("address")};
- },
- cell: ({ row }) => {
- const originalRow = row.original;
- return originalRow.address ? (
-
- {originalRow.address}
-
- ) : (
- "-"
- );
- }
- },
- {
- id: "actions",
- enableHiding: false,
- header: () => ,
- cell: ({ row }) => {
- const siteRow = row.original;
- return (
-
-
-
-
-
-
-
- {t("viewSettings")}
-
-
-
-
- {t("sitesTableViewPublicResources")}
-
-
-
-
- {t("sitesTableViewPrivateResources")}
-
-
-
-
-
-
- {t("edit")}
-
-
-
-
- );
+
+ );
+ }
+
+ // Fallback if no remoteExitNodeId
+ return {originalRow.exitNodeName};
+ }
+ },
+ {
+ accessorKey: "address",
+ header: () => {
+ return {t("address")};
+ },
+ cell: ({ row }) => {
+ const originalRow = row.original;
+ return originalRow.address ? (
+
+ {originalRow.address}
+
+ ) : (
+ "-"
+ );
+ }
+ },
+ {
+ id: "actions",
+ enableHiding: false,
+ header: () => ,
+ cell: ({ row }) => {
+ const siteRow = row.original;
+ return (
+
+
+
+
+
+ Open menu
+
+
+
+
+
+
+
+ {t("viewSettings")}
+
+
+
+
+ {t("sitesTableViewPublicResources")}
+
+
+
+
+ {t(
+ "sitesTableViewPrivateResources"
+ )}
+
+
+
+
+
+
+ {t("edit")}
+
+
+
+
+ );
+ }
}
- }
];
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
accessorKey: "labels",
header: () => (
-
- {t("labels")}
-
+
+ handleFilterChange("labels", value)
+ }
+ label={t("labels")}
+ className="p-3"
+ />
),
cell: ({ row }: { row: { original: SiteRow } }) => (