mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-04 11:34:19 +00:00
713 lines
26 KiB
TypeScript
713 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";
|
|
import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover";
|
|
|
|
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,
|
|
wildcard: false
|
|
};
|
|
}, [initialFilterResource, resourceIdQ, resourceIdNum, t]);
|
|
|
|
const rows = healthChecks;
|
|
|
|
function refreshList() {
|
|
startRefresh(() => {
|
|
router.refresh();
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
router.refresh();
|
|
}, 30_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={dataTableFilterPopoverContentClassName}
|
|
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={dataTableFilterPopoverContentClassName}
|
|
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 || !!r.resourceId
|
|
}
|
|
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|