Files
pangolin/src/components/SitesTable.tsx
T
Hayyan Hajwani b52baceb50 fix(sites): fall back to newtVersion so the Type badge is never empty
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
2026-09-16 13:55:24 +05:30

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)}
/>
);
}