mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-31 02:39:26 +00:00
441 lines
15 KiB
TypeScript
441 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { Badge } from "@app/components/ui/badge";
|
|
import { Button } from "@app/components/ui/button";
|
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
|
import { toast } from "@app/hooks/useToast";
|
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
import { build } from "@server/build";
|
|
import { type PaginationState } from "@tanstack/react-table";
|
|
import {
|
|
ArrowDown01Icon,
|
|
ArrowUp10Icon,
|
|
ArrowUpRight,
|
|
Check,
|
|
ChevronsUpDownIcon
|
|
} from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { useState, useTransition } from "react";
|
|
import { useDebouncedCallback } from "use-debounce";
|
|
import z from "zod";
|
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
|
import {
|
|
ControlledDataTable,
|
|
type ExtendedColumnDef
|
|
} from "./ui/controlled-data-table";
|
|
import { SiteRow } from "./SitesTable";
|
|
|
|
type PendingSitesTableProps = {
|
|
sites: SiteRow[];
|
|
pagination: PaginationState;
|
|
orgId: string;
|
|
rowCount: number;
|
|
};
|
|
|
|
export default function PendingSitesTable({
|
|
sites,
|
|
orgId,
|
|
pagination,
|
|
rowCount
|
|
}: PendingSitesTableProps) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const {
|
|
navigate: filter,
|
|
isNavigating: isFiltering,
|
|
searchParams
|
|
} = useNavigationContext();
|
|
|
|
const [isRefreshing, startTransition] = useTransition();
|
|
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
|
|
|
|
const api = createApiClient(useEnvContext());
|
|
const t = useTranslations();
|
|
|
|
const booleanSearchFilterSchema = z
|
|
.enum(["true", "false"])
|
|
.optional()
|
|
.catch(undefined);
|
|
|
|
function handleFilterChange(
|
|
column: string,
|
|
value: string | undefined | null
|
|
) {
|
|
const sp = new URLSearchParams(searchParams);
|
|
sp.delete(column);
|
|
sp.delete("page");
|
|
|
|
if (value) {
|
|
sp.set(column, value);
|
|
}
|
|
startTransition(() => router.push(`${pathname}?${sp.toString()}`));
|
|
}
|
|
|
|
function refreshData() {
|
|
startTransition(async () => {
|
|
try {
|
|
router.refresh();
|
|
} catch (error) {
|
|
toast({
|
|
title: t("error"),
|
|
description: t("refreshError"),
|
|
variant: "destructive"
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
async function approveSite(siteId: number) {
|
|
setApprovingIds((prev) => new Set(prev).add(siteId));
|
|
try {
|
|
await api.post(`/site/${siteId}`, { status: "approved" });
|
|
toast({
|
|
title: t("success"),
|
|
description: t("siteApproveSuccess"),
|
|
variant: "default"
|
|
});
|
|
router.refresh();
|
|
} catch (e) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: t("siteApproveError"),
|
|
description: formatAxiosError(e, t("siteApproveError"))
|
|
});
|
|
} finally {
|
|
setApprovingIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(siteId);
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
|
|
const columns: ExtendedColumnDef<SiteRow>[] = [
|
|
{
|
|
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>;
|
|
},
|
|
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="text-green-500 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="text-neutral-500 flex items-center space-x-2">
|
|
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
|
<span>{t("offline")}</span>
|
|
</span>
|
|
);
|
|
}
|
|
} else {
|
|
return <span>-</span>;
|
|
}
|
|
}
|
|
},
|
|
{
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
}
|
|
},
|
|
{
|
|
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"
|
|
].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>
|
|
);
|
|
}
|
|
|
|
if (originalRow.remoteExitNodeId) {
|
|
return (
|
|
<Link
|
|
href={`/${originalRow.orgId}/settings/remote-exit-nodes/${originalRow.remoteExitNodeId}`}
|
|
>
|
|
<Button variant="outline">
|
|
{originalRow.exitNodeName}
|
|
<ArrowUpRight className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
return <span>{originalRow.exitNodeName}</span>;
|
|
}
|
|
},
|
|
{
|
|
accessorKey: "address",
|
|
header: () => {
|
|
return <span className="p-3">{t("address")}</span>;
|
|
},
|
|
cell: ({ row }: { row: any }) => {
|
|
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;
|
|
const isApproving = approvingIds.has(siteRow.id);
|
|
return (
|
|
<div className="flex items-center gap-2 justify-end">
|
|
<Button
|
|
variant="outline"
|
|
disabled={isApproving}
|
|
onClick={() => approveSite(siteRow.id)}
|
|
>
|
|
<Check className="mr-2 w-4 h-4" />
|
|
{t("approve")}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
];
|
|
|
|
function toggleSort(column: string) {
|
|
const newSearch = getNextSortOrder(column, searchParams);
|
|
|
|
filter({
|
|
searchParams: newSearch
|
|
});
|
|
}
|
|
|
|
const handlePaginationChange = (newPage: PaginationState) => {
|
|
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
|
searchParams.set("pageSize", newPage.pageSize.toString());
|
|
filter({
|
|
searchParams
|
|
});
|
|
};
|
|
|
|
const handleSearchChange = useDebouncedCallback((query: string) => {
|
|
searchParams.set("query", query);
|
|
searchParams.delete("page");
|
|
filter({
|
|
searchParams
|
|
});
|
|
}, 300);
|
|
|
|
return (
|
|
<ControlledDataTable
|
|
columns={columns}
|
|
rows={sites}
|
|
tableId="pending-sites-table"
|
|
searchPlaceholder={t("searchSitesProgress")}
|
|
pagination={pagination}
|
|
onPaginationChange={handlePaginationChange}
|
|
searchQuery={searchParams.get("query")?.toString()}
|
|
onSearch={handleSearchChange}
|
|
onRefresh={refreshData}
|
|
isRefreshing={isRefreshing || isFiltering}
|
|
rowCount={rowCount}
|
|
columnVisibility={{
|
|
niceId: false,
|
|
nice: false,
|
|
exitNode: false,
|
|
address: false
|
|
}}
|
|
enableColumnVisibility
|
|
stickyLeftColumn="name"
|
|
stickyRightColumn="actions"
|
|
/>
|
|
);
|
|
}
|