diff --git a/messages/en-US.json b/messages/en-US.json index 2a499866..9d502d5c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2143,5 +2143,19 @@ "deviceDeleted": "Device deleted", "deviceDeletedDescription": "The device has been successfully deleted.", "errorDeletingDevice": "Error deleting device", - "failedToDeleteDevice": "Failed to delete device" + "failedToDeleteDevice": "Failed to delete device", + "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/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 98bdfe44..7d1da1c5 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -352,20 +352,38 @@ export async function validateOidcCallback( if (!userOrgInfo.length) { if (existingUser) { - // delete the user - // cascade will also delete org users + // get existing user orgs + const existingUserOrgs = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, existingUser.userId), + eq(userOrgs.autoProvisioned, false) + ) + ); - await db - .delete(users) - .where(eq(users.userId, existingUser.userId)); + if (!existingUserOrgs.length) { + // delete the user + // await db + // .delete(users) + // .where(eq(users.userId, existingUser.userId)); + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + `No policies matched for ${userIdentifier}. This user must be added to an organization before logging in.` + ) + ); + } + } else { + // no orgs to provision and user doesn't exist + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + `No policies matched for ${userIdentifier}. This user must be added to an organization before logging in.` + ) + ); } - - return next( - createHttpError( - HttpCode.UNAUTHORIZED, - `No policies matched for ${userIdentifier}. This user must be added to an organization before logging in.` - ) - ); } const orgUserCounts: { orgId: string; userCount: number }[] = []; diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 22a10605..e612d5ec 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -6,7 +6,9 @@ import { userResources, roleResources, resourcePassword, - resourcePincode + resourcePincode, + targets, + targetHealthCheck, } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -40,6 +42,59 @@ 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; + headerAuthId: number | null; + + targetId: number | null; + targetIp: string | null; + targetPort: number | null; + targetEnabled: boolean | null; + + hcHealth: string | null; + hcEnabled: 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; + headerAuthId: number | null; + targets: Array<{ + targetId: number; + ip: string; + port: number; + enabled: boolean; + healthStatus?: 'healthy' | 'unhealthy' | 'unknown'; + }>; +}; + function queryResources(accessibleResourceIds: number[], orgId: string) { return db .select({ @@ -57,7 +112,15 @@ 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, + + hcHealth: targetHealthCheck.hcHealth, + hcEnabled: targetHealthCheck.hcEnabled, }) .from(resources) .leftJoin( @@ -72,6 +135,11 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) + .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ) .where( and( inArray(resources.resourceId, accessibleResourceIds), @@ -81,7 +149,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { } export type ListResourcesResponse = { - resources: NonNullable>>; + resources: ResourceWithTargets[]; pagination: { total: number; limit: number; offset: number }; }; @@ -146,7 +214,7 @@ export async function listResources( ); } - let accessibleResources; + let accessibleResources: Array<{ resourceId: number }>; if (req.user) { accessibleResources = await db .select({ @@ -183,9 +251,56 @@ 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, + headerAuthId: row.headerAuthId, + targets: [], + }; + map.set(row.resourceId, entry); + } + + if (row.targetId != null && row.targetIp && row.targetPort != null && row.targetEnabled != null) { + let healthStatus: 'healthy' | 'unhealthy' | 'unknown' = 'unknown'; + + if (row.hcEnabled && row.hcHealth) { + healthStatus = row.hcHealth as 'healthy' | 'unhealthy' | 'unknown'; + } + + entry.targets.push({ + targetId: row.targetId, + ip: row.targetIp, + port: row.targetPort, + enabled: row.targetEnabled, + healthStatus: healthStatus, + }); + } + } + + 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 f9a691c7..6079eca2 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -43,7 +43,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) { await authCookieHeader() ); resources = res.data.data.resources; - } catch (e) { } + } catch (e) {} let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; try { @@ -51,7 +51,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) { AxiosResponse >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); siteResources = res.data.data.siteResources; - } catch (e) { } + } catch (e) {} let org = null; try { @@ -88,11 +88,18 @@ export default async function ResourcesPage(props: ResourcesPageProps) { resource.passwordId !== null || resource.whitelist || resource.headerAuthId - ? "protected" - : "not_protected", + ? "protected" + : "not_protected", enabled: resource.enabled, domainId: resource.domainId || undefined, - ssl: resource.ssl + ssl: resource.ssl, + targets: resource.targets?.map((target) => ({ + targetId: target.targetId, + ip: target.ip, + port: target.port, + enabled: target.enabled, + healthStatus: target.healthStatus + })) }; }); @@ -104,7 +111,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) { orgId: params.orgId, siteName: siteResource.siteName, siteAddress: siteResource.siteAddress || null, - mode: siteResource.mode || "port" as any, + mode: siteResource.mode || ("port" as any), protocol: siteResource.protocol, proxyPort: siteResource.proxyPort, siteId: siteResource.siteId, diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index a00749c9..a0e084e4 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -30,7 +30,16 @@ import { ShieldOff, ShieldCheck, RefreshCw, - Columns + Columns, + Settings2, + Plus, + Search, + ChevronDown, + Clock, + Wifi, + WifiOff, + CheckCircle2, + XCircle } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -49,7 +58,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, @@ -70,6 +78,14 @@ import EditInternalResourceDialog from "@app/components/EditInternalResourceDial import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import { Alert, AlertDescription } from "@app/components/ui/alert"; +export type TargetHealth = { + targetId: number; + ip: string; + port: number; + enabled: boolean; + healthStatus?: "healthy" | "unhealthy" | "unknown"; +}; + export type ResourceRow = { id: number; nice: string | null; @@ -83,8 +99,64 @@ export type ResourceRow = { 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; + } +} export type InternalResourceRow = { id: number; name: string; @@ -232,6 +304,7 @@ export default function ResourcesTable({ const [proxySorting, setProxySorting] = useState( defaultSort ? [defaultSort] : [] ); + const [proxyColumnFilters, setProxyColumnFilters] = useState([]); const [proxyGlobalFilter, setProxyGlobalFilter] = useState([]); @@ -243,12 +316,14 @@ export default function ResourcesTable({ useState([]); const [internalGlobalFilter, setInternalGlobalFilter] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); - const [proxyColumnVisibility, setProxyColumnVisibility] = useState( - () => getStoredColumnVisibility("proxy-resources", {}) - ); - const [internalColumnVisibility, setInternalColumnVisibility] = useState( - () => getStoredColumnVisibility("internal-resources", {}) - ); + const [proxyColumnVisibility, setProxyColumnVisibility] = + useState(() => + getStoredColumnVisibility("proxy-resources", {}) + ); + const [internalColumnVisibility, setInternalColumnVisibility] = + useState(() => + getStoredColumnVisibility("internal-resources", {}) + ); const currentView = searchParams.get("view") || defaultView; @@ -408,6 +483,106 @@ export default function ResourcesTable({ }); } + function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { + const overallStatus = getOverallHealthStatus(targets); + + if (!targets || targets.length === 0) { + return ( +
+ + + No targets + +
+ ); + } + + 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 + ? "Disabled" + : "Not monitored"} + +
+ ))} + + )} +
+
+ ); + } + const proxyColumns: ColumnDef[] = [ { accessorKey: "name", @@ -443,7 +618,7 @@ export default function ResourcesTable({ }, { accessorKey: "protocol", - header: () => ({t("protocol")}), + header: () => {t("protocol")}, cell: ({ row }) => { const resourceRow = row.original; return ( @@ -457,9 +632,41 @@ export default function ResourcesTable({ ); } }, + { + id: "status", + accessorKey: "status", + header: ({ column }) => { + return ( + + ); + }, + 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", - header: () => ({t("access")}), + header: () => {t("access")}, cell: ({ row }) => { const resourceRow = row.original; return ( @@ -522,7 +729,7 @@ export default function ResourcesTable({ }, { accessorKey: "enabled", - header: () => ({t("enabled")}), + header: () => {t("enabled")}, cell: ({ row }) => ( ({t("actions")}), + header: () => {t("actions")}, cell: ({ row }) => { const resourceRow = row.original; return ( @@ -609,7 +816,7 @@ export default function ResourcesTable({ }, { accessorKey: "siteName", - header: () => ({t("siteName")}), + header: () => {t("siteName")}, cell: ({ row }) => { const resourceRow = row.original; return ( @@ -626,7 +833,11 @@ export default function ResourcesTable({ }, { accessorKey: "mode", - header: () => ({t("editInternalResourceDialogMode")}), + header: () => ( + + {t("editInternalResourceDialogMode")} + + ), cell: ({ row }) => { const resourceRow = row.original; const modeLabels: Record<"host" | "cidr" | "port", string> = { @@ -639,13 +850,20 @@ export default function ResourcesTable({ }, { accessorKey: "destination", - header: () => ({t("resourcesTableDestination")}), + header: () => ( + {t("resourcesTableDestination")} + ), cell: ({ row }) => { const resourceRow = row.original; let displayText: string; let copyText: string; - if (resourceRow.mode === "port" && resourceRow.protocol && resourceRow.proxyPort && resourceRow.destinationPort) { + if ( + resourceRow.mode === "port" && + resourceRow.protocol && + resourceRow.proxyPort && + resourceRow.destinationPort + ) { const protocol = resourceRow.protocol.toUpperCase(); // For port mode: site part uses alias or site address, destination part uses destination IP // If site address has CIDR notation, extract just the IP address @@ -658,25 +876,33 @@ export default function ResourcesTable({ copyText = `${siteDisplay}:${resourceRow.proxyPort}`; } else if (resourceRow.mode === "host") { // For host mode: use alias if available, otherwise use destination - const destinationDisplay = resourceRow.alias || resourceRow.destination; + const destinationDisplay = + resourceRow.alias || resourceRow.destination; displayText = destinationDisplay; copyText = destinationDisplay; } else if (resourceRow.mode === "cidr") { displayText = resourceRow.destination; copyText = resourceRow.destination; } else { - const destinationDisplay = resourceRow.alias || resourceRow.destination; + const destinationDisplay = + resourceRow.alias || resourceRow.destination; displayText = destinationDisplay; copyText = destinationDisplay; } - return ; + return ( + + ); } }, { id: "actions", - header: () => ({t("actions")}), + header: () => {t("actions")}, cell: ({ row }) => { const resourceRow = row.original; return ( @@ -788,7 +1014,10 @@ export default function ResourcesTable({ }, [proxyColumnVisibility]); useEffect(() => { - setStoredColumnVisibility(internalColumnVisibility, "internal-resources"); + setStoredColumnVisibility( + internalColumnVisibility, + "internal-resources" + ); }, [internalColumnVisibility]); return ( @@ -861,80 +1090,122 @@ export default function ResourcesTable({ )}
- {currentView === "proxy" && proxyTable.getAllColumns().some((column) => column.getCanHide()) && ( - - - - - - - {t("toggleColumns") || "Toggle columns"} - - - {proxyTable - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {typeof column.columnDef.header === "string" - ? column.columnDef.header - : column.id} - - ); - })} - - - )} - {currentView === "internal" && internalTable.getAllColumns().some((column) => column.getCanHide()) && ( - - - - - - - {t("toggleColumns") || "Toggle columns"} - - - {internalTable - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - - column.toggleVisibility(!!value) - } - > - {typeof column.columnDef.header === "string" - ? column.columnDef.header - : column.id} - - ); - })} - - - )} + {currentView === "proxy" && + proxyTable + .getAllColumns() + .some((column) => + column.getCanHide() + ) && ( + + + + + + + {t("toggleColumns") || + "Toggle columns"} + + + {proxyTable + .getAllColumns() + .filter((column) => + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility( + !!value + ) + } + > + {typeof column + .columnDef + .header === + "string" + ? column + .columnDef + .header + : column.id} + + ); + })} + + + )} + {currentView === "internal" && + internalTable + .getAllColumns() + .some((column) => + column.getCanHide() + ) && ( + + + + + + + {t("toggleColumns") || + "Toggle columns"} + + + {internalTable + .getAllColumns() + .filter((column) => + column.getCanHide() + ) + .map((column) => { + return ( + + column.toggleVisibility( + !!value + ) + } + > + {typeof column + .columnDef + .header === + "string" + ? column + .columnDef + .header + : column.id} + + ); + })} + + + )}