@@ -299,22 +300,21 @@ export default function GeneralPage() {
},
{
accessorKey: "action",
- header: ({ column }) => {
+ header: () => {
return (
-
- {t("action")}
-
+
handleFilterChange("action", value)
}
- // placeholder=""
- searchPlaceholder="Search..."
- emptyMessage="None found"
+ searchPlaceholder={t("searchPlaceholder")}
+ emptyMessage={t("emptySearchOptions")}
/>
);
@@ -329,17 +329,14 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
- header: ({ column }) => {
- return t("ip");
- }
+ header: ({ column }) =>
{t("ip")}
},
{
accessorKey: "location",
header: ({ column }) => {
return (
-
- {t("location")}
-
+ ({
value: location,
@@ -351,8 +348,9 @@ export default function GeneralPage() {
handleFilterChange("location", value)
}
// placeholder=""
- searchPlaceholder="Search..."
- emptyMessage="None found"
+ label={t("location")}
+ searchPlaceholder={t("searchPlaceholder")}
+ emptyMessage={t("emptySearchOptions")}
/>
);
@@ -377,9 +375,8 @@ export default function GeneralPage() {
accessorKey: "resourceName",
header: ({ column }) => {
return (
-
- {t("resource")}
-
+ ({
value: res.id.toString(),
label: res.name || "Unnamed Resource"
@@ -388,9 +385,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("resourceId", value)
}
- // placeholder=""
- searchPlaceholder="Search..."
- emptyMessage="None found"
+ label={t("resource")}
+ searchPlaceholder={t("searchPlaceholder")}
+ emptyMessage={t("emptySearchOptions")}
/>
);
@@ -417,9 +414,8 @@ export default function GeneralPage() {
accessorKey: "host",
header: ({ column }) => {
return (
-
- {t("host")}
-
+ ({
value: host,
label: host
@@ -428,9 +424,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("host", value)
}
- // placeholder=""
- searchPlaceholder="Search..."
- emptyMessage="None found"
+ label={t("host")}
+ searchPlaceholder={t("searchPlaceholder")}
+ emptyMessage={t("emptySearchOptions")}
/>
);
@@ -452,9 +448,8 @@ export default function GeneralPage() {
accessorKey: "path",
header: ({ column }) => {
return (
-
- {t("path")}
-
+ ({
value: path,
label: path
@@ -463,9 +458,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("path", value)
}
- // placeholder=""
- searchPlaceholder="Search..."
- emptyMessage="None found"
+ label={t("path")}
+ searchPlaceholder={t("searchPlaceholder")}
+ emptyMessage={t("emptySearchOptions")}
/>
);
@@ -482,9 +477,8 @@ export default function GeneralPage() {
accessorKey: "method",
header: ({ column }) => {
return (
-
- {t("method")}
-
+
handleFilterChange("method", value)
}
- // placeholder=""
- searchPlaceholder="Search..."
- emptyMessage="None found"
+ label={t("method")}
+ searchPlaceholder={t("searchPlaceholder")}
+ emptyMessage={t("emptySearchOptions")}
/>
);
@@ -510,9 +504,8 @@ export default function GeneralPage() {
accessorKey: "reason",
header: ({ column }) => {
return (
-
- {t("reason")}
-
+
handleFilterChange("reason", value)
}
- // placeholder=""
- searchPlaceholder="Search..."
- emptyMessage="None found"
+ label={t("reason")}
+ searchPlaceholder={t("searchPlaceholder")}
+ emptyMessage={t("emptySearchOptions")}
/>
);
@@ -556,9 +549,8 @@ export default function GeneralPage() {
accessorKey: "actor",
header: ({ column }) => {
return (
-
- {t("actor")}
-
+ ({
value: actor,
label: actor
@@ -567,9 +559,9 @@ export default function GeneralPage() {
onValueChange={(value) =>
handleFilterChange("actor", value)
}
- // placeholder=""
- searchPlaceholder="Search..."
- emptyMessage="None found"
+ label={t("actor")}
+ searchPlaceholder={t("searchPlaceholder")}
+ emptyMessage={t("emptySearchOptions")}
/>
);
diff --git a/src/components/ColumnFilterButton.tsx b/src/components/ColumnFilterButton.tsx
index 689f78983..5945dc887 100644
--- a/src/components/ColumnFilterButton.tsx
+++ b/src/components/ColumnFilterButton.tsx
@@ -17,6 +17,7 @@ import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react";
import { cn } from "@app/lib/cn";
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
import { Badge } from "./ui/badge";
+import { useTranslations } from "next-intl";
interface FilterOption {
value: string;
@@ -27,7 +28,6 @@ interface ColumnFilterButtonProps {
options: FilterOption[];
selectedValue?: string;
onValueChange: (value: string | undefined) => void;
- placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
className?: string;
@@ -38,7 +38,6 @@ export function ColumnFilterButton({
options,
selectedValue,
onValueChange,
- placeholder,
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
@@ -50,6 +49,8 @@ export function ColumnFilterButton({
(option) => option.value === selectedValue
);
+ const t = useTranslations();
+
return (
@@ -94,7 +95,7 @@ export function ColumnFilterButton({
}}
className="text-muted-foreground"
>
- Clear filter
+ {t("accessFilterClear")}
)}
{options.map((option) => (
@@ -109,6 +110,7 @@ export function ColumnFilterButton({
);
setOpen(false);
}}
+ className="break-all"
>
- {t("accessUsersRoleFilterClear")}
+ {t("accessFilterClear")}
)}
{options.map((option) => (
@@ -130,6 +130,7 @@ export function ColumnMultiFilterButton({
onSelect={() => {
toggle(option.value);
}}
+ className="break-all"
>
- {t("accessLabelFilterClear")}
+ {t("accessFilterClear")}
)}
{labels.map((label) => (
diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx
index f2bc5b0da..64833b0a0 100644
--- a/src/components/LogDataTable.tsx
+++ b/src/components/LogDataTable.tsx
@@ -32,7 +32,7 @@ import {
RefreshCw
} from "lucide-react";
import { useTranslations } from "next-intl";
-import { useState, useEffect, useMemo } from "react";
+import { useEffect, useMemo, useState } from "react";
import {
Tooltip,
TooltipContent,
diff --git a/src/components/PrivateResourcesTable.tsx b/src/components/PrivateResourcesTable.tsx
index b529358d1..2a4d63e85 100644
--- a/src/components/PrivateResourcesTable.tsx
+++ b/src/components/PrivateResourcesTable.tsx
@@ -2,9 +2,17 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CopyToClipboard from "@app/components/CopyToClipboard";
-import { ExtendedColumnDef } from "@app/components/ui/data-table";
+import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog";
+import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog";
+import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
+import {
+ ResourceSitesStatusCell,
+ type ResourceSiteRow
+} from "@app/components/ResourceSitesStatusCell";
+import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
+import { ExtendedColumnDef } from "@app/components/ui/data-table";
import {
DropdownMenu,
DropdownMenuContent,
@@ -18,53 +26,35 @@ import {
PopoverTrigger
} from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext";
+import { useNavigationContext } from "@app/hooks/useNavigationContext";
+import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
+import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
+import { cn } from "@app/lib/cn";
+import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
+import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
+import { build } from "@server/build";
+import { tierMatrix } from "@server/lib/billing/tierMatrix";
+import type { PaginationState } from "@tanstack/react-table";
import {
ArrowDown01Icon,
ArrowUp10Icon,
ArrowUpDown,
- ArrowUpRight,
- ChevronDown,
ChevronsUpDownIcon,
Funnel,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
-import Link from "next/link";
import { useRouter } from "next/navigation";
-import { Selectedsite, SitesSelector } from "@app/components/site-selector";
-import {
- startTransition,
- useEffect,
- useMemo,
- useState,
- useTransition
-} from "react";
-import CreatePrivateResourceDialog from "@app/components/CreatePrivateResourceDialog";
-import EditPrivateResourceDialog from "@app/components/EditPrivateResourceDialog";
-import type { PaginationState } from "@tanstack/react-table";
-import { ControlledDataTable } from "./ui/controlled-data-table";
-import { useNavigationContext } from "@app/hooks/useNavigationContext";
+import { startTransition, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import { ColumnFilterButton } from "./ColumnFilterButton";
-import { cn } from "@app/lib/cn";
-import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
-import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess";
-import {
- ResourceSitesStatusCell,
- type ResourceSiteRow
-} from "@app/components/ResourceSitesStatusCell";
-import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
-import { build } from "@server/build";
-import { usePaidStatus } from "@app/hooks/usePaidStatus";
-import { tierMatrix } from "@server/lib/billing/tierMatrix";
-import { type SelectedLabel } from "./labels-selector";
-import { LabelsTableCell } from "./LabelsTableCell";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
-import { useLocalLabels } from "@app/hooks/useLocalLabels";
-import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
+import { LabelsTableCell } from "./LabelsTableCell";
+import { ControlledDataTable } from "./ui/controlled-data-table";
+import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
export type InternalResourceSiteRow = ResourceSiteRow;
@@ -157,7 +147,6 @@ export default function PrivateResourcesTable({
const [editingResource, setEditingResource] =
useState();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
- const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [isRefreshing, startRefreshTransition] = useTransition();
@@ -171,27 +160,6 @@ export default function PrivateResourcesTable({
// return () => clearInterval(interval);
// }, [router]);
- const siteIdQ = searchParams.get("siteId");
- const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
- const selectedSite: Selectedsite | null = useMemo(() => {
- if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
- return null;
- }
- if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
- return initialFilterSite;
- }
- return {
- siteId: siteIdNum,
- name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
- type: "newt"
- };
- }, [initialFilterSite, siteIdQ, siteIdNum, t]);
-
- const createInitialSites = useMemo(
- () => (selectedSite ? [selectedSite] : undefined),
- [selectedSite]
- );
-
const refreshData = () => {
startRefreshTransition(() => {
try {
@@ -285,58 +253,27 @@ export default function PrivateResourcesTable({
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
- header: () => (
-
-
-
-
-
-
-
-
-
-
-
- ),
+ header: () => {
+ const siteIdQ = searchParams.get("siteId");
+ const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
+
+ const selectedSiteId =
+ !siteIdQ ||
+ !Number.isInteger(siteIdNum) ||
+ siteIdNum <= 0
+ ? null
+ : siteIdNum;
+
+ return (
+
+ handleFilterChange("siteId", value?.toString())
+ }
+ orgId={orgId}
+ />
+ );
+ },
cell: ({ row }) => {
const resourceRow = row.original;
return (
@@ -586,16 +523,6 @@ export default function PrivateResourcesTable({
});
}
- const clearSiteFilter = () => {
- handleFilterChange("siteId", undefined);
- setSiteFilterOpen(false);
- };
-
- const onPickSite = (site: Selectedsite) => {
- handleFilterChange("siteId", String(site.siteId));
- setSiteFilterOpen(false);
- };
-
function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams);
@@ -691,7 +618,6 @@ export default function PrivateResourcesTable({
open={isCreateDialogOpen}
setOpen={setIsCreateDialogOpen}
orgId={orgId}
- initialSites={createInitialSites}
onSuccess={() => {
// Delay refresh to allow modal to close smoothly
setTimeout(() => {
diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx
index 0f041e35d..80f20983e 100644
--- a/src/components/PublicResourcesTable.tsx
+++ b/src/components/PublicResourcesTable.tsx
@@ -76,6 +76,7 @@ import { useLocalLabels } from "@app/hooks/useLocalLabels";
import { LabelsTableCell } from "./LabelsTableCell";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { refresh } from "next/cache";
+import { SitesColumnFilterButton } from "./SitesColumnFilterButton";
export type TargetHealth = {
targetId: number;
@@ -154,30 +155,6 @@ export default function PublicResourcesTable({
const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition();
- const [siteFilterOpen, setSiteFilterOpen] = useState(false);
-
- const siteIdQ = searchParams.get("siteId");
- const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
- const selectedSite: Selectedsite | null = useMemo(() => {
- if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
- return null;
- }
- if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
- return initialFilterSite;
- }
- return {
- siteId: siteIdNum,
- name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
- type: "newt"
- };
- }, [initialFilterSite, siteIdQ, siteIdNum, t]);
-
- // useEffect(() => {
- // const interval = setInterval(() => {
- // router.refresh();
- // }, 30_000);
- // return () => clearInterval(interval);
- // }, [router]);
const refreshData = () => {
startTransition(() => {
@@ -227,28 +204,6 @@ export default function PublicResourcesTable({
}
}
- const clearSiteFilter = () => {
- handleFilterChange("siteId", undefined);
- setSiteFilterOpen(false);
- };
-
- const onPickSite = (site: Selectedsite) => {
- handleFilterChange("siteId", String(site.siteId));
- setSiteFilterOpen(false);
- };
-
- const siteFilterOpenRef = useRef(siteFilterOpen);
- siteFilterOpenRef.current = siteFilterOpen;
-
- const selectedSiteRef = useRef(selectedSite);
- selectedSiteRef.current = selectedSite;
-
- const clearSiteFilterRef = useRef(clearSiteFilter);
- clearSiteFilterRef.current = clearSiteFilter;
-
- const onPickSiteRef = useRef(onPickSite);
- onPickSiteRef.current = onPickSite;
-
const proxyColumns = useMemo[]>(() => {
const cols: ExtendedColumnDef[] = [
{
@@ -291,61 +246,27 @@ export default function PublicResourcesTable({
accessorFn: (row) =>
row.sites.map((s) => s.siteName).join(", "),
friendlyName: t("sites"),
- header: () => (
-
-
-
-
-
-
-
-
-
- onPickSiteRef.current(site)
- }
- />
-
-
- ),
+ header: () => {
+ const siteIdQ = searchParams.get("siteId");
+ const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
+
+ const selectedSiteId =
+ !siteIdQ ||
+ !Number.isInteger(siteIdNum) ||
+ siteIdNum <= 0
+ ? null
+ : siteIdNum;
+
+ return (
+
+ handleFilterChange("siteId", value?.toString())
+ }
+ orgId={orgId}
+ />
+ );
+ },
cell: ({ row }) => (
void;
+ orgId: string;
+};
+
+export function SitesColumnFilterButton({
+ selectedSiteId,
+ onValueChange,
+ orgId
+}: SitesColumnFilterButtonProps) {
+ const [open, setOpen] = useState(false);
+
+ const t = useTranslations();
+
+ const [siteSearchQuery, setSiteSearchQuery] = useState("");
+ const [debouncedQuery] = useDebounce(siteSearchQuery, 150);
+
+ const { data: sites = [] } = useQuery(
+ orgQueries.sites({
+ orgId,
+ query: debouncedQuery,
+ perPage: 500
+ })
+ );
+
+ const selectedSite = useMemo(() => {
+ let selected = undefined;
+ if (selectedSiteId) {
+ selected = sites.find((site) => site.siteId === selectedSiteId) ?? {
+ siteId: Number(selectedSiteId),
+ name: t("standaloneHcFilterSiteIdFallback", {
+ id: Number(selectedSiteId)
+ }),
+ type: "newt"
+ };
+ }
+
+ return selected;
+ }, [selectedSiteId, sites]);
+
+ // always include the selected site in the list of sites shown
+ const sitesShown = useMemo(() => {
+ const allSites: Array = [...sites];
+ if (
+ debouncedQuery.trim().length === 0 &&
+ selectedSite &&
+ !allSites.find((site) => site.siteId === selectedSite?.siteId)
+ ) {
+ allSites.unshift(selectedSite);
+ }
+ return allSites;
+ }, [debouncedQuery, sites, selectedSite]);
+
+ return (
+
+
+
+
+
+
+ setSiteSearchQuery(v)}
+ />
+
+ {t("siteNotFound")}
+
+ {selectedSite && (
+ {
+ onValueChange(undefined);
+ }}
+ className="text-muted-foreground"
+ >
+ {t("accessFilterClear")}
+
+ )}
+ {sitesShown.map((site) => (
+ {
+ onValueChange(site.siteId);
+ }}
+ >
+
+
+
+ {site.name}
+
+ {site.online != null && (
+
+ )}
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx
index 76255e824..acb8b7dd9 100644
--- a/src/components/multi-site-selector.tsx
+++ b/src/components/multi-site-selector.tsx
@@ -115,7 +115,6 @@ export function MultiSitesSelector({
)}
diff --git a/src/components/site-selector.tsx b/src/components/site-selector.tsx
index 778a6fcf6..2a7717572 100644
--- a/src/components/site-selector.tsx
+++ b/src/components/site-selector.tsx
@@ -26,11 +26,12 @@ export type Selectedsite = Pick<
type SiteOnlineStatusProps = {
type: Selectedsite["type"];
online: Selectedsite["online"];
- t: (key: "online" | "offline") => string;
};
/** Dot-only indicator matching `SitesTable` colors (newt/wireguard only; nothing for local or missing status). */
-export function SiteOnlineStatus({ type, online, t }: SiteOnlineStatusProps) {
+export function SiteOnlineStatus({ type, online }: SiteOnlineStatusProps) {
+ const t = useTranslations();
+
if (type !== "newt" && type !== "wireguard") {
return null;
}
@@ -128,7 +129,6 @@ export function SitesSelector({