"use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { ResourceSitesStatusCell, type ResourceSiteRow } from "@app/components/ResourceSitesStatusCell"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { Selectedsite, SitesSelector } from "@app/components/site-selector"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { UpdateResourceResponse } from "@server/routers/resource"; import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { ArrowDown01Icon, ArrowRight, ArrowUp10Icon, CheckCircle2, ChevronDown, ChevronsUpDownIcon, Clock, Funnel, MoreHorizontal, ShieldCheck, ShieldOff, XCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useOptimistic, useRef, useState, useTransition, type ComponentRef } from "react"; import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; import UptimeMiniBar from "./UptimeMiniBar"; import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator"; import { build } from "@server/build"; export type TargetHealth = { targetId: number; ip: string; port: number; enabled: boolean; healthStatus: "healthy" | "unhealthy" | "unknown" | null; siteName: string | null; }; export type ResourceRow = { id: number; nice: string | null; name: string; orgId: string; domain: string; authState: string; http: boolean; protocol: string; proxyPort: number | null; enabled: boolean; domainId?: string; /** Hostname for certificate API (without scheme); distinct from `domain` URL shown in Access column */ fullDomain?: string | null; ssl: boolean; targetHost?: string; targetPort?: number; targets?: TargetHealth[]; health?: "healthy" | "degraded" | "unhealthy" | "unknown"; sites: ResourceSiteRow[]; }; function StatusIcon({ status, className = "" }: { status: string | undefined | null; className?: string; }) { const iconClass = `h-4 w-4 ${className}`; switch (status) { case "healthy": return ; case "degraded": return ; case "unhealthy": return ; case "unknown": return ; default: return null; } } type ProxyResourcesTableProps = { resources: ResourceRow[]; orgId: string; pagination: PaginationState; rowCount: number; initialFilterSite?: Selectedsite | null; }; export default function ProxyResourcesTable({ resources, orgId, pagination, rowCount, initialFilterSite = null }: ProxyResourcesTableProps) { const router = useRouter(); const { navigate: filter, isNavigating: isFiltering, searchParams } = useNavigationContext(); const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = useState(); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); const [siteFilterOpen, setSiteFilterOpen] = useState(false); 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]); useEffect(() => { const interval = setInterval(() => { router.refresh(); }, 30_000); return () => clearInterval(interval); }, [router]); const refreshData = () => { startTransition(() => { try { router.refresh(); } catch (error) { toast({ title: t("error"), description: t("refreshError"), variant: "destructive" }); } }); }; const deleteResource = (resourceId: number) => { api.delete(`/resource/${resourceId}`) .catch((e) => { console.error(t("resourceErrorDelte"), e); toast({ variant: "destructive", title: t("resourceErrorDelte"), description: formatAxiosError(e, t("resourceErrorDelte")) }); }) .then(() => { startTransition(() => { router.refresh(); setIsDeleteModalOpen(false); }); }); }; async function toggleResourceEnabled(val: boolean, resourceId: number) { try { await api.post>( `resource/${resourceId}`, { enabled: val } ); router.refresh(); } catch (e) { toast({ variant: "destructive", title: t("resourcesErrorUpdate"), description: formatAxiosError( e, t("resourcesErrorUpdateDescription") ) }); } } function TargetStatusCell({ targets, healthStatus }: { targets?: TargetHealth[]; healthStatus?: string; }) { const overallStatus = healthStatus; if (!targets || targets.length === 0) { return (
{t("resourcesTableNoTargets")}
); } const monitoredTargets = targets.filter( (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" ); const unknownTargets = targets.filter( (t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown" ); return ( {monitoredTargets.length > 0 && ( <> {monitoredTargets.map((target) => (
{target.siteName ? `${target.siteName} (${target.ip}:${target.port})` : `${target.ip}:${target.port}`}
{target.healthStatus}
))} )} {unknownTargets.length > 0 && ( <> {unknownTargets.map((target) => (
{target.siteName ? `${target.siteName} (${target.ip}:${target.port})` : `${target.ip}:${target.port}`}
{!target.enabled ? t("disabled") : t("resourcesTableNotMonitored")}
))} )}
); } const proxyColumns: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, friendlyName: t("name"), header: () => { const nameOrder = getSortDirection("name", searchParams); const Icon = nameOrder === "asc" ? ArrowDown01Icon : nameOrder === "desc" ? ArrowUp10Icon : ChevronsUpDownIcon; return ( ); } }, { id: "niceId", accessorKey: "nice", friendlyName: t("identifier"), enableHiding: true, header: () => {t("identifier")}, cell: ({ row }) => { return {row.original.nice || "-"}; } }, { id: "sites", accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), friendlyName: t("sites"), header: () => (
), cell: ({ row }) => ( ) }, { accessorKey: "protocol", friendlyName: t("protocol"), enableHiding: true, header: () => {t("protocol")}, cell: ({ row }) => { const resourceRow = row.original; return ( {resourceRow.http ? resourceRow.ssl ? "HTTPS" : "HTTP" : resourceRow.protocol.toUpperCase()} ); } }, { id: "status", accessorKey: "status", friendlyName: t("health"), header: () => ( handleFilterChange("healthStatus", value) } searchPlaceholder={t("searchPlaceholder")} emptyMessage={t("emptySearchOptions")} label={t("health")} className="p-3" /> ), cell: ({ row }) => { const resourceRow = row.original; return ( ); }, sortingFn: (rowA, rowB) => { const statusA = rowA.original.health; const statusB = rowB.original.health; if (!statusA && !statusB) return 0; if (!statusA) return 1; if (!statusB) return -1; const statusOrder = { healthy: 3, degraded: 2, unhealthy: 1, unknown: 0 }; return statusOrder[statusA] - statusOrder[statusB]; } }, { id: "statusHistory", friendlyName: t("uptime30d"), header: () => {t("uptime30d")}, cell: ({ row }) => { const resourceRow = row.original; return ; } }, { accessorKey: "domain", friendlyName: t("access"), header: () => {t("access")}, cell: ({ row }) => { const resourceRow = row.original; if (!resourceRow.http) { return (
); } if (!resourceRow.domainId) { return (
); } const domainId = resourceRow.domainId; const certHostname = resourceRow.fullDomain; const showHttpsCertIndicator = build !== "oss" && resourceRow.ssl && certHostname != null && certHostname !== ""; return (
{showHttpsCertIndicator ? ( ) : null}
); } }, { accessorKey: "authState", friendlyName: t("authentication"), header: () => ( handleFilterChange("authState", value) } searchPlaceholder={t("searchPlaceholder")} emptyMessage={t("emptySearchOptions")} label={t("authentication")} className="p-3" /> ), cell: ({ row }) => { const resourceRow = row.original; return (
{resourceRow.authState === "protected" ? ( {t("protected")} ) : resourceRow.authState === "not_protected" ? ( {t("notProtected")} ) : ( - )}
); } }, { accessorKey: "enabled", friendlyName: t("enabled"), header: () => ( handleFilterChange("enabled", value) } searchPlaceholder={t("searchPlaceholder")} emptyMessage={t("emptySearchOptions")} label={t("enabled")} className="p-3" /> ), cell: ({ row }) => ( ) }, { id: "actions", enableHiding: false, header: () => , cell: ({ row }) => { const resourceRow = row.original; return (
{t("viewSettings")} { setSelectedResource(resourceRow); setIsDeleteModalOpen(true); }} > {t("delete")}
); } } ]; const booleanSearchFilterSchema = z .enum(["true", "false"]) .optional() .catch(undefined); function handleFilterChange( column: string, value: string | undefined | null ) { searchParams.delete(column); searchParams.delete("page"); if (value) { searchParams.set(column, value); } filter({ searchParams }); } const clearSiteFilter = () => { handleFilterChange("siteId", undefined); setSiteFilterOpen(false); }; const onPickSite = (site: Selectedsite) => { handleFilterChange("siteId", String(site.siteId)); setSiteFilterOpen(false); }; 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 ( <> {selectedResource && ( { setIsDeleteModalOpen(val); setSelectedResource(null); }} dialog={

{t("resourceQuestionRemove")}

{t("resourceMessageRemove")}

} buttonText={t("resourceDeleteConfirm")} onConfirm={async () => deleteResource(selectedResource!.id)} string={selectedResource.name} title={t("resourceDelete")} /> )} startNavigation(() => router.push(`/${orgId}/settings/resources/proxy/create`) ) } addButtonText={t("resourceAdd")} onRefresh={refreshData} isRefreshing={isRefreshing || isFiltering} isNavigatingToAddPage={isNavigatingToAddPage} enableColumnVisibility columnVisibility={{ niceId: false, protocol: false }} stickyLeftColumn="name" stickyRightColumn="actions" /> ); } type ResourceEnabledFormProps = { resource: ResourceRow; onToggleResourceEnabled: ( val: boolean, resourceId: number ) => Promise; }; function ResourceEnabledForm({ resource, onToggleResourceEnabled }: ResourceEnabledFormProps) { const enabled = resource.http ? !!resource.domainId && resource.enabled : resource.enabled; const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(enabled); const formRef = useRef>(null); async function submitAction(formData: FormData) { const newEnabled = !(formData.get("enabled") === "on"); setOptimisticEnabled(newEnabled); await onToggleResourceEnabled(newEnabled, resource.id); } return (
formRef.current?.requestSubmit()} /> ); }