diff --git a/messages/en-US.json b/messages/en-US.json index 97272c6f..063d9efc 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2081,5 +2081,19 @@ "supportSend": "Send", "supportMessageSent": "Message Sent!", "supportWillContact": "We'll be in touch shortly!", - "selectLogRetention": "Select log retention" + "selectLogRetention": "Select log retention", + "showColumns": "Show Columns", + "hideColumns": "Hide Columns", + "columnVisibility": "Column Visibility", + "toggleColumn": "Toggle {columnName} column", + "allColumns": "All Columns", + "defaultColumns": "Default Columns", + "customizeView": "Customize View", + "viewOptions": "View Options", + "selectAll": "Select All", + "selectNone": "Select None", + "selectedResources": "Selected Resources", + "enableSelected": "Enable Selected", + "disableSelected": "Disable Selected", + "checkSelectedStatus": "Check Status of Selected" } diff --git a/server/auth/actions.ts b/server/auth/actions.ts index d08457e5..4e271de9 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -23,6 +23,8 @@ export enum ActionsEnum { deleteResource = "deleteResource", getResource = "getResource", listResources = "listResources", + tcpCheck = "tcpCheck", + batchTcpCheck = "batchTcpCheck", updateResource = "updateResource", createTarget = "createTarget", deleteTarget = "deleteTarget", diff --git a/server/routers/external.ts b/server/routers/external.ts index 5c235902..26254802 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -306,6 +306,20 @@ authenticated.get( resource.listResources ); +authenticated.post( + "/org/:orgId/resources/tcp-check", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.tcpCheck), + resource.tcpCheck +); + +authenticated.post( + "/org/:orgId/resources/tcp-check-batch", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.batchTcpCheck), + resource.batchTcpCheck +); + authenticated.get( "/org/:orgId/user-resources", verifyOrgAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index d1c7011d..a757cae3 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -25,3 +25,4 @@ export * from "./getUserResources"; export * from "./setResourceHeaderAuth"; export * from "./addEmailToResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist"; +export * from "./tcpCheck"; diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 22a10605..8272ac3a 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -6,7 +6,8 @@ import { userResources, roleResources, resourcePassword, - resourcePincode + resourcePincode, + targets, } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -40,6 +41,53 @@ const listResourcesSchema = z.object({ .pipe(z.number().int().nonnegative()) }); +// (resource fields + a single joined target) +type JoinedRow = { + resourceId: number; + niceId: string; + name: string; + ssl: boolean; + fullDomain: string | null; + passwordId: number | null; + sso: boolean; + pincodeId: number | null; + whitelist: boolean; + http: boolean; + protocol: string; + proxyPort: number | null; + enabled: boolean; + domainId: string | null; + + targetId: number | null; + targetIp: string | null; + targetPort: number | null; + targetEnabled: boolean | null; +}; + +// grouped by resource with targets[]) +export type ResourceWithTargets = { + resourceId: number; + name: string; + ssl: boolean; + fullDomain: string | null; + passwordId: number | null; + sso: boolean; + pincodeId: number | null; + whitelist: boolean; + http: boolean; + protocol: string; + proxyPort: number | null; + enabled: boolean; + domainId: string | null; + niceId: string | null; + targets: Array<{ + targetId: number; + ip: string; + port: number; + enabled: boolean; + }>; +}; + function queryResources(accessibleResourceIds: number[], orgId: string) { return db .select({ @@ -57,7 +105,13 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { enabled: resources.enabled, domainId: resources.domainId, niceId: resources.niceId, - headerAuthId: resourceHeaderAuth.headerAuthId + headerAuthId: resourceHeaderAuth.headerAuthId, + + targetId: targets.targetId, + targetIp: targets.ip, + targetPort: targets.port, + targetEnabled: targets.enabled, + }) .from(resources) .leftJoin( @@ -72,6 +126,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) .where( and( inArray(resources.resourceId, accessibleResourceIds), @@ -81,7 +136,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { } export type ListResourcesResponse = { - resources: NonNullable>>; + resources: ResourceWithTargets[]; pagination: { total: number; limit: number; offset: number }; }; @@ -146,7 +201,7 @@ export async function listResources( ); } - let accessibleResources; + let accessibleResources: Array<{ resourceId: number }>; if (req.user) { accessibleResources = await db .select({ @@ -183,9 +238,49 @@ export async function listResources( const baseQuery = queryResources(accessibleResourceIds, orgId); - const resourcesList = await baseQuery!.limit(limit).offset(offset); + const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset); + + // avoids TS issues with reduce/never[] + const map = new Map(); + + for (const row of rows) { + let entry = map.get(row.resourceId); + if (!entry) { + entry = { + resourceId: row.resourceId, + niceId: row.niceId, + name: row.name, + ssl: row.ssl, + fullDomain: row.fullDomain, + passwordId: row.passwordId, + sso: row.sso, + pincodeId: row.pincodeId, + whitelist: row.whitelist, + http: row.http, + protocol: row.protocol, + proxyPort: row.proxyPort, + enabled: row.enabled, + domainId: row.domainId, + targets: [], + }; + map.set(row.resourceId, entry); + } + + // Push target if present (left join can be null) + if (row.targetId != null && row.targetIp && row.targetPort != null && row.targetEnabled != null) { + entry.targets.push({ + targetId: row.targetId, + ip: row.targetIp, + port: row.targetPort, + enabled: row.targetEnabled, + }); + } + } + + const resourcesList: ResourceWithTargets[] = Array.from(map.values()); + const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + const totalCount = totalCountResult[0]?.count ?? 0; return response(res, { data: { diff --git a/server/routers/resource/tcpCheck.ts b/server/routers/resource/tcpCheck.ts new file mode 100644 index 00000000..1779cc10 --- /dev/null +++ b/server/routers/resource/tcpCheck.ts @@ -0,0 +1,290 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import * as net from "net"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; + +const tcpCheckSchema = z + .object({ + host: z.string().min(1, "Host is required"), + port: z.number().int().min(1).max(65535), + timeout: z.number().int().min(1000).max(30000).optional().default(5000) + }) + .strict(); + +export type TcpCheckResponse = { + connected: boolean; + host: string; + port: number; + responseTime?: number; + error?: string; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/resources/tcp-check", + description: "Check TCP connectivity to a host and port", + tags: [OpenAPITags.Resource], + request: { + body: { + content: { + "application/json": { + schema: tcpCheckSchema + } + } + } + }, + responses: { + 200: { + description: "TCP check result", + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + data: z.object({ + connected: z.boolean(), + host: z.string(), + port: z.number(), + responseTime: z.number().optional(), + error: z.string().optional() + }), + message: z.string() + }) + } + } + } + } +}); + +function checkTcpConnection(host: string, port: number, timeout: number): Promise { + return new Promise((resolve) => { + const startTime = Date.now(); + const socket = new net.Socket(); + + const cleanup = () => { + socket.removeAllListeners(); + if (!socket.destroyed) { + socket.destroy(); + } + }; + + const timer = setTimeout(() => { + cleanup(); + resolve({ + connected: false, + host, + port, + error: 'Connection timeout' + }); + }, timeout); + + socket.setTimeout(timeout); + + socket.on('connect', () => { + const responseTime = Date.now() - startTime; + clearTimeout(timer); + cleanup(); + resolve({ + connected: true, + host, + port, + responseTime + }); + }); + + socket.on('error', (error) => { + clearTimeout(timer); + cleanup(); + resolve({ + connected: false, + host, + port, + error: error.message + }); + }); + + socket.on('timeout', () => { + clearTimeout(timer); + cleanup(); + resolve({ + connected: false, + host, + port, + error: 'Socket timeout' + }); + }); + + try { + socket.connect(port, host); + } catch (error) { + clearTimeout(timer); + cleanup(); + resolve({ + connected: false, + host, + port, + error: error instanceof Error ? error.message : 'Unknown connection error' + }); + } + }); +} + +export async function tcpCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = tcpCheckSchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { host, port, timeout } = parsedBody.data; + + + const result = await checkTcpConnection(host, port, timeout); + + logger.info(`TCP check for ${host}:${port} - Connected: ${result.connected}`, { + host, + port, + connected: result.connected, + responseTime: result.responseTime, + error: result.error + }); + + return response(res, { + data: result, + success: true, + error: false, + message: `TCP check completed for ${host}:${port}`, + status: HttpCode.OK + }); + } catch (error) { + logger.error("TCP check error:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred during TCP check" + ) + ); + } +} + +// Batch TCP check endpoint for checking multiple resources at once +const batchTcpCheckSchema = z + .object({ + checks: z.array(z.object({ + id: z.number().int().positive(), + host: z.string().min(1), + port: z.number().int().min(1).max(65535) + })).max(50), // Limit to 50 concurrent checks + timeout: z.number().int().min(1000).max(30000).optional().default(5000) + }) + .strict(); + +export type BatchTcpCheckResponse = { + results: Array; +}; + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/resources/tcp-check-batch", + description: "Check TCP connectivity to multiple hosts and ports", + tags: [OpenAPITags.Resource], + request: { + body: { + content: { + "application/json": { + schema: batchTcpCheckSchema + } + } + } + }, + responses: { + 200: { + description: "Batch TCP check results", + content: { + "application/json": { + schema: z.object({ + success: z.boolean(), + data: z.object({ + results: z.array(z.object({ + id: z.number(), + connected: z.boolean(), + host: z.string(), + port: z.number(), + responseTime: z.number().optional(), + error: z.string().optional() + })) + }), + message: z.string() + }) + } + } + } + } +}); + +export async function batchTcpCheck( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = batchTcpCheckSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { checks, timeout } = parsedBody.data; + + // all TCP checks concurrently + const checkPromises = checks.map(async (check) => { + const result = await checkTcpConnection(check.host, check.port, timeout); + return { + id: check.id, + ...result + }; + }); + + const results = await Promise.all(checkPromises); + + logger.info(`Batch TCP check completed for ${checks.length} resources`, { + totalChecks: checks.length, + successfulConnections: results.filter(r => r.connected).length, + failedConnections: results.filter(r => !r.connected).length + }); + + return response(res, { + data: { results }, + success: true, + error: false, + message: `Batch TCP check completed for ${checks.length} resources`, + status: HttpCode.OK + }); + } catch (error) { + logger.error("Batch TCP check error:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred during batch TCP check" + ) + ); + } +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index eadb19d4..0f8ee262 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -1,8 +1,8 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import ResourcesTable, { - ResourceRow, - InternalResourceRow + ResourceRow, + InternalResourceRow } from "../../../../components/ResourcesTable"; import { AxiosResponse } from "axios"; import { ListResourcesResponse } from "@server/routers/resource"; @@ -17,123 +17,123 @@ import { pullEnv } from "@app/lib/pullEnv"; import { toUnicode } from "punycode"; type ResourcesPageProps = { - params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + params: Promise<{ orgId: string }>; + searchParams: Promise<{ view?: string }>; }; export const dynamic = "force-dynamic"; export default async function ResourcesPage(props: ResourcesPageProps) { - const params = await props.params; - const searchParams = await props.searchParams; - const t = await getTranslations(); + const params = await props.params; + const searchParams = await props.searchParams; + const t = await getTranslations(); - const env = pullEnv(); + const env = pullEnv(); - // Default to 'proxy' view, or use the query param if provided - let defaultView: "proxy" | "internal" = "proxy"; - if (env.flags.enableClients) { - defaultView = searchParams.view === "internal" ? "internal" : "proxy"; - } + // Default to 'proxy' view, or use the query param if provided + let defaultView: "proxy" | "internal" = "proxy"; + if (env.flags.enableClients) { + defaultView = searchParams.view === "internal" ? "internal" : "proxy"; + } - let resources: ListResourcesResponse["resources"] = []; - try { - const res = await internal.get>( - `/org/${params.orgId}/resources`, - await authCookieHeader() - ); - resources = res.data.data.resources; - } catch (e) { } - - let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; - try { - const res = await internal.get< - AxiosResponse - >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); - siteResources = res.data.data.siteResources; - } catch (e) { } - - let org = null; - try { - const getOrg = cache(async () => - internal.get>( - `/org/${params.orgId}`, - await authCookieHeader() - ) - ); - const res = await getOrg(); - org = res.data.data; - } catch { - redirect(`/${params.orgId}/settings/resources`); - } - - if (!org) { - redirect(`/${params.orgId}/settings/resources`); - } - - const resourceRows: ResourceRow[] = resources.map((resource) => { - return { - id: resource.resourceId, - name: resource.name, - orgId: params.orgId, - nice: resource.niceId, - domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`, - protocol: resource.protocol, - proxyPort: resource.proxyPort, - http: resource.http, - authState: !resource.http - ? "none" - : resource.sso || - resource.pincodeId !== null || - resource.passwordId !== null || - resource.whitelist || - resource.headerAuthId - ? "protected" - : "not_protected", - enabled: resource.enabled, - domainId: resource.domainId || undefined, - ssl: resource.ssl - }; - }); - - const internalResourceRows: InternalResourceRow[] = siteResources.map( - (siteResource) => { - return { - id: siteResource.siteResourceId, - name: siteResource.name, - orgId: params.orgId, - siteName: siteResource.siteName, - protocol: siteResource.protocol, - proxyPort: siteResource.proxyPort, - siteId: siteResource.siteId, - destinationIp: siteResource.destinationIp, - destinationPort: siteResource.destinationPort, - siteNiceId: siteResource.siteNiceId - }; - } + let resources: ListResourcesResponse["resources"] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/resources`, + await authCookieHeader() ); + resources = res.data.data.resources; + } catch (e) { } - return ( - <> - + let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; + try { + const res = await internal.get< + AxiosResponse + >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); + siteResources = res.data.data.siteResources; + } catch (e) { } - - - - + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${params.orgId}`, + await authCookieHeader() + ) ); -} + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + + if (!org) { + redirect(`/${params.orgId}/settings/resources`); + } + + const resourceRows: ResourceRow[] = resources.map((resource) => { + return { + id: resource.resourceId, + name: resource.name, + orgId: params.orgId, + nice: resource.niceId, + domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`, + protocol: resource.protocol, + proxyPort: resource.proxyPort, + http: resource.http, + authState: !resource.http + ? "none" + : resource.sso || + resource.pincodeId !== null || + resource.passwordId !== null || + resource.whitelist || + resource.headerAuthId + ? "protected" + : "not_protected", + enabled: resource.enabled, + domainId: resource.domainId || undefined, + ssl: resource.ssl + }; + }); + + const internalResourceRows: InternalResourceRow[] = siteResources.map( + (siteResource) => { + return { + id: siteResource.siteResourceId, + name: siteResource.name, + orgId: params.orgId, + siteName: siteResource.siteName, + protocol: siteResource.protocol, + proxyPort: siteResource.proxyPort, + siteId: siteResource.siteId, + destinationIp: siteResource.destinationIp, + destinationPort: siteResource.destinationPort, + siteNiceId: siteResource.siteNiceId + }; + } + ); + + return ( + <> + + + + + + + ); +} \ No newline at end of file diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index 200b3142..d2cf4384 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -9,13 +9,16 @@ import { SortingState, getSortedRowModel, ColumnFiltersState, - getFilteredRowModel + getFilteredRowModel, + VisibilityState } from "@tanstack/react-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuTrigger + DropdownMenuTrigger, + DropdownMenuCheckboxItem, + DropdownMenuSeparator } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; import { @@ -25,7 +28,14 @@ import { ArrowUpRight, ShieldOff, ShieldCheck, - RefreshCw + RefreshCw, + Settings2, + Wifi, + WifiOff, + Clock, + Plus, + Search, + ChevronDown, } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -44,7 +54,6 @@ import { useTranslations } from "next-intl"; import { InfoPopup } from "@app/components/ui/info-popup"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search } from "lucide-react"; import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { Table, @@ -64,6 +73,14 @@ import { useSearchParams } from "next/navigation"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { Badge } from "@app/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { useResourceHealth } from "@app/hooks/useResourceHealth"; export type ResourceRow = { id: number; @@ -78,6 +95,8 @@ export type ResourceRow = { enabled: boolean; domainId?: string; ssl: boolean; + targetHost?: string; + targetPort?: number; }; export type InternalResourceRow = { @@ -143,6 +162,25 @@ const setStoredPageSize = (pageSize: number, tableId?: string): void => { }; +function StatusIcon({ status, className = "" }: { + status: 'checking' | 'online' | 'offline' | undefined; + className?: string; +}) { + const iconClass = `h-4 w-4 ${className}`; + + switch (status) { + case 'checking': + return ; + case 'online': + return ; + case 'offline': + return ; + default: + return null; + } +} + + export default function ResourcesTable({ resources, internalResources, @@ -158,6 +196,7 @@ export default function ResourcesTable({ const api = createApiClient({ env }); + const [proxyPageSize, setProxyPageSize] = useState(() => getStoredPageSize('proxy-resources', 20) ); @@ -165,6 +204,9 @@ export default function ResourcesTable({ getStoredPageSize('internal-resources', 20) ); + const { resourceStatus, targetStatus } = useResourceHealth(orgId, resources); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = useState(); @@ -179,6 +221,10 @@ export default function ResourcesTable({ const [proxySorting, setProxySorting] = useState( defaultSort ? [defaultSort] : [] ); + + const [proxyColumnVisibility, setProxyColumnVisibility] = useState({}); + const [internalColumnVisibility, setInternalColumnVisibility] = useState({}); + const [proxyColumnFilters, setProxyColumnFilters] = useState([]); const [proxyGlobalFilter, setProxyGlobalFilter] = useState([]); @@ -272,6 +318,39 @@ export default function ResourcesTable({ ); }; + const getColumnToggle = () => { + const table = currentView === "internal" ? internalTable : proxyTable; + + return ( + + + + + + {table.getAllColumns() + .filter(column => column.getCanHide()) + .map(column => ( + column.toggleVisibility(!!value)} + > + {column.id === "target" ? t("target") : + column.id === "authState" ? t("authentication") : + column.id === "enabled" ? t("enabled") : + column.id === "status" ? t("status") : + column.id} + + ))} + + + ); + }; + const getActionButton = () => { if (currentView === "internal") { return ( @@ -390,6 +469,126 @@ export default function ResourcesTable({ return {resourceRow.http ? (resourceRow.ssl ? "HTTPS" : "HTTP") : resourceRow.protocol.toUpperCase()}; } }, + { + id: "target", + accessorKey: "target", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const resourceRow = row.original as ResourceRow & { + targets?: { host: string; port: number }[]; + }; + + const targets = resourceRow.targets ?? []; + + if (targets.length === 0) { + return -; + } + + const count = targets.length; + + return ( + + + + + + + {targets.map((target, idx) => { + const key = `${resourceRow.id}:${target.host}:${target.port}`; + const status = targetStatus[key]; + + const color = + status === "online" + ? "bg-green-500" + : status === "offline" + ? "bg-red-500 " + : "bg-gray-400"; + + return ( + +
+ + + ); + })} + + + ); + }, + }, + { + id: "status", + accessorKey: "status", + header: t("status"), + cell: ({ row }) => { + const resourceRow = row.original; + const status = resourceStatus[resourceRow.id]; + + if (!resourceRow.enabled) { + return ( + + + + + {t("disabled")} + + + +

{t("resourceDisabled")}

+
+
+
+ ); + } + + return ( + + + +
+ + + {status === 'checking' ? t("checking") : + status === 'online' ? t("online") : + status === 'offline' ? t("offline") : '-'} + +
+
+ +

+ {status === 'checking' ? t("checkingConnection") : + status === 'online' ? t("connectionSuccessful") : + status === 'offline' ? t("connectionFailed") : + t("statusUnknown")} +

+
+
+
+ ); + } + }, { accessorKey: "domain", header: t("access"), @@ -647,6 +846,7 @@ export default function ResourcesTable({ onColumnFiltersChange: setProxyColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setProxyGlobalFilter, + onColumnVisibilityChange: setProxyColumnVisibility, initialState: { pagination: { pageSize: proxyPageSize, @@ -656,7 +856,8 @@ export default function ResourcesTable({ state: { sorting: proxySorting, columnFilters: proxyColumnFilters, - globalFilter: proxyGlobalFilter + globalFilter: proxyGlobalFilter, + columnVisibility: proxyColumnVisibility } }); @@ -670,6 +871,7 @@ export default function ResourcesTable({ onColumnFiltersChange: setInternalColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setInternalGlobalFilter, + onColumnVisibilityChange: setInternalColumnVisibility, initialState: { pagination: { pageSize: internalPageSize, @@ -679,7 +881,8 @@ export default function ResourcesTable({ state: { sorting: internalSorting, columnFilters: internalColumnFilters, - globalFilter: internalGlobalFilter + globalFilter: internalGlobalFilter, + columnVisibility: internalColumnVisibility } }); @@ -784,6 +987,7 @@ export default function ResourcesTable({
+ {getColumnToggle()} {getActionButton()}
diff --git a/src/hooks/useResourceHealth.ts b/src/hooks/useResourceHealth.ts new file mode 100644 index 00000000..315f0a00 --- /dev/null +++ b/src/hooks/useResourceHealth.ts @@ -0,0 +1,104 @@ +import { useState, useEffect } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; + +type Target = { + host: string; + port: number; +}; + +type ResourceRow = { + id: number; + enabled: boolean; + targets?: Target[]; +}; + +type Status = "checking" | "online" | "offline"; + +export function useResourceHealth(orgId: string, resources: ResourceRow[]) { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const [resourceStatus, setResourceStatus] = useState>({}); + const [targetStatus, setTargetStatus] = useState>({}); + + useEffect(() => { + if (!orgId || resources.length === 0) return; + + // init all as "checking" + const initialRes: Record = {}; + const initialTargets: Record = {}; + resources.forEach((r) => { + initialRes[r.id] = "checking"; + r.targets?.forEach((t) => { + const key = `${r.id}:${t.host}:${t.port}`; + initialTargets[key] = "checking"; + }); + }); + setResourceStatus(initialRes); + setTargetStatus(initialTargets); + + // build batch checks + const checks = resources.flatMap((r) => + r.enabled && r.targets?.length + ? r.targets.map((t) => ({ + id: r.id, + host: t.host, + port: t.port, + })) + : [] + ); + + if (checks.length === 0) return; + + api.post(`/org/${orgId}/resources/tcp-check-batch`, { + checks, + timeout: 5000, + }) + .then((res) => { + const results = res.data.data.results as Array<{ + id: number; + host: string; + port: number; + connected: boolean; + }>; + + // build maps + const newTargetStatus: Record = {}; + const grouped: Record = {}; + + results.forEach((r) => { + const key = `${r.id}:${r.host}:${r.port}`; + newTargetStatus[key] = r.connected ? "online" : "offline"; + + if (!grouped[r.id]) grouped[r.id] = []; + grouped[r.id].push(r.connected); + }); + + const newResourceStatus: Record = {}; + Object.entries(grouped).forEach(([id, arr]) => { + newResourceStatus[+id] = arr.some(Boolean) ? "online" : "offline"; + }); + + setTargetStatus((prev) => ({ ...prev, ...newTargetStatus })); + setResourceStatus((prev) => ({ ...prev, ...newResourceStatus })); + }) + .catch(() => { + // fallback all offline + const fallbackRes: Record = {}; + const fallbackTargets: Record = {}; + resources.forEach((r) => { + if (r.enabled) { + fallbackRes[r.id] = "offline"; + r.targets?.forEach((t) => { + fallbackTargets[`${r.id}:${t.host}:${t.port}`] = "offline"; + }); + } + }); + setResourceStatus((prev) => ({ ...prev, ...fallbackRes })); + setTargetStatus((prev) => ({ ...prev, ...fallbackTargets })); + }); + }, [orgId, resources]); + + return { resourceStatus, targetStatus }; +}