"use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; 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 { Switch } from "@app/components/ui/switch"; 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 { UpdateResourceResponse } from "@server/routers/resource"; import type { PaginationState } from "@tanstack/react-table"; import { AxiosResponse } from "axios"; import { ArrowRight, CheckCircle2, ChevronDown, Clock, MoreHorizontal, ShieldCheck, ShieldOff, XCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { 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"; export type TargetHealth = { targetId: number; ip: string; port: number; enabled: boolean; healthStatus: "healthy" | "unhealthy" | "unknown" | 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; ssl: boolean; targetHost?: string; targetPort?: number; targets?: TargetHealth[]; }; function getOverallHealthStatus( targets?: TargetHealth[] ): "online" | "degraded" | "offline" | "unknown" { if (!targets || targets.length === 0) { return "unknown"; } const monitoredTargets = targets.filter( (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" ); if (monitoredTargets.length === 0) { return "unknown"; } const healthyCount = monitoredTargets.filter( (t) => t.healthStatus === "healthy" ).length; const unhealthyCount = monitoredTargets.filter( (t) => t.healthStatus === "unhealthy" ).length; if (healthyCount === monitoredTargets.length) { return "online"; } else if (unhealthyCount === monitoredTargets.length) { return "offline"; } else { return "degraded"; } } function StatusIcon({ status, className = "" }: { status: "online" | "degraded" | "offline" | "unknown"; className?: string; }) { const iconClass = `h-4 w-4 ${className}`; switch (status) { case "online": return ; case "degraded": return ; case "offline": return ; case "unknown": return ; default: return null; } } type ProxyResourcesTableProps = { resources: ResourceRow[]; orgId: string; pagination: PaginationState; rowCount: number; }; export default function ProxyResourcesTable({ resources, orgId, pagination, rowCount }: 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 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 }: { targets?: TargetHealth[] }) { const overallStatus = getOverallHealthStatus(targets); 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.ip}:${target.port}`}
{target.healthStatus}
))} )} {unknownTargets.length > 0 && ( <> {unknownTargets.map((target) => (
{`${target.ip}:${target.port}`}
{!target.enabled ? t("disabled") : t("resourcesTableNotMonitored")}
))} )}
); } const proxyColumns: ExtendedColumnDef[] = [ { accessorKey: "name", enableHiding: false, friendlyName: t("name"), header: () => {t("name")} }, { id: "niceId", accessorKey: "nice", friendlyName: t("identifier"), enableHiding: true, header: () => {t("identifier")}, cell: ({ row }) => { return {row.original.nice || "-"}; } }, { accessorKey: "protocol", friendlyName: t("protocol"), 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("status"), header: () => ( handleFilterChange("healthStatus", value) } searchPlaceholder={t("searchPlaceholder")} emptyMessage={t("emptySearchOptions")} label={t("status")} className="p-3" /> ), cell: ({ row }) => { const resourceRow = row.original; return ; }, sortingFn: (rowA, rowB) => { const statusA = getOverallHealthStatus(rowA.original.targets); const statusB = getOverallHealthStatus(rowB.original.targets); const statusOrder = { online: 3, degraded: 2, offline: 1, unknown: 0 }; return statusOrder[statusA] - statusOrder[statusB]; } }, { accessorKey: "domain", friendlyName: t("access"), header: () => {t("access")}, cell: ({ row }) => { const resourceRow = row.original; return (
{!resourceRow.http ? ( ) : !resourceRow.domainId ? ( ) : ( )}
); } }, { 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 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 }} 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()} /> ); }