Files
pangolin/src/components/HealthChecksTable.tsx
2026-04-21 18:35:38 -07:00

711 lines
26 KiB
TypeScript

"use client";
import UptimeMiniBar from "@app/components/UptimeMiniBar";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import HealthCheckCredenza, {
HealthCheckRow
} from "@app/components/HealthCheckCredenza";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { Badge } from "@app/components/ui/badge";
import { Button } from "@app/components/ui/button";
import {
ControlledDataTable,
type ExtendedColumnDef
} from "@app/components/ui/controlled-data-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Switch } from "@app/components/ui/switch";
import { toast } from "@app/hooks/useToast";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
import {
ResourceSelector,
SelectedResource
} from "@app/components/resource-selector";
import {
ArrowUpDown,
ArrowUpRight,
Funnel,
MoreHorizontal
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState, useTransition, useEffect, useMemo } from "react";
import type { PaginationState } from "@tanstack/react-table";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useDebouncedCallback } from "use-debounce";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { cn } from "@app/lib/cn";
type StandaloneHealthChecksTableProps = {
orgId: string;
healthChecks: HealthCheckRow[];
rowCount: number;
pagination: PaginationState;
initialFilterSite?: Selectedsite | null;
initialFilterResource?: SelectedResource | null;
};
function formatTarget(row: HealthCheckRow): string {
if (!row.hcHostname) return "-";
if (row.hcMode === "tcp") {
if (!row.hcPort) return row.hcHostname;
return `${row.hcHostname}:${row.hcPort}`;
}
if (row.hcMode === "snmp" || row.hcMode === "ping") {
if (row.hcPort) {
return `${row.hcHostname}:${row.hcPort}`;
}
return row.hcHostname;
}
// HTTP / default
const scheme = row.hcScheme ?? "http";
const host = row.hcHostname;
const port = row.hcPort ? `:${row.hcPort}` : "";
const path = row.hcPath ?? "/";
return `${scheme}://${host}${port}${path}`;
}
export default function HealthChecksTable({
orgId,
healthChecks,
rowCount,
pagination,
initialFilterSite = null,
initialFilterResource = null
}: StandaloneHealthChecksTableProps) {
const router = useRouter();
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [isRefreshing, startRefresh] = useTransition();
const { isPaidUser } = usePaidStatus();
const isPaid = isPaidUser(tierMatrix.standaloneHealthChecks);
const [credenzaOpen, setCredenzaOpen] = useState(false);
const {
navigate: filter,
isNavigating: isFiltering,
searchParams
} = useNavigationContext();
const [deleteOpen, setDeleteOpen] = useState(false);
const [selected, setSelected] = useState<HealthCheckRow | null>(null);
const [togglingId, setTogglingId] = useState<number | null>(null);
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
const [resourceFilterOpen, setResourceFilterOpen] = useState(false);
const pageSize = pagination.pageSize;
const query = searchParams.get("query") ?? undefined;
const siteIdQ = searchParams.get("siteId");
const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN;
const selectedSite: Selectedsite | null = useMemo(() => {
if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) {
return null;
}
if (initialFilterSite && initialFilterSite.siteId === siteIdNum) {
return initialFilterSite;
}
return {
siteId: siteIdNum,
name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }),
type: "newt"
};
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
const resourceIdQ = searchParams.get("resourceId");
const resourceIdNum = resourceIdQ ? parseInt(resourceIdQ, 10) : NaN;
const selectedResource: SelectedResource | null = useMemo(() => {
if (
!resourceIdQ ||
!Number.isInteger(resourceIdNum) ||
resourceIdNum <= 0
) {
return null;
}
if (
initialFilterResource &&
initialFilterResource.resourceId === resourceIdNum
) {
return initialFilterResource;
}
return {
name: t("standaloneHcFilterResourceIdFallback", {
id: resourceIdNum
}),
resourceId: resourceIdNum,
fullDomain: null,
niceId: "",
ssl: false
};
}, [initialFilterResource, resourceIdQ, resourceIdNum, t]);
const rows = healthChecks;
function refreshList() {
startRefresh(() => {
router.refresh();
});
}
useEffect(() => {
const interval = setInterval(() => {
router.refresh();
}, 10_000);
return () => clearInterval(interval);
}, [router]);
const handlePaginationChange = (newState: PaginationState) => {
searchParams.set("page", (newState.pageIndex + 1).toString());
searchParams.set("pageSize", newState.pageSize.toString());
filter({ searchParams });
};
const handleSearchChange = useDebouncedCallback((value: string) => {
if (value) {
searchParams.set("query", value);
} else {
searchParams.delete("query");
}
searchParams.delete("page");
filter({ searchParams });
}, 300);
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);
}
filter({ searchParams: sp });
}
const clearSiteFilter = () => {
handleFilterChange("siteId", undefined);
setSiteFilterOpen(false);
};
const clearResourceFilter = () => {
handleFilterChange("resourceId", undefined);
setResourceFilterOpen(false);
};
const onPickSite = (site: Selectedsite) => {
handleFilterChange("siteId", String(site.siteId));
setSiteFilterOpen(false);
};
const onPickResource = (resource: SelectedResource) => {
handleFilterChange("resourceId", String(resource.resourceId));
setResourceFilterOpen(false);
};
const handleToggleEnabled = async (
row: HealthCheckRow,
enabled: boolean
) => {
setTogglingId(row.targetHealthCheckId);
try {
await api.post(
`/org/${orgId}/health-check/${row.targetHealthCheckId}`,
{ hcEnabled: enabled }
);
refreshList();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setTogglingId(null);
}
};
const handleDelete = async () => {
if (!selected) return;
try {
await api.delete(
`/org/${orgId}/health-check/${selected.targetHealthCheckId}`
);
refreshList();
toast({ title: t("standaloneHcDeleted") });
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setDeleteOpen(false);
setSelected(null);
}
};
const modeParam = searchParams.get("hcMode");
const selectedHcMode =
modeParam === "http" ||
modeParam === "tcp" ||
modeParam === "snmp" ||
modeParam === "ping"
? modeParam
: undefined;
const healthParam = searchParams.get("hcHealth");
const selectedHcHealth =
healthParam === "healthy" ||
healthParam === "unhealthy" ||
healthParam === "unknown"
? healthParam
: undefined;
const enabledParam = searchParams.get("hcEnabled");
const selectedHcEnabled =
enabledParam === "true" || enabledParam === "false"
? enabledParam
: undefined;
const columns: ExtendedColumnDef<HealthCheckRow>[] = [
{
accessorKey: "name",
enableHiding: false,
friendlyName: t("name"),
header: ({ column }) => (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<span>{row.original.name ? row.original.name : "-"}</span>
)
},
{
id: "mode",
friendlyName: t("standaloneHcColumnMode"),
header: () => (
<ColumnFilterButton
options={[
{
value: "http",
label: t("standaloneHcFilterModeHttp")
},
{ value: "tcp", label: t("standaloneHcFilterModeTcp") },
{
value: "snmp",
label: t("standaloneHcFilterModeSnmp")
},
{
value: "ping",
label: t("standaloneHcFilterModePing")
}
]}
selectedValue={selectedHcMode}
onValueChange={(value) =>
handleFilterChange("hcMode", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("standaloneHcColumnMode")}
className="p-3"
/>
),
cell: ({ row }) => (
<span>{row.original.hcMode?.toUpperCase() ?? "-"}</span>
)
},
{
id: "target",
friendlyName: t("standaloneHcColumnTarget"),
header: () => (
<span className="p-3">{t("standaloneHcColumnTarget")}</span>
),
cell: ({ row }) => <span>{formatTarget(row.original)}</span>
},
{
id: "resource",
friendlyName: t("resource"),
header: () => (
<Popover
open={resourceFilterOpen}
onOpenChange={setResourceFilterOpen}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedResource && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("resource")}
<Funnel className="size-4 flex-none" />
{selectedResource && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedResource.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0"
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearResourceFilter}
>
{t("standaloneHcFilterAnyResource")}
</Button>
</div>
<ResourceSelector
orgId={orgId}
selectedResource={selectedResource}
onSelectResource={onPickResource}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const r = row.original;
if (!r.resourceId || !r.resourceName || !r.resourceNiceId) {
return <span className="text-neutral-400">-</span>;
}
return (
<Link
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
>
<Button variant="outline" size="sm">
{r.resourceName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
{
id: "site",
friendlyName: t("site"),
header: () => (
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
role="combobox"
className={cn(
"justify-between text-sm h-8 px-2 w-full p-3",
!selectedSite && "text-muted-foreground"
)}
>
<div className="flex items-center gap-2 min-w-0">
{t("site")}
<Funnel className="size-4 flex-none" />
{selectedSite && (
<Badge
className="truncate max-w-[10rem]"
variant="secondary"
>
{selectedSite.name}
</Badge>
)}
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[min(20rem,var(--radix-popover-trigger-width))] p-0"
align="start"
>
<div className="border-b p-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 w-full justify-start font-normal"
onClick={clearSiteFilter}
>
{t("standaloneHcFilterAnySite")}
</Button>
</div>
<SitesSelector
orgId={orgId}
selectedSite={selectedSite}
onSelectSite={onPickSite}
/>
</PopoverContent>
</Popover>
),
cell: ({ row }) => {
const r = row.original;
if (!r.siteId || !r.siteName || !r.siteNiceId) {
return <span className="text-neutral-400">-</span>;
}
return (
<Link
href={`/${orgId}/settings/sites/${r.siteNiceId}/general`}
>
<Button variant="outline" size="sm">
{r.siteName}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
{
id: "health",
friendlyName: t("standaloneHcColumnHealth"),
header: () => (
<ColumnFilterButton
options={[
{
value: "healthy",
label: t("standaloneHcHealthStateHealthy")
},
{
value: "unhealthy",
label: t("standaloneHcHealthStateUnhealthy")
},
{
value: "unknown",
label: t("standaloneHcHealthStateUnknown")
}
]}
selectedValue={selectedHcHealth}
onValueChange={(value) =>
handleFilterChange("hcHealth", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("standaloneHcColumnHealth")}
className="p-3"
/>
),
cell: ({ row }) => {
const health = row.original.hcHealth;
if (health === "healthy") {
return (
<span className="text-green-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-green-500 rounded-full" />
<span>{t("standaloneHcHealthStateHealthy")}</span>
</span>
);
} else if (health === "unhealthy") {
return (
<span className="text-red-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-red-500 rounded-full" />
<span>{t("standaloneHcHealthStateUnhealthy")}</span>
</span>
);
} else {
return (
<span className="text-neutral-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-neutral-500 rounded-full" />
<span>{t("standaloneHcHealthStateUnknown")}</span>
</span>
);
}
}
},
{
id: "uptime",
friendlyName: t("uptime30d"),
header: () => <span className="p-3">{t("uptime30d")}</span>,
cell: ({ row }) => {
return (
<UptimeMiniBar
orgId={orgId}
healthCheckId={row.original.targetHealthCheckId}
days={30}
/>
);
}
},
{
accessorKey: "hcEnabled",
friendlyName: t("alertingColumnEnabled"),
header: () => (
<ColumnFilterButton
options={[
{
value: "true",
label: t("standaloneHcFilterEnabledOn")
},
{
value: "false",
label: t("standaloneHcFilterEnabledOff")
}
]}
selectedValue={selectedHcEnabled}
onValueChange={(value) =>
handleFilterChange("hcEnabled", value)
}
searchPlaceholder={t("searchPlaceholder")}
emptyMessage={t("emptySearchOptions")}
label={t("alertingColumnEnabled")}
className="p-3"
/>
),
cell: ({ row }) => {
const r = row.original;
return (
<Switch
checked={r.hcEnabled}
disabled={
!isPaid || togglingId === r.targetHealthCheckId
}
onCheckedChange={(v) => handleToggleEnabled(r, v)}
/>
);
}
},
{
id: "rowActions",
enableHiding: false,
header: () => <span className="p-3" />,
cell: ({ row }) => {
const r = 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">
{t("openMenu")}
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={!isPaid || !!r.resourceId}
onClick={() => {
setSelected(r);
setDeleteOpen(true);
}}
>
<span className="text-red-500">
{t("delete")}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{r.resourceId && r.resourceName && r.resourceNiceId ? (
<Link
href={`/${orgId}/settings/resources/proxy/${r.resourceNiceId}`}
>
<Button variant="outline" disabled={!isPaid}>
{t("edit")}
</Button>
</Link>
) : (
<Button
variant="outline"
disabled={!isPaid}
onClick={() => {
setSelected(r);
setCredenzaOpen(true);
}}
>
{t("edit")}
</Button>
)}
</div>
);
}
}
];
return (
<>
{selected && deleteOpen && (
<ConfirmDeleteDialog
open={deleteOpen}
setOpen={(val) => {
setDeleteOpen(val);
if (!val) setSelected(null);
}}
dialog={
<div className="space-y-2">
<p>{t("standaloneHcDeleteQuestion")}</p>
</div>
}
buttonText={t("delete")}
onConfirm={handleDelete}
string={selected.name}
title={t("standaloneHcDeleteTitle")}
/>
)}
<HealthCheckCredenza
mode="submit"
open={credenzaOpen}
setOpen={(val) => {
setCredenzaOpen(val);
if (!val) setSelected(null);
}}
orgId={orgId}
initialValues={selected}
onSaved={refreshList}
/>
<PaidFeaturesAlert tiers={tierMatrix.standaloneHealthChecks} />
<ControlledDataTable
columns={columns}
rows={rows}
tableId="health-checks-table"
searchPlaceholder={t("standaloneHcSearchPlaceholder")}
onSearch={handleSearchChange}
searchQuery={query}
onAdd={() => {
setSelected(null);
setCredenzaOpen(true);
}}
addButtonDisabled={!isPaid}
onRefresh={refreshList}
isRefreshing={isRefreshing || isFiltering}
addButtonText={t("standaloneHcAddButton")}
enableColumnVisibility
stickyLeftColumn="name"
stickyRightColumn="rowActions"
pagination={pagination}
onPaginationChange={handlePaginationChange}
rowCount={rowCount}
/>
</>
);
}