From ad425e8d9e87240d5f1ddb05c6db5684c116611d Mon Sep 17 00:00:00 2001 From: Pallavi Kumari Date: Sat, 1 Nov 2025 11:58:09 +0530 Subject: [PATCH 01/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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 }[] = []; From 2a7529c39e9baa7eb3fb7dfcdacc289903175175 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 6 Nov 2025 16:48:53 -0800 Subject: [PATCH 23/45] don't delete user --- server/routers/idp/validateOidcCallback.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 376dd7bc..7d1da1c5 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -365,9 +365,9 @@ export async function validateOidcCallback( if (!existingUserOrgs.length) { // delete the user - await db - .delete(users) - .where(eq(users.userId, existingUser.userId)); + // await db + // .delete(users) + // .where(eq(users.userId, existingUser.userId)); return next( createHttpError( HttpCode.UNAUTHORIZED, From 83bd5957cd39dfea3b263ccd112c2b6fdd0b9887 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 8 Nov 2025 12:18:36 -0800 Subject: [PATCH 24/45] Dont allow editing a config managed domain Ref #1816 --- src/app/[orgId]/settings/domains/[domainId]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index ce744c41..39ad02db 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -73,7 +73,7 @@ export default async function DomainSettingsPage({ - {domain.type == "wildcard" && ( + {domain.type == "wildcard" && !domain.configManaged && ( Date: Sat, 8 Nov 2025 13:57:00 -0800 Subject: [PATCH 25/45] remove target unique check --- server/routers/target/createTarget.ts | 8 +-- server/routers/target/updateTarget.ts | 8 +-- .../resources/[niceId]/proxy/page.tsx | 52 +++---------------- .../settings/resources/create/page.tsx | 18 ------- src/components/DNSRecordsDataTable.tsx | 1 - 5 files changed, 12 insertions(+), 75 deletions(-) diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 1aef3251..b35d8d2a 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -163,12 +163,8 @@ export async function createTarget( ); if (existingTarget) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}` - ) - ); + // log a warning + logger.warn(`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}`); } let newTarget: Target[] = []; diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index d332609d..6e9a8fc9 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -170,12 +170,8 @@ export async function updateTarget( ); if (foundTarget) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `Target with IP ${targetData.ip}, port ${targetData.port}, and method ${targetData.method} already exists on the same site.` - ) - ); + // log a warning + logger.warn(`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${target.resourceId}`); } const { internalPort, targetIps } = await pickPort(site.siteId!, db); diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 158726fe..aa268250 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -501,25 +501,6 @@ export default function ReverseProxyTargets(props: { return; } - // Check if target with same IP, port and method already exists - const isDuplicate = targets.some( - (t) => - t.targetId !== target.targetId && - t.ip === target.ip && - t.port === target.port && - t.method === target.method && - t.siteId === target.siteId - ); - - if (isDuplicate) { - toast({ - variant: "destructive", - title: t("targetErrorDuplicate"), - description: t("targetErrorDuplicateDescription") - }); - return; - } - try { setTargetsLoading(true); @@ -585,24 +566,6 @@ export default function ReverseProxyTargets(props: { } async function addTarget(data: z.infer) { - // Check if target with same IP, port and method already exists - const isDuplicate = targets.some( - (target) => - target.ip === data.ip && - target.port === data.port && - target.method === data.method && - target.siteId === data.siteId - ); - - if (isDuplicate) { - toast({ - variant: "destructive", - title: t("targetErrorDuplicate"), - description: t("targetErrorDuplicateDescription") - }); - return; - } - // if (site && site.type == "wireguard" && site.subnet) { // // make sure that the target IP is within the site subnet // const targetIp = data.ip; @@ -899,7 +862,7 @@ export default function ReverseProxyTargets(props: { const healthCheckColumn: ColumnDef = { accessorKey: "healthCheck", - header: t("healthCheck"), + header: () => ({t("healthCheck")}), cell: ({ row }) => { const status = row.original.hcHealth || "unknown"; const isEnabled = row.original.hcEnabled; @@ -971,7 +934,7 @@ export default function ReverseProxyTargets(props: { const matchPathColumn: ColumnDef = { accessorKey: "path", - header: t("matchPath"), + header: () => ({t("matchPath")}), cell: ({ row }) => { const hasPathMatch = !!( row.original.path || row.original.pathMatchType @@ -1033,7 +996,7 @@ export default function ReverseProxyTargets(props: { const addressColumn: ColumnDef = { accessorKey: "address", - header: t("address"), + header: () => ({t("address")}), cell: ({ row }) => { const selectedSite = sites.find( (site) => site.siteId === row.original.siteId @@ -1052,7 +1015,7 @@ export default function ReverseProxyTargets(props: { return (
-
+
{selectedSite && selectedSite.type === "newt" && (() => { @@ -1247,7 +1210,7 @@ export default function ReverseProxyTargets(props: { const rewritePathColumn: ColumnDef = { accessorKey: "rewritePath", - header: t("rewritePath"), + header: () => ({t("rewritePath")}), cell: ({ row }) => { const hasRewritePath = !!( row.original.rewritePath || row.original.rewritePathType @@ -1317,7 +1280,7 @@ export default function ReverseProxyTargets(props: { const enabledColumn: ColumnDef = { accessorKey: "enabled", - header: t("enabled"), + header: () => ({t("enabled")}), cell: ({ row }) => (
= { id: "actions", + header: () => ({t("actions")}), cell: ({ row }) => ( -
+
+ +
); } From 564b290244a2ec7dbfb494badeae9634cfd8a00d Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 8 Nov 2025 14:24:28 -0800 Subject: [PATCH 28/45] Fix #1830 --- messages/en-US.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index b25c7d08..178a9bb9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -179,7 +179,7 @@ "baseDomain": "Base Domain", "subdomnainDescription": "The subdomain where your resource will be accessible.", "resourceRawSettings": "TCP/UDP Settings", - "resourceRawSettingsDescription": "Configure how your resource will be accessed over TCP/UDP. You map the resource to a port on the host Pangolin server, so you can access the resource from :. (https://docs.pangolin.net/manage/resources/tcp-udp-resources)", + "resourceRawSettingsDescription": "Configure how your resource will be accessed over TCP/UDP. You map the resource to a port on the host Pangolin server, so you can access the resource from server-public-ip:mapped-port.", "protocol": "Protocol", "protocolSelect": "Select a protocol", "resourcePortNumber": "Port Number", @@ -2096,4 +2096,4 @@ "enableSelected": "Enable Selected", "disableSelected": "Disable Selected", "checkSelectedStatus": "Check Status of Selected" -} +} \ No newline at end of file From a717ca2675bb2e47e9dd3c0ca13edd949b6af450 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 8 Nov 2025 15:42:46 -0800 Subject: [PATCH 29/45] Only uppercase the value if its a country Fixes #1813 --- server/lib/blueprints/proxyResources.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 323e6a6a..d85befed 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -30,6 +30,7 @@ import { pickPort } from "@server/routers/target/helpers"; import { resourcePassword } from "@server/db"; import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; +import { get } from "http"; export type ProxyResourcesResults = { proxyResource: Resource; @@ -544,7 +545,7 @@ export async function updateProxyResources( if ( existingRule.action !== getRuleAction(rule.action) || existingRule.match !== rule.match.toUpperCase() || - existingRule.value !== rule.value.toUpperCase() + existingRule.value !== getRuleValue(rule.match.toUpperCase(), rule.value) ) { validateRule(rule); await trx @@ -552,7 +553,7 @@ export async function updateProxyResources( .set({ action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value.toUpperCase() + value: getRuleValue(rule.match.toUpperCase(), rule.value), }) .where( eq(resourceRules.ruleId, existingRule.ruleId) @@ -564,7 +565,7 @@ export async function updateProxyResources( resourceId: existingResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value.toUpperCase(), + value: getRuleValue(rule.match.toUpperCase(), rule.value), priority: index + 1 // start priorities at 1 }); } @@ -722,7 +723,7 @@ export async function updateProxyResources( resourceId: newResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value.toUpperCase(), + value: getRuleValue(rule.match.toUpperCase(), rule.value), priority: index + 1 // start priorities at 1 }); } @@ -752,6 +753,14 @@ function getRuleAction(input: string) { return action; } +function getRuleValue(match: string, value: string) { + // if the match is a country, uppercase the value + if (match == "COUNTRY") { + return value.toUpperCase(); + } + return value; +} + function validateRule(rule: any) { if (rule.match === "cidr") { if (!isValidCIDR(rule.value)) { From da15e5e77b02147825bab996eb864b2371e2e23b Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 8 Nov 2025 16:13:42 -0800 Subject: [PATCH 30/45] Remove software-properties-common Fixes #1828 --- install/containers.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install/containers.go b/install/containers.go index cea3a6ef..9993e117 100644 --- a/install/containers.go +++ b/install/containers.go @@ -73,7 +73,7 @@ func installDocker() error { case strings.Contains(osRelease, "ID=ubuntu"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` apt-get update && - apt-get install -y apt-transport-https ca-certificates curl software-properties-common && + apt-get install -y apt-transport-https ca-certificates curl && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && apt-get update && @@ -82,7 +82,7 @@ func installDocker() error { case strings.Contains(osRelease, "ID=debian"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` apt-get update && - apt-get install -y apt-transport-https ca-certificates curl software-properties-common && + apt-get install -y apt-transport-https ca-certificates curl && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && apt-get update && From 265cab5b64e93194038488d6ab2f189e62248635 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:24 -0800 Subject: [PATCH 31/45] New translations en-us.json (French) --- messages/fr-FR.json | 79 +++++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 1eba2b7d..c688b9a2 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -10,20 +10,20 @@ "setupErrorIdentifier": "L'ID de l'organisation est déjà pris. Veuillez en choisir un autre.", "componentsErrorNoMemberCreate": "Vous n'êtes actuellement membre d'aucune organisation. Créez une organisation pour commencer.", "componentsErrorNoMember": "Vous n'êtes actuellement membre d'aucune organisation.", - "welcome": "Bienvenue à Pangolin", + "welcome": "Bienvenue sur Pangolin !", "welcomeTo": "Bienvenue chez", "componentsCreateOrg": "Créer une organisation", "componentsMember": "Vous êtes membre de {count, plural, =0 {aucune organisation} one {une organisation} other {# organisations}}.", - "componentsInvalidKey": "Clés de licence invalides ou expirées détectées. Suivez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", - "dismiss": "Refuser", - "componentsLicenseViolation": "Violation de licence : Ce serveur utilise des sites {usedSites} qui dépassent la limite autorisée des sites {maxSites} . Suivez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", + "componentsInvalidKey": "Clés de licence invalides ou expirées détectées. Veuillez respecter les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", + "dismiss": "Rejeter", + "componentsLicenseViolation": "Violation de licence : ce serveur utilise {usedSites} sites, ce qui dépasse la limite autorisée de {maxSites} sites. Respectez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "componentsSupporterMessage": "Merci de soutenir Pangolin en tant que {tier}!", - "inviteErrorNotValid": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder n'ait pas été acceptée ou n'est plus valide.", - "inviteErrorUser": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder ne soit pas pour cet utilisateur.", - "inviteLoginUser": "Assurez-vous que vous êtes bien connecté en tant qu'utilisateur correct.", - "inviteErrorNoUser": "Nous sommes désolés, mais il semble que l'invitation que vous essayez d'accéder ne soit pas pour un utilisateur qui existe.", + "inviteErrorNotValid": "Nous sommes désolés, mais il semble que l'invitation à laquelle vous essayez d'accéder n'ait pas été acceptée ou ne soit plus valide.", + "inviteErrorUser": "Nous sommes désolés, mais il semble que l'invitation à laquelle vous essayez d'accéder ne soit pas pour cet utilisateur.", + "inviteLoginUser": "Veuillez vous assurer que vous êtes connecté avec le bon utilisateur.", + "inviteErrorNoUser": "Nous sommes désolés, mais il semble que l'invitation à laquelle vous essayez d'accéder ne concerne pas un utilisateur existant.", "inviteCreateUser": "Veuillez d'abord créer un compte.", - "goHome": "Retour à la maison", + "goHome": "Retour à l'accueil", "inviteLogInOtherUser": "Se connecter en tant qu'utilisateur différent", "createAnAccount": "Créer un compte", "inviteNotAccepted": "Invitation non acceptée", @@ -39,12 +39,12 @@ "online": "En ligne", "offline": "Hors ligne", "site": "Site", - "dataIn": "Données dans", - "dataOut": "Données épuisées", + "dataIn": "Données entrantes", + "dataOut": "Données sortantes", "connectionType": "Type de connexion", "tunnelType": "Type de tunnel", "local": "Locale", - "edit": "Editer", + "edit": "Éditer", "siteConfirmDelete": "Confirmer la suppression du site", "siteDelete": "Supprimer le site", "siteMessageRemove": "Une fois supprimé, le site ne sera plus accessible. Toutes les cibles associées au site seront également supprimées.", @@ -63,11 +63,11 @@ "siteLearnNewt": "Apprenez à installer Newt sur votre système", "siteSeeConfigOnce": "Vous ne pourrez voir la configuration qu'une seule fois.", "siteLoadWGConfig": "Chargement de la configuration WireGuard...", - "siteDocker": "Développer les détails du déploiement Docker", + "siteDocker": "Développer pour obtenir plus de détails sur le déploiement Docker", "toggle": "Activer/désactiver", "dockerCompose": "Composition Docker", "dockerRun": "Exécution Docker", - "siteLearnLocal": "Les sites locaux ne tunnel, en savoir plus", + "siteLearnLocal": "Les sites locaux ne font pas de tunnel, en savoir plus", "siteConfirmCopy": "J'ai copié la configuration", "searchSitesProgress": "Rechercher des sites...", "siteAdd": "Ajouter un site", @@ -78,7 +78,7 @@ "operatingSystem": "Système d'exploitation", "commands": "Commandes", "recommended": "Recommandé", - "siteNewtDescription": "Pour une meilleure expérience d'utilisateur, utilisez Newt. Il utilise WireGuard sous le capot et vous permet d'adresser vos ressources privées par leur adresse LAN sur votre réseau privé à partir du tableau de bord Pangolin.", + "siteNewtDescription": "Pour une meilleure expérience d'utilisateur, utilisez Newt. Il utilise WireGuard sous le capot et vous permet de vous connecter à vos ressources privées par leur adresse LAN sur votre réseau privé à partir du tableau de bord Pangolin.", "siteRunsInDocker": "Exécute dans Docker", "siteRunsInShell": "Exécute en shell sur macOS, Linux et Windows", "siteErrorDelete": "Erreur lors de la suppression du site", @@ -93,7 +93,7 @@ "siteNewtTunnelDescription": "La façon la plus simple de créer un point d'entrée dans votre réseau. Pas de configuration supplémentaire.", "siteWg": "WireGuard basique", "siteWgDescription": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise.", - "siteWgDescriptionSaas": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise. FONCTIONNE UNIQUEMENT SUR DES NŒUDS AUTONOMES", + "siteWgDescriptionSaas": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise. FONCTIONNE UNIQUEMENT SUR DES NŒUDS AUTONOMES.", "siteLocalDescription": "Ressources locales seulement. Pas de tunneling.", "siteLocalDescriptionSaas": "Ressources locales uniquement. Pas de tunneling. Disponible uniquement sur les nœuds distants.", "siteSeeAll": "Voir tous les sites", @@ -130,9 +130,9 @@ "shareTitleOptional": "Titre (facultatif)", "expireIn": "Expire dans", "neverExpire": "N'expire jamais", - "shareExpireDescription": "Le temps d'expiration est combien de temps le lien sera utilisable et fournira un accès à la ressource. Après cette période, le lien ne fonctionnera plus et les utilisateurs qui ont utilisé ce lien perdront l'accès à la ressource.", - "shareSeeOnce": "Vous ne pourrez voir ce lien. Assurez-vous de le copier.", - "shareAccessHint": "N'importe qui avec ce lien peut accéder à la ressource. Partagez-le avec soin.", + "shareExpireDescription": "La durée d'expiration correspond à la période pendant laquelle le lien sera utilisable et permettra d'accéder à la ressource. Passé ce délai, le lien ne fonctionnera plus et les utilisateurs qui l'ont utilisé perdront l'accès à la ressource.", + "shareSeeOnce": "Vous ne pourrez voir ce lien qu'une seule fois. Assurez-vous de le copier.", + "shareAccessHint": "N'importe qui avec ce lien peut accéder à la ressource. Partagez-le avec précaution.", "shareTokenUsage": "Voir Utilisation du jeton d'accès", "createLink": "Créer un lien", "resourcesNotFound": "Aucune ressource trouvée", @@ -155,9 +155,9 @@ "resourceMessageRemove": "Une fois supprimée, la ressource ne sera plus accessible. Toutes les cibles associées à la ressource seront également supprimées.", "resourceQuestionRemove": "Êtes-vous sûr de vouloir supprimer la ressource de l'organisation ?", "resourceHTTP": "Ressource HTTPS", - "resourceHTTPDescription": "Requêtes de proxy à votre application via HTTPS en utilisant un sous-domaine ou un domaine de base.", + "resourceHTTPDescription": "Requêtes de proxy vers votre application via HTTPS en utilisant un sous-domaine ou un domaine de base.", "resourceRaw": "Ressource TCP/UDP brute", - "resourceRawDescription": "Demandes de proxy à votre application via TCP/UDP en utilisant un numéro de port.", + "resourceRawDescription": "Demandes de proxy vers votre application via TCP/UDP en utilisant un numéro de port.", "resourceCreate": "Créer une ressource", "resourceCreateDescription": "Suivez les étapes ci-dessous pour créer une nouvelle ressource", "resourceSeeAll": "Voir toutes les ressources", @@ -179,7 +179,7 @@ "baseDomain": "Domaine de base", "subdomnainDescription": "Le sous-domaine où votre ressource sera accessible.", "resourceRawSettings": "Paramètres TCP/UDP", - "resourceRawSettingsDescription": "Configurer comment votre ressource sera accédée via TCP/UDP", + "resourceRawSettingsDescription": "Configurer comment votre ressource sera accédée via TCP/UDP. Vous mappez la ressource à un port sur le serveur Pangolin, de sorte que vous puissiez accéder à la ressource depuis server-public-ip:mapped-port.", "protocol": "Protocole", "protocolSelect": "Sélectionner un protocole", "resourcePortNumber": "Numéro de port", @@ -206,7 +206,7 @@ "resourceSetting": "Réglages {resourceName}", "alwaysAllow": "Toujours autoriser", "alwaysDeny": "Toujours refuser", - "passToAuth": "Paser à l'authentification", + "passToAuth": "Passer à l'authentification", "orgSettingsDescription": "Configurer les paramètres généraux de votre organisation", "orgGeneralSettings": "Paramètres de l'organisation", "orgGeneralSettingsDescription": "Gérer les détails et la configuration de votre organisation", @@ -342,7 +342,7 @@ "licenseTitleDescription": "Voir et gérer les clés de licence dans le système", "licenseHost": "Licence Hôte", "licenseHostDescription": "Gérer la clé de licence principale de l'hôte.", - "licensedNot": "Non licencié", + "licensedNot": "Pas de licence", "hostId": "ID de l'hôte", "licenseReckeckAll": "Revérifier toutes les clés", "licenseSiteUsage": "Utilisation des sites", @@ -350,7 +350,7 @@ "licenseNoSiteLimit": "Il n'y a pas de limite sur le nombre de sites utilisant un hôte non autorisé.", "licensePurchase": "Acheter une licence", "licensePurchaseSites": "Acheter des sites supplémentaires", - "licenseSitesUsedMax": "{usedSites} des sites {maxSites} utilisés", + "licenseSitesUsedMax": "{usedSites} des {maxSites} sites utilisés", "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} dans le système.", "licensePurchaseDescription": "Choisissez le nombre de sites que vous voulez {selectedMode, select, license {achetez une licence. Vous pouvez toujours ajouter plus de sites plus tard.} other {ajouter à votre licence existante.}}", "licenseFee": "Frais de licence", @@ -371,7 +371,7 @@ "inviteQuestionRemove": "Êtes-vous sûr de vouloir supprimer l'invitation?", "inviteMessageRemove": "Une fois supprimée, cette invitation ne sera plus valide. Vous pourrez toujours réinviter l'utilisateur plus tard.", "inviteMessageConfirm": "Pour confirmer, veuillez saisir l'adresse e-mail de l'invitation ci-dessous.", - "inviteQuestionRegenerate": "Êtes-vous sûr de vouloir régénérer l'invitation {email}? Cela révoquera l'invitation précédente.", + "inviteQuestionRegenerate": "Êtes-vous sûr de vouloir régénérer l'invitation pour {email}? Cela révoquera l'invitation précédente.", "inviteRemoveConfirm": "Confirmer la suppression de l'invitation", "inviteRegenerated": "Invitation régénérée", "inviteSent": "Une nouvelle invitation a été envoyée à {email}.", @@ -465,7 +465,7 @@ "proxyErrorTls": "Nom de serveur TLS invalide. Utilisez le format de nom de domaine, ou laissez vide pour supprimer le nom de serveur TLS.", "proxyEnableSSL": "Activer SSL", "proxyEnableSSLDescription": "Activez le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers vos cibles.", - "target": "Target", + "target": "Cible", "configureTarget": "Configurer les cibles", "targetErrorFetch": "Échec de la récupération des cibles", "targetErrorFetchDescription": "Une erreur s'est produite lors de la récupération des cibles", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Domaines", "sidebarBluePrints": "Plans", "blueprints": "Plans", - "blueprintsDescription": "Les plans sont des configurations YAML déclaratives qui définissent vos ressources et leurs paramètres", + "blueprintsDescription": "Appliquer les configurations déclaratives et afficher les exécutions précédentes", "blueprintAdd": "Ajouter un Plan", "blueprintGoBack": "Voir tous les plans", "blueprintCreate": "Créer un Plan", "blueprintCreateDescription2": "Suivez les étapes ci-dessous pour créer et appliquer un nouveau plan", "blueprintDetails": "Détails du Plan", - "blueprintDetailsDescription": "Voir les détails de l'exécution des plans", + "blueprintDetailsDescription": "Voir le résultat du plan appliqué et les erreurs qui se sont produites", "blueprintInfo": "Informations sur le Plan", "message": "Message", "blueprintContentsDescription": "Définissez le contenu YAML décrivant votre infrastructure", @@ -1181,7 +1181,7 @@ "appliedAt": "Appliqué à", "source": "Source", "contents": "Contenus", - "parsedContents": "Contenu analysé", + "parsedContents": "Contenu analysé (lecture seule)", "enableDockerSocket": "Activer le Plan Docker", "enableDockerSocketDescription": "Activer le ramassage d'étiquettes de socket Docker pour les étiquettes de plan. Le chemin de socket doit être fourni à Newt.", "enableDockerSocketLink": "En savoir plus", @@ -2080,5 +2080,20 @@ "supportSending": "Envoi...", "supportSend": "Envoyer", "supportMessageSent": "Message envoyé !", - "supportWillContact": "Nous vous contacterons sous peu!" -} + "supportWillContact": "Nous vous contacterons sous peu!", + "selectLogRetention": "Sélectionner la durée de rétention du journal", + "showColumns": "Afficher les colonnes", + "hideColumns": "Cacher les colonnes", + "columnVisibility": "Visibilité des colonnes", + "toggleColumn": "Activer/désactiver la colonne {columnName}", + "allColumns": "Toutes les colonnes", + "defaultColumns": "Colonnes par défaut", + "customizeView": "Personnaliser la vue", + "viewOptions": "Voir les options", + "selectAll": "Tout sélectionner", + "selectNone": "Ne rien sélectionner", + "selectedResources": "Ressources sélectionnées", + "enableSelected": "Activer la sélection", + "disableSelected": "Désactiver la sélection", + "checkSelectedStatus": "Vérifier le statut de la sélection" +} \ No newline at end of file From a51c21cdd2d0f73aabd27a48b0794e6495f0545d Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:26 -0800 Subject: [PATCH 32/45] New translations en-us.json (Spanish) --- messages/es-ES.json | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index c8ef3e98..e8409c25 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -131,7 +131,7 @@ "expireIn": "Caduca en", "neverExpire": "Nunca expirar", "shareExpireDescription": "El tiempo de caducidad es cuánto tiempo el enlace será utilizable y proporcionará acceso al recurso. Después de este tiempo, el enlace ya no funcionará, y los usuarios que usaron este enlace perderán el acceso al recurso.", - "shareSeeOnce": "Sólo podrá ver este enlace una vez. Asegúrese de copiarlo.", + "shareSeeOnce": "Sólo podrás ver este enlace una vez. Asegúrate de copiarlo.", "shareAccessHint": "Cualquiera con este enlace puede acceder al recurso. Compártelo con cuidado.", "shareTokenUsage": "Ver Uso de Token de Acceso", "createLink": "Crear enlace", @@ -179,7 +179,7 @@ "baseDomain": "Dominio base", "subdomnainDescription": "El subdominio al que su recurso será accesible.", "resourceRawSettings": "Configuración TCP/UDP", - "resourceRawSettingsDescription": "Configurar cómo se accederá a su recurso a través de TCP/UDP", + "resourceRawSettingsDescription": "Configure cómo se accederá a su recurso a través de TCP/UDP. Mapeas el recurso a un puerto en el servidor Pangolin host, así puedes acceder al recurso desde el servidor-public-ip:mapped-port.", "protocol": "Protocolo", "protocolSelect": "Seleccionar un protocolo", "resourcePortNumber": "Número de puerto", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Dominios", "sidebarBluePrints": "Planos", "blueprints": "Planos", - "blueprintsDescription": "Los planos son configuraciones YAML declarativas que definen sus recursos y sus configuraciones", + "blueprintsDescription": "Aplicar configuraciones declarativas y ver ejecuciones anteriores", "blueprintAdd": "Añadir plano", "blueprintGoBack": "Ver todos los Planos", "blueprintCreate": "Crear Plano", "blueprintCreateDescription2": "Siga los siguientes pasos para crear y aplicar un nuevo plano", "blueprintDetails": "Detalles del plano", - "blueprintDetailsDescription": "Ver los detalles de la ejecución del plano", + "blueprintDetailsDescription": "Ver el resultado del plano aplicado y cualquier error que haya ocurrido", "blueprintInfo": "Información del plano", "message": "Mensaje", "blueprintContentsDescription": "Defina el contenido YAML describiendo su infraestructura", @@ -1181,7 +1181,7 @@ "appliedAt": "Aplicado en", "source": "Fuente", "contents": "Contenido", - "parsedContents": "Contenido analizado", + "parsedContents": "Contenido analizado (Sólo lectura)", "enableDockerSocket": "Habilitar Plano Docker", "enableDockerSocketDescription": "Activar el raspado de etiquetas de Socket Docker para etiquetas de planos. La ruta del Socket debe proporcionarse a Newt.", "enableDockerSocketLink": "Saber más", @@ -2080,5 +2080,20 @@ "supportSending": "Enviando...", "supportSend": "Enviar", "supportMessageSent": "¡Mensaje enviado!", - "supportWillContact": "¡Estaremos en contacto en breve!" -} + "supportWillContact": "¡Estaremos en contacto en breve!", + "selectLogRetention": "Seleccionar retención de registro", + "showColumns": "Mostrar columnas", + "hideColumns": "Ocultar columnas", + "columnVisibility": "Visibilidad de la columna", + "toggleColumn": "Cambiar columna {columnName}", + "allColumns": "Todas las columnas", + "defaultColumns": "Columnas por defecto", + "customizeView": "Personalizar vista", + "viewOptions": "Ver opciones", + "selectAll": "Seleccionar todo", + "selectNone": "No seleccionar", + "selectedResources": "Recursos seleccionados", + "enableSelected": "Habilitar seleccionados", + "disableSelected": "Desactivar Seleccionado", + "checkSelectedStatus": "Comprobar el estado de selección" +} \ No newline at end of file From d9991a18e2f41e0b031ae6c801a3524ba5062941 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:27 -0800 Subject: [PATCH 33/45] New translations en-us.json (Bulgarian) --- messages/bg-BG.json | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index aea8bf5a..ff6a1ad4 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -131,7 +131,7 @@ "expireIn": "Изтече", "neverExpire": "Никога не изтича", "shareExpireDescription": "Времето на изтичане е колко дълго връзката ще бъде използваема и ще предоставя достъп до ресурса. След това време, връзката няма да работи и потребителите, които са я използвали, ще загубят достъп до ресурса.", - "shareSeeOnce": "Ще можете да видите тази връзка само веднъж. Уверете се да я копирате.", + "shareSeeOnce": "Ще можете да видите този линк само веднъж. Уверете се, че го копирате.", "shareAccessHint": "Всеки с тази връзка може да има достъп до ресурса. Споделяйте я с внимание.", "shareTokenUsage": "Вижте използването на токена за достъп", "createLink": "Създаване на връзка", @@ -179,7 +179,7 @@ "baseDomain": "Базов домейн", "subdomnainDescription": "Субдомейнът, в който ще бъде достъпен вашият ресурс.", "resourceRawSettings": "TCP/UDP настройки", - "resourceRawSettingsDescription": "Конфигурирайте как вашият ресурс ще бъде достъпен през TCP/UDP", + "resourceRawSettingsDescription": "Настройте как ресурсът ви ще бъде достъпен през TCP/UDP. Свързвате ресурса към порт на хост сървъра Pangolin, за да го достъпвате от server-public-ip:mapped-port.", "protocol": "Протокол", "protocolSelect": "Изберете протокол", "resourcePortNumber": "Номер на порт", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Домейни", "sidebarBluePrints": "Чертежи", "blueprints": "Чертежи", - "blueprintsDescription": "Чертежите са декларативни YAML конфигурации, които определят вашите ресурси и техните настройки", + "blueprintsDescription": "Прилагайте декларативни конфигурации и преглеждайте предишни изпълнения", "blueprintAdd": "Добави Чертеж", "blueprintGoBack": "Виж всички Чертежи", "blueprintCreate": "Създай Чертеж", "blueprintCreateDescription2": "Следвайте стъпките по-долу, за да създадете и приложите нов чертеж", - "blueprintDetails": "Детайли за Чертежа", - "blueprintDetailsDescription": "Вижте детайлите за изпълнението на чертежа", + "blueprintDetails": "Детайли на чертежа", + "blueprintDetailsDescription": "Вижте резултата от приложените чертежи и всички възникнали грешки", "blueprintInfo": "Информация за Чертежа", "message": "Съобщение", "blueprintContentsDescription": "Дефинирайте YAML съдържанието, описващо вашата инфраструктура", @@ -1181,7 +1181,7 @@ "appliedAt": "Приложено във", "source": "Източник", "contents": "Съдържание", - "parsedContents": "Анализирано съдържание", + "parsedContents": "Парсирано съдържание (само за четене)", "enableDockerSocket": "Активиране на Docker Чернова", "enableDockerSocketDescription": "Активиране на Docker Socket маркировка за изтегляне на етикети на чернова. Пътят на гнездото трябва да бъде предоставен на Newt.", "enableDockerSocketLink": "Научете повече", @@ -2080,5 +2080,20 @@ "supportSending": "Изпращане...", "supportSend": "Изпрати", "supportMessageSent": "Съобщението е изпратено!", - "supportWillContact": "Ще се свържем с вас скоро!" -} + "supportWillContact": "Ще се свържем с вас скоро!", + "selectLogRetention": "Изберете съхранение на логовете", + "showColumns": "Покажи колони", + "hideColumns": "Скрий колони", + "columnVisibility": "Видимост на колоните", + "toggleColumn": "Превключване на колоната {columnName}", + "allColumns": "Всички колони", + "defaultColumns": "По подразбиране колони", + "customizeView": "Персонализиране на изгледа", + "viewOptions": "Опции за изгледа", + "selectAll": "Избери всички", + "selectNone": "Избери нищо", + "selectedResources": "Избрани ресурси", + "enableSelected": "Разреши избраните", + "disableSelected": "Забрани избраните", + "checkSelectedStatus": "Проверете състоянието на избраните" +} \ No newline at end of file From d00f12967d26359852bc0c969bb992ecd4f71974 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:28 -0800 Subject: [PATCH 34/45] New translations en-us.json (Czech) --- messages/cs-CZ.json | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 4e0e1f87..37c7a16d 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -131,7 +131,7 @@ "expireIn": "Platnost vyprší za", "neverExpire": "Nikdy nevyprší", "shareExpireDescription": "Doba platnosti určuje, jak dlouho bude odkaz použitelný a bude poskytovat přístup ke zdroji. Po této době odkaz již nebude fungovat a uživatelé kteří tento odkaz používali ztratí přístup ke zdroji.", - "shareSeeOnce": "Tento odkaz uvidíte pouze jednou. Ujistěte se, že jste jej zkopírovali.", + "shareSeeOnce": "Tento odkaz uvidíte pouze jednou. Nezapomeňte jej zkopírovat.", "shareAccessHint": "Kdokoli s tímto odkazem může přistupovat ke zdroji. Sdílejte jej s rozvahou.", "shareTokenUsage": "Zobrazit využití přístupového tokenu", "createLink": "Vytvořit odkaz", @@ -179,7 +179,7 @@ "baseDomain": "Základní doména", "subdomnainDescription": "Subdoména, kde bude váš zdroj přístupný.", "resourceRawSettings": "Nastavení TCP/UDP", - "resourceRawSettingsDescription": "Nakonfigurujte, jak bude váš dokument přístupný přes TCP/UDP", + "resourceRawSettingsDescription": "Nakonfigurujte, jak bude váš dokument přístupný přes TCP/UDP. Mapováte zdroj na port na serveru Pangolin, takže můžete přistupovat ke zdroji ze serveru-veřejné ip:mapped-port.", "protocol": "Protokol", "protocolSelect": "Vybrat protokol", "resourcePortNumber": "Číslo portu", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Domény", "sidebarBluePrints": "Plány", "blueprints": "Plány", - "blueprintsDescription": "Plány jsou deklarativní YAML konfigurace, které definují vaše zdroje a jejich nastavení", + "blueprintsDescription": "Použít deklarativní konfigurace a zobrazit předchozí běhy", "blueprintAdd": "Přidat plán", "blueprintGoBack": "Zobrazit všechny plány", "blueprintCreate": "Vytvořit plán", "blueprintCreateDescription2": "Postupujte podle níže uvedených kroků pro vytvoření a použití nového plánu", "blueprintDetails": "Podrobnosti plánu", - "blueprintDetailsDescription": "Podívejte se na detaily běhu plánu", + "blueprintDetailsDescription": "Podívejte se na výsledek použitého plánu a případné chyby, které se vyskytly", "blueprintInfo": "Informace o plánu", "message": "Zpráva", "blueprintContentsDescription": "Definujte obsah YAML popisující vaši infrastrukturu", @@ -1181,7 +1181,7 @@ "appliedAt": "Použito v", "source": "Zdroj", "contents": "Obsah", - "parsedContents": "Parsovaný obsah", + "parsedContents": "Parsed content (Pouze pro čtení)", "enableDockerSocket": "Povolit Docker plán", "enableDockerSocketDescription": "Povolte seškrábání štítků na Docker Socket pro popisky plánů. Nová cesta musí být k dispozici.", "enableDockerSocketLink": "Zjistit více", @@ -2080,5 +2080,20 @@ "supportSending": "Odesílání...", "supportSend": "Poslat", "supportMessageSent": "Zpráva odeslána!", - "supportWillContact": "Brzy budeme v kontaktu!" -} + "supportWillContact": "Brzy budeme v kontaktu!", + "selectLogRetention": "Vyberte záznam", + "showColumns": "Zobrazit sloupce", + "hideColumns": "Skrýt sloupce", + "columnVisibility": "Viditelnost sloupců", + "toggleColumn": "Přepnout sloupec {columnName}", + "allColumns": "Všechny sloupce", + "defaultColumns": "Výchozí sloupce", + "customizeView": "Přizpůsobit zobrazení", + "viewOptions": "Možnosti zobrazení", + "selectAll": "Vybrat vše", + "selectNone": "Nevybrat žádný", + "selectedResources": "Vybrané zdroje", + "enableSelected": "Povolit vybrané", + "disableSelected": "Zakázat vybrané", + "checkSelectedStatus": "Zkontrolovat stav vybraného" +} \ No newline at end of file From 7bdf05bdf58ce23e3258304d2c1c888bd600ac9f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:30 -0800 Subject: [PATCH 35/45] New translations en-us.json (German) --- messages/de-DE.json | 77 +++++++++++++++++++++++++++------------------ 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 5a4935fe..001550d4 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -131,7 +131,7 @@ "expireIn": "Verfällt in", "neverExpire": "Nie ablaufen", "shareExpireDescription": "Ablaufzeit ist, wie lange der Link verwendet werden kann und bietet Zugriff auf die Ressource. Nach dieser Zeit wird der Link nicht mehr funktionieren und Benutzer, die diesen Link benutzt haben, verlieren den Zugriff auf die Ressource.", - "shareSeeOnce": "Sie können diesen Link nur ein einziges Mal sehen. Bitte kopieren Sie ihn.", + "shareSeeOnce": "Sie können diesen Link nur einmal sehen. Bitte kopieren Sie ihn.", "shareAccessHint": "Jeder mit diesem Link kann auf die Ressource zugreifen. Teilen Sie sie mit Vorsicht.", "shareTokenUsage": "Zugriffstoken-Nutzung anzeigen", "createLink": "Link erstellen", @@ -179,7 +179,7 @@ "baseDomain": "Basis-Domain", "subdomnainDescription": "Die Subdomain, auf der Ihre Ressource erreichbar sein soll.", "resourceRawSettings": "TCP/UDP Einstellungen", - "resourceRawSettingsDescription": "Konfigurieren Sie den Zugriff auf Ihre Ressource über TCP/UDP", + "resourceRawSettingsDescription": "Legen Sie fest, wie auf Ihre Ressource über TCP/UDP zugegriffen wird. Sie ordnen die Ressource einem Port auf dem Pangolin-Server zu, so dass Sie auf die Ressource von server-public-ip:mapped-port zugreifen können.", "protocol": "Protokoll", "protocolSelect": "Wählen Sie ein Protokoll", "resourcePortNumber": "Portnummer", @@ -1021,7 +1021,7 @@ "actionDeleteSite": "Standort löschen", "actionGetSite": "Standort abrufen", "actionListSites": "Standorte auflisten", - "actionApplyBlueprint": "Blueprint anwenden", + "actionApplyBlueprint": "Blaupause anwenden", "setupToken": "Setup-Token", "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenRequired": "Setup-Token ist erforderlich", @@ -1080,11 +1080,11 @@ "actionDeleteIdpOrg": "IDP-Organisationsrichtlinie löschen", "actionListIdpOrgs": "IDP-Organisationen auflisten", "actionUpdateIdpOrg": "IDP-Organisation aktualisieren", - "actionCreateClient": "Client erstellen", - "actionDeleteClient": "Client löschen", - "actionUpdateClient": "Client aktualisieren", - "actionListClients": "Clientsn auflisten", - "actionGetClient": "Client abrufen", + "actionCreateClient": "Kunde erstellen", + "actionDeleteClient": "Kunde löschen", + "actionUpdateClient": "Kunde aktualisieren", + "actionListClients": "Kunden auflisten", + "actionGetClient": "Kunde holen", "actionCreateSiteResource": "Site-Ressource erstellen", "actionDeleteSiteResource": "Site-Ressource löschen", "actionGetSiteResource": "Site-Ressource abrufen", @@ -1161,29 +1161,29 @@ "sidebarAllUsers": "Alle Benutzer", "sidebarIdentityProviders": "Identitätsanbieter", "sidebarLicense": "Lizenz", - "sidebarClients": "Clients", + "sidebarClients": "Kunden", "sidebarDomains": "Domänen", - "sidebarBluePrints": "Blueprints", - "blueprints": "Blueprints", - "blueprintsDescription": "Blueprints sind deklarative YAML-Konfigurationen, die deine Ressourcen und deren Einstellungen definieren", - "blueprintAdd": "Blueprint hinzufügen", - "blueprintGoBack": "Alle Blueprints ansehen", - "blueprintCreate": "Blueprint erstellen", - "blueprintCreateDescription2": "Folge den Schritten unten, um einen neuen Blueprint zu erstellen und anzuwenden", - "blueprintDetails": "Blueprintdetails", - "blueprintDetailsDescription": "Siehe die Blueprint Details", - "blueprintInfo": "Blueprint Information", + "sidebarBluePrints": "Baupläne", + "blueprints": "Baupläne", + "blueprintsDescription": "Deklarative Konfigurationen anwenden und vorherige Abläufe anzeigen", + "blueprintAdd": "Blaupause hinzufügen", + "blueprintGoBack": "Alle Blaupausen ansehen", + "blueprintCreate": "Blaupause erstellen", + "blueprintCreateDescription2": "Folge den Schritten unten, um eine neue Blaupause zu erstellen und anzuwenden", + "blueprintDetails": "Blaupausendetails", + "blueprintDetailsDescription": "Siehe das Ergebnis der angewendeten Blaupause und alle aufgetretenen Fehler", + "blueprintInfo": "Blaupauseninformation", "message": "Nachricht", "blueprintContentsDescription": "Definieren Sie den YAML-Inhalt, der Ihre Infrastruktur beschreibt", - "blueprintErrorCreateDescription": "Fehler beim Anwenden des Blueprints", - "blueprintErrorCreate": "Fehler beim Erstellen des Blueprints", - "searchBlueprintProgress": "Blueprints suchen...", + "blueprintErrorCreateDescription": "Fehler beim Anwenden der Blaupause", + "blueprintErrorCreate": "Fehler beim Erstellen der Blaupause", + "searchBlueprintProgress": "Blaupausen suchen...", "appliedAt": "Angewandt am", "source": "Quelle", "contents": "Inhalt", - "parsedContents": "Analysierte Inhalte", - "enableDockerSocket": "Docker Blueprints aktivieren", - "enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blueprintbeschriftungen. Der Socket-Pfad muss neu angegeben werden.", + "parsedContents": "Analysierte Inhalte (Nur lesen)", + "enableDockerSocket": "Docker Blaupause aktivieren", + "enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blaupausenbeschriftungen. Der Socket-Pfad muss neu angegeben werden.", "enableDockerSocketLink": "Mehr erfahren", "viewDockerContainers": "Docker Container anzeigen", "containersIn": "Container in {siteName}", @@ -1423,14 +1423,14 @@ }, "siteRequired": "Standort ist erforderlich.", "olmTunnel": "Olm-Tunnel", - "olmTunnelDescription": "Nutzen Sie Olm für die Clientverbindung", + "olmTunnelDescription": "Nutzen Sie Olm für die Kundenverbindung", "errorCreatingClient": "Fehler beim Erstellen des Clients", "clientDefaultsNotFound": "Standardeinstellungen des Clients nicht gefunden", "createClient": "Client erstellen", "createClientDescription": "Erstellen Sie einen neuen Client für die Verbindung zu Ihren Standorten.", "seeAllClients": "Alle Clients anzeigen", - "clientInformation": "Clientninformationen", - "clientNamePlaceholder": "Clientname", + "clientInformation": "Kundeninformationen", + "clientNamePlaceholder": "Kundenname", "address": "Adresse", "subnetPlaceholder": "Subnetz", "addressDescription": "Die Adresse, die dieser Client für die Verbindung verwenden wird.", @@ -2049,7 +2049,7 @@ "orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt", "loadingDNSRecords": "Lade DNS-Einträge...", "olmUpdateAvailableInfo": "Eine aktualisierte Version von Olm ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für die beste Erfahrung.", - "client": "Client", + "client": "Kunde", "proxyProtocol": "Proxy-Protokoll-Einstellungen", "proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP/UDP-Dienste zu erhalten.", "enableProxyProtocol": "Proxy-Protokoll aktivieren", @@ -2080,5 +2080,20 @@ "supportSending": "Senden...", "supportSend": "Senden", "supportMessageSent": "Nachricht gesendet!", - "supportWillContact": "Wir werden in Kürze kontaktieren!" -} + "supportWillContact": "Wir werden in Kürze kontaktieren!", + "selectLogRetention": "Log-Speicherung auswählen", + "showColumns": "Spalten anzeigen", + "hideColumns": "Spalten ausblenden", + "columnVisibility": "Spaltensichtbarkeit", + "toggleColumn": "{columnName} Spalte umschalten", + "allColumns": "Alle Spalten", + "defaultColumns": "Standardspalten", + "customizeView": "Ansicht anpassen", + "viewOptions": "Optionen anzeigen", + "selectAll": "Alle auswählen", + "selectNone": "Nichts auswählen", + "selectedResources": "Ausgewählte Ressourcen", + "enableSelected": "Ausgewählte aktivieren", + "disableSelected": "Ausgewählte deaktivieren", + "checkSelectedStatus": "Status der Auswahl überprüfen" +} \ No newline at end of file From 263fd80c18d960bf58ddc0c4793e388b1d9586a6 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:31 -0800 Subject: [PATCH 36/45] New translations en-us.json (Italian) --- messages/it-IT.json | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index c76e6b5a..0e8c2d83 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -131,7 +131,7 @@ "expireIn": "Scadenza In", "neverExpire": "Mai scadere", "shareExpireDescription": "Il tempo di scadenza è per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.", - "shareSeeOnce": "Potrai vedere solo questo linkonce. Assicurati di copiarlo.", + "shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.", "shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.", "shareTokenUsage": "Vedi Utilizzo Token Di Accesso", "createLink": "Crea Collegamento", @@ -179,7 +179,7 @@ "baseDomain": "Dominio Base", "subdomnainDescription": "Il sottodominio in cui la tua risorsa sarà accessibile.", "resourceRawSettings": "Impostazioni TCP/UDP", - "resourceRawSettingsDescription": "Configura come accedere alla tua risorsa tramite TCP/UDP", + "resourceRawSettingsDescription": "Configura come sarà possibile accedere alla tua risorsa tramite TCP/UDP. Mappare la risorsa a una porta sul server host Pangolin, in modo da poter accedere alla risorsa dal server-public ip:mapped-port.", "protocol": "Protocollo", "protocolSelect": "Seleziona un protocollo", "resourcePortNumber": "Numero Porta", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Domini", "sidebarBluePrints": "Progetti", "blueprints": "Progetti", - "blueprintsDescription": "I progetti sono configurazioni YAML dichiarative che definiscono le tue risorse e le loro impostazioni", + "blueprintsDescription": "Applica le configurazioni dichiarative e visualizza le partite precedenti", "blueprintAdd": "Aggiungi Progetto", "blueprintGoBack": "Vedi tutti i progetti", "blueprintCreate": "Crea Progetto", "blueprintCreateDescription2": "Segui i passaggi qui sotto per creare e applicare un nuovo progetto", - "blueprintDetails": "Dettagli progetto", - "blueprintDetailsDescription": "Vedi i dettagli dell'esecuzione del progetto", + "blueprintDetails": "Dettagli Progetto", + "blueprintDetailsDescription": "Vedere il risultato del progetto applicato e gli eventuali errori verificatisi", "blueprintInfo": "Informazioni Sul Progetto", "message": "Messaggio", "blueprintContentsDescription": "Definisci il contenuto di YAML che descrive la tua infrastruttura", @@ -1181,7 +1181,7 @@ "appliedAt": "Applicato Il", "source": "Fonte", "contents": "Contenuti", - "parsedContents": "Sommario Analizzato", + "parsedContents": "Sommario Analizzato (Solo Lettura)", "enableDockerSocket": "Abilita Progetto Docker", "enableDockerSocketDescription": "Abilita la raschiatura dell'etichetta Docker Socket per le etichette dei progetti. Il percorso del socket deve essere fornito a Newt.", "enableDockerSocketLink": "Scopri di più", @@ -2080,5 +2080,20 @@ "supportSending": "Invio...", "supportSend": "Invia", "supportMessageSent": "Messaggio Inviato!", - "supportWillContact": "Saremo in contatto a breve!" -} + "supportWillContact": "Saremo in contatto a breve!", + "selectLogRetention": "Seleziona ritenzione log", + "showColumns": "Mostra Colonne", + "hideColumns": "Nascondi Colonne", + "columnVisibility": "Visibilità Colonna", + "toggleColumn": "Attiva/disattiva colonna {columnName}", + "allColumns": "Tutte Le Colonne", + "defaultColumns": "Colonne Predefinite", + "customizeView": "Personalizza Vista", + "viewOptions": "Opzioni Visualizzazione", + "selectAll": "Seleziona Tutto", + "selectNone": "Seleziona Nessuno", + "selectedResources": "Risorse Selezionate", + "enableSelected": "Abilita Selezionati", + "disableSelected": "Disabilita Selezionati", + "checkSelectedStatus": "Controlla lo stato dei selezionati" +} \ No newline at end of file From 8a8c3575636da3277ac521113d4c421b7e7f7887 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:32 -0800 Subject: [PATCH 37/45] New translations en-us.json (Korean) --- messages/ko-KR.json | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index bf4b28fa..d892a5a2 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -179,7 +179,7 @@ "baseDomain": "기본 도메인", "subdomnainDescription": "리소스에 접근할 수 있는 하위 도메인입니다.", "resourceRawSettings": "TCP/UDP 설정", - "resourceRawSettingsDescription": "TCP/UDP를 통해 리소스에 접근하는 방법을 구성하세요.", + "resourceRawSettingsDescription": "리소스를 TCP/UDP를 통해 액세스하는 방법을 구성합니다. 리소스를 호스트 Pangolin 서버의 포트에 매핑하여 서버-public-ip:매핑된 포트에서 리소스에 액세스할 수 있습니다.", "protocol": "프로토콜", "protocolSelect": "프로토콜 선택", "resourcePortNumber": "포트 번호", @@ -1165,13 +1165,13 @@ "sidebarDomains": "도메인", "sidebarBluePrints": "청사진", "blueprints": "청사진", - "blueprintsDescription": "청사진은 리소스와 그 설정을 정의하는 선언적인 YAML 구성입니다", + "blueprintsDescription": "선언적 구성을 적용하고 이전 실행을 봅니다", "blueprintAdd": "청사진 추가", "blueprintGoBack": "모든 청사진 보기", "blueprintCreate": "청사진 생성", "blueprintCreateDescription2": "새 청사진을 생성하고 적용하려면 아래 단계를 따르십시오", - "blueprintDetails": "청사진 세부 사항", - "blueprintDetailsDescription": "청사진 실행 세부 정보 보기", + "blueprintDetails": "청사진 세부사항", + "blueprintDetailsDescription": "적용된 청사진의 결과와 발생한 오류를 확인합니다", "blueprintInfo": "청사진 정보", "message": "메시지", "blueprintContentsDescription": "인프라를 설명하는 YAML 콘텐츠를 정의하십시오", @@ -1181,7 +1181,7 @@ "appliedAt": "적용 시점", "source": "출처", "contents": "콘텐츠", - "parsedContents": "구문 분석된 콘텐츠", + "parsedContents": "구문 분석된 콘텐츠 (읽기 전용)", "enableDockerSocket": "Docker 청사진 활성화", "enableDockerSocketDescription": "블루프린트 레이블을 위한 Docker 소켓 레이블 수집을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.", "enableDockerSocketLink": "자세히 알아보기", @@ -2080,5 +2080,20 @@ "supportSending": "발송 중...", "supportSend": "보내기", "supportMessageSent": "메시지 전송 완료!", - "supportWillContact": "곧 연락드리겠습니다!" -} + "supportWillContact": "곧 연락드리겠습니다!", + "selectLogRetention": "로그 보존 선택", + "showColumns": "열 표시", + "hideColumns": "열 숨기기", + "columnVisibility": "열 가시성", + "toggleColumn": "{columnName} 열 토글", + "allColumns": "모든 열", + "defaultColumns": "기본 열", + "customizeView": "보기 사용자 지정", + "viewOptions": "보기 옵션", + "selectAll": "모두 선택", + "selectNone": "선택하지 않음", + "selectedResources": "선택된 리소스", + "enableSelected": "선택된 항목 활성화", + "disableSelected": "선택된 항목 비활성화", + "checkSelectedStatus": "선택된 항목 상태 확인" +} \ No newline at end of file From ac5ee5c7ca92c45a4fb7bc3e61516a9aeb6d89c8 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:34 -0800 Subject: [PATCH 38/45] New translations en-us.json (Dutch) --- messages/nl-NL.json | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index db0c5414..0a6c2ae5 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -131,7 +131,7 @@ "expireIn": "Vervalt in", "neverExpire": "Nooit verlopen", "shareExpireDescription": "Vervaltijd is hoe lang de link bruikbaar is en geeft toegang tot de bron. Na deze tijd zal de link niet meer werken en zullen gebruikers die deze link hebben gebruikt de toegang tot de pagina verliezen.", - "shareSeeOnce": "Je kunt deze koppeling alleen zien. Zorg ervoor dat je het kopieert.", + "shareSeeOnce": "U kunt deze link slechts één keer zien. Zorg ervoor dat u deze kopieert.", "shareAccessHint": "Iedereen met deze link heeft toegang tot de bron. Deel deze met zorg.", "shareTokenUsage": "Zie Toegangstoken Gebruik", "createLink": "Koppeling aanmaken", @@ -179,7 +179,7 @@ "baseDomain": "Basis domein", "subdomnainDescription": "Het subdomein waar de bron toegankelijk is.", "resourceRawSettings": "TCP/UDP instellingen", - "resourceRawSettingsDescription": "Stel in hoe je bron wordt benaderd via TCP/UDP", + "resourceRawSettingsDescription": "Stel in hoe uw bron wordt benaderd via TCP/UDP. Je gooit de bron toe aan een poort op de host-Pangolin server, zodat je de bron kan bereiken vanaf server-public-ip:mapped-port.", "protocol": "Protocol", "protocolSelect": "Selecteer een protocol", "resourcePortNumber": "Nummer van poort", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Domeinen", "sidebarBluePrints": "Blauwdrukken", "blueprints": "Blauwdrukken", - "blueprintsDescription": "Blauwdrukken zijn declaratieve YAML-configuraties die je bronnen en hun instellingen bepalen", + "blueprintsDescription": "Gebruik declaratieve configuraties en bekijk vorige uitvoeringen.", "blueprintAdd": "Blauwdruk toevoegen", "blueprintGoBack": "Bekijk alle Blauwdrukken", "blueprintCreate": "Creëer blauwdruk", "blueprintCreateDescription2": "Volg de onderstaande stappen om een nieuwe blauwdruk te maken en toe te passen", - "blueprintDetails": "Blauwdruk details", - "blueprintDetailsDescription": "Bekijk de blauwdruk run details", + "blueprintDetails": "Blauwdruk Details", + "blueprintDetailsDescription": "Bekijk het resultaat van de toegepaste blauwdruk en eventuele fouten", "blueprintInfo": "Blauwdruk Informatie", "message": "bericht", "blueprintContentsDescription": "Definieer de YAML content die je infrastructuur beschrijft", @@ -1181,7 +1181,7 @@ "appliedAt": "Toegepast op", "source": "Bron", "contents": "Inhoud", - "parsedContents": "Geparseerde inhoud", + "parsedContents": "Geparseerde inhoud (alleen lezen)", "enableDockerSocket": "Schakel Docker Blauwdruk in", "enableDockerSocketDescription": "Schakel Docker Socket label in voor blauwdruk labels. Pad naar Nieuw.", "enableDockerSocketLink": "Meer informatie", @@ -2080,5 +2080,20 @@ "supportSending": "Verzenden...", "supportSend": "Verzenden", "supportMessageSent": "Bericht verzonden!", - "supportWillContact": "We nemen binnenkort contact met u op!" -} + "supportWillContact": "We nemen binnenkort contact met u op!", + "selectLogRetention": "Selecteer log retentie", + "showColumns": "Kolommen weergeven", + "hideColumns": "Kolommen verbergen", + "columnVisibility": "Zichtbaarheid kolommen", + "toggleColumn": "{columnName} kolom in-/uitschakelen", + "allColumns": "Alle kolommen", + "defaultColumns": "Standaard Kolommen", + "customizeView": "Weergave aanpassen", + "viewOptions": "Bekijk opties", + "selectAll": "Alles selecteren", + "selectNone": "Niets selecteren", + "selectedResources": "Geselecteerde bronnen", + "enableSelected": "Selectie inschakelen", + "disableSelected": "Selectie uitschakelen", + "checkSelectedStatus": "Controleer de status van de geselecteerde" +} \ No newline at end of file From 4a87cecf89e12790cf1ce0b448cb8367fe34dcc5 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:35 -0800 Subject: [PATCH 39/45] New translations en-us.json (Polish) --- messages/pl-PL.json | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index b137ab74..2acb3291 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -131,7 +131,7 @@ "expireIn": "Wygasa za", "neverExpire": "Nigdy nie wygasa", "shareExpireDescription": "Czas wygaśnięcia to jak długo link będzie mógł być użyty i zapewni dostęp do zasobu. Po tym czasie link nie będzie już działał, a użytkownicy, którzy użyli tego linku, utracą dostęp do zasobu.", - "shareSeeOnce": "Możesz zobaczyć tylko ten link. Upewnij się, że go skopiowało.", + "shareSeeOnce": "Możesz zobaczyć ten link tylko raz. Pamiętaj, aby go skopiować.", "shareAccessHint": "Każdy z tym linkiem może uzyskać dostęp do zasobu. Podziel się nim ostrożnie.", "shareTokenUsage": "Zobacz użycie tokenu dostępu", "createLink": "Utwórz link", @@ -179,7 +179,7 @@ "baseDomain": "Bazowa domena", "subdomnainDescription": "Poddomena, w której twój zasób będzie dostępny.", "resourceRawSettings": "Ustawienia TCP/UDP", - "resourceRawSettingsDescription": "Skonfiguruj jak twój zasób będzie dostępny przez TCP/UDP", + "resourceRawSettingsDescription": "Skonfiguruj jak twój zasób będzie dostępny przez TCP/UDP. Zmapujesz zasób do portu na serwerze hosta Pangolin, dzięki czemu możesz uzyskać dostęp do zasobu z serwera-public ip:mapped-port.", "protocol": "Protokół", "protocolSelect": "Wybierz protokół", "resourcePortNumber": "Numer portu", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Domeny", "sidebarBluePrints": "Schematy", "blueprints": "Schematy", - "blueprintsDescription": "Plany to deklaratywne konfiguracje YAML, które definiują twoje zasoby i ich ustawienia", + "blueprintsDescription": "Zastosuj konfiguracje deklaracyjne i wyświetl poprzednie operacje", "blueprintAdd": "Dodaj schemat", "blueprintGoBack": "Zobacz wszystkie schematy", "blueprintCreate": "Utwórz schemat", "blueprintCreateDescription2": "Wykonaj poniższe kroki, aby utworzyć i zastosować nowy schemat", - "blueprintDetails": "Szczegóły projektu", - "blueprintDetailsDescription": "Zobacz szczegóły uruchomienia schematu", + "blueprintDetails": "Szczegóły Projektu", + "blueprintDetailsDescription": "Zobacz wynik zastosowanego schematu i wszelkie błędy, które wystąpiły", "blueprintInfo": "Informacje o projekcie", "message": "Wiadomość", "blueprintContentsDescription": "Zdefiniuj zawartość YAML opisującą Twoją infrastrukturę", @@ -1181,7 +1181,7 @@ "appliedAt": "Zastosowano", "source": "Źródło", "contents": "Treść", - "parsedContents": "Przetworzona zawartość", + "parsedContents": "Przetworzona zawartość (tylko do odczytu)", "enableDockerSocket": "Włącz schemat dokera", "enableDockerSocketDescription": "Włącz etykietowanie kieszeni dokującej dla etykiet schematów. Ścieżka do gniazda musi być dostarczona do Newt.", "enableDockerSocketLink": "Dowiedz się więcej", @@ -2080,5 +2080,20 @@ "supportSending": "Wysyłanie...", "supportSend": "Wyślij", "supportMessageSent": "Wiadomość wysłana!", - "supportWillContact": "Wkrótce będziemy w kontakcie!" -} + "supportWillContact": "Wkrótce będziemy w kontakcie!", + "selectLogRetention": "Wybierz zatrzymanie dziennika", + "showColumns": "Pokaż kolumny", + "hideColumns": "Ukryj kolumny", + "columnVisibility": "Widoczność kolumn", + "toggleColumn": "Przełącz kolumnę {columnName}", + "allColumns": "Wszystkie kolumny", + "defaultColumns": "Kolumny domyślne", + "customizeView": "Dostosuj widok", + "viewOptions": "Opcje widoku", + "selectAll": "Zaznacz wszystko", + "selectNone": "Nie wybierz żadnego", + "selectedResources": "Wybrane Zasoby", + "enableSelected": "Włącz zaznaczone", + "disableSelected": "Wyłącz zaznaczone", + "checkSelectedStatus": "Sprawdź status zaznaczonych" +} \ No newline at end of file From 4fddaa8f112f3229e1b2877e6a1be0b9df2d03ad Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:36 -0800 Subject: [PATCH 40/45] New translations en-us.json (Portuguese) --- messages/pt-PT.json | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 983e3f12..cb0bc29b 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -179,7 +179,7 @@ "baseDomain": "Domínio Base", "subdomnainDescription": "O subdomínio onde seu recurso estará acessível.", "resourceRawSettings": "Configurações TCP/UDP", - "resourceRawSettingsDescription": "Configure como seu recurso será acessado sobre TCP/UDP", + "resourceRawSettingsDescription": "Configure como seu recurso será acessado sobre TCP/UDP. Você mapeia o recurso para uma porta no servidor Pangolin do hospedeiro, para que você possa acessar o recurso do server-public-ip:mapped-port.", "protocol": "Protocolo", "protocolSelect": "Selecione um protocolo", "resourcePortNumber": "Número da Porta", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Domínios", "sidebarBluePrints": "Diagramas", "blueprints": "Diagramas", - "blueprintsDescription": "Diagramas são configurações declarativas YAML que definem seus recursos e suas configurações", + "blueprintsDescription": "Aplicar configurações declarativas e ver execuções anteriores", "blueprintAdd": "Adicionar Diagrama", "blueprintGoBack": "Ver todos os Diagramas", "blueprintCreate": "Criar Diagrama", "blueprintCreateDescription2": "Siga as etapas abaixo para criar e aplicar um novo diagrama", "blueprintDetails": "Detalhes do Diagrama", - "blueprintDetailsDescription": "Veja os detalhes da execução do diagrama", + "blueprintDetailsDescription": "Veja o resultado do diagrama aplicado e todos os erros que ocorreram", "blueprintInfo": "Informação do Diagrama", "message": "mensagem", "blueprintContentsDescription": "Defina o conteúdo YAML descrevendo a sua infraestrutura", @@ -1181,7 +1181,7 @@ "appliedAt": "Aplicado em", "source": "fonte", "contents": "Conteúdo", - "parsedContents": "Conteúdo analisado", + "parsedContents": "Conteúdo analisado (Somente Leitura)", "enableDockerSocket": "Habilitar o Diagrama Docker", "enableDockerSocketDescription": "Ativar a scraping de rótulo Docker para rótulos de diagramas. Caminho de Socket deve ser fornecido para Newt.", "enableDockerSocketLink": "Saiba mais", @@ -2080,5 +2080,20 @@ "supportSending": "Enviando...", "supportSend": "Mandar", "supportMessageSent": "Mensagem enviada!", - "supportWillContact": "Entraremos em contato em breve!" -} + "supportWillContact": "Entraremos em contato em breve!", + "selectLogRetention": "Selecionar retenção de log", + "showColumns": "Exibir Colunas", + "hideColumns": "Ocultar colunas", + "columnVisibility": "Visibilidade da Coluna", + "toggleColumn": "Alternar coluna {columnName}", + "allColumns": "Todas as colunas", + "defaultColumns": "Colunas padrão", + "customizeView": "Personalizar visualização", + "viewOptions": "Opções de visualização", + "selectAll": "Selecionar Todos", + "selectNone": "Não selecionar nada", + "selectedResources": "Recursos Selecionados", + "enableSelected": "Habilitar Selecionados", + "disableSelected": "Desativar Selecionados", + "checkSelectedStatus": "Status de Verificação dos Selecionados" +} \ No newline at end of file From 5e0d822d45e5782deb8c6cccb76cf291108b89c7 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:38 -0800 Subject: [PATCH 41/45] New translations en-us.json (Russian) --- messages/ru-RU.json | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 732bee6a..b3608b98 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -131,7 +131,7 @@ "expireIn": "Срок действия", "neverExpire": "Бессрочный доступ", "shareExpireDescription": "Срок действия - это период, в течение которого ссылка будет работать и предоставлять доступ к ресурсу. После этого времени ссылка перестанет работать, и пользователи, использовавшие эту ссылку, потеряют доступ к ресурсу.", - "shareSeeOnce": "Вы сможете увидеть эту ссылку только один раз. Обязательно скопируйте её.", + "shareSeeOnce": "Вы сможете увидеть эту ссылку только один раз. Обязательно скопируйте ее.", "shareAccessHint": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу. Делитесь ею с осторожностью.", "shareTokenUsage": "Посмотреть использование токена доступа", "createLink": "Создать ссылку", @@ -179,7 +179,7 @@ "baseDomain": "Базовый домен", "subdomnainDescription": "Поддомен, на котором будет доступен ресурс.", "resourceRawSettings": "Настройки TCP/UDP", - "resourceRawSettingsDescription": "Настройте, как будет осуществляться доступ к вашему ресурсу через TCP/UDP", + "resourceRawSettingsDescription": "Настройте доступ к вашему ресурсу по TCP/UDP. Вы соотносите ресурс с портом на сервере хоста Pangolin, так что вы можете получить доступ к ресурсу с сервера server-public-ip:mapped-порта.", "protocol": "Протокол", "protocolSelect": "Выберите протокол", "resourcePortNumber": "Номер порта", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Домены", "sidebarBluePrints": "Чертежи", "blueprints": "Чертежи", - "blueprintsDescription": "Чертежи являются декларативными конфигурациями YAML, которые определяют ваши ресурсы и их настройки", + "blueprintsDescription": "Применить декларирующие конфигурации и просмотреть предыдущие запуски", "blueprintAdd": "Добавить чертёж", "blueprintGoBack": "Посмотреть все чертежи", "blueprintCreate": "Создать чертёж", "blueprintCreateDescription2": "Для создания и применения нового чертежа выполните следующие шаги", - "blueprintDetails": "Подробности чертежа", - "blueprintDetailsDescription": "Посмотреть детали запуска чертежа", + "blueprintDetails": "Детали чертежа", + "blueprintDetailsDescription": "Посмотреть результат примененного чертежа и все возникшие ошибки", "blueprintInfo": "Информация о чертеже", "message": "Сообщение", "blueprintContentsDescription": "Определите содержимое YAML, описывающее вашу инфраструктуру", @@ -1181,7 +1181,7 @@ "appliedAt": "Заявка на", "source": "Источник", "contents": "Содержание", - "parsedContents": "Обработанное содержимое", + "parsedContents": "Переработанное содержимое (только для чтения)", "enableDockerSocket": "Включить чертёж Docker", "enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.", "enableDockerSocketLink": "Узнать больше", @@ -2080,5 +2080,20 @@ "supportSending": "Отправка...", "supportSend": "Отправить", "supportMessageSent": "Сообщение отправлено!", - "supportWillContact": "Мы скоро свяжемся с Вами!" -} + "supportWillContact": "Мы скоро свяжемся с Вами!", + "selectLogRetention": "Выберите удержание журнала", + "showColumns": "Показать колонки", + "hideColumns": "Скрыть столбцы", + "columnVisibility": "Видимость столбцов", + "toggleColumn": "Столбец {columnName}", + "allColumns": "Все колонки", + "defaultColumns": "Столбцы по умолчанию", + "customizeView": "Настроить вид", + "viewOptions": "Параметры просмотра", + "selectAll": "Выделить все", + "selectNone": "Не выбирать", + "selectedResources": "Выбранные ресурсы", + "enableSelected": "Включить выбранные", + "disableSelected": "Отключить выбранные", + "checkSelectedStatus": "Проверить статус выбранных" +} \ No newline at end of file From 7995fd364e81f81f9eb1fb3185a22051bfabca4c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:39 -0800 Subject: [PATCH 42/45] New translations en-us.json (Turkish) --- messages/tr-TR.json | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index a3470f53..a396059e 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -179,7 +179,7 @@ "baseDomain": "Temel Alan Adı", "subdomnainDescription": "Kaynağınızın erişilebileceği alt alan adı.", "resourceRawSettings": "TCP/UDP Ayarları", - "resourceRawSettingsDescription": "Kaynağınıza TCP/UDP üzerinden erişimin nasıl sağlanacağını yapılandırın", + "resourceRawSettingsDescription": "Kaynağınızın TCP/UDP üzerinden nasıl erişileceğini yapılandırın. Kaynağı, sunucudan erişebilmeniz için bir ana bilgisayar Pangolin sunucusundaki bir bağlantı noktasına eşlersiniz: sunucu genel-IP: eşlenen-bağlantı-noktası.", "protocol": "Protokol", "protocolSelect": "Bir protokol seçin", "resourcePortNumber": "Port Numarası", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Alan Adları", "sidebarBluePrints": "Planlar", "blueprints": "Planlar", - "blueprintsDescription": "Planlar, kaynaklarınızı ve ayarlarını tanımlayan bildirimsel YAML yapılandırmalarıdır", + "blueprintsDescription": "Deklaratif yapılandırmaları uygulayın ve önceki çalışmaları görüntüleyin", "blueprintAdd": "Plan Ekle", "blueprintGoBack": "Tüm Planları Gör", "blueprintCreate": "Plan Oluştur", "blueprintCreateDescription2": "Yeni bir plan oluşturup uygulamak için aşağıdaki adımları izleyin", - "blueprintDetails": "Plan Detayları", - "blueprintDetailsDescription": "Plan çalıştırma detaylarını görün", + "blueprintDetails": "Mavi Yazılım Detayları", + "blueprintDetailsDescription": "Uygulanan mavi yazılımın sonucunu ve oluşan hataları görün", "blueprintInfo": "Plan Bilgileri", "message": "Mesaj", "blueprintContentsDescription": "Altyapınızı tanımlayan YAML içeriğini tanımlayın", @@ -1181,7 +1181,7 @@ "appliedAt": "Uygulama Zamanı", "source": "Kaynak", "contents": "İçerik", - "parsedContents": "Ayrıştırılmış İçerik", + "parsedContents": "Verilerin Ayrıştırılmış İçeriği (Salt Okunur)", "enableDockerSocket": "Docker Soketini Etkinleştir", "enableDockerSocketDescription": "Plan etiketleri için Docker Socket etiket toplamasını etkinleştirin. Newt'e soket yolu sağlanmalıdır.", "enableDockerSocketLink": "Daha fazla bilgi", @@ -2080,5 +2080,20 @@ "supportSending": "Gönderiliyor...", "supportSend": "Gönder", "supportMessageSent": "Mesaj Gönderildi!", - "supportWillContact": "En kısa sürede size geri döneceğiz!" -} + "supportWillContact": "En kısa sürede size geri döneceğiz!", + "selectLogRetention": "Kayıt saklama seç", + "showColumns": "Sütunları Göster", + "hideColumns": "Sütunları Gizle", + "columnVisibility": "Sütun Görünürlüğü", + "toggleColumn": "{columnName} sütununu aç/kapat", + "allColumns": "Tüm Sütunlar", + "defaultColumns": "Varsayılan Sütunlar", + "customizeView": "Görünümü Özelleştir", + "viewOptions": "Görünüm Seçenekleri", + "selectAll": "Tümünü Seç", + "selectNone": "Hiçbirini Seçme", + "selectedResources": "Seçilen Kaynaklar", + "enableSelected": "Seçilenleri Etkinleştir", + "disableSelected": "Seçilenleri Devre Dışı Bırak", + "checkSelectedStatus": "Seçilenlerin Durumunu Kontrol Et" +} \ No newline at end of file From a229fc1c6125f9109cb97d30c0c9508d9ef637b1 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:40 -0800 Subject: [PATCH 43/45] New translations en-us.json (Chinese Simplified) --- messages/zh-CN.json | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 32b09130..87401859 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -131,7 +131,7 @@ "expireIn": "过期时间", "neverExpire": "永不过期", "shareExpireDescription": "过期时间是链接可以使用并提供对资源的访问时间。 此时间后,链接将不再工作,使用此链接的用户将失去对资源的访问。", - "shareSeeOnce": "您只能看到此链接。请确保复制它。", + "shareSeeOnce": "您只能看到一次此链接。请确保复制它。", "shareAccessHint": "任何具有此链接的人都可以访问该资源。小心地分享它。", "shareTokenUsage": "查看访问令牌使用情况", "createLink": "创建链接", @@ -179,7 +179,7 @@ "baseDomain": "根域名", "subdomnainDescription": "您的资源可以访问的子域名。", "resourceRawSettings": "TCP/UDP 设置", - "resourceRawSettingsDescription": "配置如何通过 TCP/UDP 访问您的资源", + "resourceRawSettingsDescription": "配置如何通过 TCP/UDP 访问您的资源。 您映射资源到主机Pangolin服务器上的端口,这样您就可以访问服务器-公共-ip:mapped端口的资源。", "protocol": "协议", "protocolSelect": "选择协议", "resourcePortNumber": "端口号", @@ -1165,13 +1165,13 @@ "sidebarDomains": "域", "sidebarBluePrints": "蓝图", "blueprints": "蓝图", - "blueprintsDescription": "蓝图是用于定义资源及其设置的 YAML 声明配置", + "blueprintsDescription": "应用声明配置并查看先前运行的", "blueprintAdd": "添加蓝图", "blueprintGoBack": "查看所有蓝图", "blueprintCreate": "创建蓝图", "blueprintCreateDescription2": "按照下面的步骤创建和应用新的蓝图", "blueprintDetails": "蓝图详细信息", - "blueprintDetailsDescription": "查看蓝图运行详情", + "blueprintDetailsDescription": "查看应用蓝图的结果和发生的任何错误", "blueprintInfo": "蓝图信息", "message": "留言", "blueprintContentsDescription": "定义描述您基础设施的 YAML 内容", @@ -1181,7 +1181,7 @@ "appliedAt": "应用于", "source": "来源", "contents": "目录", - "parsedContents": "解析内容", + "parsedContents": "解析内容 (只读)", "enableDockerSocket": "启用 Docker 蓝图", "enableDockerSocketDescription": "启用 Docker Socket 标签擦除蓝图标签。套接字路径必须提供给新的。", "enableDockerSocketLink": "了解更多", @@ -2080,5 +2080,20 @@ "supportSending": "正在发送...", "supportSend": "发送", "supportMessageSent": "消息已发送!", - "supportWillContact": "我们很快就会联系起来!" -} + "supportWillContact": "我们很快就会联系起来!", + "selectLogRetention": "选择保留日志", + "showColumns": "显示列", + "hideColumns": "隐藏列", + "columnVisibility": "列可见性", + "toggleColumn": "切换 {columnName} 列", + "allColumns": "全部列", + "defaultColumns": "默认列", + "customizeView": "自定义视图", + "viewOptions": "查看选项", + "selectAll": "选择所有", + "selectNone": "没有选择", + "selectedResources": "选定的资源", + "enableSelected": "启用选中的", + "disableSelected": "禁用选中的", + "checkSelectedStatus": "检查选中的状态" +} \ No newline at end of file From a33695506617419645de1c79dae24c420b2177ca Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sat, 8 Nov 2025 16:22:42 -0800 Subject: [PATCH 44/45] New translations en-us.json (Norwegian Bokmal) --- messages/nb-NO.json | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index d59d5e83..fabeec04 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -131,7 +131,7 @@ "expireIn": "Utløper om", "neverExpire": "Utløper aldri", "shareExpireDescription": "Utløpstid er hvor lenge lenken vil være brukbar og gi tilgang til ressursen. Etter denne tiden vil lenken ikke lenger fungere, og brukere som brukte denne lenken vil miste tilgangen til ressursen.", - "shareSeeOnce": "Du får bare se denne lenken én gang. Pass på å kopiere den.", + "shareSeeOnce": "Du vil bare kunne se denne linken én gang. Pass på å kopiere den.", "shareAccessHint": "Alle med denne lenken kan få tilgang til ressursen. Del forsiktig.", "shareTokenUsage": "Se tilgangstokenbruk", "createLink": "Opprett lenke", @@ -179,7 +179,7 @@ "baseDomain": "Grunndomene", "subdomnainDescription": "Underdomenet der ressursen din vil være tilgjengelig.", "resourceRawSettings": "TCP/UDP-innstillinger", - "resourceRawSettingsDescription": "Konfigurer tilgang til ressursen din over TCP/UDP", + "resourceRawSettingsDescription": "Konfigurer hvordan din ressurs vil bli tilgjengelig over TCP/UDP. Du kartlegger ressursen til en port på vertsserveren Pangolin slik at du får tilgang til ressursene fra server-ip:mappet port.", "protocol": "Protokoll", "protocolSelect": "Velg en protokoll", "resourcePortNumber": "Portnummer", @@ -1165,13 +1165,13 @@ "sidebarDomains": "Domener", "sidebarBluePrints": "Tegninger", "blueprints": "Tegninger", - "blueprintsDescription": "Tegninger er deklarative YAML konfigurasjoner som definerer dine ressurser og deres innstillinger", + "blueprintsDescription": "Bruk deklarative konfigurasjoner og vis tidligere kjøringer", "blueprintAdd": "Legg til blåkopi", "blueprintGoBack": "Se alle blåkopier", "blueprintCreate": "Opprette mal", "blueprintCreateDescription2": "Følg trinnene nedenfor for å opprette og bruke en ny plantegning", "blueprintDetails": "Blåkopi detaljer", - "blueprintDetailsDescription": "Se detaljer om plantegning", + "blueprintDetailsDescription": "Se resultatet av den påførte blåkopien og alle feil som oppstod", "blueprintInfo": "Blåkopi informasjon", "message": "Melding", "blueprintContentsDescription": "Definer innhold av YAML som beskriver din infrastruktur", @@ -1181,7 +1181,7 @@ "appliedAt": "Anvendt på", "source": "Kilde", "contents": "Innhold", - "parsedContents": "Parket innhold", + "parsedContents": "Parastinnhold (kun lese)", "enableDockerSocket": "Aktiver Docker blåkopi", "enableDockerSocketDescription": "Aktiver skraping av Docker Socket for blueprint Etiketter. Socket bane må brukes for nye.", "enableDockerSocketLink": "Lær mer", @@ -2080,5 +2080,20 @@ "supportSending": "Sender...", "supportSend": "Sende", "supportMessageSent": "Melding sendt!", - "supportWillContact": "Vi kommer raskt til å ta kontakt!" -} + "supportWillContact": "Vi kommer raskt til å ta kontakt!", + "selectLogRetention": "Velg oppbevaring av logg", + "showColumns": "Vis kolonner", + "hideColumns": "Skjul kolonner", + "columnVisibility": "Kolonne Synlighet", + "toggleColumn": "Veksle {columnName} kolonne", + "allColumns": "Alle kolonner", + "defaultColumns": "Standard kolonner", + "customizeView": "Tilpass visning", + "viewOptions": "Vis alternativer", + "selectAll": "Velg alle", + "selectNone": "Velg ingen", + "selectedResources": "Valgte ressurser", + "enableSelected": "Aktiver valgte", + "disableSelected": "Deaktiver valgte", + "checkSelectedStatus": "Kontroller status for valgte" +} \ No newline at end of file From 37acdc2796ddbcfc39ee853efe09b2be8349babb Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 8 Nov 2025 16:33:36 -0800 Subject: [PATCH 45/45] Revert transaction --- server/routers/target/deleteTarget.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 8a400d60..596691e4 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -48,12 +48,10 @@ export async function deleteTarget( const { targetId } = parsedParams.data; - const [deletedTarget] = await db.transaction(async (tx) => { - return await tx - .delete(targets) - .where(eq(targets.targetId, targetId)) - .returning(); - }); + const [deletedTarget] = await db + .delete(targets) + .where(eq(targets.targetId, targetId)) + .returning(); if (!deletedTarget) { return next(