Merge pull request #3130 from Fredkiss3/feat/filter-on-label-column

feat: add label filter column to sites, resource & client tables
This commit is contained in:
Milo Schwartz
2026-05-26 21:01:22 -07:00
committed by GitHub
13 changed files with 791 additions and 468 deletions
+2
View File
@@ -1164,6 +1164,8 @@
"siteLabelsDescription": "Manage labels associated with this site.", "siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "Labels not found", "labelsNotFound": "Labels not found",
"labelSearch": "Search labels", "labelSearch": "Search labels",
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"accessLabelFilterClear": "Clear label filters",
"selectColor": "Select color", "selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"", "createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.", "inviteInvalidDescription": "The invite link is invalid.",
+47 -3
View File
@@ -118,7 +118,27 @@ const listClientsSchema = z.object({
description: description:
"Filter by client status. Can be a comma-separated list of values. Defaults to 'active'." "Filter by client status. Can be a comma-separated list of values. Defaults to 'active'."
}) })
) ),
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 client labels"
})
}); });
function queryClientsBase() { function queryClientsBase() {
@@ -210,8 +230,16 @@ export async function listClients(
) )
); );
} }
const { page, pageSize, online, query, status, sort_by, order } = const {
parsedQuery.data; page,
pageSize,
online,
query,
status,
sort_by,
order,
labels: labelFilter
} = parsedQuery.data;
const parsedParams = listClientsParamsSchema.safeParse(req.params); const parsedParams = listClientsParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@@ -298,6 +326,22 @@ export async function listClients(
conditions.push(or(...filterAggregates)); conditions.push(or(...filterAggregates));
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
conditions.push(
inArray(
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(inArray(labels.name, labelFilter))
)
);
}
if (query) { if (query) {
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
const queryList = [ const queryList = [
+41 -3
View File
@@ -71,7 +71,7 @@ const listResourcesSchema = z.object({
}), }),
query: z.string().optional(), query: z.string().optional(),
sort_by: z sort_by: z
.enum(["name"]) .literal("name")
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
@@ -123,7 +123,27 @@ const listResourcesSchema = z.object({
type: "integer", type: "integer",
description: description:
"When set, only resources that have at least one target on this site are returned" "When set, only resources that have at least one target on this site are returned"
}) }),
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 resource labels"
})
}); });
// grouped by resource with targets[]) // grouped by resource with targets[])
@@ -261,7 +281,8 @@ export async function listResources(
healthStatus, healthStatus,
sort_by, sort_by,
order, order,
siteId siteId,
labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
const parsedParams = listResourcesParamsSchema.safeParse(req.params); const parsedParams = listResourcesParamsSchema.safeParse(req.params);
@@ -379,6 +400,23 @@ export async function listResources(
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))); .where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
conditions.push(inArray(resources.resourceId, resourcesWithSite)); conditions.push(inArray(resources.resourceId, resourcesWithSite));
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
conditions.push(
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(inArray(labels.name, labelFilter))
)
);
}
if (query) { if (query) {
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
const queryList = [ const queryList = [
+50 -3
View File
@@ -187,6 +187,26 @@ const listSitesSchema = z.object({
type: "string", type: "string",
enum: ["pending", "approved"], enum: ["pending", "approved"],
description: "Filter by site status" 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 tierMatrix.labels
); );
const { pageSize, page, query, sort_by, order, online, status } = const {
parsedQuery.data; pageSize,
page,
query,
sort_by,
order,
online,
status,
labels: labelFilter
} = parsedQuery.data;
const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
@@ -337,6 +365,23 @@ export async function listSites(
if (typeof status !== "undefined") { if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status)); 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) { if (query) {
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
const queryList = [ const queryList = [
@@ -366,7 +411,9 @@ export async function listSites(
// we need to add `as` so that drizzle filters the result as a subquery // we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count( const countQuery = db.$count(
querySitesBase().where(and(...conditions)).as("filtered_sites") querySitesBase()
.where(and(...conditions))
.as("filtered_sites")
); );
const siteListQuery = baseQuery const siteListQuery = baseQuery
@@ -85,7 +85,27 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
type: "integer", type: "integer",
description: description:
"When set, only site resources associated with this site (via network) are returned" "When set, only site resources associated with this site (via network) are returned"
}) }),
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 resource labels"
})
}); });
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
@@ -239,8 +259,16 @@ export async function listAllSiteResourcesByOrg(
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { page, pageSize, query, mode, sort_by, order, siteId } = const {
parsedQuery.data; page,
pageSize,
query,
mode,
sort_by,
order,
siteId,
labels: labelFilter
} = parsedQuery.data;
const isLabelFeatureEnabled = await isLicensedOrSubscribed( const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId, orgId,
@@ -276,6 +304,22 @@ export async function listAllSiteResourcesByOrg(
conditions.push(eq(siteResources.mode, mode)); conditions.push(eq(siteResources.mode, mode));
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
conditions.push(
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(inArray(labels.name, labelFilter))
)
);
}
if (query) { if (query) {
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
const queryList = [ const queryList = [
+15 -59
View File
@@ -64,6 +64,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelBadge } from "./label-badge"; import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector"; import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
export type InternalResourceSiteRow = ResourceSiteRow; export type InternalResourceSiteRow = ResourceSiteRow;
@@ -220,59 +221,6 @@ export default function ClientResourcesTable({
} }
}; };
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
const { siteNames, siteNiceIds, orgId } = resourceRow;
if (!siteNames || siteNames.length === 0) {
return (
<span className="text-muted-foreground">
{t("noSites", { defaultValue: "No sites" })}
</span>
);
}
if (siteNames.length === 1) {
return (
<Link href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}>
<Button variant="outline">
{siteNames[0]}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<span>
{siteNames.length} {t("sites")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{siteNames.map((siteName, idx) => (
<DropdownMenuItem key={siteNiceIds[idx]} asChild>
<Link
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
className="flex items-center gap-2 cursor-pointer"
>
{siteName}
<ArrowUpRight className="h-3 w-3" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
const internalColumns = useMemo< const internalColumns = useMemo<
ExtendedColumnDef<InternalResourceRow>[] ExtendedColumnDef<InternalResourceRow>[]
>(() => { >(() => {
@@ -585,9 +533,15 @@ export default function ClientResourcesTable({
id: "labels", id: "labels",
accessorKey: "labels", accessorKey: "labels",
header: () => ( header: () => (
<span className="p-3 text-end w-full inline-block"> <LabelColumnFilterButton
{t("labels")} orgId={orgId}
</span> selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
), ),
cell: ({ row }: { row: { original: InternalResourceRow } }) => ( cell: ({ row }: { row: { original: InternalResourceRow } }) => (
<ClientResourceLabelCell <ClientResourceLabelCell
@@ -603,13 +557,15 @@ export default function ClientResourcesTable({
function handleFilterChange( function handleFilterChange(
column: string, column: string,
value: string | undefined | null value: string | undefined | null | string[]
) { ) {
searchParams.delete(column); searchParams.delete(column);
searchParams.delete("page"); searchParams.delete("page");
if (value) { if (typeof value === "string") {
searchParams.set(column, value); searchParams.set(column, value);
} else if (value) {
value.forEach((val) => searchParams.append(column, val));
} }
filter({ filter({
searchParams searchParams
@@ -682,7 +638,7 @@ export default function ClientResourcesTable({
rows={internalResources} rows={internalResources}
tableId="internal-resources" tableId="internal-resources"
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("resourcesSearch")}
searchQuery={searchParams.get("query") ?? ""} searchQuery={searchParams.get("query")?.toString()}
onAdd={() => setIsCreateDialogOpen(true)} onAdd={() => setIsCreateDialogOpen(true)}
addButtonText={t("resourceAdd")} addButtonText={t("resourceAdd")}
onSearch={handleSearchChange} onSearch={handleSearchChange}
+1 -1
View File
@@ -109,7 +109,7 @@ export default function CreateBlueprintForm({
if (res && res.status === 201) { if (res && res.status === 201) {
const createdBlueprint = res.data.data; const createdBlueprint = res.data.data;
toast({ toast({
variant: "warning", variant: createdBlueprint.succeeded ? "default" : "warning",
title: createdBlueprint.succeeded ? "Success" : "Warning", title: createdBlueprint.succeeded ? "Success" : "Warning",
description: createdBlueprint.message description: createdBlueprint.message
}); });
+3 -2
View File
@@ -109,7 +109,6 @@ export default function HealthChecksTable({
const [siteFilterOpen, setSiteFilterOpen] = useState(false); const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [resourceFilterOpen, setResourceFilterOpen] = useState(false); const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
const pageSize = pagination.pageSize;
const query = searchParams.get("query") ?? undefined; const query = searchParams.get("query") ?? undefined;
const siteIdQ = searchParams.get("siteId"); const siteIdQ = searchParams.get("siteId");
@@ -586,7 +585,9 @@ export default function HealthChecksTable({
<Switch <Switch
checked={r.hcEnabled} checked={r.hcEnabled}
disabled={ disabled={
!isPaid || togglingId === r.targetHealthCheckId || !!r.resourceId !isPaid ||
togglingId === r.targetHealthCheckId ||
!!r.resourceId
} }
onCheckedChange={(v) => handleToggleEnabled(r, v)} onCheckedChange={(v) => handleToggleEnabled(r, v)}
/> />
+187
View File
@@ -0,0 +1,187 @@
"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 (
<div className="inline-flex items-center gap-1">
<div
className="size-3 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": foundLabel.color
}}
/>
{foundLabel.name}
</div>
);
}
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 (
<div className="flex items-center justify-end">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
"justify-between text-sm h-8 px-2",
selectedValues.length === 0 &&
"text-muted-foreground",
className
)}
>
<div className="flex items-center gap-2 min-w-0">
<span className="shrink-0">{label}</span>
<Funnel className="size-4 flex-none shrink-0" />
{summary && (
<Badge
className={cn(
"truncate max-w-40",
selectedValues.length === 1 &&
"pl-1.5 pr-2 h-auto"
)}
variant="secondary"
>
{summary}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className={dataTableFilterPopoverContentClassName}
align="start"
>
<Command shouldFilter={false}>
<CommandInput
placeholder={t("labelSearch")}
value={labelSearchQuery}
onValueChange={setlabelsSearchQuery}
/>
<CommandList>
<CommandEmpty>{t("labelsNotFound")}</CommandEmpty>
<CommandGroup>
{selectedValues.length > 0 && (
<CommandItem
onSelect={() => {
onSelectedValuesChange([]);
setOpen(false);
}}
className="text-muted-foreground"
>
{t("accessLabelFilterClear")}
</CommandItem>
)}
{labels.map((label) => (
<CommandItem
key={label.name}
value={label.name}
onSelect={() => {
toggle(label.name);
}}
className="flex items-center gap-2"
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
selectedSet.has(label.name)
? "opacity-100"
: "opacity-0"
)}
/>
<div
className="size-4 rounded-full bg-(--color) flex-none"
style={{
// @ts-expect-error css color
"--color": label.color
}}
/>
{label.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
+31 -56
View File
@@ -2,7 +2,7 @@
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -10,19 +10,21 @@ import {
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { PaginationState } from "@tanstack/react-table";
import { import {
ArrowRight,
ArrowUpDown,
MoreHorizontal,
CircleSlash,
ArrowDown01Icon, ArrowDown01Icon,
ArrowRight,
ArrowUp10Icon, ArrowUp10Icon,
ChevronsUpDownIcon, ChevronsUpDownIcon,
CircleSlash,
MoreHorizontal,
PlusIcon PlusIcon
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -35,21 +37,15 @@ import {
useState, useState,
useTransition useTransition
} from "react"; } from "react";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "./ui/popover";
import { Badge } from "./ui/badge";
import type { PaginationState } from "@tanstack/react-table";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce"; import { useDebouncedCallback } from "use-debounce";
import z from "zod"; import z from "zod";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { ColumnFilterButton } from "./ColumnFilterButton"; import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import { Badge } from "./ui/badge";
import { ControlledDataTable } from "./ui/controlled-data-table";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
export type ClientRow = { export type ClientRow = {
id: number; id: number;
@@ -415,9 +411,15 @@ export default function MachineClientsTable({
id: "labels", id: "labels",
accessorKey: "labels", accessorKey: "labels",
header: () => ( header: () => (
<span className="p-3 text-end w-full inline-block"> <LabelColumnFilterButton
{t("labels")} orgId={orgId}
</span> selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
), ),
cell: ({ row }: { row: { original: ClientRow } }) => ( cell: ({ row }: { row: { original: ClientRow } }) => (
<MachineClientLabelCell <MachineClientLabelCell
@@ -510,11 +512,6 @@ export default function MachineClientsTable({
return baseColumns; return baseColumns;
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]); }, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
function handleFilterChange( function handleFilterChange(
column: string, column: string,
value: string | null | undefined | string[] value: string | null | undefined | string[]
@@ -585,6 +582,7 @@ export default function MachineClientsTable({
rows={machineClients} rows={machineClients}
tableId="machine-clients" tableId="machine-clients"
searchPlaceholder={t("machinesSearch")} searchPlaceholder={t("machinesSearch")}
searchQuery={searchParams.get("query")?.toString()}
onAdd={() => onAdd={() =>
startNavigation(() => startNavigation(() =>
router.push(`/${orgId}/settings/clients/machine/create`) router.push(`/${orgId}/settings/clients/machine/create`)
@@ -602,35 +600,6 @@ export default function MachineClientsTable({
columnVisibility={defaultMachineColumnVisibility} columnVisibility={defaultMachineColumnVisibility}
stickyLeftColumn="name" stickyLeftColumn="name"
stickyRightColumn="actions" stickyRightColumn="actions"
filters={[
{
id: "status",
label: t("status") || "Status",
multiSelect: true,
displayMode: "calculated",
options: [
{
id: "active",
label: t("active") || "Active",
value: "active"
},
{
id: "archived",
label: t("archived") || "Archived",
value: "archived"
},
{
id: "blocked",
label: t("blocked") || "Blocked",
value: "blocked"
}
],
onValueChange(selectedValues: string[]) {
handleFilterChange("status", selectedValues);
},
values: searchParams.getAll("status")
}
]}
/> />
</> </>
); );
@@ -641,7 +610,10 @@ type MachineClientLabelCellProps = {
orgId: string; orgId: string;
}; };
function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps) { function MachineClientLabelCell({
client,
orgId
}: MachineClientLabelCellProps) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false);
@@ -650,7 +622,10 @@ function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps)
const labels = client.labels ?? []; const labels = client.labels ?? [];
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels); const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
function toggleClientLabel(label: SelectedLabel, action: "attach" | "detach") { function toggleClientLabel(
label: SelectedLabel,
action: "attach" | "detach"
) {
startTransition(async () => { startTransition(async () => {
try { try {
if (action === "attach") { if (action === "attach") {
+15 -5
View File
@@ -72,6 +72,7 @@ import { ControlledDataTable } from "./ui/controlled-data-table";
import UptimeMiniBar from "./UptimeMiniBar"; import UptimeMiniBar from "./UptimeMiniBar";
import { LabelsSelector, type SelectedLabel } from "./labels-selector"; import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import { LabelBadge } from "./label-badge"; import { LabelBadge } from "./label-badge";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
export type TargetHealth = { export type TargetHealth = {
targetId: number; targetId: number;
@@ -640,9 +641,15 @@ export default function ProxyResourcesTable({
id: "labels", id: "labels",
accessorKey: "labels", accessorKey: "labels",
header: () => ( header: () => (
<span className="p-3 text-end w-full inline-block"> <LabelColumnFilterButton
{t("labels")} orgId={orgId}
</span> selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
), ),
cell: ({ row }: { row: { original: ResourceRow } }) => ( cell: ({ row }: { row: { original: ResourceRow } }) => (
<ResourceLabelCell resource={row.original} orgId={orgId} /> <ResourceLabelCell resource={row.original} orgId={orgId} />
@@ -655,13 +662,15 @@ export default function ProxyResourcesTable({
function handleFilterChange( function handleFilterChange(
column: string, column: string,
value: string | undefined | null value: string | undefined | null | string[]
) { ) {
searchParams.delete(column); searchParams.delete(column);
searchParams.delete("page"); searchParams.delete("page");
if (value) { if (typeof value === "string") {
searchParams.set(column, value); searchParams.set(column, value);
} else if (value) {
value.forEach((val) => searchParams.append(column, val));
} }
filter({ filter({
searchParams searchParams
@@ -721,6 +730,7 @@ export default function ProxyResourcesTable({
searchPlaceholder={t("resourcesSearch")} searchPlaceholder={t("resourcesSearch")}
pagination={pagination} pagination={pagination}
rowCount={rowCount} rowCount={rowCount}
searchQuery={searchParams.get("query")?.toString()}
onSearch={handleSearchChange} onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange} onPaginationChange={handlePaginationChange}
onAdd={() => onAdd={() =>
+351 -333
View File
@@ -64,6 +64,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelBadge } from "./label-badge"; import { LabelBadge } from "./label-badge";
import { LabelsSelector, type SelectedLabel } from "./labels-selector"; import { LabelsSelector, type SelectedLabel } from "./labels-selector";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
export type SiteRow = { export type SiteRow = {
id: number; id: number;
@@ -136,14 +137,16 @@ export default function SitesTable({
function handleFilterChange( function handleFilterChange(
column: string, column: string,
value: string | undefined | null value: string | undefined | null | string[]
) { ) {
const sp = new URLSearchParams(searchParams); const sp = new URLSearchParams(searchParams);
sp.delete(column); sp.delete(column);
sp.delete("page"); sp.delete("page");
if (value) { if (typeof value === "string") {
sp.set(column, value); sp.set(column, value);
} else if (value) {
value.forEach((val) => sp.append(column, val));
} }
startTransition(() => router.push(`${pathname}?${sp.toString()}`)); startTransition(() => router.push(`${pathname}?${sp.toString()}`));
} }
@@ -183,358 +186,373 @@ export default function SitesTable({
const columns = useMemo<ExtendedColumnDef<SiteRow>[]>(() => { const columns = useMemo<ExtendedColumnDef<SiteRow>[]>(() => {
const cols: ExtendedColumnDef<SiteRow>[] = [ const cols: ExtendedColumnDef<SiteRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
header: () => { header: () => {
const nameOrder = getSortDirection("name", searchParams); const nameOrder = getSortDirection("name", searchParams);
const Icon = const Icon =
nameOrder === "asc" nameOrder === "asc"
? ArrowDown01Icon ? ArrowDown01Icon
: nameOrder === "desc" : nameOrder === "desc"
? ArrowUp10Icon ? ArrowUp10Icon
: ChevronsUpDownIcon; : ChevronsUpDownIcon;
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="p-3" className="p-3"
onClick={() => toggleSort("name")} onClick={() => toggleSort("name")}
> >
{t("name")} {t("name")}
<Icon className="ml-2 h-4 w-4" /> <Icon className="ml-2 h-4 w-4" />
</Button> </Button>
); );
} }
},
{
id: "niceId",
accessorKey: "nice",
friendlyName: t("identifier"),
enableHiding: true,
header: () => {
return <span className="p-3">{t("identifier")}</span>;
}, },
cell: ({ row }) => { {
return <span>{row.original.nice || "-"}</span>; id: "niceId",
} accessorKey: "nice",
}, friendlyName: t("identifier"),
{ enableHiding: true,
accessorKey: "online", header: () => {
friendlyName: t("online"), return <span className="p-3">{t("identifier")}</span>;
header: () => { },
return ( cell: ({ row }) => {
<ColumnFilterButton return <span>{row.original.nice || "-"}</span>;
options={[ }
{ value: "true", label: t("online") }, },
{ value: "false", label: t("offline") } {
]} accessorKey: "online",
selectedValue={booleanSearchFilterSchema.parse( friendlyName: t("online"),
searchParams.get("online") header: () => {
)} return (
onValueChange={(value) => <ColumnFilterButton
handleFilterChange("online", value) options={[
{ value: "true", label: t("online") },
{ value: "false", label: t("offline") }
]}
selectedValue={booleanSearchFilterSchema.parse(
searchParams.get("online")
)}
onValueChange={(value) =>
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 (
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</span>
);
} else {
return (
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div>
<span>{t("offline")}</span>
</span>
);
} }
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 (
<span className="flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>{t("online")}</span>
</span>
);
} else { } else {
return <span>-</span>;
}
}
},
{
id: "uptime",
friendlyName: "Uptime",
header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.type == "local") {
return <span>-</span>;
}
return <UptimeMiniBar siteId={originalRow.id} days={30} />;
}
},
{
accessorKey: "mbIn",
friendlyName: t("dataIn"),
header: () => {
const dataInOrder = getSortDirection(
"megabytesIn",
searchParams
);
const Icon =
dataInOrder === "asc"
? ArrowDown01Icon
: dataInOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("megabytesIn")}
>
{t("dataIn")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: t("dataOut"),
header: () => {
const dataOutOrder = getSortDirection(
"megabytesOut",
searchParams
);
const Icon =
dataOutOrder === "asc"
? ArrowDown01Icon
: dataOutOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("megabytesOut")}
>
{t("dataOut")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "type",
friendlyName: t("type"),
header: () => {
return <span className="p-3">{t("type")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.type === "newt") {
return ( return (
<span className="flex items-center space-x-2"> <div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-neutral-500 rounded-full"></div> <Badge variant="secondary">
<span>{t("offline")}</span> <div className="flex items-center space-x-1">
</span> <span>Newt</span>
{originalRow.newtVersion && (
<span>
v{originalRow.newtVersion}
</span>
)}
</div>
</Badge>
{originalRow.newtUpdateAvailable && (
<InfoPopup
info={t("newtUpdateAvailableInfo")}
/>
)}
</div>
); );
} }
} else {
return <span>-</span>;
}
}
},
{
id: "uptime",
friendlyName: "Uptime",
header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.type == "local") {
return <span>-</span>;
}
return <UptimeMiniBar siteId={originalRow.id} days={30} />;
}
},
{
accessorKey: "mbIn",
friendlyName: t("dataIn"),
header: () => {
const dataInOrder = getSortDirection(
"megabytesIn",
searchParams
);
const Icon =
dataInOrder === "asc"
? ArrowDown01Icon
: dataInOrder === "desc"
? ArrowUp10Icon
: ChevronsUpDownIcon;
return (
<Button
variant="ghost"
onClick={() => toggleSort("megabytesIn")}
>
{t("dataIn")}
<Icon className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "mbOut",
friendlyName: t("dataOut"),
header: () => {
const dataOutOrder = getSortDirection(
"megabytesOut",
searchParams
);
const Icon = if (originalRow.type === "wireguard") {
dataOutOrder === "asc" return (
? ArrowDown01Icon <div className="flex items-center space-x-2">
: dataOutOrder === "desc" <Badge variant="secondary">WireGuard</Badge>
? ArrowUp10Icon </div>
: ChevronsUpDownIcon; );
return ( }
<Button
variant="ghost" if (originalRow.type === "local") {
onClick={() => toggleSort("megabytesOut")} return (
> <div className="flex items-center space-x-2">
{t("dataOut")} <Badge variant="secondary">Local</Badge>
<Icon className="ml-2 h-4 w-4" /> </div>
</Button> );
); }
} }
},
{
accessorKey: "type",
friendlyName: t("type"),
header: () => {
return <span className="p-3">{t("type")}</span>;
}, },
cell: ({ row }) => { {
const originalRow = row.original; id: "resources",
accessorKey: "resourceCount",
if (originalRow.type === "newt") { friendlyName: t("resources"),
header: () => <span className="p-3">{t("resources")}</span>,
cell: ({ row }) => {
const siteRow = row.original;
return ( return (
<div className="flex items-center space-x-1"> <Button
type="button"
variant="ghost"
size="sm"
onClick={() => setResourcesDialogSite(siteRow)}
className="flex h-8 items-center gap-2 px-2 font-normal"
>
<span className="text-sm tabular-nums">
{siteRow.resourceCount} {t("resources")}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
);
}
},
{
accessorKey: "exitNode",
friendlyName: t("exitNode"),
header: () => {
return <span className="p-3">{t("exitNode")}</span>;
},
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 (
<Badge variant="secondary"> <Badge variant="secondary">
<div className="flex items-center space-x-1"> Pangolin {capitalizedName}
<span>Newt</span>
{originalRow.newtVersion && (
<span>v{originalRow.newtVersion}</span>
)}
</div>
</Badge> </Badge>
{originalRow.newtUpdateAvailable && ( );
<InfoPopup }
info={t("newtUpdateAvailableInfo")}
/>
)}
</div>
);
}
if (originalRow.type === "wireguard") { // Self-hosted node
return ( if (originalRow.remoteExitNodeId) {
<div className="flex items-center space-x-2"> return (
<Badge variant="secondary">WireGuard</Badge> <Link
</div> href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
); >
} <Button variant="outline" size="sm">
{originalRow.exitNodeName}
if (originalRow.type === "local") { <ArrowUpRight className="ml-2 h-3 w-3" />
return (
<div className="flex items-center space-x-2">
<Badge variant="secondary">Local</Badge>
</div>
);
}
}
},
{
id: "resources",
accessorKey: "resourceCount",
friendlyName: t("resources"),
header: () => <span className="p-3">{t("resources")}</span>,
cell: ({ row }) => {
const siteRow = row.original;
return (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setResourcesDialogSite(siteRow)}
className="flex h-8 items-center gap-2 px-2 font-normal"
>
<span className="text-sm tabular-nums">
{siteRow.resourceCount} {t("resources")}
</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</Button>
);
}
},
{
accessorKey: "exitNode",
friendlyName: t("exitNode"),
header: () => {
return <span className="p-3">{t("exitNode")}</span>;
},
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 (
<Badge variant="secondary">
Pangolin {capitalizedName}
</Badge>
);
}
// Self-hosted node
if (originalRow.remoteExitNodeId) {
return (
<Link
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
>
<Button variant="outline" size="sm">
{originalRow.exitNodeName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
// Fallback if no remoteExitNodeId
return <span>{originalRow.exitNodeName}</span>;
}
},
{
accessorKey: "address",
header: () => {
return <span className="p-3">{t("address")}</span>;
},
cell: ({ row }) => {
const originalRow = row.original;
return originalRow.address ? (
<div className="flex items-center space-x-2">
<span>{originalRow.address}</span>
</div>
) : (
"-"
);
}
},
{
id: "actions",
enableHiding: false,
header: () => <span className="p-3"></span>,
cell: ({ row }) => {
const siteRow = row.original;
return (
<div className="flex items-center gap-2 justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </Link>
<DropdownMenuContent align="end"> );
<Link }
className="block w-full"
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`} // Fallback if no remoteExitNodeId
> return <span>{originalRow.exitNodeName}</span>;
<DropdownMenuItem> }
{t("viewSettings")} },
</DropdownMenuItem> {
</Link> accessorKey: "address",
<Link header: () => {
className="block w-full" return <span className="p-3">{t("address")}</span>;
href={`/${siteRow.orgId}/settings/resources/proxy?siteId=${siteRow.id}`} },
> cell: ({ row }) => {
<DropdownMenuItem> const originalRow = row.original;
{t("sitesTableViewPublicResources")} return originalRow.address ? (
</DropdownMenuItem> <div className="flex items-center space-x-2">
</Link> <span>{originalRow.address}</span>
<Link </div>
className="block w-full" ) : (
href={`/${siteRow.orgId}/settings/resources/client?siteId=${siteRow.id}`} "-"
> );
<DropdownMenuItem> }
{t("sitesTableViewPrivateResources")} },
</DropdownMenuItem> {
</Link> id: "actions",
</DropdownMenuContent> enableHiding: false,
</DropdownMenu> header: () => <span className="p-3"></span>,
<Link cell: ({ row }) => {
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`} const siteRow = row.original;
> return (
<Button variant={"outline"}> <div className="flex items-center gap-2 justify-end">
{t("edit")} <DropdownMenu>
<ArrowRight className="ml-2 w-4 h-4" /> <DropdownMenuTrigger asChild>
</Button> <Button
</Link> variant="ghost"
</div> className="h-8 w-8 p-0"
); >
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<Link
className="block w-full"
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<DropdownMenuItem>
{t("viewSettings")}
</DropdownMenuItem>
</Link>
<Link
className="block w-full"
href={`/${siteRow.orgId}/settings/resources/proxy?siteId=${siteRow.id}`}
>
<DropdownMenuItem>
{t("sitesTableViewPublicResources")}
</DropdownMenuItem>
</Link>
<Link
className="block w-full"
href={`/${siteRow.orgId}/settings/resources/client?siteId=${siteRow.id}`}
>
<DropdownMenuItem>
{t(
"sitesTableViewPrivateResources"
)}
</DropdownMenuItem>
</Link>
</DropdownMenuContent>
</DropdownMenu>
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"outline"}>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
} }
}
]; ];
if (isLabelFeatureEnabled) { if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, { cols.splice(cols.length - 1, 0, {
accessorKey: "labels", accessorKey: "labels",
header: () => ( header: () => (
<span className="p-3 text-end w-full inline-block"> <LabelColumnFilterButton
{t("labels")} orgId={orgId}
</span> selectedValues={searchParams.getAll("labels")}
onSelectedValuesChange={(value) =>
handleFilterChange("labels", value)
}
label={t("labels")}
className="p-3"
/>
), ),
cell: ({ row }: { row: { original: SiteRow } }) => ( cell: ({ row }: { row: { original: SiteRow } }) => (
<SiteLabelCell site={row.original} orgId={orgId} /> <SiteLabelCell site={row.original} orgId={orgId} />
+1
View File
@@ -794,6 +794,7 @@ export default function UserDevicesTable({
columnVisibility={defaultUserColumnVisibility} columnVisibility={defaultUserColumnVisibility}
onSearch={handleSearchChange} onSearch={handleSearchChange}
onPaginationChange={handlePaginationChange} onPaginationChange={handlePaginationChange}
searchQuery={searchParams.get("query")?.toString()}
pagination={pagination} pagination={pagination}
rowCount={rowCount} rowCount={rowCount}
stickyLeftColumn="name" stickyLeftColumn="name"