mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-16 15:50:09 +02:00
b52baceb50
The site overview rendered an empty badge for any site whose newt has not reported the newer agent fields. The early return only bails out when both agent and newtVersion are missing, so a site with newtVersion set but agent null fell through to a badge whose label came solely from agent and whose version came solely from agentVersion, leaving both blank. Label such a site Newt and fall back to newtVersion for the version. Updating the newt populated the new fields, which is why the badge appeared to fix itself on upgrade. Closes #3766
844 lines
32 KiB
TypeScript
844 lines
32 KiB
TypeScript
"use client";
|
|
|
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
|
import { UptimeMiniBar } from "@app/components/UptimeMiniBar";
|
|
|
|
import {
|
|
Credenza,
|
|
CredenzaBody,
|
|
CredenzaContent,
|
|
CredenzaDescription,
|
|
CredenzaFooter,
|
|
CredenzaHeader,
|
|
CredenzaTitle
|
|
} from "@app/components/Credenza";
|
|
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
|
|
import { Badge } from "@app/components/ui/badge";
|
|
import { Button } from "@app/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger
|
|
} from "@app/components/ui/dropdown-menu";
|
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
|
import { toast } from "@app/hooks/useToast";
|
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
|
import { build } from "@server/build";
|
|
import { type PaginationState } from "@tanstack/react-table";
|
|
import {
|
|
ArrowDown01Icon,
|
|
ArrowRight,
|
|
ArrowUp10Icon,
|
|
ArrowUpRight,
|
|
ChevronDown,
|
|
ChevronsUpDownIcon,
|
|
MoreHorizontal
|
|
} from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { startTransition, useMemo, 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 { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
|
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import semver from "semver";
|
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
|
import { LabelsTableCell } from "./LabelsTableCell";
|
|
|
|
export type SiteRow = {
|
|
id: number;
|
|
nice: string;
|
|
name: string;
|
|
mbIn: string;
|
|
mbOut: string;
|
|
orgId: string;
|
|
type: "newt" | "wireguard" | "local";
|
|
newtVersion?: string;
|
|
agent?: string;
|
|
agentVersion?: string;
|
|
newtUpdateAvailable?: boolean;
|
|
online?: boolean | null;
|
|
address?: string;
|
|
exitNodeName?: string;
|
|
exitNodeEndpoint?: string;
|
|
remoteExitNodeId?: string;
|
|
resourceCount: number;
|
|
labels?: Array<{
|
|
labelId: number;
|
|
name: string;
|
|
color: string;
|
|
}>;
|
|
};
|
|
|
|
type SitesTableProps = {
|
|
sites: SiteRow[];
|
|
pagination: PaginationState;
|
|
orgId: string;
|
|
rowCount: number;
|
|
};
|
|
|
|
const SITE_STATUS_HISTORY_DAYS = 30;
|
|
|
|
export default function SitesTable({
|
|
sites,
|
|
orgId,
|
|
pagination,
|
|
rowCount
|
|
}: SitesTableProps) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const {
|
|
navigate: filter,
|
|
isNavigating: isFiltering,
|
|
searchParams
|
|
} = useNavigationContext();
|
|
|
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
const [deleteWithResources, setDeleteWithResources] = useState(false);
|
|
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
|
const [restartingSite, setRestartingSite] = useState<SiteRow | null>(null);
|
|
const [resourcesDialogSite, setResourcesDialogSite] =
|
|
useState<SiteRow | null>(null);
|
|
const [isRefreshing, startTransition] = useTransition();
|
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
|
|
|
const siteIds = useMemo(() => sites.map((s) => s.id), [sites]);
|
|
|
|
const statusHistoryQuery = useQuery({
|
|
...orgQueries.batchedSiteStatusHistory({
|
|
orgId,
|
|
siteIds,
|
|
days: SITE_STATUS_HISTORY_DAYS
|
|
}),
|
|
enabled: siteIds.length > 0
|
|
});
|
|
|
|
const api = createApiClient(useEnvContext());
|
|
const t = useTranslations();
|
|
|
|
const { data: latestVersions } = useQuery(
|
|
productUpdatesQueries.latestVersion(true)
|
|
);
|
|
|
|
const latestNewtVersion = latestVersions?.data?.newt?.latestVersion;
|
|
|
|
const booleanSearchFilterSchema = z
|
|
.enum(["true", "false"])
|
|
.optional()
|
|
.catch(undefined);
|
|
|
|
function handleFilterChange(
|
|
column: string,
|
|
value: string | undefined | null | string[]
|
|
) {
|
|
const sp = new URLSearchParams(searchParams);
|
|
sp.delete(column);
|
|
sp.delete("page");
|
|
|
|
if (typeof value === "string") {
|
|
sp.set(column, value);
|
|
} else if (value) {
|
|
value.forEach((val) => sp.append(column, val));
|
|
}
|
|
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 restartSite(siteId: number) {
|
|
try {
|
|
await api.post(`/site/${siteId}/restart`);
|
|
toast({
|
|
title: t("siteRestarted"),
|
|
description: t("siteRestartedDescription")
|
|
});
|
|
} catch (e) {
|
|
toast({
|
|
variant: "destructive",
|
|
title: t("siteErrorRestart"),
|
|
description: formatAxiosError(
|
|
e,
|
|
t("siteErrorRestartDescription")
|
|
)
|
|
});
|
|
} finally {
|
|
setRestartingSite(null);
|
|
}
|
|
}
|
|
|
|
function deleteSite(siteId: number, withResources: boolean) {
|
|
startTransition(async () => {
|
|
await api
|
|
.delete(`/site/${siteId}`, {
|
|
params: { deleteResources: withResources }
|
|
})
|
|
.catch((e) => {
|
|
console.error(t("siteErrorDelete"), e);
|
|
toast({
|
|
variant: "destructive",
|
|
title: t("siteErrorDelete"),
|
|
description: formatAxiosError(e, t("siteErrorDelete"))
|
|
});
|
|
})
|
|
.then(() => {
|
|
router.refresh();
|
|
setIsDeleteModalOpen(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
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("status"),
|
|
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("status")}
|
|
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>
|
|
);
|
|
}
|
|
} 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>;
|
|
}
|
|
const data = statusHistoryQuery.data?.[row.original.id];
|
|
return (
|
|
<UptimeMiniBar
|
|
isLoading={statusHistoryQuery.isLoading}
|
|
data={data}
|
|
days={SITE_STATUS_HISTORY_DAYS}
|
|
/>
|
|
);
|
|
}
|
|
},
|
|
{
|
|
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("agent"),
|
|
header: () => {
|
|
return <span className="p-3">{t("type")}</span>;
|
|
},
|
|
cell: ({ row }) => {
|
|
const originalRow = row.original;
|
|
|
|
const updateAvailable = Boolean(
|
|
latestNewtVersion &&
|
|
originalRow.newtVersion &&
|
|
semver.valid(originalRow.newtVersion) &&
|
|
semver.lt(originalRow.newtVersion, latestNewtVersion)
|
|
);
|
|
|
|
if (originalRow.type === "newt") {
|
|
if (!originalRow.agent && !originalRow.newtVersion) {
|
|
// it has not checked in yet
|
|
return <span>-</span>;
|
|
}
|
|
// agent and agentVersion were added after newtVersion, so a
|
|
// site still running an older Newt reports only newtVersion.
|
|
// Without these fallbacks the badge renders with no label and
|
|
// no version at all.
|
|
const agentLabel =
|
|
originalRow.agent == "cli" ? "Pangolin CLI" : "Newt";
|
|
const agentVersion =
|
|
originalRow.agentVersion ?? originalRow.newtVersion;
|
|
return (
|
|
<div className="flex items-center space-x-1">
|
|
<Badge variant="secondary">
|
|
<div className="flex items-center space-x-1">
|
|
<span>{agentLabel}</span>
|
|
{agentVersion && (
|
|
<span>v{agentVersion}</span>
|
|
)}
|
|
</div>
|
|
</Badge>
|
|
{updateAvailable && (
|
|
<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-0 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",
|
|
"erid"
|
|
].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>
|
|
) : (
|
|
"-"
|
|
);
|
|
}
|
|
},
|
|
{
|
|
accessorKey: "labels",
|
|
header: () => (
|
|
<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} />
|
|
)
|
|
},
|
|
{
|
|
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/public?siteId=${siteRow.id}`}
|
|
>
|
|
<DropdownMenuItem>
|
|
{t("sitesTableViewPublicResources")}
|
|
</DropdownMenuItem>
|
|
</Link>
|
|
<Link
|
|
className="block w-full"
|
|
href={`/${siteRow.orgId}/settings/resources/private?siteId=${siteRow.id}`}
|
|
>
|
|
<DropdownMenuItem>
|
|
{t(
|
|
"sitesTableViewPrivateResources"
|
|
)}
|
|
</DropdownMenuItem>
|
|
</Link>
|
|
<DropdownMenuSeparator />
|
|
{siteRow.type === "newt" && (
|
|
<>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
setRestartingSite(siteRow)
|
|
}
|
|
>
|
|
{t("siteRestartButton")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
)}
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setSelectedSite(siteRow);
|
|
setDeleteWithResources(false);
|
|
setIsDeleteModalOpen(true);
|
|
}}
|
|
>
|
|
<span className="text-red-500">
|
|
{t("sitesTableDeleteSite")}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
{siteRow.resourceCount <= 250 && (
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setSelectedSite(siteRow);
|
|
setDeleteWithResources(true);
|
|
setIsDeleteModalOpen(true);
|
|
}}
|
|
>
|
|
<span className="text-red-500">
|
|
{t(
|
|
"sitesTableDeleteSiteAndResources"
|
|
)}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|
|
}
|
|
];
|
|
|
|
return cols;
|
|
}, [
|
|
orgId,
|
|
t,
|
|
searchParams,
|
|
latestNewtVersion,
|
|
statusHistoryQuery.data,
|
|
statusHistoryQuery.isLoading
|
|
]);
|
|
|
|
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 (
|
|
<>
|
|
<Credenza
|
|
open={Boolean(resourcesDialogSite)}
|
|
onOpenChange={(open) => {
|
|
if (!open) setResourcesDialogSite(null);
|
|
}}
|
|
>
|
|
<CredenzaContent className="md:max-w-7xl">
|
|
<CredenzaHeader>
|
|
<CredenzaTitle>{t("siteResourcesTab")}</CredenzaTitle>
|
|
<CredenzaDescription>
|
|
{t("siteResourcesDialogDescription")}
|
|
</CredenzaDescription>
|
|
</CredenzaHeader>
|
|
<CredenzaBody>
|
|
{resourcesDialogSite != null && (
|
|
<SiteResourcesOverview
|
|
orgIdOverride={orgId}
|
|
siteId={resourcesDialogSite.id}
|
|
initialPublicData={null}
|
|
initialPrivateData={null}
|
|
initialPublicForbidden={false}
|
|
initialPrivateForbidden={false}
|
|
/>
|
|
)}
|
|
</CredenzaBody>
|
|
<CredenzaFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setResourcesDialogSite(null)}
|
|
>
|
|
{t("close")}
|
|
</Button>
|
|
</CredenzaFooter>
|
|
</CredenzaContent>
|
|
</Credenza>
|
|
|
|
{restartingSite && (
|
|
<ConfirmDeleteDialog
|
|
open={Boolean(restartingSite)}
|
|
setOpen={(val) => {
|
|
if (!val) setRestartingSite(null);
|
|
}}
|
|
dialog={
|
|
<p>
|
|
{t.rich("siteRestartDialogMessage", {
|
|
name: restartingSite.name,
|
|
b: (chunks) => <b>{chunks}</b>
|
|
})}
|
|
</p>
|
|
}
|
|
buttonText={t("siteRestartButton")}
|
|
onConfirm={() => restartSite(restartingSite.id)}
|
|
string={restartingSite.name}
|
|
warningText={t("siteRestartWarning")}
|
|
title={t("siteRestartTitle")}
|
|
/>
|
|
)}
|
|
|
|
{selectedSite && (
|
|
<ConfirmDeleteDialog
|
|
open={isDeleteModalOpen}
|
|
setOpen={(val) => {
|
|
setIsDeleteModalOpen(val);
|
|
setSelectedSite(null);
|
|
setDeleteWithResources(false);
|
|
}}
|
|
dialog={
|
|
<div className="space-y-2">
|
|
<p>
|
|
{deleteWithResources
|
|
? t("siteQuestionRemoveAndResources")
|
|
: t("siteQuestionRemove")}
|
|
</p>
|
|
<p>
|
|
{deleteWithResources
|
|
? t("siteMessageRemoveAndResources")
|
|
: t("siteMessageRemove")}
|
|
</p>
|
|
</div>
|
|
}
|
|
buttonText={
|
|
deleteWithResources
|
|
? t("siteConfirmDeleteAndResources")
|
|
: t("siteConfirmDelete")
|
|
}
|
|
onConfirm={async () =>
|
|
startTransition(() =>
|
|
deleteSite(selectedSite!.id, deleteWithResources)
|
|
)
|
|
}
|
|
string={selectedSite.name}
|
|
title={
|
|
deleteWithResources
|
|
? t("siteDeleteAndResources")
|
|
: t("siteDelete")
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<ControlledDataTable
|
|
columns={columns}
|
|
rows={sites}
|
|
tableId="sites-table"
|
|
searchPlaceholder={t("searchSitesProgress")}
|
|
pagination={pagination}
|
|
onPaginationChange={handlePaginationChange}
|
|
onAdd={() =>
|
|
startNavigation(() =>
|
|
router.push(`/${orgId}/settings/sites/create`)
|
|
)
|
|
}
|
|
isNavigatingToAddPage={isNavigatingToAddPage}
|
|
searchQuery={searchParams.get("query")?.toString()}
|
|
onSearch={handleSearchChange}
|
|
addButtonText={t("siteAdd")}
|
|
onRefresh={refreshData}
|
|
isRefreshing={isRefreshing || isFiltering}
|
|
rowCount={rowCount}
|
|
columnVisibility={{
|
|
niceId: false,
|
|
nice: false,
|
|
exitNode: false,
|
|
address: false,
|
|
labels: true
|
|
}}
|
|
enableColumnVisibility
|
|
stickyLeftColumn="name"
|
|
stickyRightColumn="actions"
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
type SiteLabelCellProps = {
|
|
site: SiteRow;
|
|
orgId: string;
|
|
};
|
|
|
|
function SiteLabelCell({ site, orgId }: SiteLabelCellProps) {
|
|
const { localLabels, refresh, toggleLabel } = useOptimisticLabels({
|
|
serverLabels: site.labels,
|
|
orgId,
|
|
entityId: site.id,
|
|
entityIdField: "siteId"
|
|
});
|
|
|
|
return (
|
|
<LabelsTableCell
|
|
orgId={orgId}
|
|
selectedLabels={localLabels}
|
|
onToggleLabel={toggleLabel}
|
|
onClosePopover={() => startTransition(refresh)}
|
|
/>
|
|
);
|
|
}
|