mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-15 02:01:55 +02:00
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:
@@ -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.",
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user