From ad425e8d9e87240d5f1ddb05c6db5684c116611d Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 1 Nov 2025 11:58:09 +0530 Subject: [PATCH 01/22] add transaction while deleting targets --- server/routers/target/deleteTarget.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 596691e4..8a400d60 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -48,10 +48,12 @@ export async function deleteTarget( const { targetId } = parsedParams.data; - const [deletedTarget] = await db - .delete(targets) - .where(eq(targets.targetId, targetId)) - .returning(); + const [deletedTarget] = await db.transaction(async (tx) => { + return await tx + .delete(targets) + .where(eq(targets.targetId, targetId)) + .returning(); + }); if (!deletedTarget) { return next( From 1b3eb32bf4eedeb2bd8aceec0d4b34601eb1ce4b Mon Sep 17 00:00:00 2001 From: Pallavi Date: Sun, 24 Aug 2025 20:57:27 +0530 Subject: [PATCH 02/22] Show targets and status icons in the dashboard --- messages/en-US.json | 16 +- server/auth/actions.ts | 2 + server/routers/external.ts | 14 + server/routers/resource/index.ts | 1 + server/routers/resource/listResources.ts | 107 +++++++- server/routers/resource/tcpCheck.ts | 290 ++++++++++++++++++++ src/app/[orgId]/settings/resources/page.tsx | 220 +++++++-------- src/components/ResourcesTable.tsx | 216 ++++++++++++++- src/hooks/useResourceHealth.ts | 104 +++++++ 9 files changed, 847 insertions(+), 123 deletions(-) create mode 100644 server/routers/resource/tcpCheck.ts create mode 100644 src/hooks/useResourceHealth.ts 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 }; +} From f21188000e4164f979adc055078634075dccf8ef Mon Sep 17 00:00:00 2001 From: Pallavi Date: Wed, 3 Sep 2025 00:15:02 +0530 Subject: [PATCH 03/22] remove status check and add column filtering on all of the tables --- server/auth/actions.ts | 2 - server/routers/external.ts | 14 -- server/routers/resource/index.ts | 1 - server/routers/resource/tcpCheck.ts | 290 ------------------------- src/components/DataTablePagination.tsx | 9 +- src/components/ResourcesTable.tsx | 184 +++++----------- src/components/ui/data-table.tsx | 59 ++++- src/hooks/useResourceHealth.ts | 104 --------- 8 files changed, 119 insertions(+), 544 deletions(-) delete mode 100644 server/routers/resource/tcpCheck.ts delete mode 100644 src/hooks/useResourceHealth.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 4e271de9..d08457e5 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -23,8 +23,6 @@ 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 26254802..5c235902 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -306,20 +306,6 @@ 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 a757cae3..d1c7011d 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -25,4 +25,3 @@ export * from "./getUserResources"; export * from "./setResourceHeaderAuth"; export * from "./addEmailToResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist"; -export * from "./tcpCheck"; diff --git a/server/routers/resource/tcpCheck.ts b/server/routers/resource/tcpCheck.ts deleted file mode 100644 index 1779cc10..00000000 --- a/server/routers/resource/tcpCheck.ts +++ /dev/null @@ -1,290 +0,0 @@ -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/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index 70d64f0c..79b09f20 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -24,6 +24,7 @@ interface DataTablePaginationProps { isServerPagination?: boolean; isLoading?: boolean; disabled?: boolean; + renderAdditionalControls?: () => React.ReactNode; } export function DataTablePagination({ @@ -33,7 +34,8 @@ export function DataTablePagination({ totalCount, isServerPagination = false, isLoading = false, - disabled = false + disabled = false, + renderAdditionalControls }: DataTablePaginationProps) { const t = useTranslations(); @@ -113,6 +115,11 @@ export function DataTablePagination({ ))} + {renderAdditionalControls && ( +
+ {renderAdditionalControls()} +
+ )}
diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index d2cf4384..3238d1e4 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -18,7 +18,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, DropdownMenuCheckboxItem, - DropdownMenuSeparator } from "@app/components/ui/dropdown-menu"; import { Button } from "@app/components/ui/button"; import { @@ -30,9 +29,6 @@ import { ShieldCheck, RefreshCw, Settings2, - Wifi, - WifiOff, - Clock, Plus, Search, ChevronDown, @@ -73,14 +69,6 @@ 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; @@ -162,25 +150,6 @@ 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, @@ -204,9 +173,6 @@ export default function ResourcesTable({ getStoredPageSize('internal-resources', 20) ); - const { resourceStatus, targetStatus } = useResourceHealth(orgId, resources); - - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = useState(); @@ -318,39 +284,6 @@ 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 ( @@ -514,18 +447,9 @@ export default function ResourcesTable({ {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 ( -
{ - 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"), @@ -987,7 +860,6 @@ export default function ResourcesTable({
- {getColumnToggle()} {getActionButton()}
@@ -1072,6 +944,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} + + ))} + + + )} /> @@ -1173,6 +1073,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 ae94b12e..9c2cab88 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({
diff --git a/src/hooks/useResourceHealth.ts b/src/hooks/useResourceHealth.ts deleted file mode 100644 index 315f0a00..00000000 --- a/src/hooks/useResourceHealth.ts +++ /dev/null @@ -1,104 +0,0 @@ -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 }; -} From 8e5dde887c6d1c19fcbc27d4d59da5815c4965eb Mon Sep 17 00:00:00 2001 From: Pallavi Date: Wed, 3 Sep 2025 00:57:46 +0530 Subject: [PATCH 04/22] list targes in frontend --- src/components/ResourcesTable.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index 3238d1e4..a717ae1b 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -420,7 +420,7 @@ export default function ResourcesTable({ }, cell: ({ row }) => { const resourceRow = row.original as ResourceRow & { - targets?: { host: string; port: number }[]; + targets?: { ip: string; port: number }[]; }; const targets = resourceRow.targets ?? []; @@ -446,12 +446,10 @@ export default function ResourcesTable({ {targets.map((target, idx) => { - const key = `${resourceRow.id}:${target.host}:${target.port}`; - return ( From cdf77087cdd6e035ad743b03d966ff893657d482 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Fri, 12 Sep 2025 22:10:00 +0530 Subject: [PATCH 05/22] get niceid --- server/routers/resource/listResources.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 8272ac3a..b6eecace 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -47,6 +47,7 @@ type JoinedRow = { niceId: string; name: string; ssl: boolean; + niceId: string; fullDomain: string | null; passwordId: number | null; sso: boolean; @@ -79,7 +80,7 @@ export type ResourceWithTargets = { proxyPort: number | null; enabled: boolean; domainId: string | null; - niceId: string | null; + niceId: string; targets: Array<{ targetId: number; ip: string; @@ -261,6 +262,7 @@ export async function listResources( proxyPort: row.proxyPort, enabled: row.enabled, domainId: row.domainId, + niceId: row.niceId, targets: [], }; map.set(row.resourceId, entry); From 49bc2dc5dae5288c392e6061e94bba946d51fee8 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Mon, 20 Oct 2025 22:04:10 +0530 Subject: [PATCH 06/22] fix duplicate --- server/routers/resource/listResources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index b6eecace..87c3bbdd 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -47,7 +47,6 @@ type JoinedRow = { niceId: string; name: string; ssl: boolean; - niceId: string; fullDomain: string | null; passwordId: number | null; sso: boolean; @@ -81,6 +80,7 @@ export type ResourceWithTargets = { enabled: boolean; domainId: string | null; niceId: string; + headerAuthId: number | null; targets: Array<{ targetId: number; ip: string; From ad6bb3da9fc8db4e018ebb1b36ff12e422761758 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Mon, 20 Oct 2025 22:31:26 +0530 Subject: [PATCH 07/22] fix type error --- server/routers/resource/listResources.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 87c3bbdd..de2158c6 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -57,6 +57,7 @@ type JoinedRow = { proxyPort: number | null; enabled: boolean; domainId: string | null; + headerAuthId: number | null; targetId: number | null; targetIp: string | null; @@ -262,14 +263,19 @@ export async function listResources( proxyPort: row.proxyPort, enabled: row.enabled, domainId: row.domainId, - niceId: row.niceId, + headerAuthId: row.headerAuthId, 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) { + // Push target if present + if ( + row.targetId != null && + row.targetIp && + row.targetPort != null && + row.targetEnabled != null + ) { entry.targets.push({ targetId: row.targetId, ip: row.targetIp, From 54f7525f1bf55ee2f288821301a860225e4fe43f Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Tue, 21 Oct 2025 11:55:14 +0530 Subject: [PATCH 08/22] add status column in resource table --- server/routers/resource/listResources.ts | 26 ++- src/app/[orgId]/settings/resources/page.tsx | 9 +- src/components/ResourcesTable.tsx | 180 +++++++++++++++----- 3 files changed, 165 insertions(+), 50 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index de2158c6..e612d5ec 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -8,6 +8,7 @@ import { resourcePassword, resourcePincode, targets, + targetHealthCheck, } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -63,6 +64,9 @@ type JoinedRow = { targetIp: string | null; targetPort: number | null; targetEnabled: boolean | null; + + hcHealth: string | null; + hcEnabled: boolean | null; }; // grouped by resource with targets[]) @@ -87,6 +91,7 @@ export type ResourceWithTargets = { ip: string; port: number; enabled: boolean; + healthStatus?: 'healthy' | 'unhealthy' | 'unknown'; }>; }; @@ -114,6 +119,8 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { targetPort: targets.port, targetEnabled: targets.enabled, + hcHealth: targetHealthCheck.hcHealth, + hcEnabled: targetHealthCheck.hcEnabled, }) .from(resources) .leftJoin( @@ -129,6 +136,10 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { 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), @@ -269,18 +280,19 @@ export async function listResources( map.set(row.resourceId, entry); } - // Push target if present - if ( - row.targetId != null && - row.targetIp && - row.targetPort != null && - row.targetEnabled != null - ) { + 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, }); } } diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index 0f8ee262..5b18c3c5 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -92,7 +92,14 @@ export default async function ResourcesPage(props: ResourcesPageProps) { : "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 + })) }; }); diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index a717ae1b..9faee629 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -32,6 +32,9 @@ import { Plus, Search, ChevronDown, + Clock, + Wifi, + WifiOff, } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -70,6 +73,15 @@ 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; @@ -85,8 +97,52 @@ export type ResourceRow = { 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; @@ -150,6 +206,7 @@ const setStoredPageSize = (pageSize: number, tableId?: string): void => { }; + export default function ResourcesTable({ resources, internalResources, @@ -361,6 +418,76 @@ 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.healthStatus} + +
+ ))} + + )} + {unknownTargets.length > 0 && ( + <> + {unknownTargets.map((target) => ( + +
+ + +
+ + {!target.enabled ? 'Disabled' : 'Not monitored'} + +
+ ))} + + )} +
+
+ ); + } + + const proxyColumns: ColumnDef[] = [ { accessorKey: "name", @@ -403,8 +530,8 @@ export default function ResourcesTable({ } }, { - id: "target", - accessorKey: "target", + id: "status", + accessorKey: "status", 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 ( - - - - ); - })} - - - ); + 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", From 6dd161fe177430e95a513321f7bd1e375141004f Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 2 Nov 2025 15:35:02 -0800 Subject: [PATCH 09/22] Add fosrl --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 597a18d7..1af1bbb4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -31,7 +31,7 @@ jobs: timeout-minutes: 120 env: # Target images - DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} + DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} steps: From 2af100cc86ddfc6c7a422ffdc81603c04da8e03f Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 1 Nov 2025 10:46:09 -0700 Subject: [PATCH 10/22] Warning -> debug --- server/private/lib/traefik/getTraefikConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index fc3de4ad..8ebf6d09 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -378,7 +378,7 @@ export async function getTraefikConfig( (cert) => cert.queriedDomain === resource.fullDomain ); if (!matchingCert) { - logger.warn( + logger.debug( `No matching certificate found for domain: ${resource.fullDomain}` ); continue; From d363b06d0e147da5066a848b347eb72c457543f5 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 2 Nov 2025 14:05:41 -0800 Subject: [PATCH 11/22] Fix rewritePath Closes #1528 --- server/lib/blueprints/proxyResources.ts | 4 ++-- server/lib/blueprints/types.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 37b69761..aaefd8b6 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -114,7 +114,7 @@ export async function updateProxyResources( internalPort: internalPortToCreate, path: targetData.path, pathMatchType: targetData["path-match"], - rewritePath: targetData.rewritePath, + rewritePath: targetData.rewritePath || targetData["rewrite-path"] || (targetData["rewrite-match"] === "stripPrefix" ? "/" : undefined), rewritePathType: targetData["rewrite-match"], priority: targetData.priority }) @@ -392,7 +392,7 @@ export async function updateProxyResources( enabled: targetData.enabled, path: targetData.path, pathMatchType: targetData["path-match"], - rewritePath: targetData.rewritePath, + rewritePath: targetData.rewritePath || targetData["rewrite-path"] || (targetData["rewrite-match"] === "stripPrefix" ? "/" : undefined), rewritePathType: targetData["rewrite-match"], priority: targetData.priority }) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index de5c8a70..1908ea1b 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -33,6 +33,7 @@ export const TargetSchema = z.object({ "path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(), healthcheck: TargetHealthCheckSchema.optional(), rewritePath: z.string().optional(), + "rewrite-path": z.string().optional(), "rewrite-match": z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(), priority: z.number().int().min(1).max(1000).optional().default(100) }); From 99031feb353d18f399714a129a195a4bc0ff2ff1 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 2 Nov 2025 14:17:38 -0800 Subject: [PATCH 12/22] Fix camel case in health checks --- server/lib/blueprints/proxyResources.ts | 31 +++++++++++++++++++------ server/lib/blueprints/types.ts | 8 ++++--- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index aaefd8b6..323e6a6a 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -114,7 +114,12 @@ export async function updateProxyResources( internalPort: internalPortToCreate, path: targetData.path, pathMatchType: targetData["path-match"], - rewritePath: targetData.rewritePath || targetData["rewrite-path"] || (targetData["rewrite-match"] === "stripPrefix" ? "/" : undefined), + rewritePath: + targetData.rewritePath || + targetData["rewrite-path"] || + (targetData["rewrite-match"] === "stripPrefix" + ? "/" + : undefined), rewritePathType: targetData["rewrite-match"], priority: targetData.priority }) @@ -139,10 +144,14 @@ export async function updateProxyResources( hcHostname: healthcheckData?.hostname, hcPort: healthcheckData?.port, hcInterval: healthcheckData?.interval, - hcUnhealthyInterval: healthcheckData?.unhealthyInterval, + hcUnhealthyInterval: + healthcheckData?.unhealthyInterval || + healthcheckData?.["unhealthy-interval"], hcTimeout: healthcheckData?.timeout, hcHeaders: hcHeaders, - hcFollowRedirects: healthcheckData?.followRedirects, + hcFollowRedirects: + healthcheckData?.followRedirects || + healthcheckData?.["follow-redirects"], hcMethod: healthcheckData?.method, hcStatus: healthcheckData?.status, hcHealth: "unknown" @@ -392,7 +401,12 @@ export async function updateProxyResources( enabled: targetData.enabled, path: targetData.path, pathMatchType: targetData["path-match"], - rewritePath: targetData.rewritePath || targetData["rewrite-path"] || (targetData["rewrite-match"] === "stripPrefix" ? "/" : undefined), + rewritePath: + targetData.rewritePath || + targetData["rewrite-path"] || + (targetData["rewrite-match"] === "stripPrefix" + ? "/" + : undefined), rewritePathType: targetData["rewrite-match"], priority: targetData.priority }) @@ -452,10 +466,13 @@ export async function updateProxyResources( hcPort: healthcheckData?.port, hcInterval: healthcheckData?.interval, hcUnhealthyInterval: - healthcheckData?.unhealthyInterval, + healthcheckData?.unhealthyInterval || + healthcheckData?.["unhealthy-interval"], hcTimeout: healthcheckData?.timeout, hcHeaders: hcHeaders, - hcFollowRedirects: healthcheckData?.followRedirects, + hcFollowRedirects: + healthcheckData?.followRedirects || + healthcheckData?.["follow-redirects"], hcMethod: healthcheckData?.method, hcStatus: healthcheckData?.status }) @@ -535,7 +552,7 @@ export async function updateProxyResources( .set({ action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value.toUpperCase(), + value: rule.value.toUpperCase() }) .where( eq(resourceRules.ruleId, existingRule.ruleId) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 1908ea1b..ca3177b3 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -13,10 +13,12 @@ export const TargetHealthCheckSchema = z.object({ scheme: z.string().optional(), mode: z.string().default("http"), interval: z.number().int().default(30), - unhealthyInterval: z.number().int().default(30), + "unhealthy-interval": z.number().int().default(30), + unhealthyInterval: z.number().int().optional(), // deprecated alias timeout: z.number().int().default(5), headers: z.array(z.object({ name: z.string(), value: z.string() })).nullable().optional().default(null), - followRedirects: z.boolean().default(true), + "follow-redirects": z.boolean().default(true), + followRedirects: z.boolean().optional(), // deprecated alias method: z.string().default("GET"), status: z.number().int().optional() }); @@ -32,7 +34,7 @@ export const TargetSchema = z.object({ path: z.string().optional(), "path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(), healthcheck: TargetHealthCheckSchema.optional(), - rewritePath: z.string().optional(), + rewritePath: z.string().optional(), // deprecated alias "rewrite-path": z.string().optional(), "rewrite-match": z.enum(["exact", "prefix", "regex", "stripPrefix"]).optional().nullable(), priority: z.number().int().min(1).max(1000).optional().default(100) From 4adbc31daed80aecc1cceecee6cccc2c096b8914 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 2 Nov 2025 14:56:19 -0800 Subject: [PATCH 13/22] Fix blueprints not applying Fixes #1795 --- server/lib/blueprints/applyNewtDockerBlueprint.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/server/lib/blueprints/applyNewtDockerBlueprint.ts b/server/lib/blueprints/applyNewtDockerBlueprint.ts index 7d3d395d..0fe7c3fe 100644 --- a/server/lib/blueprints/applyNewtDockerBlueprint.ts +++ b/server/lib/blueprints/applyNewtDockerBlueprint.ts @@ -34,11 +34,7 @@ export async function applyNewtDockerBlueprint( return; } - if (isEmptyObject(blueprint["proxy-resources"])) { - return; - } - - if (isEmptyObject(blueprint["client-resources"])) { + if (isEmptyObject(blueprint["proxy-resources"]) && isEmptyObject(blueprint["client-resources"])) { return; } From 1cd098252e5dc12d1006a79d40c12a27fd4f59a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20Sch=C3=A4fer?= Date: Sun, 2 Nov 2025 00:44:03 +0100 Subject: [PATCH 14/22] Refactor CI/CD workflow for improved release process Updated CI/CD workflow to include new permissions, job definitions, and steps for version validation, tagging, and artifact management. --- .github/workflows/cicd.yml | 757 ++++++++++++++++++++++++++++++------- 1 file changed, 620 insertions(+), 137 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 597a18d7..9125687e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -4,9 +4,12 @@ name: CI/CD Pipeline # Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events. permissions: - contents: read - packages: write # for GHCR push - id-token: write # for Cosign Keyless (OIDC) Signing + contents: write # gh-release + packages: write # GHCR push + id-token: write # Keyless-Signatures & Attestations + attestations: write # actions/attest-build-provenance + security-events: write # upload-sarif + actions: read # Required secrets: # - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub @@ -14,167 +17,647 @@ permissions: # - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing on: - push: - tags: - - "[0-9]+.[0-9]+.[0-9]+" - - "[0-9]+.[0-9]+.[0-9]+.rc.[0-9]+" + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+.rc.[0-9]+" + workflow_dispatch: + inputs: + version: + description: "SemVer version to release (e.g., 1.2.3, no leading 'v')" + required: true + type: string + publish_latest: + description: "Also publish the 'latest' image tag" + required: true + type: boolean + default: false + publish_minor: + description: "Also publish the 'major.minor' image tag (e.g., 1.2)" + required: true + type: boolean + default: false + target_branch: + description: "Branch to tag" + required: false + default: "main" concurrency: - group: ${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }} cancel-in-progress: true jobs: - release: - name: Build and Release - runs-on: [self-hosted, linux, x64] - # Job-level timeout to avoid runaway or stuck runs - timeout-minutes: 120 + prepare: + if: github.event_name == 'workflow_dispatch' + name: Prepare release (create tag) + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + + - name: Validate version input + shell: bash env: - # Target images - DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} - GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + INPUT_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if ! [[ "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Invalid version: $INPUT_VERSION (expected X.Y.Z or X.Y.Z-rc.N)" >&2 + exit 1 + fi + - name: Create and push tag + shell: bash + env: + TARGET_BRANCH: ${{ inputs.target_branch }} + VERSION: ${{ inputs.version }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch --prune origin + git checkout "$TARGET_BRANCH" + git pull --ff-only origin "$TARGET_BRANCH" + if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "Tag $VERSION already exists" >&2 + exit 1 + fi + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "refs/tags/$VERSION" + release: + if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.actor != 'github-actions[bot]') }} + name: Build and Release + runs-on: ubuntu-24.04 + timeout-minutes: 120 + env: + DOCKERHUB_IMAGE: docker.io/${{ vars.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} - steps: - - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + steps: + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 - - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + - name: Capture created timestamp + run: echo "IMAGE_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV + shell: bash - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - - name: Log in to Docker Hub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - with: - registry: docker.io - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - name: Extract tag name - id: get-tag - run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - shell: bash + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - - name: Install Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 - with: - go-version: 1.24 + - name: Log in to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: docker.io + username: ${{ vars.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - - name: Update version in package.json - run: | - TAG=${{ env.TAG }} - sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts - cat server/lib/consts.ts - shell: bash + - name: Log in to GHCR + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - - name: Pull latest Gerbil version - id: get-gerbil-tag - run: | - LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') - echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV - shell: bash + - name: Normalize image names to lowercase + run: | + set -euo pipefail + echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" + shell: bash - - name: Pull latest Badger version - id: get-badger-tag - run: | - LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') - echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV - shell: bash + - name: Extract tag name + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "TAG=${INPUT_VERSION}" >> $GITHUB_ENV + else + echo "TAG=${{ github.ref_name }}" >> $GITHUB_ENV + fi + shell: bash - - name: Update install/main.go - run: | - PANGOLIN_VERSION=${{ env.TAG }} - GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} - BADGER_VERSION=${{ env.LATEST_BADGER_TAG }} - sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go - sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go - sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go - echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION" - cat install/main.go - shell: bash + - name: Validate pushed tag format (no leading 'v') + if: ${{ github.event_name == 'push' }} + shell: bash + env: + TAG_GOT: ${{ env.TAG }} + run: | + set -euo pipefail + if [[ "$TAG_GOT" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then + echo "Tag OK: $TAG_GOT" + exit 0 + fi + echo "ERROR: Tag '$TAG_GOT' is not allowed. Use 'X.Y.Z' or 'X.Y.Z-rc.N' (no leading 'v')." >&2 + exit 1 + - name: Wait for tag to be visible (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + set -euo pipefail + for i in {1..90}; do + if git ls-remote --tags origin "refs/tags/${TAG}" | grep -qE "refs/tags/${TAG}$"; then + echo "Tag ${TAG} is visible on origin"; exit 0 + fi + echo "Tag not yet visible, retrying... ($i/90)" + sleep 2 + done + echo "Tag ${TAG} not visible after waiting"; exit 1 + shell: bash - - name: Build installer - working-directory: install - run: | - make go-build-release + - name: Ensure repository is at the tagged commit (dispatch only) + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + set -euo pipefail + git fetch --tags --force + git checkout "refs/tags/${TAG}" + echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" + shell: bash - - name: Upload artifacts from /install/bin - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - with: - name: install-bin - path: install/bin/ + - name: Detect release candidate (rc) + run: | + set -euo pipefail + if [[ "${TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + echo "IS_RC=true" >> $GITHUB_ENV + else + echo "IS_RC=false" >> $GITHUB_ENV + fi + shell: bash - - name: Build and push Docker images (Docker Hub) - run: | - TAG=${{ env.TAG }} - make build-release tag=$TAG - echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" - shell: bash + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version-file: .nvmrc + cache: npm - - name: Install skopeo + jq - # skopeo: copy/inspect images between registries - # jq: JSON parsing tool used to extract digest values - run: | - sudo apt-get update -y - sudo apt-get install -y skopeo jq - skopeo --version - shell: bash + - name: Install dependencies + run: npm ci + shell: bash - - name: Login to GHCR - run: | - skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" - shell: bash + - name: Copy config file + run: cp config/config.example.yml config/config.yml + shell: bash - - name: Copy tag from Docker Hub to GHCR - # Mirror the already-built image (all architectures) to GHCR so we can sign it - run: | - set -euo pipefail - TAG=${{ env.TAG }} - echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" - skopeo copy --all --retry-times 3 \ - docker://$DOCKERHUB_IMAGE:$TAG \ - docker://$GHCR_IMAGE:$TAG - shell: bash + - name: Configure default build flavor + run: | + npm run set:oss + npm run set:sqlite + shell: bash - - name: Install cosign - # cosign is used to sign and verify container images (key and keyless) - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + - name: Update version references + env: + TAG_VALUE: ${{ env.TAG }} + run: | + set -euo pipefail + python3 - <<'PY' + from pathlib import Path + import os - - name: Dual-sign and verify (GHCR & Docker Hub) - # Sign each image by digest using keyless (OIDC) and key-based signing, - # then verify both the public key signature and the keyless OIDC signature. - env: - TAG: ${{ env.TAG }} - COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} - COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} - COSIGN_YES: "true" - run: | - set -euo pipefail + tag = os.environ["TAG_VALUE"] + file_path = Path('server/lib/consts.ts') + content = file_path.read_text() + marker = 'export const APP_VERSION = "' + if marker not in content: + raise SystemExit('APP_VERSION constant not found in server/lib/consts.ts') + start = content.index(marker) + len(marker) + end = content.index('"', start) + updated = content[:start] + tag + content[end:] + file_path.write_text(updated) + PY + shell: bash - issuer="https://token.actions.githubusercontent.com" - id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs) + - name: Generate SQLite migrations + run: npm run db:sqlite:generate + shell: bash - for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do - echo "Processing ${IMAGE}:${TAG}" + - name: Apply SQLite migrations + run: npm run db:sqlite:push + shell: bash - DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')" - REF="${IMAGE}@${DIGEST}" - echo "Resolved digest: ${REF}" + - name: Generate SQLite init snapshot + run: npx drizzle-kit generate --dialect sqlite --schema ./server/db/sqlite/schema --out init + shell: bash - echo "==> cosign sign (keyless) --recursive ${REF}" - cosign sign --recursive "${REF}" + - name: Type check + run: npx tsc --noEmit + shell: bash - echo "==> cosign sign (key) --recursive ${REF}" - cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + - name: Build SQLite distribution + env: + NODE_ENV: production + NEXT_TELEMETRY_DISABLED: "1" + CI: "true" + run: | + npm run build:sqlite + npm run build:cli + shell: bash - echo "==> cosign verify (public key) ${REF}" - cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text + - name: Include init artifacts in dist + run: | + set -euo pipefail + mkdir -p dist/init + if [ -d init ]; then + cp -a init/. dist/init/ + fi + shell: bash - echo "==> cosign verify (keyless policy) ${REF}" - cosign verify \ - --certificate-oidc-issuer "${issuer}" \ - --certificate-identity-regexp "${id_regex}" \ - "${REF}" -o text - done - shell: bash + - name: Package distribution artifact + env: + TAG_VALUE: ${{ env.TAG }} + run: | + set -euo pipefail + tar -czf pangolin-${TAG_VALUE}-sqlite-dist.tar.gz dist + shell: bash + + - name: Resolve publish-latest flag + env: + EVENT_NAME: ${{ github.event_name }} + PL_INPUT: ${{ inputs.publish_latest }} + PL_VAR: ${{ vars.PUBLISH_LATEST }} + run: | + set -euo pipefail + val="false" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "${PL_INPUT}" = "true" ]; then val="true"; fi + else + if [ "${PL_VAR}" = "true" ]; then val="true"; fi + fi + echo "PUBLISH_LATEST=$val" >> $GITHUB_ENV + shell: bash + + - name: Resolve publish-minor flag + env: + EVENT_NAME: ${{ github.event_name }} + PM_INPUT: ${{ inputs.publish_minor }} + PM_VAR: ${{ vars.PUBLISH_MINOR }} + run: | + set -euo pipefail + val="false" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + if [ "${PM_INPUT}" = "true" ]; then val="true"; fi + else + if [ "${PM_VAR}" = "true" ]; then val="true"; fi + fi + echo "PUBLISH_MINOR=$val" >> $GITHUB_ENV + shell: bash + + - name: Resolve license fallback + run: echo "IMAGE_LICENSE=${{ github.event.repository.license.spdx_id || 'NOASSERTION' }}" >> $GITHUB_ENV + shell: bash + + - name: Resolve registries list (GHCR always, Docker Hub only if creds) + shell: bash + run: | + set -euo pipefail + images="${GHCR_IMAGE}" + if [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ] && [ -n "${{ vars.DOCKER_HUB_USERNAME }}" ]; then + images="${images}\n${DOCKERHUB_IMAGE}" + fi + { + echo 'IMAGE_LIST<> "$GITHUB_ENV" + - name: Docker meta + id: meta + uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 + with: + images: ${{ env.IMAGE_LIST }} + tags: | + type=semver,pattern={{version}},value=${{ env.TAG }} + type=semver,pattern={{major}}.{{minor}},value=${{ env.TAG }},enable=${{ env.PUBLISH_MINOR == 'true' && env.IS_RC != 'true' }} + type=raw,value=latest,enable=${{ env.PUBLISH_LATEST == 'true' && env.IS_RC != 'true' }} + flavor: | + latest=false + labels: | + org.opencontainers.image.title=${{ github.event.repository.name }} + org.opencontainers.image.version=${{ env.TAG }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=${{ github.event.repository.html_url }} + org.opencontainers.image.url=${{ github.event.repository.html_url }} + org.opencontainers.image.documentation=${{ github.event.repository.html_url }} + org.opencontainers.image.description=${{ github.event.repository.description }} + org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }} + org.opencontainers.image.created=${{ env.IMAGE_CREATED }} + org.opencontainers.image.ref.name=${{ env.TAG }} + org.opencontainers.image.authors=${{ github.repository_owner }} + - name: Echo build config (non-secret) + shell: bash + env: + IMAGE_TITLE: ${{ github.event.repository.name }} + IMAGE_VERSION: ${{ env.TAG }} + IMAGE_REVISION: ${{ github.sha }} + IMAGE_SOURCE_URL: ${{ github.event.repository.html_url }} + IMAGE_URL: ${{ github.event.repository.html_url }} + IMAGE_DESCRIPTION: ${{ github.event.repository.description }} + IMAGE_LICENSE: ${{ env.IMAGE_LICENSE }} + DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }} + GHCR_IMAGE: ${{ env.GHCR_IMAGE }} + DOCKER_HUB_USER: ${{ vars.DOCKER_HUB_USERNAME }} + REPO: ${{ github.repository }} + OWNER: ${{ github.repository_owner }} + WORKFLOW_REF: ${{ github.workflow_ref }} + REF: ${{ github.ref }} + REF_NAME: ${{ github.ref_name }} + RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -euo pipefail + echo "=== OCI Label Values ===" + echo "org.opencontainers.image.title=${IMAGE_TITLE}" + echo "org.opencontainers.image.version=${IMAGE_VERSION}" + echo "org.opencontainers.image.revision=${IMAGE_REVISION}" + echo "org.opencontainers.image.source=${IMAGE_SOURCE_URL}" + echo "org.opencontainers.image.url=${IMAGE_URL}" + echo "org.opencontainers.image.description=${IMAGE_DESCRIPTION}" + echo "org.opencontainers.image.licenses=${IMAGE_LICENSE}" + echo + echo "=== Images ===" + echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE}" + echo "GHCR_IMAGE=${GHCR_IMAGE}" + echo "DOCKER_HUB_USERNAME=${DOCKER_HUB_USER}" + echo + echo "=== GitHub Kontext ===" + echo "repository=${REPO}" + echo "owner=${OWNER}" + echo "workflow_ref=${WORKFLOW_REF}" + echo "ref=${REF}" + echo "ref_name=${REF_NAME}" + echo "run_url=${RUN_URL}" + echo + echo "=== docker/metadata-action outputs (Tags/Labels), raw ===" + echo "::group::tags" + echo "${{ steps.meta.outputs.tags }}" + echo "::endgroup::" + echo "::group::labels" + echo "${{ steps.meta.outputs.labels }}" + echo "::endgroup::" + - name: Build and push (Docker Hub + GHCR) + id: build + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=${{ github.repository }} + cache-to: type=gha,mode=max,scope=${{ github.repository }} + provenance: mode=max + sbom: true + + - name: Compute image digest refs + run: | + echo "DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV + echo "GHCR_REF=$GHCR_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV + echo "DH_REF=$DOCKERHUB_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV + echo "Built digest: ${{ steps.build.outputs.digest }}" + shell: bash + + - name: Attest build provenance (GHCR) + id: attest-ghcr + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-name: ${{ env.GHCR_IMAGE }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + show-summary: true + + - name: Attest build provenance (Docker Hub) + continue-on-error: true + id: attest-dh + uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 + with: + subject-name: index.docker.io/${{ vars.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + show-summary: true + + - name: Install cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + with: + cosign-release: 'v3.0.2' + + - name: Sanity check cosign private key + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null + shell: bash + + - name: Sign GHCR image (digest) with key (recursive) + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + echo "Signing ${GHCR_REF} (digest) recursively with provided key" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${GHCR_REF}" + shell: bash + + - name: Generate SBOM (SPDX JSON) + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 + with: + image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} + format: spdx-json + output: sbom.spdx.json + + - name: Validate SBOM JSON + run: jq -e . sbom.spdx.json >/dev/null + shell: bash + + - name: Minify SBOM JSON (optional hardening) + run: jq -c . sbom.spdx.json > sbom.min.json && mv sbom.min.json sbom.spdx.json + shell: bash + + - name: Create SBOM attestation (GHCR, private key) + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + set -euo pipefail + cosign attest \ + --key env://COSIGN_PRIVATE_KEY \ + --type spdxjson \ + --predicate sbom.spdx.json \ + "${GHCR_REF}" + shell: bash + + - name: Create SBOM attestation (Docker Hub, private key) + continue-on-error: true + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + cosign attest \ + --key env://COSIGN_PRIVATE_KEY \ + --type spdxjson \ + --predicate sbom.spdx.json \ + "${DH_REF}" + shell: bash + + - name: Keyless sign & verify GHCR digest (OIDC) + env: + COSIGN_YES: "true" + WORKFLOW_REF: ${{ github.workflow_ref }} # owner/repo/.github/workflows/@refs/tags/ + ISSUER: https://token.actions.githubusercontent.com + run: | + set -euo pipefail + echo "Keyless signing ${GHCR_REF}" + cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${GHCR_REF}" + echo "Verify keyless (OIDC) signature policy on ${GHCR_REF}" + cosign verify \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${WORKFLOW_REF}" \ + "${GHCR_REF}" -o text + shell: bash + + - name: Sign Docker Hub image (digest) with key (recursive) + continue-on-error: true + env: + COSIGN_YES: "true" + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Signing ${DH_REF} (digest) recursively with provided key (Docker media types fallback)" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${DH_REF}" + shell: bash + + - name: Keyless sign & verify Docker Hub digest (OIDC) + continue-on-error: true + env: + COSIGN_YES: "true" + ISSUER: https://token.actions.githubusercontent.com + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Keyless signing ${DH_REF} (force public-good Rekor)" + cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${DH_REF}" + echo "Keyless verify via Rekor (strict identity)" + if ! cosign verify \ + --rekor-url https://rekor.sigstore.dev \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ + "${DH_REF}" -o text; then + echo "Rekor verify failed — retry offline bundle verify (no Rekor)" + if ! cosign verify \ + --offline \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ + "${DH_REF}" -o text; then + echo "Offline bundle verify failed — ignore tlog (TEMP for debugging)" + cosign verify \ + --insecure-ignore-tlog=true \ + --certificate-oidc-issuer "${ISSUER}" \ + --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ + "${DH_REF}" -o text || true + fi + fi + - name: Verify signature (public key) GHCR digest + tag + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + run: | + set -euo pipefail + TAG_VAR="${TAG}" + echo "Verifying (digest) ${GHCR_REF}" + cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_REF" -o text + echo "Verifying (tag) $GHCR_IMAGE:$TAG_VAR" + cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_IMAGE:$TAG_VAR" -o text + shell: bash + + - name: Verify SBOM attestation (GHCR) + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + run: cosign verify-attestation --key env://COSIGN_PUBLIC_KEY --type spdxjson "$GHCR_REF" -o text + shell: bash + + - name: Verify SLSA provenance (GHCR) + env: + ISSUER: https://token.actions.githubusercontent.com + WFREF: ${{ github.workflow_ref }} + run: | + set -euo pipefail + cosign download attestation "$GHCR_REF" \ + | jq -r '.payload | @base64d | fromjson | .predicateType' | sort -u || true + cosign verify-attestation \ + --type 'https://slsa.dev/provenance/v1' \ + --certificate-oidc-issuer "$ISSUER" \ + --certificate-identity "https://github.com/${WFREF}" \ + --rekor-url https://rekor.sigstore.dev \ + "$GHCR_REF" -o text + shell: bash + + - name: Verify signature (public key) Docker Hub digest + continue-on-error: true + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Verifying (digest) ${DH_REF} with Docker media types" + cosign verify --key env://COSIGN_PUBLIC_KEY "${DH_REF}" -o text + shell: bash + + - name: Verify signature (public key) Docker Hub tag + continue-on-error: true + env: + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_DOCKER_MEDIA_TYPES: "1" + run: | + set -euo pipefail + echo "Verifying (tag) $DOCKERHUB_IMAGE:$TAG with Docker media types" + cosign verify --key env://COSIGN_PUBLIC_KEY "$DOCKERHUB_IMAGE:$TAG" -o text + shell: bash + + - name: Trivy scan (GHCR image) + id: trivy + uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 + with: + image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} + format: sarif + output: trivy-ghcr.sarif + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + exit-code: ${{ (vars.TRIVY_FAIL || '0') }} + + - name: Upload SARIF + if: ${{ always() && hashFiles('trivy-ghcr.sarif') != '' }} + uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 + with: + sarif_file: trivy-ghcr.sarif + category: Image Vulnerability Scan + + - name: Create GitHub Release + uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 + with: + tag_name: ${{ env.TAG }} + generate_release_notes: true + prerelease: ${{ env.IS_RC == 'true' }} + files: | + pangolin-${{ env.TAG }}-sqlite-dist.tar.gz + fail_on_unmatched_files: true + body: | + ## Container Images + - GHCR: `${{ env.GHCR_REF }}` + - Docker Hub: `${{ env.DH_REF || 'N/A' }}` + **Digest:** `${{ steps.build.outputs.digest }}` + + ## Application Bundles + - SQLite build: `pangolin-${{ env.TAG }}-sqlite-dist.tar.gz` From 3547c4832b1c70b7ce678b575f43b183433e60b8 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 2 Nov 2025 15:22:03 -0800 Subject: [PATCH 15/22] Revert "Refactor CI/CD workflow for improved release process" --- .github/workflows/cicd.yml | 757 +++++++------------------------------ 1 file changed, 137 insertions(+), 620 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 9125687e..597a18d7 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -4,12 +4,9 @@ name: CI/CD Pipeline # Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events. permissions: - contents: write # gh-release - packages: write # GHCR push - id-token: write # Keyless-Signatures & Attestations - attestations: write # actions/attest-build-provenance - security-events: write # upload-sarif - actions: read + contents: read + packages: write # for GHCR push + id-token: write # for Cosign Keyless (OIDC) Signing # Required secrets: # - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub @@ -17,647 +14,167 @@ permissions: # - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing on: - push: - tags: - - "[0-9]+.[0-9]+.[0-9]+" - - "[0-9]+.[0-9]+.[0-9]+.rc.[0-9]+" - workflow_dispatch: - inputs: - version: - description: "SemVer version to release (e.g., 1.2.3, no leading 'v')" - required: true - type: string - publish_latest: - description: "Also publish the 'latest' image tag" - required: true - type: boolean - default: false - publish_minor: - description: "Also publish the 'major.minor' image tag (e.g., 1.2)" - required: true - type: boolean - default: false - target_branch: - description: "Branch to tag" - required: false - default: "main" + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+.rc.[0-9]+" concurrency: - group: ${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version || github.ref_name }} + group: ${{ github.ref }} cancel-in-progress: true jobs: - prepare: - if: github.event_name == 'workflow_dispatch' - name: Prepare release (create tag) - runs-on: ubuntu-24.04 - permissions: - contents: write - steps: - - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 0 - - - name: Validate version input - shell: bash + release: + name: Build and Release + runs-on: [self-hosted, linux, x64] + # Job-level timeout to avoid runaway or stuck runs + timeout-minutes: 120 env: - INPUT_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - if ! [[ "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then - echo "Invalid version: $INPUT_VERSION (expected X.Y.Z or X.Y.Z-rc.N)" >&2 - exit 1 - fi - - name: Create and push tag - shell: bash - env: - TARGET_BRANCH: ${{ inputs.target_branch }} - VERSION: ${{ inputs.version }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git fetch --prune origin - git checkout "$TARGET_BRANCH" - git pull --ff-only origin "$TARGET_BRANCH" - if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then - echo "Tag $VERSION already exists" >&2 - exit 1 - fi - git tag -a "$VERSION" -m "Release $VERSION" - git push origin "refs/tags/$VERSION" - release: - if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.actor != 'github-actions[bot]') }} - name: Build and Release - runs-on: ubuntu-24.04 - timeout-minutes: 120 - env: - DOCKERHUB_IMAGE: docker.io/${{ vars.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} - GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + # Target images + DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} - steps: - - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - with: - fetch-depth: 0 + steps: + - name: Checkout code + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - - name: Capture created timestamp - run: echo "IMAGE_CREATED=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_ENV - shell: bash + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 - - name: Set up QEMU - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + - name: Log in to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash - - name: Log in to Docker Hub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - with: - registry: docker.io - username: ${{ vars.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + - name: Install Go + uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version: 1.24 - - name: Log in to GHCR - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + - name: Update version in package.json + run: | + TAG=${{ env.TAG }} + sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts + cat server/lib/consts.ts + shell: bash - - name: Normalize image names to lowercase - run: | - set -euo pipefail - echo "GHCR_IMAGE=${GHCR_IMAGE,,}" >> "$GITHUB_ENV" - echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE,,}" >> "$GITHUB_ENV" - shell: bash + - name: Pull latest Gerbil version + id: get-gerbil-tag + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') + echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV + shell: bash - - name: Extract tag name - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.version }} - run: | - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - echo "TAG=${INPUT_VERSION}" >> $GITHUB_ENV - else - echo "TAG=${{ github.ref_name }}" >> $GITHUB_ENV - fi - shell: bash + - name: Pull latest Badger version + id: get-badger-tag + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') + echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV + shell: bash - - name: Validate pushed tag format (no leading 'v') - if: ${{ github.event_name == 'push' }} - shell: bash - env: - TAG_GOT: ${{ env.TAG }} - run: | - set -euo pipefail - if [[ "$TAG_GOT" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then - echo "Tag OK: $TAG_GOT" - exit 0 - fi - echo "ERROR: Tag '$TAG_GOT' is not allowed. Use 'X.Y.Z' or 'X.Y.Z-rc.N' (no leading 'v')." >&2 - exit 1 - - name: Wait for tag to be visible (dispatch only) - if: ${{ github.event_name == 'workflow_dispatch' }} - run: | - set -euo pipefail - for i in {1..90}; do - if git ls-remote --tags origin "refs/tags/${TAG}" | grep -qE "refs/tags/${TAG}$"; then - echo "Tag ${TAG} is visible on origin"; exit 0 - fi - echo "Tag not yet visible, retrying... ($i/90)" - sleep 2 - done - echo "Tag ${TAG} not visible after waiting"; exit 1 - shell: bash + - name: Update install/main.go + run: | + PANGOLIN_VERSION=${{ env.TAG }} + GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} + BADGER_VERSION=${{ env.LATEST_BADGER_TAG }} + sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go + sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go + sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go + echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION" + cat install/main.go + shell: bash - - name: Ensure repository is at the tagged commit (dispatch only) - if: ${{ github.event_name == 'workflow_dispatch' }} - run: | - set -euo pipefail - git fetch --tags --force - git checkout "refs/tags/${TAG}" - echo "Checked out $(git rev-parse --short HEAD) for tag ${TAG}" - shell: bash + - name: Build installer + working-directory: install + run: | + make go-build-release - - name: Detect release candidate (rc) - run: | - set -euo pipefail - if [[ "${TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then - echo "IS_RC=true" >> $GITHUB_ENV - else - echo "IS_RC=false" >> $GITHUB_ENV - fi - shell: bash + - name: Upload artifacts from /install/bin + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: install-bin + path: install/bin/ - - name: Set up Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 - with: - node-version-file: .nvmrc - cache: npm + - name: Build and push Docker images (Docker Hub) + run: | + TAG=${{ env.TAG }} + make build-release tag=$TAG + echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" + shell: bash - - name: Install dependencies - run: npm ci - shell: bash + - name: Install skopeo + jq + # skopeo: copy/inspect images between registries + # jq: JSON parsing tool used to extract digest values + run: | + sudo apt-get update -y + sudo apt-get install -y skopeo jq + skopeo --version + shell: bash - - name: Copy config file - run: cp config/config.example.yml config/config.yml - shell: bash + - name: Login to GHCR + run: | + skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" + shell: bash - - name: Configure default build flavor - run: | - npm run set:oss - npm run set:sqlite - shell: bash + - name: Copy tag from Docker Hub to GHCR + # Mirror the already-built image (all architectures) to GHCR so we can sign it + run: | + set -euo pipefail + TAG=${{ env.TAG }} + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:$TAG \ + docker://$GHCR_IMAGE:$TAG + shell: bash - - name: Update version references - env: - TAG_VALUE: ${{ env.TAG }} - run: | - set -euo pipefail - python3 - <<'PY' - from pathlib import Path - import os + - name: Install cosign + # cosign is used to sign and verify container images (key and keyless) + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - tag = os.environ["TAG_VALUE"] - file_path = Path('server/lib/consts.ts') - content = file_path.read_text() - marker = 'export const APP_VERSION = "' - if marker not in content: - raise SystemExit('APP_VERSION constant not found in server/lib/consts.ts') - start = content.index(marker) + len(marker) - end = content.index('"', start) - updated = content[:start] + tag + content[end:] - file_path.write_text(updated) - PY - shell: bash + - name: Dual-sign and verify (GHCR & Docker Hub) + # Sign each image by digest using keyless (OIDC) and key-based signing, + # then verify both the public key signature and the keyless OIDC signature. + env: + TAG: ${{ env.TAG }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_YES: "true" + run: | + set -euo pipefail - - name: Generate SQLite migrations - run: npm run db:sqlite:generate - shell: bash + issuer="https://token.actions.githubusercontent.com" + id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs) - - name: Apply SQLite migrations - run: npm run db:sqlite:push - shell: bash + for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do + echo "Processing ${IMAGE}:${TAG}" - - name: Generate SQLite init snapshot - run: npx drizzle-kit generate --dialect sqlite --schema ./server/db/sqlite/schema --out init - shell: bash + DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')" + REF="${IMAGE}@${DIGEST}" + echo "Resolved digest: ${REF}" - - name: Type check - run: npx tsc --noEmit - shell: bash + echo "==> cosign sign (keyless) --recursive ${REF}" + cosign sign --recursive "${REF}" - - name: Build SQLite distribution - env: - NODE_ENV: production - NEXT_TELEMETRY_DISABLED: "1" - CI: "true" - run: | - npm run build:sqlite - npm run build:cli - shell: bash + echo "==> cosign sign (key) --recursive ${REF}" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" - - name: Include init artifacts in dist - run: | - set -euo pipefail - mkdir -p dist/init - if [ -d init ]; then - cp -a init/. dist/init/ - fi - shell: bash + echo "==> cosign verify (public key) ${REF}" + cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text - - name: Package distribution artifact - env: - TAG_VALUE: ${{ env.TAG }} - run: | - set -euo pipefail - tar -czf pangolin-${TAG_VALUE}-sqlite-dist.tar.gz dist - shell: bash - - - name: Resolve publish-latest flag - env: - EVENT_NAME: ${{ github.event_name }} - PL_INPUT: ${{ inputs.publish_latest }} - PL_VAR: ${{ vars.PUBLISH_LATEST }} - run: | - set -euo pipefail - val="false" - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - if [ "${PL_INPUT}" = "true" ]; then val="true"; fi - else - if [ "${PL_VAR}" = "true" ]; then val="true"; fi - fi - echo "PUBLISH_LATEST=$val" >> $GITHUB_ENV - shell: bash - - - name: Resolve publish-minor flag - env: - EVENT_NAME: ${{ github.event_name }} - PM_INPUT: ${{ inputs.publish_minor }} - PM_VAR: ${{ vars.PUBLISH_MINOR }} - run: | - set -euo pipefail - val="false" - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then - if [ "${PM_INPUT}" = "true" ]; then val="true"; fi - else - if [ "${PM_VAR}" = "true" ]; then val="true"; fi - fi - echo "PUBLISH_MINOR=$val" >> $GITHUB_ENV - shell: bash - - - name: Resolve license fallback - run: echo "IMAGE_LICENSE=${{ github.event.repository.license.spdx_id || 'NOASSERTION' }}" >> $GITHUB_ENV - shell: bash - - - name: Resolve registries list (GHCR always, Docker Hub only if creds) - shell: bash - run: | - set -euo pipefail - images="${GHCR_IMAGE}" - if [ -n "${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}" ] && [ -n "${{ vars.DOCKER_HUB_USERNAME }}" ]; then - images="${images}\n${DOCKERHUB_IMAGE}" - fi - { - echo 'IMAGE_LIST<> "$GITHUB_ENV" - - name: Docker meta - id: meta - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 - with: - images: ${{ env.IMAGE_LIST }} - tags: | - type=semver,pattern={{version}},value=${{ env.TAG }} - type=semver,pattern={{major}}.{{minor}},value=${{ env.TAG }},enable=${{ env.PUBLISH_MINOR == 'true' && env.IS_RC != 'true' }} - type=raw,value=latest,enable=${{ env.PUBLISH_LATEST == 'true' && env.IS_RC != 'true' }} - flavor: | - latest=false - labels: | - org.opencontainers.image.title=${{ github.event.repository.name }} - org.opencontainers.image.version=${{ env.TAG }} - org.opencontainers.image.revision=${{ github.sha }} - org.opencontainers.image.source=${{ github.event.repository.html_url }} - org.opencontainers.image.url=${{ github.event.repository.html_url }} - org.opencontainers.image.documentation=${{ github.event.repository.html_url }} - org.opencontainers.image.description=${{ github.event.repository.description }} - org.opencontainers.image.licenses=${{ env.IMAGE_LICENSE }} - org.opencontainers.image.created=${{ env.IMAGE_CREATED }} - org.opencontainers.image.ref.name=${{ env.TAG }} - org.opencontainers.image.authors=${{ github.repository_owner }} - - name: Echo build config (non-secret) - shell: bash - env: - IMAGE_TITLE: ${{ github.event.repository.name }} - IMAGE_VERSION: ${{ env.TAG }} - IMAGE_REVISION: ${{ github.sha }} - IMAGE_SOURCE_URL: ${{ github.event.repository.html_url }} - IMAGE_URL: ${{ github.event.repository.html_url }} - IMAGE_DESCRIPTION: ${{ github.event.repository.description }} - IMAGE_LICENSE: ${{ env.IMAGE_LICENSE }} - DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }} - GHCR_IMAGE: ${{ env.GHCR_IMAGE }} - DOCKER_HUB_USER: ${{ vars.DOCKER_HUB_USERNAME }} - REPO: ${{ github.repository }} - OWNER: ${{ github.repository_owner }} - WORKFLOW_REF: ${{ github.workflow_ref }} - REF: ${{ github.ref }} - REF_NAME: ${{ github.ref_name }} - RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: | - set -euo pipefail - echo "=== OCI Label Values ===" - echo "org.opencontainers.image.title=${IMAGE_TITLE}" - echo "org.opencontainers.image.version=${IMAGE_VERSION}" - echo "org.opencontainers.image.revision=${IMAGE_REVISION}" - echo "org.opencontainers.image.source=${IMAGE_SOURCE_URL}" - echo "org.opencontainers.image.url=${IMAGE_URL}" - echo "org.opencontainers.image.description=${IMAGE_DESCRIPTION}" - echo "org.opencontainers.image.licenses=${IMAGE_LICENSE}" - echo - echo "=== Images ===" - echo "DOCKERHUB_IMAGE=${DOCKERHUB_IMAGE}" - echo "GHCR_IMAGE=${GHCR_IMAGE}" - echo "DOCKER_HUB_USERNAME=${DOCKER_HUB_USER}" - echo - echo "=== GitHub Kontext ===" - echo "repository=${REPO}" - echo "owner=${OWNER}" - echo "workflow_ref=${WORKFLOW_REF}" - echo "ref=${REF}" - echo "ref_name=${REF_NAME}" - echo "run_url=${RUN_URL}" - echo - echo "=== docker/metadata-action outputs (Tags/Labels), raw ===" - echo "::group::tags" - echo "${{ steps.meta.outputs.tags }}" - echo "::endgroup::" - echo "::group::labels" - echo "${{ steps.meta.outputs.labels }}" - echo "::endgroup::" - - name: Build and push (Docker Hub + GHCR) - id: build - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=${{ github.repository }} - cache-to: type=gha,mode=max,scope=${{ github.repository }} - provenance: mode=max - sbom: true - - - name: Compute image digest refs - run: | - echo "DIGEST=${{ steps.build.outputs.digest }}" >> $GITHUB_ENV - echo "GHCR_REF=$GHCR_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV - echo "DH_REF=$DOCKERHUB_IMAGE@${{ steps.build.outputs.digest }}" >> $GITHUB_ENV - echo "Built digest: ${{ steps.build.outputs.digest }}" - shell: bash - - - name: Attest build provenance (GHCR) - id: attest-ghcr - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 - with: - subject-name: ${{ env.GHCR_IMAGE }} - subject-digest: ${{ steps.build.outputs.digest }} - push-to-registry: true - show-summary: true - - - name: Attest build provenance (Docker Hub) - continue-on-error: true - id: attest-dh - uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0 - with: - subject-name: index.docker.io/${{ vars.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} - subject-digest: ${{ steps.build.outputs.digest }} - push-to-registry: true - show-summary: true - - - name: Install cosign - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 - with: - cosign-release: 'v3.0.2' - - - name: Sanity check cosign private key - env: - COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} - COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - run: | - set -euo pipefail - cosign public-key --key env://COSIGN_PRIVATE_KEY >/dev/null - shell: bash - - - name: Sign GHCR image (digest) with key (recursive) - env: - COSIGN_YES: "true" - COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} - COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - run: | - set -euo pipefail - echo "Signing ${GHCR_REF} (digest) recursively with provided key" - cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${GHCR_REF}" - shell: bash - - - name: Generate SBOM (SPDX JSON) - uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 - with: - image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} - format: spdx-json - output: sbom.spdx.json - - - name: Validate SBOM JSON - run: jq -e . sbom.spdx.json >/dev/null - shell: bash - - - name: Minify SBOM JSON (optional hardening) - run: jq -c . sbom.spdx.json > sbom.min.json && mv sbom.min.json sbom.spdx.json - shell: bash - - - name: Create SBOM attestation (GHCR, private key) - env: - COSIGN_YES: "true" - COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} - COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - run: | - set -euo pipefail - cosign attest \ - --key env://COSIGN_PRIVATE_KEY \ - --type spdxjson \ - --predicate sbom.spdx.json \ - "${GHCR_REF}" - shell: bash - - - name: Create SBOM attestation (Docker Hub, private key) - continue-on-error: true - env: - COSIGN_YES: "true" - COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} - COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - COSIGN_DOCKER_MEDIA_TYPES: "1" - run: | - set -euo pipefail - cosign attest \ - --key env://COSIGN_PRIVATE_KEY \ - --type spdxjson \ - --predicate sbom.spdx.json \ - "${DH_REF}" - shell: bash - - - name: Keyless sign & verify GHCR digest (OIDC) - env: - COSIGN_YES: "true" - WORKFLOW_REF: ${{ github.workflow_ref }} # owner/repo/.github/workflows/@refs/tags/ - ISSUER: https://token.actions.githubusercontent.com - run: | - set -euo pipefail - echo "Keyless signing ${GHCR_REF}" - cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${GHCR_REF}" - echo "Verify keyless (OIDC) signature policy on ${GHCR_REF}" - cosign verify \ - --certificate-oidc-issuer "${ISSUER}" \ - --certificate-identity "https://github.com/${WORKFLOW_REF}" \ - "${GHCR_REF}" -o text - shell: bash - - - name: Sign Docker Hub image (digest) with key (recursive) - continue-on-error: true - env: - COSIGN_YES: "true" - COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} - COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} - COSIGN_DOCKER_MEDIA_TYPES: "1" - run: | - set -euo pipefail - echo "Signing ${DH_REF} (digest) recursively with provided key (Docker media types fallback)" - cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${DH_REF}" - shell: bash - - - name: Keyless sign & verify Docker Hub digest (OIDC) - continue-on-error: true - env: - COSIGN_YES: "true" - ISSUER: https://token.actions.githubusercontent.com - COSIGN_DOCKER_MEDIA_TYPES: "1" - run: | - set -euo pipefail - echo "Keyless signing ${DH_REF} (force public-good Rekor)" - cosign sign --rekor-url https://rekor.sigstore.dev --recursive "${DH_REF}" - echo "Keyless verify via Rekor (strict identity)" - if ! cosign verify \ - --rekor-url https://rekor.sigstore.dev \ - --certificate-oidc-issuer "${ISSUER}" \ - --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ - "${DH_REF}" -o text; then - echo "Rekor verify failed — retry offline bundle verify (no Rekor)" - if ! cosign verify \ - --offline \ - --certificate-oidc-issuer "${ISSUER}" \ - --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ - "${DH_REF}" -o text; then - echo "Offline bundle verify failed — ignore tlog (TEMP for debugging)" - cosign verify \ - --insecure-ignore-tlog=true \ - --certificate-oidc-issuer "${ISSUER}" \ - --certificate-identity "https://github.com/${{ github.workflow_ref }}" \ - "${DH_REF}" -o text || true - fi - fi - - name: Verify signature (public key) GHCR digest + tag - env: - COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} - run: | - set -euo pipefail - TAG_VAR="${TAG}" - echo "Verifying (digest) ${GHCR_REF}" - cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_REF" -o text - echo "Verifying (tag) $GHCR_IMAGE:$TAG_VAR" - cosign verify --key env://COSIGN_PUBLIC_KEY "$GHCR_IMAGE:$TAG_VAR" -o text - shell: bash - - - name: Verify SBOM attestation (GHCR) - env: - COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} - run: cosign verify-attestation --key env://COSIGN_PUBLIC_KEY --type spdxjson "$GHCR_REF" -o text - shell: bash - - - name: Verify SLSA provenance (GHCR) - env: - ISSUER: https://token.actions.githubusercontent.com - WFREF: ${{ github.workflow_ref }} - run: | - set -euo pipefail - cosign download attestation "$GHCR_REF" \ - | jq -r '.payload | @base64d | fromjson | .predicateType' | sort -u || true - cosign verify-attestation \ - --type 'https://slsa.dev/provenance/v1' \ - --certificate-oidc-issuer "$ISSUER" \ - --certificate-identity "https://github.com/${WFREF}" \ - --rekor-url https://rekor.sigstore.dev \ - "$GHCR_REF" -o text - shell: bash - - - name: Verify signature (public key) Docker Hub digest - continue-on-error: true - env: - COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} - COSIGN_DOCKER_MEDIA_TYPES: "1" - run: | - set -euo pipefail - echo "Verifying (digest) ${DH_REF} with Docker media types" - cosign verify --key env://COSIGN_PUBLIC_KEY "${DH_REF}" -o text - shell: bash - - - name: Verify signature (public key) Docker Hub tag - continue-on-error: true - env: - COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} - COSIGN_DOCKER_MEDIA_TYPES: "1" - run: | - set -euo pipefail - echo "Verifying (tag) $DOCKERHUB_IMAGE:$TAG with Docker media types" - cosign verify --key env://COSIGN_PUBLIC_KEY "$DOCKERHUB_IMAGE:$TAG" -o text - shell: bash - - - name: Trivy scan (GHCR image) - id: trivy - uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # v0.33.1 - with: - image-ref: ${{ env.GHCR_IMAGE }}@${{ steps.build.outputs.digest }} - format: sarif - output: trivy-ghcr.sarif - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL,HIGH - exit-code: ${{ (vars.TRIVY_FAIL || '0') }} - - - name: Upload SARIF - if: ${{ always() && hashFiles('trivy-ghcr.sarif') != '' }} - uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 - with: - sarif_file: trivy-ghcr.sarif - category: Image Vulnerability Scan - - - name: Create GitHub Release - uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 - with: - tag_name: ${{ env.TAG }} - generate_release_notes: true - prerelease: ${{ env.IS_RC == 'true' }} - files: | - pangolin-${{ env.TAG }}-sqlite-dist.tar.gz - fail_on_unmatched_files: true - body: | - ## Container Images - - GHCR: `${{ env.GHCR_REF }}` - - Docker Hub: `${{ env.DH_REF || 'N/A' }}` - **Digest:** `${{ steps.build.outputs.digest }}` - - ## Application Bundles - - SQLite build: `pangolin-${{ env.TAG }}-sqlite-dist.tar.gz` + echo "==> cosign verify (keyless policy) ${REF}" + cosign verify \ + --certificate-oidc-issuer "${issuer}" \ + --certificate-identity-regexp "${id_regex}" \ + "${REF}" -o text + done + shell: bash From 43590896e933ec0595941e6af656241a45034d04 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 2 Nov 2025 15:35:02 -0800 Subject: [PATCH 16/22] Add fosrl --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 597a18d7..1af1bbb4 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -31,7 +31,7 @@ jobs: timeout-minutes: 120 env: # Target images - DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKER_HUB_USERNAME }}/${{ github.event.repository.name }} + DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} steps: From 6174599754330d8a0bf1c590ec1550dda62fd5ca Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 3 Nov 2025 09:54:41 -0800 Subject: [PATCH 17/22] Allow >30 days on oss --- server/routers/org/updateOrg.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 7cfe072d..8ab809e4 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -92,7 +92,7 @@ export async function updateOrg( const { orgId } = parsedParams.data; const isLicensed = await isLicensedOrSubscribed(orgId); - if (!isLicensed) { + if (build == "enterprise" && !isLicensed) { parsedBody.data.requireTwoFactor = undefined; parsedBody.data.maxSessionLengthHours = undefined; parsedBody.data.passwordExpiryDays = undefined; @@ -100,6 +100,7 @@ export async function updateOrg( const { tier } = await getOrgTierData(orgId); if ( + build == "saas" && tier != TierId.STANDARD && parsedBody.data.settingsLogRetentionDaysRequest && parsedBody.data.settingsLogRetentionDaysRequest > 30 From 8942cb7aa768ac1f4775b2804b009bd14480785f Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 3 Nov 2025 17:38:50 -0800 Subject: [PATCH 18/22] Update const --- server/lib/consts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 5b00b146..c2cf6698 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.12.0-rc.0"; +export const APP_VERSION = "1.12.1"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); From 301654b63e7c43860466a1bf1f31f07293ff194a Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Nov 2025 11:38:14 -0800 Subject: [PATCH 19/22] Fix styling --- src/components/ResourcesTable.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index 9faee629..32661b65 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -35,6 +35,8 @@ import { Clock, Wifi, WifiOff, + CheckCircle2, + XCircle, } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -132,11 +134,11 @@ function StatusIcon({ status, className = "" }: { switch (status) { case 'online': - return ; + return ; case 'degraded': - return ; + return ; case 'offline': - return ; + return ; case 'unknown': return ; default: @@ -457,9 +459,9 @@ export default function ResourcesTable({ status={target.healthStatus === 'healthy' ? 'online' : 'offline'} className="h-3 w-3" /> - + {`${target.ip}:${target.port}`} - {target.healthStatus} @@ -473,9 +475,9 @@ export default function ResourcesTable({
- + {`${target.ip}:${target.port}`}
- + {!target.enabled ? 'Disabled' : 'Not monitored'}
From 6ddfc9b8fe5cee6806bb83ff6aae315982103d86 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 5 Nov 2025 11:41:07 -0800 Subject: [PATCH 20/22] Revert columns --- src/components/DataTablePagination.tsx | 9 +--- src/components/ui/data-table.tsx | 59 ++------------------------ 2 files changed, 5 insertions(+), 63 deletions(-) diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index 79b09f20..70d64f0c 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -24,7 +24,6 @@ interface DataTablePaginationProps { isServerPagination?: boolean; isLoading?: boolean; disabled?: boolean; - renderAdditionalControls?: () => React.ReactNode; } export function DataTablePagination({ @@ -34,8 +33,7 @@ export function DataTablePagination({ totalCount, isServerPagination = false, isLoading = false, - disabled = false, - renderAdditionalControls + disabled = false }: DataTablePaginationProps) { const t = useTranslations(); @@ -115,11 +113,6 @@ export function DataTablePagination({ ))} - {renderAdditionalControls && ( -
- {renderAdditionalControls()} -
- )}
diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 9c2cab88..ae94b12e 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -9,9 +9,7 @@ import { SortingState, getSortedRowModel, ColumnFiltersState, - getFilteredRowModel, - VisibilityState, - Column + getFilteredRowModel } from "@tanstack/react-table"; import { Table, @@ -25,7 +23,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, Settings2 } from "lucide-react"; +import { Plus, Search, RefreshCw } from "lucide-react"; import { Card, CardContent, @@ -34,12 +32,6 @@ 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', @@ -101,7 +93,6 @@ type DataTableProps = { defaultTab?: string; persistPageSize?: boolean | string; defaultPageSize?: number; - enableColumnToggle?: boolean; }; export function DataTable({ @@ -118,8 +109,7 @@ export function DataTable({ tabs, defaultTab, persistPageSize = false, - defaultPageSize = 20, - enableColumnToggle = true + defaultPageSize = 20 }: DataTableProps) { const t = useTranslations(); @@ -139,7 +129,6 @@ export function DataTable({ ); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState([]); - const [columnVisibility, setColumnVisibility] = useState({}); const [activeTab, setActiveTab] = useState( defaultTab || tabs?.[0]?.id || "" ); @@ -168,7 +157,6 @@ export function DataTable({ onColumnFiltersChange: setColumnFilters, getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setGlobalFilter, - onColumnVisibilityChange: setColumnVisibility, initialState: { pagination: { pageSize: pageSize, @@ -178,8 +166,7 @@ export function DataTable({ state: { sorting, columnFilters, - globalFilter, - columnVisibility + globalFilter } }); @@ -212,43 +199,6 @@ 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 (
@@ -362,7 +312,6 @@ export function DataTable({
From 0a9f37c44ddf3035b8dfb626b271756d82cd9c18 Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Thu, 6 Nov 2025 22:57:03 +0530 Subject: [PATCH 21/22] revert column from resource table --- src/components/ResourcesTable.tsx | 56 ------------------------------- 1 file changed, 56 deletions(-) diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index 32661b65..0e613da8 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -1040,34 +1040,6 @@ 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} - - ))} - - - )} />
@@ -1169,34 +1141,6 @@ 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} - - ))} - - - )} /> From fce887436d5a195cdb5ea9008a782efa0bb942ab Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 6 Nov 2025 15:46:54 -0800 Subject: [PATCH 22/22] fix bug causing auto provision to override manually created users --- server/routers/idp/validateOidcCallback.ts | 42 +++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 98bdfe44..376dd7bc 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 }[] = [];