Files
pangolin/src/app/[orgId]/settings/logs/connection/page.tsx
T
2026-08-19 10:51:21 -04:00

666 lines
25 KiB
TypeScript

"use client";
import { Button } from "@app/components/ui/button";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { logQueries } from "@app/lib/queries";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types";
import { useQuery } from "@tanstack/react-query";
import { ColumnDef } from "@tanstack/react-table";
import axios from "axios";
import { ArrowUpRight, Laptop, User } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useMemo, useState, useTransition } from "react";
function formatDuration(startedAt: number, endedAt: number | null): string {
if (endedAt === null || endedAt === undefined) return "Active";
const durationSec = endedAt - startedAt;
if (durationSec < 0) return "-";
if (durationSec < 60) return `${durationSec}s`;
if (durationSec < 3600) {
const m = Math.floor(durationSec / 60);
const s = durationSec % 60;
return `${m}m ${s}s`;
}
const h = Math.floor(durationSec / 3600);
const m = Math.floor((durationSec % 3600) / 60);
return `${h}h ${m}m`;
}
export default function ConnectionLogsPage() {
const router = useRouter();
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { orgId } = useParams();
const searchParams = useSearchParams();
const { isPaidUser } = usePaidStatus();
const [isExporting, startTransition] = useTransition();
const [filters, setFilters] = useState<{
protocol?: string;
destAddr?: string;
clientId?: string;
siteResourceId?: string;
userId?: string;
}>({
protocol: searchParams.get("protocol") || undefined,
destAddr: searchParams.get("destAddr") || undefined,
clientId: searchParams.get("clientId") || undefined,
siteResourceId: searchParams.get("siteResourceId") || undefined,
userId: searchParams.get("userId") || undefined
});
const [currentPage, setCurrentPage] = useState<number>(0);
const [pageSize, setPageSize] = useStoredPageSize(
"connection-audit-logs",
20
);
const getDefaultDateRange = () => {
const startParam = searchParams.get("start");
const endParam = searchParams.get("end");
if (startParam && endParam) {
return {
startDate: { date: new Date(startParam) },
endDate: { date: new Date(endParam) }
};
}
return {
startDate: { date: getSevenDaysAgo() },
endDate: { date: new Date() }
};
};
const [dateRange, setDateRange] = useState<{
startDate: DateTimeValue;
endDate: DateTimeValue;
}>(getDefaultDateRange());
const queryFilters = useMemo(() => {
let timeStart: string | undefined;
let timeEnd: string | undefined;
if (dateRange.startDate?.date) {
const dt = new Date(dateRange.startDate.date);
if (dateRange.startDate.time) {
const [h, m, s] = dateRange.startDate.time
.split(":")
.map(Number);
dt.setHours(h, m, s || 0);
}
timeStart = dt.toISOString();
}
if (dateRange.endDate?.date) {
const dt = new Date(dateRange.endDate.date);
if (dateRange.endDate.time) {
const [h, m, s] = dateRange.endDate.time.split(":").map(Number);
dt.setHours(h, m, s || 0);
} else {
const now = new Date();
dt.setHours(
now.getHours(),
now.getMinutes(),
now.getSeconds(),
now.getMilliseconds()
);
}
timeEnd = dt.toISOString();
}
return {
timeStart,
timeEnd,
page: currentPage,
pageSize,
...filters,
clientId: filters.clientId ? Number(filters.clientId) : undefined,
siteResourceId: filters.siteResourceId
? Number(filters.siteResourceId)
: undefined
};
}, [dateRange, currentPage, pageSize, filters]);
const { data, isFetching, isLoading, refetch } = useQuery({
...logQueries.connection({
orgId: orgId as string,
filters: queryFilters
}),
enabled: isPaidUser(tierMatrix.connectionLogs) && build !== "oss"
});
const rows = isLoading ? generateSampleConnectionLogs() : (data?.log ?? []);
const totalCount = data?.pagination?.total ?? 0;
const filterAttributes = data?.filterAttributes ?? {
protocols: [],
destAddrs: [],
clients: [],
resources: [],
users: []
};
const handleDateRangeChange = (
startDate: DateTimeValue,
endDate: DateTimeValue
) => {
setDateRange({ startDate, endDate });
setCurrentPage(0);
updateUrlParamsForAllFilters({
start: startDate.date?.toISOString() || "",
end: endDate.date?.toISOString() || ""
});
};
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
};
const handleRefresh = () => {
// When the end date has no explicit time, it represents an
// open-ended "up to now" upper bound. Since dateRange is only
// recomputed on user interaction, that upper bound otherwise stays
// frozen at whenever the page first loaded, so refreshing would
// never surface logs created since then. Bump it to the current
// time so the query key changes and refetches the latest window.
if (dateRange.endDate?.date && !dateRange.endDate.time) {
setDateRange((prev) => ({
...prev,
endDate: { date: new Date() }
}));
} else {
refetch();
}
};
const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize);
setCurrentPage(0);
};
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters);
setCurrentPage(0);
updateUrlParamsForAllFilters(newFilters);
};
const updateUrlParamsForAllFilters = (
newFilters:
| typeof filters
| {
start: string;
end: string;
}
) => {
const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
router.replace(`?${params.toString()}`, { scroll: false });
};
const exportData = async () => {
try {
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
: undefined,
timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString()
: undefined,
...filters
};
const response = await api.get(
`/org/${orgId}/logs/connection/export`,
{
responseType: "blob",
params
}
);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
const epoch = Math.floor(Date.now() / 1000);
link.setAttribute(
"download",
`connection-audit-logs-${orgId}-${epoch}.csv`
);
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
} catch (error) {
let apiErrorMessage: string | null = null;
if (axios.isAxiosError(error) && error.response) {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
}
}
toast({
title: t("error"),
description: apiErrorMessage ?? t("exportError"),
variant: "destructive"
});
}
};
const columns: ColumnDef<any>[] = [
{
accessorKey: "startedAt",
header: () => <span className="px-2">{t("timestamp")}</span>,
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
{new Date(
row.original.startedAt * 1000
).toLocaleString()}
</div>
);
}
},
{
accessorKey: "protocol",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.protocols.map(
(protocol) => ({
label: protocol.toUpperCase(),
value: protocol
})
)}
label={t("protocol")}
selectedValue={filters.protocol}
onValueChange={(value) =>
handleFilterChange("protocol", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return (
<span className="whitespace-nowrap font-mono text-xs">
{row.original.protocol?.toUpperCase() || (
<span className="text-muted-foreground">-</span>
)}
</span>
);
}
},
{
accessorKey: "resourceName",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.resources.map((res) => ({
value: res.id.toString(),
label: res.name || "Unnamed Resource"
}))}
label={t("resource")}
selectedValue={filters.siteResourceId}
onValueChange={(value) =>
handleFilterChange("siteResourceId", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
if (
!row.original.resourceNiceId ||
!row.original.resourceName
) {
return (
<span className="text-xs text-muted-foreground">-</span>
);
}
return (
<Link
href={getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)}
>
<Button variant="outline" size="sm">
{row.original.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
{
accessorKey: "clientName",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.clients.map((c) => ({
value: c.id.toString(),
label: c.name
}))}
label={t("client")}
selectedValue={filters.clientId}
onValueChange={(value) =>
handleFilterChange("clientId", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
const clientType = row.original.userId ? "user" : "machine";
if (row.original.clientName && row.original.clientNiceId) {
return (
<Link
href={`/${row.original.orgId}/settings/clients/${clientType}/${row.original.clientNiceId}`}
>
<Button variant="outline" size="sm">
<Laptop className="mr-1 h-3 w-3" />
{row.original.clientName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
if (row.original.clientName) {
return (
<span className="whitespace-nowrap">
{row.original.clientName}
</span>
);
}
return <span className="text-xs text-muted-foreground">-</span>;
}
},
{
accessorKey: "userEmail",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.users.map((u) => ({
value: u.id,
label: u.email || u.id
}))}
label={t("user")}
selectedValue={filters.userId}
onValueChange={(value) =>
handleFilterChange("userId", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
if (row.original.userEmail || row.original.userId) {
return (
<span className="flex items-center gap-1 whitespace-nowrap">
<User className="h-4 w-4" />
{row.original.userEmail ?? row.original.userId}
</span>
);
}
return <span className="text-xs text-muted-foreground">-</span>;
}
},
{
accessorKey: "sourceAddr",
header: () => <span className="px-2">{t("sourceAddress")}</span>,
cell: ({ row }) => {
return row.original.sourceAddr ? (
<span className="whitespace-nowrap font-mono text-xs">
{row.original.sourceAddr}
</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
);
}
},
{
accessorKey: "destAddr",
header: () => {
return (
<div className="flex items-center gap-2 px-2">
<ColumnFilterButton
options={filterAttributes.destAddrs.map((addr) => ({
value: addr,
label: addr
}))}
label={t("destinationAddress")}
selectedValue={filters.destAddr}
onValueChange={(value) =>
handleFilterChange("destAddr", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
/>
</div>
);
},
cell: ({ row }) => {
return row.original.destAddr ? (
<span className="whitespace-nowrap font-mono text-xs">
{row.original.destAddr}
</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
);
}
},
{
accessorKey: "duration",
header: () => <span className="px-2">{t("duration")}</span>,
cell: ({ row }) => {
return (
<span className="whitespace-nowrap">
{formatDuration(
row.original.startedAt,
row.original.endedAt
)}
</span>
);
}
}
];
const renderExpandedRow = (row: any) => {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs">
<div className="space-y-2">
<div>
<strong>Session ID:</strong>{" "}
<span className="font-mono">
{row.sessionId ?? "-"}
</span>
</div>
<div>
<strong>Protocol:</strong>{" "}
{row.protocol?.toUpperCase() ?? "-"}
</div>
<div>
<strong>Source:</strong>{" "}
<span className="font-mono">
{row.sourceAddr ?? "-"}
</span>
</div>
<div>
<strong>Destination:</strong>{" "}
<span className="font-mono">
{row.destAddr ?? "-"}
</span>
</div>
</div>
<div className="space-y-2">
<div>
<strong>Client Endpoint:</strong>{" "}
<span className="font-mono">
{row.clientEndpoint ?? "-"}
</span>
</div>
<div>
<strong>Site:</strong> {row.siteName ?? "-"}
{row.siteNiceId && (
<span className="text-muted-foreground ml-1">
({row.siteNiceId})
</span>
)}
</div>
<div>
<strong>Site ID:</strong> {row.siteId ?? "-"}
</div>
<div>
<strong>Started At:</strong>{" "}
{row.startedAt
? new Date(
row.startedAt * 1000
).toLocaleString()
: "-"}
</div>
<div>
<strong>Ended At:</strong>{" "}
{row.endedAt
? new Date(row.endedAt * 1000).toLocaleString()
: "Active"}
</div>
<div>
<strong>Duration:</strong>{" "}
{formatDuration(row.startedAt, row.endedAt)}
</div>
</div>
<div className="space-y-2" />
</div>
</div>
);
};
return (
<>
<SettingsSectionTitle
title={t("connectionLogs")}
description={t("connectionLogsDescription")}
/>
<PaidFeaturesAlert tiers={tierMatrix.connectionLogs} />
<LogDataTable
columns={columns}
data={rows}
title={t("connectionLogs")}
searchPlaceholder={t("searchLogs")}
searchColumn="protocol"
onRefresh={handleRefresh}
isRefreshing={isFetching}
onExport={() => startTransition(exportData)}
isExporting={isExporting}
onDateRangeChange={handleDateRangeChange}
dateRange={{
start: dateRange.startDate,
end: dateRange.endDate
}}
defaultSort={{
id: "startedAt",
desc: true
}}
totalCount={totalCount}
currentPage={currentPage}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
isLoading={isLoading}
expandable={true}
renderExpandedRow={renderExpandedRow}
disabled={
!isPaidUser(tierMatrix.connectionLogs) || build === "oss"
}
/>
</>
);
}
function generateSampleConnectionLogs(): QueryConnectionAuditLogResponse["log"] {
const protocols = ["tcp", "udp", "icmp"];
const destAddrs = [
"10.0.0.1:22",
"10.0.0.2:80",
"10.0.0.3:443",
"192.168.1.10:3306"
];
const now = Math.floor(Date.now() / 1000);
const sevenDaysAgo = now - 7 * 24 * 60 * 60;
return Array.from({ length: 10 }, (_, i) => {
const startedAt = Math.floor(
sevenDaysAgo + Math.random() * (now - sevenDaysAgo)
);
const active = Math.random() > 0.3;
return {
sessionId: `session-${i}`,
siteResourceId: (i % 3) + 1,
orgId: "sample-org",
siteId: 1,
clientId: (i % 4) + 1,
clientEndpoint: `10.0.0.${i + 1}:51820`,
userId: i % 2 === 0 ? `user-${i}` : null,
sourceAddr: `192.168.1.${i + 1}:${40000 + i}`,
destAddr: destAddrs[Math.floor(Math.random() * destAddrs.length)],
protocol: protocols[Math.floor(Math.random() * protocols.length)],
startedAt,
endedAt: active
? null
: startedAt + Math.floor(Math.random() * 3600),
bytesTx: active ? null : Math.floor(Math.random() * 1024 * 1024),
bytesRx: active ? null : Math.floor(Math.random() * 1024 * 1024),
resourceName: `Resource ${(i % 3) + 1}`,
resourceNiceId: `resource-${(i % 3) + 1}`,
siteName: "Sample Site",
siteNiceId: "sample-site",
clientName: `Client ${(i % 4) + 1}`,
clientNiceId: `client-${(i % 4) + 1}`,
clientType: i % 2 === 0 ? "user" : "machine",
userEmail: i % 2 === 0 ? `user${i}@example.com` : null
};
});
}