mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-27 19:22:50 +00:00
✨ add label filter column to sitesTable
This commit is contained in:
@@ -1164,6 +1164,8 @@
|
||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||
"labelsNotFound": "Labels not found",
|
||||
"labelSearch": "Search labels",
|
||||
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||
"accessLabelFilterClear": "Clear label filters",
|
||||
"selectColor": "Select color",
|
||||
"createNewLabel": "Create new org label \"{label}\"",
|
||||
"inviteInvalidDescription": "The invite link is invalid.",
|
||||
|
||||
@@ -71,7 +71,7 @@ const listResourcesSchema = z.object({
|
||||
}),
|
||||
query: z.string().optional(),
|
||||
sort_by: z
|
||||
.enum(["name"])
|
||||
.literal("name")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
|
||||
@@ -187,6 +187,26 @@ const listSitesSchema = z.object({
|
||||
type: "string",
|
||||
enum: ["pending", "approved"],
|
||||
description: "Filter by site status"
|
||||
}),
|
||||
labels: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(val)) {
|
||||
return val;
|
||||
}
|
||||
// the array is returned as this
|
||||
if (typeof val === "string") {
|
||||
return val.split(",");
|
||||
}
|
||||
return undefined;
|
||||
}, z.array(z.string()))
|
||||
.optional()
|
||||
.catch([])
|
||||
.openapi({
|
||||
type: "array",
|
||||
description: "Filter by site labels"
|
||||
})
|
||||
});
|
||||
|
||||
@@ -319,8 +339,16 @@ export async function listSites(
|
||||
tierMatrix.labels
|
||||
);
|
||||
|
||||
const { pageSize, page, query, sort_by, order, online, status } =
|
||||
parsedQuery.data;
|
||||
const {
|
||||
pageSize,
|
||||
page,
|
||||
query,
|
||||
sort_by,
|
||||
order,
|
||||
online,
|
||||
status,
|
||||
labels: labelFilter
|
||||
} = parsedQuery.data;
|
||||
|
||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||
|
||||
@@ -337,6 +365,23 @@ export async function listSites(
|
||||
if (typeof status !== "undefined") {
|
||||
conditions.push(eq(sites.status, status));
|
||||
}
|
||||
|
||||
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
sites.siteId,
|
||||
db
|
||||
.select({ id: siteLabels.siteId })
|
||||
.from(siteLabels)
|
||||
.innerJoin(
|
||||
labels,
|
||||
eq(labels.labelId, siteLabels.labelId)
|
||||
)
|
||||
.where(inArray(labels.name, labelFilter))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
const q = "%" + query.toLowerCase() + "%";
|
||||
const queryList = [
|
||||
@@ -366,7 +411,9 @@ export async function listSites(
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(
|
||||
querySitesBase().where(and(...conditions)).as("filtered_sites")
|
||||
querySitesBase()
|
||||
.where(and(...conditions))
|
||||
.as("filtered_sites")
|
||||
);
|
||||
|
||||
const siteListQuery = baseQuery
|
||||
|
||||
184
src/components/LabelColumnFilterButton.tsx
Normal file
184
src/components/LabelColumnFilterButton.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from "@app/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
||||
import { CheckIcon, Funnel } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebounce } from "use-debounce";
|
||||
|
||||
type LabelColumnFilterButtonProps = {
|
||||
selectedValues: string[];
|
||||
onSelectedValuesChange: (values: string[]) => void;
|
||||
className?: string;
|
||||
label: string;
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
export function LabelColumnFilterButton({
|
||||
selectedValues,
|
||||
onSelectedValuesChange,
|
||||
className,
|
||||
label,
|
||||
orgId
|
||||
}: LabelColumnFilterButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
|
||||
const [labelSearchQuery, setlabelsSearchQuery] = useState("");
|
||||
const [debouncedQuery] = useDebounce(labelSearchQuery, 150);
|
||||
|
||||
const { data: labels = [] } = useQuery(
|
||||
orgQueries.labels({
|
||||
orgId,
|
||||
query: debouncedQuery,
|
||||
perPage: 500
|
||||
})
|
||||
);
|
||||
|
||||
const selectedSet = useMemo(
|
||||
() => new Set(selectedValues),
|
||||
[selectedValues]
|
||||
);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (selectedValues.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (selectedValues.length === 1) {
|
||||
const foundLabel = labels.find((o) => o.name === selectedValues[0]);
|
||||
|
||||
if (foundLabel) {
|
||||
return (
|
||||
<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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { LabelBadge } from "./label-badge";
|
||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||
|
||||
export type SiteRow = {
|
||||
id: number;
|
||||
@@ -136,14 +137,16 @@ export default function SitesTable({
|
||||
|
||||
function handleFilterChange(
|
||||
column: string,
|
||||
value: string | undefined | null
|
||||
value: string | undefined | null | string[]
|
||||
) {
|
||||
const sp = new URLSearchParams(searchParams);
|
||||
sp.delete(column);
|
||||
sp.delete("page");
|
||||
|
||||
if (value) {
|
||||
if (typeof value === "string") {
|
||||
sp.set(column, value);
|
||||
} else if (value) {
|
||||
value.forEach((val) => sp.append(column, val));
|
||||
}
|
||||
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
||||
}
|
||||
@@ -183,358 +186,373 @@ export default function SitesTable({
|
||||
|
||||
const columns = useMemo<ExtendedColumnDef<SiteRow>[]>(() => {
|
||||
const cols: ExtendedColumnDef<SiteRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
header: () => {
|
||||
const nameOrder = getSortDirection("name", searchParams);
|
||||
const Icon =
|
||||
nameOrder === "asc"
|
||||
? ArrowDown01Icon
|
||||
: nameOrder === "desc"
|
||||
? ArrowUp10Icon
|
||||
: ChevronsUpDownIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "niceId",
|
||||
accessorKey: "nice",
|
||||
friendlyName: t("identifier"),
|
||||
enableHiding: true,
|
||||
header: () => {
|
||||
return <span className="p-3">{t("identifier")}</span>;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-3"
|
||||
onClick={() => toggleSort("name")}
|
||||
>
|
||||
{t("name")}
|
||||
<Icon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <span>{row.original.nice || "-"}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: t("online"),
|
||||
header: () => {
|
||||
return (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{ value: "true", label: t("online") },
|
||||
{ value: "false", label: t("offline") }
|
||||
]}
|
||||
selectedValue={booleanSearchFilterSchema.parse(
|
||||
searchParams.get("online")
|
||||
)}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("online", value)
|
||||
{
|
||||
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>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "online",
|
||||
friendlyName: t("online"),
|
||||
header: () => {
|
||||
return (
|
||||
<ColumnFilterButton
|
||||
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 {
|
||||
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 (
|
||||
<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>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Badge variant="secondary">
|
||||
<div className="flex items-center space-x-1">
|
||||
<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 =
|
||||
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>;
|
||||
if (originalRow.type === "wireguard") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary">WireGuard</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.type === "local") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary">Local</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const originalRow = row.original;
|
||||
|
||||
if (originalRow.type === "newt") {
|
||||
{
|
||||
id: "resources",
|
||||
accessorKey: "resourceCount",
|
||||
friendlyName: t("resources"),
|
||||
header: () => <span className="p-3">{t("resources")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const siteRow = row.original;
|
||||
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">
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>Newt</span>
|
||||
{originalRow.newtVersion && (
|
||||
<span>v{originalRow.newtVersion}</span>
|
||||
)}
|
||||
</div>
|
||||
Pangolin {capitalizedName}
|
||||
</Badge>
|
||||
{originalRow.newtUpdateAvailable && (
|
||||
<InfoPopup
|
||||
info={t("newtUpdateAvailableInfo")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.type === "wireguard") {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="secondary">WireGuard</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (originalRow.type === "local") {
|
||||
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" />
|
||||
// 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>
|
||||
</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>
|
||||
);
|
||||
</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>
|
||||
</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) {
|
||||
cols.splice(cols.length - 1, 0, {
|
||||
accessorKey: "labels",
|
||||
header: () => (
|
||||
<span className="p-3 text-end w-full inline-block">
|
||||
{t("labels")}
|
||||
</span>
|
||||
<LabelColumnFilterButton
|
||||
orgId={orgId}
|
||||
selectedValues={searchParams.getAll("labels")}
|
||||
onSelectedValuesChange={(value) =>
|
||||
handleFilterChange("labels", value)
|
||||
}
|
||||
label={t("labels")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }: { row: { original: SiteRow } }) => (
|
||||
<SiteLabelCell site={row.original} orgId={orgId} />
|
||||
|
||||
Reference in New Issue
Block a user