diff --git a/messages/en-US.json b/messages/en-US.json index 2e97bcd13..b8b66190c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1519,5 +1519,20 @@ "domainPickerSubdomainSanitized": "Subdomain sanitized", "domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"", "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", - "resourceExposePortsEditFile": "Edit file: docker-compose.yml" + "resourceExposePortsEditFile": "Edit file: docker-compose.yml", + "target": "Target", + "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/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 27605be65..9052974e0 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; + name: string; + ssl: boolean; + niceId: string; + 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; + targets: Array<{ + targetId: number; + ip: string; + port: number; + enabled: boolean; + }>; +}; + function queryResources(accessibleResourceIds: number[], orgId: string) { return db .select({ @@ -56,7 +104,13 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { proxyPort: resources.proxyPort, enabled: resources.enabled, domainId: resources.domainId, - niceId: resources.niceId + niceId: resources.niceId, + + targetId: targets.targetId, + targetIp: targets.ip, + targetPort: targets.port, + targetEnabled: targets.enabled, + }) .from(resources) .leftJoin( @@ -67,6 +121,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { resourcePincode, eq(resourcePincode.resourceId, resources.resourceId) ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) .where( and( inArray(resources.resourceId, accessibleResourceIds), @@ -76,7 +131,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { } export type ListResourcesResponse = { - resources: NonNullable>>; + resources: ResourceWithTargets[]; pagination: { total: number; limit: number; offset: number }; }; @@ -141,7 +196,7 @@ export async function listResources( ); } - let accessibleResources; + let accessibleResources: Array<{ resourceId: number }>; if (req.user) { accessibleResources = await db .select({ @@ -178,9 +233,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, + 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, + niceId: row.niceId, + 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/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index 97abdd4c7..55b7401af 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,118 +17,119 @@ 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 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 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`); - } + 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`); - } + 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 - ? "protected" - : "not_protected", - enabled: resource.enabled, - domainId: resource.domainId || undefined, - ssl: resource.ssl - }; - }); + 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 + ? "protected" + : "not_protected", + enabled: resource.enabled, + domainId: resource.domainId || undefined, + targets: resource.targets, + 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 - }; - } - ); + 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 ( - <> - + return ( + <> + - - - - - ); -} + + + + + ); +} \ No newline at end of file diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index af0a0fe66..219f349df 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -19,11 +19,13 @@ import { useTranslations } from "next-intl"; interface DataTablePaginationProps { table: Table; onPageSizeChange?: (pageSize: number) => void; + renderAdditionalControls?: () => React.ReactNode; } export function DataTablePagination({ table, - onPageSizeChange + onPageSizeChange, + renderAdditionalControls }: DataTablePaginationProps) { const t = useTranslations(); @@ -57,6 +59,11 @@ export function DataTablePagination({ ))} + {renderAdditionalControls && ( +
+ {renderAdditionalControls()} +
+ )}
diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index cb7983c7d..ad8936d88 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -9,13 +9,15 @@ import { SortingState, getSortedRowModel, ColumnFiltersState, - getFilteredRowModel + getFilteredRowModel, + VisibilityState } from "@tanstack/react-table"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuTrigger + DropdownMenuTrigger, + DropdownMenuCheckboxItem, } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; import { @@ -24,7 +26,11 @@ import { MoreHorizontal, ArrowUpRight, ShieldOff, - ShieldCheck + ShieldCheck, + Settings2, + Plus, + Search, + ChevronDown, } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -43,7 +49,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, @@ -77,6 +82,8 @@ export type ResourceRow = { enabled: boolean; domainId?: string; ssl: boolean; + targetHost?: string; + targetPort?: number; }; export type InternalResourceRow = { @@ -152,6 +159,7 @@ export default function ResourcesTable({ const api = createApiClient({ env }); + const [proxyPageSize, setProxyPageSize] = useState(() => getStoredPageSize('proxy-resources', 20) ); @@ -170,6 +178,10 @@ export default function ResourcesTable({ const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [sites, setSites] = useState([]); + + const [proxyColumnVisibility, setProxyColumnVisibility] = useState({}); + const [internalColumnVisibility, setInternalColumnVisibility] = useState({}); + const [proxySorting, setProxySorting] = useState([]); const [proxyColumnFilters, setProxyColumnFilters] = useState([]); @@ -362,6 +374,64 @@ 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?: { ip: string; port: number }[]; + }; + + const targets = resourceRow.targets ?? []; + + if (targets.length === 0) { + return -; + } + + const count = targets.length; + + return ( + + + + + + + {targets.map((target, idx) => { + return ( + + + + ); + })} + + + ); + }, + }, { accessorKey: "domain", header: t("access"), @@ -619,6 +689,7 @@ export default function ResourcesTable({ onColumnFiltersChange: setProxyColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setProxyGlobalFilter, + onColumnVisibilityChange: setProxyColumnVisibility, initialState: { pagination: { pageSize: proxyPageSize, @@ -628,7 +699,8 @@ export default function ResourcesTable({ state: { sorting: proxySorting, columnFilters: proxyColumnFilters, - globalFilter: proxyGlobalFilter + globalFilter: proxyGlobalFilter, + columnVisibility: proxyColumnVisibility } }); @@ -642,6 +714,7 @@ export default function ResourcesTable({ onColumnFiltersChange: setInternalColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setInternalGlobalFilter, + onColumnVisibilityChange: setInternalColumnVisibility, initialState: { pagination: { pageSize: internalPageSize, @@ -651,7 +724,8 @@ export default function ResourcesTable({ state: { sorting: internalSorting, columnFilters: internalColumnFilters, - globalFilter: internalGlobalFilter + globalFilter: internalGlobalFilter, + columnVisibility: internalColumnVisibility } }); @@ -836,6 +910,34 @@ export default function ResourcesTable({ ( + + + + + + {proxyTable.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} + + ))} + + + )} />
@@ -937,6 +1039,34 @@ export default function ResourcesTable({ ( + + + + + + {internalTable.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} + + ))} + + + )} /> diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index ae94b12e5..9c2cab88a 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -9,7 +9,9 @@ import { SortingState, getSortedRowModel, ColumnFiltersState, - getFilteredRowModel + getFilteredRowModel, + VisibilityState, + Column } from "@tanstack/react-table"; import { Table, @@ -23,7 +25,7 @@ import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search, RefreshCw } from "lucide-react"; +import { Plus, Search, RefreshCw, Settings2 } from "lucide-react"; import { Card, CardContent, @@ -32,6 +34,12 @@ import { } from "@app/components/ui/card"; import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; import { useTranslations } from "next-intl"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuCheckboxItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; const STORAGE_KEYS = { PAGE_SIZE: 'datatable-page-size', @@ -93,6 +101,7 @@ type DataTableProps = { defaultTab?: string; persistPageSize?: boolean | string; defaultPageSize?: number; + enableColumnToggle?: boolean; }; export function DataTable({ @@ -109,7 +118,8 @@ export function DataTable({ tabs, defaultTab, persistPageSize = false, - defaultPageSize = 20 + defaultPageSize = 20, + enableColumnToggle = true }: DataTableProps) { const t = useTranslations(); @@ -129,6 +139,7 @@ export function DataTable({ ); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState([]); + const [columnVisibility, setColumnVisibility] = useState({}); const [activeTab, setActiveTab] = useState( defaultTab || tabs?.[0]?.id || "" ); @@ -157,6 +168,7 @@ export function DataTable({ onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setGlobalFilter, + onColumnVisibilityChange: setColumnVisibility, initialState: { pagination: { pageSize: pageSize, @@ -166,7 +178,8 @@ export function DataTable({ state: { sorting, columnFilters, - globalFilter + globalFilter, + columnVisibility } }); @@ -199,6 +212,43 @@ export function DataTable({ } }; + const getColumnLabel = (column: Column) => { + return typeof column.columnDef.header === "string" ? + column.columnDef.header : + column.id; // fallback to id if header is JSX + }; + + + const renderColumnToggle = () => { + if (!enableColumnToggle) return null; + + return ( + + + + + + {table.getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => ( + column.toggleVisibility(!!value)} + > + {getColumnLabel(column)} + + ))} + + + ); + }; + + return (
@@ -312,6 +362,7 @@ export function DataTable({