diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index f2227a7bc..cc8f38ef3 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -291,6 +291,12 @@ export async function getTraefikConfig( domainCertResolver: domains.certResolver, preferWildcardCert: domains.preferWildcardCert, domainNamespaceId: domainNamespaces.domainNamespaceId, + // Maintenance fields + maintenanceModeEnabled: resources.maintenanceModeEnabled, + maintenanceModeType: resources.maintenanceModeType, + maintenanceTitle: resources.maintenanceTitle, + maintenanceMessage: resources.maintenanceMessage, + maintenanceEstimatedTime: resources.maintenanceEstimatedTime, // Browser gateway target fields browserGatewayTargetId: browserGatewayTarget.browserGatewayTargetId, bgType: browserGatewayTarget.type, @@ -340,6 +346,11 @@ export async function getTraefikConfig( wildcard: boolean | null; domainCertResolver: string | null; preferWildcardCert: boolean | null; + maintenanceModeEnabled: boolean | null; + maintenanceModeType: string | null; + maintenanceTitle: string | null; + maintenanceMessage: string | null; + maintenanceEstimatedTime: string | null; targets: { browserGatewayTargetId: number; bgType: string; @@ -371,6 +382,11 @@ export async function getTraefikConfig( wildcard: row.wildcard, domainCertResolver: row.domainCertResolver, preferWildcardCert: row.preferWildcardCert, + maintenanceModeEnabled: row.maintenanceModeEnabled, + maintenanceModeType: row.maintenanceModeType, + maintenanceTitle: row.maintenanceTitle, + maintenanceMessage: row.maintenanceMessage, + maintenanceEstimatedTime: row.maintenanceEstimatedTime, targets: [] }); } @@ -1118,6 +1134,75 @@ export async function getTraefikConfig( // Collect online sites for this resource (for any type) const anySiteOnline = bgResource.targets.some((t) => t.siteOnline); + // Maintenance page logic for browser gateway resources + let showBgMaintenancePage = false; + if (bgResource.maintenanceModeEnabled) { + if (bgResource.maintenanceModeType === "forced") { + showBgMaintenancePage = true; + } else if (bgResource.maintenanceModeType === "automatic") { + showBgMaintenancePage = !anySiteOnline; + } + } + + if (showBgMaintenancePage && allowMaintenancePage) { + const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`; + const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`; + const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`; + + const entrypointHttp = + config.getRawConfig().traefik.http_entrypoint; + const entrypointHttps = + config.getRawConfig().traefik.https_entrypoint; + + const maintenancePort = config.getRawConfig().server.next_port; + const maintenanceHost = + config.getRawConfig().server.internal_hostname; + + if (!config_output.http.services) config_output.http.services = {}; + if (!config_output.http.middlewares) + config_output.http.middlewares = {}; + if (!config_output.http.routers) config_output.http.routers = {}; + + config_output.http.services![bgMaintenanceServiceName] = { + loadBalancer: { + servers: [ + { url: `http://${maintenanceHost}:${maintenancePort}` } + ], + passHostHeader: true + } + }; + + config_output.http.middlewares![bgRewriteMiddlewareName] = { + replacePathRegex: { + regex: "^/(.*)", + replacement: "/maintenance-screen" + } + }; + + config_output.http.routers![bgMaintenanceRouterName] = { + entryPoints: [ + bgResource.ssl ? entrypointHttps : entrypointHttp + ], + service: bgMaintenanceServiceName, + middlewares: [bgRewriteMiddlewareName], + rule: hostRule, + priority: 2000, + ...(bgResource.ssl ? { tls } : {}) + }; + + config_output.http.routers![`${bgMaintenanceRouterName}-assets`] = { + entryPoints: [ + bgResource.ssl ? entrypointHttps : entrypointHttp + ], + service: bgMaintenanceServiceName, + rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`, + priority: 2001, + ...(bgResource.ssl ? { tls } : {}) + }; + + continue; + } + // Group targets by type and generate per-type websocket routers and services const typeMap = new Map(); for (const t of bgResource.targets) { diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index b057abf89..c217da489 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -12,6 +12,7 @@ import { userSites, labels, siteLabels, + browserGatewayTarget, type Label } from "@server/db"; import cache from "#dynamic/lib/cache"; @@ -240,6 +241,10 @@ function querySitesBase() { ON ${siteResources.networkId} = ${siteNetworks.networkId} WHERE ${siteNetworks.siteId} = ${sites.siteId} AND ${siteResources.orgId} = ${sites.orgId} + ) + ( + SELECT COUNT(DISTINCT ${browserGatewayTarget.resourceId}) + FROM ${browserGatewayTarget} + WHERE ${browserGatewayTarget.siteId} = ${sites.siteId} )`, status: sites.status }) @@ -307,7 +312,6 @@ export async function listSites( ) ); } - const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 7e18fa283..1b57ff910 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -1812,74 +1812,68 @@ export function PrivateResourceForm({ /> - {/* Auth Method (standard only) */} - {!isNative && ( -
-

- {t("sshAuthenticationMethod")} -

- ( - - - - value={ - field.value ?? - "passthrough" - } - options={[ - { - id: "passthrough", - title: t( - "sshAuthMethodManual" - ), - description: t( - "sshAuthMethodManualDescription" - ), - disabled: - sshSectionDisabled - }, - { - id: "push", - title: t( - "sshAuthMethodAutomated" - ), - description: t( - "sshAuthMethodAutomatedDescription" - ), - disabled: - sshSectionDisabled - } - ]} - onChange={(v) => { - if ( +
+

+ {t("sshAuthenticationMethod")} +

+ ( + + + + value={ + field.value ?? + "passthrough" + } + options={[ + { + id: "passthrough", + title: t( + "sshAuthMethodManual" + ), + description: t( + "sshAuthMethodManualDescription" + ), + disabled: sshSectionDisabled - ) - return; - field.onChange(v); - if ( - v === - "passthrough" - ) { - form.setValue( - "authDaemonPort", - null - ); - } - }} - cols={2} - /> - - - - )} - /> -
- )} + }, + { + id: "push", + title: t( + "sshAuthMethodAutomated" + ), + description: t( + "sshAuthMethodAutomatedDescription" + ), + disabled: + sshSectionDisabled + } + ]} + onChange={(v) => { + if (sshSectionDisabled) + return; + field.onChange(v); + if ( + v === "passthrough" + ) { + form.setValue( + "authDaemonPort", + null + ); + } + }} + cols={2} + /> +
+ +
+ )} + /> +
{/* Daemon Location (standard + push) */} {showDaemonLocation && (