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 ( +
+
+ {foundLabel.name} +
+ ); + } + 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")} - - - - - - - -
- ); + + ); + } + + // 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" + )} + + + + + + + +
+ ); + } } - } ]; 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 } }) => (