From 2c8b7b5ca5ccc031f60f4c3b627483bade477ecc Mon Sep 17 00:00:00 2001 From: Siddharth Bansal Date: Sun, 19 Apr 2026 12:27:16 +0530 Subject: [PATCH 001/107] (fix): Added a logrotate function to the crowdsec.go installer file --- install/crowdsec.go | 80 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/install/crowdsec.go b/install/crowdsec.go index c75dccf32..d2817a76c 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -6,6 +6,7 @@ import ( "log" "os" "os/exec" + "path/filepath" "strings" "gopkg.in/yaml.v3" @@ -40,6 +41,8 @@ func installCrowdsec(config Config) error { os.Exit(1) } + setupTraefikLogRotate() + if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { fmt.Printf("Error copying docker service: %v\n", err) os.Exit(1) @@ -208,3 +211,80 @@ func CheckAndAddCrowdsecDependency(composePath string) error { fmt.Println("Added dependency of crowdsec to traefik") return nil } + +// setupTraefikLogRotate writes a logrotate config for the Traefik access log +// that CrowdSec depends on. This is only needed when CrowdSec is installed +// because the default Pangolin install does not enable Traefik access logs. +// +// copytruncate is used so Traefik does not need to be restarted or sent a +// signal after rotation — it keeps writing to the same file descriptor while +// the rotated copy is made and the original is truncated in place. +func setupTraefikLogRotate() { + const logrotateDir = "/etc/logrotate.d" + const logrotateFile = "/etc/logrotate.d/pangolin-traefik" + + // Resolve the absolute path to the install directory so the logrotate + // config references the correct log file regardless of where the user + // installed Pangolin. + installDir, err := filepath.Abs(".") + if err != nil { + fmt.Printf("[logrotate] Warning: could not resolve install directory: %v\n", err) + fmt.Println("[logrotate] Skipping logrotate setup. Set it up manually:") + printLogrotateConfig("/config/traefik/logs/access.log") + return + } + + logPath := filepath.Join(installDir, "config/traefik/logs/access.log") + + if os.Geteuid() != 0 { + fmt.Println("\n[logrotate] Skipping automatic logrotate setup: not running as root.") + fmt.Println("[logrotate] To prevent unbounded growth of the Traefik access log used by CrowdSec,") + fmt.Println("[logrotate] create the file /etc/logrotate.d/pangolin-traefik manually with:") + printLogrotateConfig(logPath) + return + } + + config := fmt.Sprintf(`# Logrotate config for Traefik access logs used by CrowdSec. +# Generated by the Pangolin installer. Safe to edit. +%s { + daily + rotate 7 + compress + delaycompress + missingok + notifempty + copytruncate +} +`, logPath) + + if err := os.MkdirAll(logrotateDir, 0755); err != nil { + fmt.Printf("[logrotate] Warning: could not create %s: %v\n", logrotateDir, err) + return + } + + if err := os.WriteFile(logrotateFile, []byte(config), 0644); err != nil { + fmt.Printf("[logrotate] Warning: could not write %s: %v\n", logrotateFile, err) + fmt.Println("[logrotate] Set it up manually:") + printLogrotateConfig(logPath) + return + } + + fmt.Printf("[logrotate] Wrote logrotate config to %s\n", logrotateFile) + fmt.Println("[logrotate] Traefik access logs will be rotated daily, keeping 7 compressed copies.") +} + +// printLogrotateConfig prints a logrotate config block to stdout so users can +// set it up manually when the installer cannot write to /etc. +func printLogrotateConfig(logPath string) { + fmt.Printf(` + %s { + daily + rotate 7 + compress + delaycompress + missingok + notifempty + copytruncate + } +`, logPath) +} From 473bce856d4d864875caa6786a4185bbe15802d2 Mon Sep 17 00:00:00 2001 From: Siddharth Bansal Date: Mon, 20 Apr 2026 21:36:42 +0530 Subject: [PATCH 002/107] Pass installdir as a parameter --- install/crowdsec.go | 17 +++-------------- install/main.go | 2 +- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/install/crowdsec.go b/install/crowdsec.go index d2817a76c..8dff42d99 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -12,7 +12,7 @@ import ( "gopkg.in/yaml.v3" ) -func installCrowdsec(config Config) error { +func installCrowdsec(config Config, installDir string) error { if err := stopContainers(config.InstallationContainerType); err != nil { return fmt.Errorf("failed to stop containers: %v", err) @@ -41,7 +41,7 @@ func installCrowdsec(config Config) error { os.Exit(1) } - setupTraefikLogRotate() + setupTraefikLogRotate(installDir) if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { fmt.Printf("Error copying docker service: %v\n", err) @@ -219,21 +219,10 @@ func CheckAndAddCrowdsecDependency(composePath string) error { // copytruncate is used so Traefik does not need to be restarted or sent a // signal after rotation — it keeps writing to the same file descriptor while // the rotated copy is made and the original is truncated in place. -func setupTraefikLogRotate() { +func setupTraefikLogRotate(installDir string) { const logrotateDir = "/etc/logrotate.d" const logrotateFile = "/etc/logrotate.d/pangolin-traefik" - // Resolve the absolute path to the install directory so the logrotate - // config references the correct log file regardless of where the user - // installed Pangolin. - installDir, err := filepath.Abs(".") - if err != nil { - fmt.Printf("[logrotate] Warning: could not resolve install directory: %v\n", err) - fmt.Println("[logrotate] Skipping logrotate setup. Set it up manually:") - printLogrotateConfig("/config/traefik/logs/access.log") - return - } - logPath := filepath.Join(installDir, "config/traefik/logs/access.log") if os.Geteuid() != 0 { diff --git a/install/main.go b/install/main.go index a38d78fc6..13e506d06 100644 --- a/install/main.go +++ b/install/main.go @@ -259,7 +259,7 @@ func main() { } config.DoCrowdsecInstall = true - err := installCrowdsec(config) + err := installCrowdsec(config, installDir) if err != nil { fmt.Printf("Error installing CrowdSec: %v\n", err) return From 1d53211fe0569a80cbcf1ac26f8e14ef531d45df Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 21 Apr 2026 23:16:01 -0700 Subject: [PATCH 003/107] fix logo size --- src/components/BrandingLogo.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/components/BrandingLogo.tsx b/src/components/BrandingLogo.tsx index aa5112409..0216dae2e 100644 --- a/src/components/BrandingLogo.tsx +++ b/src/components/BrandingLogo.tsx @@ -57,11 +57,6 @@ export default function BrandingLogo(props: BrandingLogoProps) { alt="Logo" width={props.width} height={props.height} - style={ - isNextImage - ? { width: "auto", height: "auto" } - : undefined - } /> ) ); From c2c8b7a631f76d2a4b7d3c9f9d7ec8b508607ab6 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 22 Apr 2026 12:08:41 -0700 Subject: [PATCH 004/107] disable overflow on header row for tables --- src/components/ui/controlled-data-table.tsx | 2 +- src/components/ui/data-table.tsx | 2 +- src/components/ui/table.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 116ce644a..a08479597 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -413,7 +413,7 @@ export function ControlledDataTable({ -
+
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 82aafe1f4..2b2861bb5 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -695,7 +695,7 @@ export function DataTable({ -
+
{table.getHeaderGroups().map((headerGroup) => ( diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index 407bbe15e..8f504ab65 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -12,7 +12,7 @@ const Table = React.forwardRef< >(({ className, sticky, ...props }, ref) => (
Date: Wed, 22 Apr 2026 12:16:39 -0700 Subject: [PATCH 005/107] fix squished alert card when disabled --- src/components/alert-rule-editor/AlertRuleGraphEditor.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 335d79bf7..273e8b637 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -174,11 +174,7 @@ export default function AlertRuleGraphEditor({
{isNew && ( From 3d5260b13e12ad9199deda14f3aac171175255a5 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 12:23:38 -0700 Subject: [PATCH 006/107] Fix strings and local sites --- server/private/lib/alerts/sendAlertWebhook.ts | 93 ++++++++++++------- .../settings/sites/[niceId]/general/page.tsx | 2 +- src/components/SitesTable.tsx | 5 +- src/components/SupporterStatus.tsx | 2 +- 4 files changed, 66 insertions(+), 36 deletions(-) diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index 5f309d577..5656026bc 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -15,6 +15,8 @@ import logger from "@server/logger"; import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types"; const REQUEST_TIMEOUT_MS = 15_000; +const MAX_RETRIES = 3; +const RETRY_BASE_DELAY_MS = 500; /** * Sends a single webhook POST for an alert event. @@ -49,45 +51,70 @@ export async function sendAlertWebhook( const body = JSON.stringify(payload); const headers = buildHeaders(webhookConfig); - const controller = new AbortController(); - const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + let lastError: Error | undefined; - let response: Response; - try { - response = await fetch(url, { - method: webhookConfig.method ?? "POST", - headers, - body, - signal: controller.signal - }); - } catch (err: unknown) { - const isAbort = err instanceof Error && err.name === "AbortError"; - if (isAbort) { - throw new Error( - `Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms` - ); - } - const msg = err instanceof Error ? err.message : String(err); - throw new Error(`Alert webhook: request to "${url}" failed – ${msg}`); - } finally { - clearTimeout(timeoutHandle); - } + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + const controller = new AbortController(); + const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - if (!response.ok) { - let snippet = ""; + let response: Response; try { - const text = await response.text(); - snippet = text.slice(0, 300); - } catch { - // best-effort + response = await fetch(url, { + method: webhookConfig.method ?? "POST", + headers, + body, + signal: controller.signal + }); + } catch (err: unknown) { + clearTimeout(timeoutHandle); + const isAbort = err instanceof Error && err.name === "AbortError"; + if (isAbort) { + lastError = new Error( + `Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms` + ); + } else { + const msg = err instanceof Error ? err.message : String(err); + lastError = new Error(`Alert webhook: request to "${url}" failed – ${msg}`); + } + if (attempt < MAX_RETRIES) { + const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); + logger.warn( + `Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed – retrying in ${delay} ms. ${lastError.message}` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + continue; + } finally { + clearTimeout(timeoutHandle); } - throw new Error( - `Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` + - (snippet ? ` – ${snippet}` : "") - ); + + if (!response.ok) { + let snippet = ""; + try { + const text = await response.text(); + snippet = text.slice(0, 300); + } catch { + // best-effort + } + lastError = new Error( + `Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` + + (snippet ? ` – ${snippet}` : "") + ); + if (attempt < MAX_RETRIES) { + const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); + logger.warn( + `Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed – retrying in ${delay} ms. ${lastError.message}` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + continue; + } + + logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}" (attempt ${attempt}/${MAX_RETRIES})`); + return; } - logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}"`); + throw lastError ?? new Error(`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`); } // --------------------------------------------------------------------------- diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index f4c4d72ef..f8bb6cf5e 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -113,7 +113,7 @@ export default function GeneralPage() { return ( - {site?.siteId && site?.orgId && ( + {site?.siteId && site?.orgId && site.type != "local" && ( {t("uptime30d")}, cell: ({ row }) => { const originalRow = row.original; + if (originalRow.type == "local") { + return -; + } return ( ); diff --git a/src/components/SupporterStatus.tsx b/src/components/SupporterStatus.tsx index 32db6beb7..7daeb8731 100644 --- a/src/components/SupporterStatus.tsx +++ b/src/components/SupporterStatus.tsx @@ -230,7 +230,7 @@ export default function SupporterStatus({

Business & Enterprise Users: For larger organizations or teams requiring advanced features, consider our self-serve enterprise license and Enterprise Edition.{" "} Date: Wed, 22 Apr 2026 12:24:52 -0700 Subject: [PATCH 007/107] visual improvements --- .../resources/proxy/[niceId]/general/page.tsx | 4 +--- src/components/Credenza.tsx | 11 +++++++---- src/components/PathMatchRenameModal.tsx | 9 +++++---- src/components/UptimeAlertSection.tsx | 9 +++++---- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 0bbe42878..2b87da745 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -161,6 +161,7 @@ function MaintenanceSectionForm({ +
- { return ( { const CredenzaHeader = isDesktop ? DialogHeader : SheetHeader; return ( - + {children} ); @@ -151,7 +154,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => { return (

{ return ( -
+
setName(e.target.value)} - placeholder="Alert name" + placeholder={t("uptimeAlertNamePlaceholder")} />
- + { @@ -263,11 +270,11 @@ export default function UptimeAlertSection({ />
- + { @@ -285,11 +292,11 @@ export default function UptimeAlertSection({ />
- + { @@ -313,14 +320,14 @@ export default function UptimeAlertSection({ - + diff --git a/src/components/UptimeBar.tsx b/src/components/UptimeBar.tsx index 992484ea4..5bd5ce3f1 100644 --- a/src/components/UptimeBar.tsx +++ b/src/components/UptimeBar.tsx @@ -10,6 +10,7 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; import { cn } from "@app/lib/cn"; +import { useTranslations } from "next-intl"; function formatDuration(seconds: number): string { if (seconds === 0) return "0s"; @@ -63,6 +64,7 @@ export default function UptimeBar({ title, className }: UptimeBarProps) { + const t = useTranslations(); const api = createApiClient(useEnvContext()); const siteQuery = useQuery({ @@ -104,7 +106,7 @@ export default function UptimeBar({
@@ -122,8 +124,8 @@ export default function UptimeBar({ ))}
- {days} days ago - Today + {t("uptimeDaysAgo", { count: days })} + {t("uptimeToday")}
); @@ -145,7 +147,7 @@ export default function UptimeBar({ {data.overallUptimePercent.toFixed(2)}% {" "} - uptime + {t("uptimeSuffix")} {data.totalDowntimeSeconds > 0 && ( @@ -154,14 +156,14 @@ export default function UptimeBar({ data.totalDowntimeSeconds )} {" "} - downtime + {t("uptimeDowntimeSuffix")} )} )} {allNoData && ( - No data available + {t("uptimeNoDataAvailable")} )}
@@ -188,7 +190,7 @@ export default function UptimeBar({
{day.status !== "no_data" && (
- Uptime:{" "} + {t("uptimeTooltipUptimeLabel")}:{" "} {day.uptimePercent.toFixed(1)}% @@ -196,7 +198,7 @@ export default function UptimeBar({ )} {day.totalDowntimeSeconds > 0 && (
- Downtime:{" "} + {t("uptimeTooltipDowntimeLabel")}:{" "} {formatDuration( day.totalDowntimeSeconds @@ -214,7 +216,7 @@ export default function UptimeBar({ {formatTime(w.start)} {w.end ? ` – ${formatTime(w.end)}` - : " – ongoing"}{" "} + : ` – ${t("uptimeOngoing")}`}{" "} ({w.status}) @@ -224,7 +226,7 @@ export default function UptimeBar({ )} {day.status === "no_data" && (
- No monitoring data + {t("uptimeNoMonitoringData")}
)} @@ -234,9 +236,9 @@ export default function UptimeBar({ {/* Date labels */}
- {days} days ago - Today + {t("uptimeDaysAgo", { count: days })} + {t("uptimeToday")}
); -} +} \ No newline at end of file diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index 5baabb4dd..b4c8aa3bd 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -10,6 +10,7 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient } from "@app/lib/api"; import { cn } from "@app/lib/cn"; +import { useTranslations } from "next-intl"; function formatDuration(seconds: number): string { if (seconds === 0) return "0s"; @@ -51,6 +52,7 @@ export default function UptimeMiniBar({ healthCheckId, days = 30 }: UptimeMiniBarProps) { + const t = useTranslations(); const api = createApiClient(useEnvContext()); const siteQuery = useQuery({ @@ -105,7 +107,7 @@ export default function UptimeMiniBar({ @@ -136,12 +138,12 @@ export default function UptimeMiniBar({
{day.status === "no_data" - ? "No data" - : `${day.uptimePercent.toFixed(1)}% uptime`} + ? t("uptimeNoData") + : `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
{day.totalDowntimeSeconds > 0 && (
- Down:{" "} + {t("uptimeMiniBarDown")}:{" "} {formatDuration(day.totalDowntimeSeconds)}
)} @@ -151,9 +153,9 @@ export default function UptimeMiniBar({
{allNoData - ? "No data" + ? t("uptimeNoData") : `${data.overallUptimePercent.toFixed(1)}%`} ); -} +} \ No newline at end of file diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index bb99d57f3..e9056256b 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -714,7 +714,7 @@ function WebhookActionFields({ name={`actions.${index}.url`} render={({ field }) => ( - URL + {t("webhookUrlLabel")} ( - + )} @@ -972,7 +972,7 @@ function WebhookHeadersField({ render={({ field }) => ( - + )} From ea4ff755523ead0c5dead6c41fe444e56019bd50 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 22 Apr 2026 14:24:55 -0700 Subject: [PATCH 009/107] cosmetic adjustments --- src/app/globals.css | 10 +++++----- src/components/LayoutSidebar.tsx | 4 ++-- src/components/OrgSelector.tsx | 4 ++-- src/components/SidebarNav.tsx | 6 +++--- src/components/SwitchInput.tsx | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index aa98b1d49..b42bf3dab 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -41,11 +41,11 @@ } .dark { - --background: #0d0d0f; + --background: #141415; --foreground: oklch(0.985 0 0); - --card: #0d0d0f; + --card: #141415; --card-foreground: oklch(0.985 0 0); - --popover: #0d0d0f; + --popover: #141415; --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.6717 0.1946 41.93); --primary-foreground: oklch(0.98 0.016 73.684); @@ -65,11 +65,11 @@ --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); - --sidebar: #040404; + --sidebar: #0C0C0D; --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.646 0.222 41.116); --sidebar-primary-foreground: oklch(0.98 0.016 73.684); - --sidebar-accent: #131317; + --sidebar-accent: #171717; --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.646 0.222 41.116); diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index faf0de959..69149c9f4 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -165,7 +165,7 @@ export function LayoutSidebar({ diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index 5f77582f5..c567f4d98 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -76,8 +76,8 @@ export function OrgSelector({ className={cn( "cursor-pointer transition-colors", isCollapsed - ? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50" - : "w-full px-5 py-4 hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50" + ? "w-full h-16 flex items-center justify-center hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50" + : "w-full px-5 py-4 hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50" )} > {isCollapsed ? ( diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index dd673dace..93f358c71 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -122,7 +122,7 @@ function CollapsibleNavItem({ "px-3 py-1.5", isActive ? "bg-sidebar-accent font-medium" - : "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground", + : "text-muted-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 hover:text-foreground", isDisabled && "cursor-not-allowed opacity-60" )} disabled={isDisabled} @@ -257,7 +257,7 @@ function CollapsedNavItemWithPopover({ "flex items-center rounded-md transition-colors px-2 py-2 justify-center w-full", isActive || isChildActive ? "bg-sidebar-accent font-medium" - : "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground", + : "text-muted-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 hover:text-foreground", isDisabled && "cursor-not-allowed opacity-60" )} @@ -451,7 +451,7 @@ export function SidebarNav({ isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5", isActive ? "bg-sidebar-accent font-medium" - : "text-muted-foreground hover:bg-sidebar-accent/80 dark:hover:bg-sidebar-accent/50 hover:text-foreground", + : "text-muted-foreground hover:bg-sidebar-accent dark:hover:bg-sidebar-accent/50 hover:text-foreground", isDisabled && "cursor-not-allowed opacity-60" )} onClick={(e) => { diff --git a/src/components/SwitchInput.tsx b/src/components/SwitchInput.tsx index 25fedb74d..a33d00ffe 100644 --- a/src/components/SwitchInput.tsx +++ b/src/components/SwitchInput.tsx @@ -45,6 +45,7 @@ export function SwitchInput({ return (
+ {label && } - {label && } {info && ( From 4c000c1d4982603e27d0629a9969517bb5024604 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 22 Apr 2026 14:33:28 -0700 Subject: [PATCH 010/107] add site online indicator to selector --- src/components/multi-site-selector.tsx | 13 +++++-- src/components/site-selector.tsx | 47 ++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/components/multi-site-selector.tsx b/src/components/multi-site-selector.tsx index 407e3b3e1..c06f6c74a 100644 --- a/src/components/multi-site-selector.tsx +++ b/src/components/multi-site-selector.tsx @@ -12,7 +12,7 @@ import { import { Checkbox } from "./ui/checkbox"; import { useTranslations } from "next-intl"; import { useDebounce } from "use-debounce"; -import type { Selectedsite } from "./site-selector"; +import { SiteOnlineStatus, type Selectedsite } from "./site-selector"; export type MultiSitesSelectorProps = { orgId: string; @@ -107,7 +107,16 @@ export function MultiSitesSelector({ aria-hidden tabIndex={-1} /> - {site.name} +
+ + {site.name} + + +
))} diff --git a/src/components/site-selector.tsx b/src/components/site-selector.tsx index db23362dc..1c6302d98 100644 --- a/src/components/site-selector.tsx +++ b/src/components/site-selector.tsx @@ -18,7 +18,41 @@ import { useDebounce } from "use-debounce"; export type Selectedsite = Pick< ListSitesResponse["sites"][number], "name" | "siteId" | "type" ->; +> & { + /** When omitted, no online/offline indicator is shown. */ + online?: ListSitesResponse["sites"][number]["online"]; +}; + +type SiteOnlineStatusProps = { + type: Selectedsite["type"]; + online: Selectedsite["online"]; + t: (key: "online" | "offline") => string; +}; + +/** Dot-only indicator matching `SitesTable` colors (newt/wireguard only; nothing for local or missing status). */ +export function SiteOnlineStatus({ type, online, t }: SiteOnlineStatusProps) { + if (type !== "newt" && type !== "wireguard") { + return null; + } + if (typeof online !== "boolean") { + return null; + } + return ( + +
+ + ); +} export type SitesSelectorProps = { orgId: string; @@ -86,7 +120,16 @@ export function SitesSelector({ : "opacity-0" )} /> - {site.name} +
+ + {site.name} + + +
))} From 2a281ec002203dd1f9b59ab7639eb242267e2324 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 22 Apr 2026 15:06:37 -0700 Subject: [PATCH 011/107] update telemetry --- server/lib/telemetry.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index 8d341bf1d..55c01c6b1 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -2,7 +2,7 @@ import { PostHog } from "posthog-node"; import config from "./config"; import { getHostMeta } from "./hostMeta"; import logger from "@server/logger"; -import { apiKeys, db, roles, siteResources } from "@server/db"; +import { alertRules, apiKeys, blueprints, db, roles, siteResources } from "@server/db"; import { sites, users, orgs, resources, clients, idp } from "@server/db"; import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm"; import { APP_VERSION } from "./consts"; @@ -15,6 +15,7 @@ class TelemetryClient { private client: PostHog | null = null; private enabled: boolean; private intervalId: NodeJS.Timeout | null = null; + private collectionIntervalDays = 14; constructor() { const enabled = config.getRawConfig().app.telemetry.anonymous_usage; @@ -33,7 +34,7 @@ class TelemetryClient { this.client = new PostHog( "phc_QYuATSSZt6onzssWcYJbXLzQwnunIpdGGDTYhzK3VjX", { - host: "https://pangolin.net/relay-O7yI" + host: "https://telemetry.fossorial.io/relay-O7yI" } ); @@ -72,7 +73,7 @@ class TelemetryClient { logger.debug("Successfully sent analytics data"); }); }, - 336 * 60 * 60 * 1000 + this.collectionIntervalDays * 24 * 60 * 60 * 1000 // Convert days to milliseconds ); this.collectAndSendAnalytics().catch((err) => { @@ -157,6 +158,14 @@ class TelemetryClient { }) .from(sites); + const [numAlertRules] = await db + .select({ count: count() }) + .from(alertRules); + + const [blueprintsCount] = await db + .select({ count: count() }) + .from(blueprints); + const supporterKey = config.getSupporterData(); const allPrivateResources = await db.select().from(siteResources); @@ -165,11 +174,14 @@ class TelemetryClient { let numPrivResourceAliases = 0; let numPrivResourceHosts = 0; let numPrivResourceCidr = 0; + let numPrivResourceHttp = 0; for (const res of allPrivateResources) { if (res.mode === "host") { numPrivResourceHosts += 1; } else if (res.mode === "cidr") { numPrivResourceCidr += 1; + } else if (res.mode === "http") { + numPrivResourceHttp += 1; } if (res.alias) { @@ -187,6 +199,9 @@ class TelemetryClient { numPrivateResources: numPrivResources, numPrivateResourceAliases: numPrivResourceAliases, numPrivateResourceHosts: numPrivResourceHosts, + numPrivateResourceCidr: numPrivResourceCidr, + numPrivateResourceHttp: numPrivResourceHttp, + numAlertRules: numAlertRules.count, numUserDevices: userDevicesCount.count, numMachineClients: machineClients.count, numIdentityProviders: idpCount.count, @@ -197,6 +212,7 @@ class TelemetryClient { appVersion: APP_VERSION, numApiKeys: numApiKeys.count, numCustomRoles: customRoles.count, + numBlueprints: blueprintsCount.count, supporterStatus: { valid: supporterKey?.valid || false, tier: supporterKey?.tier || "None", @@ -285,10 +301,12 @@ class TelemetryClient { num_private_resource_aliases: stats.numPrivateResourceAliases, num_private_resource_hosts: stats.numPrivateResourceHosts, + num_private_resource_cidr: stats.numPrivateResourceCidr, num_user_devices: stats.numUserDevices, num_machine_clients: stats.numMachineClients, num_identity_providers: stats.numIdentityProviders, num_sites_online: stats.numSitesOnline, + num_blueprint_runs: stats.numBlueprints, num_resources_sso_enabled: stats.resources.filter( (r) => r.sso ).length, From c956e0d401cfebed577647d2147e6528182cef19 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 22 Apr 2026 16:09:16 -0700 Subject: [PATCH 012/107] add meta titles to auth pages --- src/app/auth/2fa/setup/layout.tsx | 13 +++++++++++++ src/app/auth/delete-account/page.tsx | 5 +++++ src/app/auth/idp/[idpId]/oidc/callback/page.tsx | 5 +++++ src/app/auth/initial-setup/layout.tsx | 5 +++++ src/app/auth/layout.tsx | 5 ++++- src/app/auth/login/device/page.tsx | 5 +++++ src/app/auth/login/device/success/layout.tsx | 13 +++++++++++++ src/app/auth/login/page.tsx | 5 +++++ src/app/auth/org/[orgId]/page.tsx | 5 +++++ src/app/auth/org/page.tsx | 5 +++++ src/app/auth/reset-password/page.tsx | 5 +++++ src/app/auth/resource/[resourceGuid]/page.tsx | 5 +++++ src/app/auth/signup/page.tsx | 5 +++++ src/app/auth/verify-email/page.tsx | 5 +++++ 14 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/app/auth/2fa/setup/layout.tsx create mode 100644 src/app/auth/login/device/success/layout.tsx diff --git a/src/app/auth/2fa/setup/layout.tsx b/src/app/auth/2fa/setup/layout.tsx new file mode 100644 index 000000000..db9a76aa4 --- /dev/null +++ b/src/app/auth/2fa/setup/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Set Up 2FA" +}; + +export default function TwoFactorSetupLayout({ + children +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/src/app/auth/delete-account/page.tsx b/src/app/auth/delete-account/page.tsx index 5cbc8d738..4a67f268e 100644 --- a/src/app/auth/delete-account/page.tsx +++ b/src/app/auth/delete-account/page.tsx @@ -5,6 +5,11 @@ import { cache } from "react"; import DeleteAccountClient from "./DeleteAccountClient"; import { getTranslations } from "next-intl/server"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Delete Account" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx index 7b3ccabfc..12d161a1b 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx @@ -8,6 +8,11 @@ import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; import { LoadLoginPageResponse } from "@server/routers/loginPage/types"; import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Complete Login" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/initial-setup/layout.tsx b/src/app/auth/initial-setup/layout.tsx index 8407f0da6..590f24431 100644 --- a/src/app/auth/initial-setup/layout.tsx +++ b/src/app/auth/initial-setup/layout.tsx @@ -3,6 +3,11 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { InitialSetupCompleteResponse } from "@server/routers/auth"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Initial Setup" +}; export default async function Layout(props: { children: React.ReactNode }) { const setupRes = await internal.get< diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 2cd8b4876..223c4e26c 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -10,7 +10,10 @@ import { getTranslations } from "next-intl/server"; import { cache } from "react"; export const metadata: Metadata = { - title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, + title: { + template: `%s - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, + default: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}` + }, description: "" }; diff --git a/src/app/auth/login/device/page.tsx b/src/app/auth/login/device/page.tsx index 01c23c999..0fb77da89 100644 --- a/src/app/auth/login/device/page.tsx +++ b/src/app/auth/login/device/page.tsx @@ -4,6 +4,11 @@ import DeviceLoginForm from "@/components/DeviceLoginForm"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { cache } from "react"; import { cleanRedirect } from "@app/lib/cleanRedirect"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Authorize Device" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/login/device/success/layout.tsx b/src/app/auth/login/device/success/layout.tsx new file mode 100644 index 000000000..53a3c8af6 --- /dev/null +++ b/src/app/auth/login/device/success/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Device Authorized" +}; + +export default function DeviceAuthSuccessLayout({ + children +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index bfb552df4..9b1639925 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -17,6 +17,11 @@ import { priv } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { LoginFormIDP } from "@app/components/LoginForm"; import { ListIdpsResponse } from "@server/routers/idp"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Log In" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/org/[orgId]/page.tsx b/src/app/auth/org/[orgId]/page.tsx index f68efa712..2329d5b7c 100644 --- a/src/app/auth/org/[orgId]/page.tsx +++ b/src/app/auth/org/[orgId]/page.tsx @@ -12,6 +12,11 @@ import { import { redirect } from "next/navigation"; import OrgLoginPage from "@app/components/OrgLoginPage"; import { pullEnv } from "@app/lib/pullEnv"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Organization Login" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/org/page.tsx b/src/app/auth/org/page.tsx index 3af4f7d6c..70ea0611b 100644 --- a/src/app/auth/org/page.tsx +++ b/src/app/auth/org/page.tsx @@ -18,6 +18,11 @@ import ValidateSessionTransferToken from "@app/components/ValidateSessionTransfe import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; import { OrgSelectionForm } from "@app/components/OrgSelectionForm"; import OrgLoginPage from "@app/components/OrgLoginPage"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Choose Organization" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index 1245ca09c..18fbcf432 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -7,6 +7,11 @@ import { cleanRedirect } from "@app/lib/cleanRedirect"; import { getTranslations } from "next-intl/server"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Reset Password" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index 919dfbd81..f22a59d6b 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -27,6 +27,11 @@ import { CheckOrgUserAccessResponse } from "@server/routers/org"; import OrgPolicyRequired from "@app/components/OrgPolicyRequired"; import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Resource Access" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index f51ac9049..be138c45d 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -7,6 +7,11 @@ import Link from "next/link"; import { redirect } from "next/navigation"; import { cache } from "react"; import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create Account" +}; export const dynamic = "force-dynamic"; diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index e4428370b..bb844057c 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -4,6 +4,11 @@ import { cleanRedirect } from "@app/lib/cleanRedirect"; import { pullEnv } from "@app/lib/pullEnv"; import { redirect } from "next/navigation"; import { cache } from "react"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Verify Email" +}; export const dynamic = "force-dynamic"; From af5394d4649cb3a4d2f98196ef6072f1311d489d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 14:38:47 -0700 Subject: [PATCH 013/107] Add more information about caches --- messages/en-US.json | 1 + src/app/private-maintenance-screen/page.tsx | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 3b4372361..378725468 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2908,6 +2908,7 @@ "maintenancePageTimeTitle": "Estimated Completion Time (Optional)", "privateMaintenanceScreenTitle": "Private Placeholder Screen", "privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.", + "privateMaintenanceScreenSteps": "Once connected, if you are still seeing this message your browser's DNS cache may still point to the old address. To fix this: fully close and reopen your browser, then navigate back to this page.", "maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM", "maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed", "editDomain": "Edit Domain", diff --git a/src/app/private-maintenance-screen/page.tsx b/src/app/private-maintenance-screen/page.tsx index 21417b6f4..3f7959206 100644 --- a/src/app/private-maintenance-screen/page.tsx +++ b/src/app/private-maintenance-screen/page.tsx @@ -18,6 +18,7 @@ export default async function MaintenanceScreen() { let title = t("privateMaintenanceScreenTitle"); let message = t("privateMaintenanceScreenMessage"); + let steps = t("privateMaintenanceScreenSteps"); return (
@@ -25,7 +26,16 @@ export default async function MaintenanceScreen() { {title} - {message} + +

{message}

+

{steps}

+ + {t("learnMore")} + +
); From 9d0a8ecb0904345d58a60e12791fba18daf7a5b6 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 16:48:33 -0700 Subject: [PATCH 014/107] Update placeholder and handle wildcard certs --- messages/en-US.json | 2 +- server/private/lib/acmeCertSync.ts | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 378725468..e85eff9e7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2908,7 +2908,7 @@ "maintenancePageTimeTitle": "Estimated Completion Time (Optional)", "privateMaintenanceScreenTitle": "Private Placeholder Screen", "privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.", - "privateMaintenanceScreenSteps": "Once connected, if you are still seeing this message your browser's DNS cache may still point to the old address. To fix this: fully close and reopen your browser, then navigate back to this page.", + "privateMaintenanceScreenSteps": "Once connected, if you are still seeing this message your browser's DNS cache may still point to the old address. To fix this: fully close and reopen this tab, or your browser, then navigate back to this page.", "maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM", "maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed", "editDomain": "Edit Domain", diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 62a18b805..014a5959b 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -279,7 +279,11 @@ async function syncAcmeCerts( } for (const cert of resolverData.Certificates) { - const domain = cert.domain?.main; + const rawDomain = cert.domain?.main; + const domain = rawDomain.startsWith("*.") + ? rawDomain.slice(2) + : rawDomain; + const wildcard = rawDomain.startsWith("*."); if (!domain) { logger.debug(`acmeCertSync: skipping cert with missing domain`); @@ -309,7 +313,12 @@ async function syncAcmeCerts( const existing = await db .select() .from(certificates) - .where(eq(certificates.domain, domain)) + .where( + and( + eq(certificates.domain, domain), + eq(certificates.wildcard, wildcard) + ) + ) .limit(1); let oldCertPem: string | null = null; @@ -364,7 +373,6 @@ async function syncAcmeCerts( } } - const wildcard = domain.startsWith("*."); const encryptedCert = encrypt( certPem, config.getRawConfig().server.secret! From d463a578c205dc328ed9f027ed2de9dd55748de6 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 17:06:22 -0700 Subject: [PATCH 015/107] Handle *. wildcard domains in the db --- server/private/lib/acmeCertSync.ts | 7 ++----- server/private/lib/certificates.ts | 28 +++++++++++++++++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 014a5959b..24d612661 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -279,11 +279,8 @@ async function syncAcmeCerts( } for (const cert of resolverData.Certificates) { - const rawDomain = cert.domain?.main; - const domain = rawDomain.startsWith("*.") - ? rawDomain.slice(2) - : rawDomain; - const wildcard = rawDomain.startsWith("*."); + const domain = cert.domain?.main; + const wildcard = domain.startsWith("*."); if (!domain) { logger.debug(`acmeCertSync: skipping cert with missing domain`); diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index af6f6fdaa..844e7b143 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -19,8 +19,6 @@ import { decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; import cache from "#private/lib/cache"; - - // Define the return type for clarity and type safety export type CertificateResult = { id: number; @@ -78,6 +76,9 @@ export async function getValidCertificatesForDomains( const parentDomainsArray = Array.from(parentDomainsToQuery); + // Build wildcard variants: for each parent domain "example.com", also query "*.example.com" + const wildcardPrefixedArray = build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : []; + // 4. Build and execute a single, efficient Drizzle query // This query fetches all potential exact and wildcard matches in one database round-trip. const potentialCerts = await db @@ -91,10 +92,13 @@ export async function getValidCertificatesForDomains( or( // Condition for exact matches on the requested domains inArray(certificates.domain, domainsToQueryArray), - // Condition for wildcard matches on the parent domains + // Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com") parentDomainsArray.length > 0 ? and( - inArray(certificates.domain, parentDomainsArray), + inArray(certificates.domain, [ + ...parentDomainsArray, + ...wildcardPrefixedArray + ]), eq(certificates.wildcard, true) ) : // If there are no possible parent domains, this condition is false @@ -103,13 +107,18 @@ export async function getValidCertificatesForDomains( ) ); + // Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.") + const normalizeWildcardDomain = (domain: string): string => + domain.startsWith("*.") ? domain.slice(2) : domain; + // 5. Process the database results, prioritizing exact matches over wildcards const exactMatches = new Map(); const wildcardMatches = new Map(); for (const cert of potentialCerts) { if (cert.wildcard) { - wildcardMatches.set(cert.domain, cert); + // Normalize to bare parent domain so lookups are consistent regardless of storage format + wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert); } else { exactMatches.set(cert.domain, cert); } @@ -122,14 +131,15 @@ export async function getValidCertificatesForDomains( if (exactMatches.has(domain)) { foundCert = exactMatches.get(domain); } - // Priority 2: Check for a wildcard certificate that matches the exact domain + // Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain else { - if (wildcardMatches.has(domain)) { - foundCert = wildcardMatches.get(domain); + const normalizedDomain = normalizeWildcardDomain(domain); + if (wildcardMatches.has(normalizedDomain)) { + foundCert = wildcardMatches.get(normalizedDomain); } // Priority 3: Check for a wildcard match on the parent domain else { - const parts = domain.split("."); + const parts = normalizedDomain.split("."); if (parts.length > 1) { const parentDomain = parts.slice(1).join("."); if (wildcardMatches.has(parentDomain)) { From 6b83d3c3f1c5072dfa171f97df8f6a96eb3b9944 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 22 Apr 2026 17:27:30 -0700 Subject: [PATCH 016/107] add meta titles to alert pages --- server/private/lib/certificates.ts | 1 + .../[orgId]/settings/alerting/[ruleId]/layout.tsx | 13 +++++++++++++ src/app/[orgId]/settings/alerting/create/layout.tsx | 13 +++++++++++++ 3 files changed, 27 insertions(+) create mode 100644 src/app/[orgId]/settings/alerting/[ruleId]/layout.tsx create mode 100644 src/app/[orgId]/settings/alerting/create/layout.tsx diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 844e7b143..8875addda 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -18,6 +18,7 @@ import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; import cache from "#private/lib/cache"; +import { build } from "@server/build"; // Define the return type for clarity and type safety export type CertificateResult = { diff --git a/src/app/[orgId]/settings/alerting/[ruleId]/layout.tsx b/src/app/[orgId]/settings/alerting/[ruleId]/layout.tsx new file mode 100644 index 000000000..be2188758 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/[ruleId]/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Edit Alert" +}; + +export default function EditAlertRuleLayout({ + children +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/src/app/[orgId]/settings/alerting/create/layout.tsx b/src/app/[orgId]/settings/alerting/create/layout.tsx new file mode 100644 index 000000000..9cb33ac65 --- /dev/null +++ b/src/app/[orgId]/settings/alerting/create/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Create Alert" +}; + +export default function CreateAlertRuleLayout({ + children +}: { + children: React.ReactNode; +}) { + return <>{children}; +} From f651ca84fa31d585a78e745f4cb114222c08e320 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 22 Apr 2026 17:43:29 -0700 Subject: [PATCH 017/107] remove empty table state lines --- .../AlertRuleGraphEditor.tsx | 14 ++-- src/components/ui/controlled-data-table.tsx | 70 ++++++++++--------- src/components/ui/data-table-empty-state.tsx | 5 +- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx index 273e8b637..e1e71507b 100644 --- a/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx +++ b/src/components/alert-rule-editor/AlertRuleGraphEditor.tsx @@ -77,11 +77,11 @@ function VerticalRuleStep({ className="flex flex-col items-center gap-0 shrink-0 w-8" aria-hidden > -
+
{stepNumber}
{!isLast && ( -
+
)}
{isNew && ( - + {t("alertingDraftBadge")} )} @@ -209,7 +209,9 @@ export default function AlertRuleGraphEditor({ render={({ field }) => ( - {t("alertingRuleCooldown")} + {t( + "alertingRuleCooldown" + )} - {t("alertingRuleCooldownDescription")} + {t( + "alertingRuleCooldownDescription" + )} diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index a08479597..58081783a 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -256,31 +256,39 @@ export function ControlledDataTable({ addButtonText && ((addActions && addActions.length > 0) || onAdd) ); const showAddActionInEmptyState = !hasRows && hasAddAction; - const addAction = addActions && addActions.length > 0 && addButtonText ? ( - - - - - - {addActions.map((action, i) => ( - action.onSelect()}> - {action.label} - - ))} - - - ) : onAdd && addButtonText ? ( - - ) : null; + const addAction = + addActions && addActions.length > 0 && addButtonText ? ( + + + + + + {addActions.map((action, i) => ( + action.onSelect()} + > + {action.label} + + ))} + + + ) : onAdd && addButtonText ? ( + + ) : null; return (
@@ -606,13 +614,11 @@ export function ControlledDataTable({ - {addAction} -
- ) - : undefined + showAddActionInEmptyState ? ( +
+ {addAction} +
+ ) : undefined } /> )} diff --git a/src/components/ui/data-table-empty-state.tsx b/src/components/ui/data-table-empty-state.tsx index 793c360f4..e7da09f03 100644 --- a/src/components/ui/data-table-empty-state.tsx +++ b/src/components/ui/data-table-empty-state.tsx @@ -26,10 +26,7 @@ export function DataTableEmptyState({ > {Array.from({ length: PLACEHOLDER_ROW_COUNT }).map( (_, i) => ( -
+
) )}
From 8481b0a07377d7b991d1df0bd57eff0c73a7ad77 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 22 Apr 2026 17:52:31 -0700 Subject: [PATCH 018/107] dont filter admin role in role selector for alerts --- src/components/UptimeAlertSection.tsx | 5 +- .../alert-rule-editor/AlertRuleFields.tsx | 140 +++++++++++------- 2 files changed, 88 insertions(+), 57 deletions(-) diff --git a/src/components/UptimeAlertSection.tsx b/src/components/UptimeAlertSection.tsx index 96a6dc286..e959c985b 100644 --- a/src/components/UptimeAlertSection.tsx +++ b/src/components/UptimeAlertSection.tsx @@ -97,10 +97,7 @@ export default function UptimeAlertSection({ ); const allRoles = useMemo( - () => - orgRoles - .map((r) => ({ id: String(r.roleId), text: r.name })) - .filter((r) => r.text !== "Admin"), + () => orgRoles.map((r) => ({ id: String(r.roleId), text: r.name })), [orgRoles] ); diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index e9056256b..1d420f433 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -30,10 +30,7 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { - RadioGroup, - RadioGroupItem -} from "@app/components/ui/radio-group"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; import { StrategySelect } from "@app/components/StrategySelect"; import { TagInput, type Tag } from "@app/components/tags/tag-input"; @@ -59,7 +56,6 @@ export function AddActionPanel({ }) { const t = useTranslations(); - const EXTERNAL_INTEGRATIONS = [ { id: "pagerduty", @@ -247,9 +243,7 @@ function HealthCheckMultiSelect({ const shown = useMemo(() => { const query = debounced.trim().toLowerCase(); const base = query - ? healthChecks.filter((hc) => - hc.name.toLowerCase().includes(query) - ) + ? healthChecks.filter((hc) => hc.name.toLowerCase().includes(query)) : healthChecks; // Always keep already-selected items visible even if they fall outside the search if (query && value.length > 0) { @@ -323,9 +317,7 @@ function HealthCheckMultiSelect({ aria-hidden tabIndex={-1} /> - - {hc.name} - + {hc.name} ))} @@ -510,8 +502,12 @@ function NotifyActionFields({ number | null >(null); - const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery(orgQueries.users({ orgId })); - const { data: orgRoles = [], isLoading: isLoadingRoles } = useQuery(orgQueries.roles({ orgId })); + const { data: orgUsers = [], isLoading: isLoadingUsers } = useQuery( + orgQueries.users({ orgId }) + ); + const { data: orgRoles = [], isLoading: isLoadingRoles } = useQuery( + orgQueries.roles({ orgId }) + ); const allUsers = useMemo( () => @@ -527,10 +523,7 @@ function NotifyActionFields({ ); const allRoles = useMemo( - () => - orgRoles - .map((r) => ({ id: String(r.roleId), text: r.name })) - .filter((r) => r.text !== "Admin"), + () => orgRoles.map((r) => ({ id: String(r.roleId), text: r.name })), [orgRoles] ); @@ -578,9 +571,18 @@ function NotifyActionFields({ hasResolvedTagsRef.current = true; }, [isLoadingUsers, isLoadingRoles, allUsers, allRoles]); - const userTags = (useWatch({ control, name: `actions.${index}.userTags` }) ?? []) as Tag[]; - const roleTags = (useWatch({ control, name: `actions.${index}.roleTags` }) ?? []) as Tag[]; - const emailTags = (useWatch({ control, name: `actions.${index}.emailTags` }) ?? []) as Tag[]; + const userTags = (useWatch({ + control, + name: `actions.${index}.userTags` + }) ?? []) as Tag[]; + const roleTags = (useWatch({ + control, + name: `actions.${index}.roleTags` + }) ?? []) as Tag[]; + const emailTags = (useWatch({ + control, + name: `actions.${index}.emailTags` + }) ?? []) as Tag[]; return (
@@ -788,7 +790,9 @@ function WebhookActionFields({ {t("httpDestAuthNoneTitle")}

- {t("httpDestAuthNoneDescription")} + {t( + "httpDestAuthNoneDescription" + )}

@@ -806,10 +810,14 @@ function WebhookActionFields({ htmlFor={`auth-bearer-${index}`} className="cursor-pointer font-medium" > - {t("httpDestAuthBearerTitle")} + {t( + "httpDestAuthBearerTitle" + )}

- {t("httpDestAuthBearerDescription")} + {t( + "httpDestAuthBearerDescription" + )}

{field.value === "bearer" && ( @@ -821,7 +829,9 @@ function WebhookActionFields({ @@ -845,10 +855,14 @@ function WebhookActionFields({ htmlFor={`auth-basic-${index}`} className="cursor-pointer font-medium" > - {t("httpDestAuthBasicTitle")} + {t( + "httpDestAuthBasicTitle" + )}

- {t("httpDestAuthBasicDescription")} + {t( + "httpDestAuthBasicDescription" + )}

{field.value === "basic" && ( @@ -860,7 +874,9 @@ function WebhookActionFields({ @@ -884,10 +900,14 @@ function WebhookActionFields({ htmlFor={`auth-custom-${index}`} className="cursor-pointer font-medium" > - {t("httpDestAuthCustomTitle")} + {t( + "httpDestAuthCustomTitle" + )}

- {t("httpDestAuthCustomDescription")} + {t( + "httpDestAuthCustomDescription" + )}

{field.value === "custom" && ( @@ -895,12 +915,16 @@ function WebhookActionFields({ ( + render={({ + field: f + }) => ( @@ -910,12 +934,16 @@ function WebhookActionFields({ ( + render={({ + field: f + }) => ( @@ -949,7 +977,7 @@ function WebhookHeadersField({ }) { const t = useTranslations(); const headers = - (useWatch({ control, name: `actions.${index}.headers` as const }) ?? []); + useWatch({ control, name: `actions.${index}.headers` as const }) ?? []; return (
{t("alertingWebhookHeaders")} @@ -961,7 +989,12 @@ function WebhookHeadersField({ render={({ field }) => ( - + )} @@ -972,7 +1005,12 @@ function WebhookHeadersField({ render={({ field }) => ( - + )} @@ -984,9 +1022,8 @@ function WebhookHeadersField({ className="shrink-0" onClick={() => { const cur = - form.getValues( - `actions.${index}.headers` - ) ?? []; + form.getValues(`actions.${index}.headers`) ?? + []; form.setValue( `actions.${index}.headers`, cur.filter((__, i) => i !== hi), @@ -1005,10 +1042,11 @@ function WebhookHeadersField({ onClick={() => { const cur = form.getValues(`actions.${index}.headers`) ?? []; - form.setValue(`actions.${index}.headers`, [ - ...cur, - { key: "", value: "" } - ], { shouldDirty: true }); + form.setValue( + `actions.${index}.headers`, + [...cur, { key: "", value: "" }], + { shouldDirty: true } + ); }} > @@ -1111,22 +1149,18 @@ export function AlertRuleSourceFields({ curTrigger !== "resource_unhealthy" && curTrigger !== "resource_toggle" ) { - setValue( - "trigger", - "resource_toggle", - { shouldValidate: true } - ); + setValue("trigger", "resource_toggle", { + shouldValidate: true + }); } } else if ( curTrigger !== "health_check_healthy" && curTrigger !== "health_check_unhealthy" && curTrigger !== "health_check_toggle" ) { - setValue( - "trigger", - "health_check_toggle", - { shouldValidate: true } - ); + setValue("trigger", "health_check_toggle", { + shouldValidate: true + }); } }} > From dcbd22b4ad5b21321edf5f4ace65954145f56b0d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 18:03:26 -0700 Subject: [PATCH 019/107] Handle all of the alerting from the functions --- server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 3 +- .../lib/alerts/events/healthCheckEvents.ts | 94 ++++++++++++++++++ .../lib/alerts/events/resourceEvents.ts | 17 ++++ .../private/lib/alerts/events/siteEvents.ts | 42 ++++++++ .../alertEvents/triggerHealthCheckAlert.ts | 8 -- .../alertEvents/triggerResourceAlert.ts | 12 +-- .../routers/alertEvents/triggerSiteAlert.ts | 10 +- .../newt/handleNewtDisconnectingMessage.ts | 9 +- server/routers/newt/offlineChecker.ts | 83 +++++----------- server/routers/newt/pingAccumulator.ts | 9 +- .../target/handleHealthcheckStatusMessage.ts | 99 ++----------------- 12 files changed, 200 insertions(+), 189 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index b61cfcf19..30dfef353 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -157,7 +157,8 @@ export const resources = pgTable("resources", { maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), - postAuthPath: text("postAuthPath") + postAuthPath: text("postAuthPath"), + health: varchar("health") // "healthy", "unhealthy" }); export const targets = pgTable("targets", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index c5600b756..0f0844d2f 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -178,7 +178,8 @@ export const resources = sqliteTable("resources", { maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), - postAuthPath: text("postAuthPath") + postAuthPath: text("postAuthPath"), + health: text("health") // "healthy", "unhealthy" }); export const targets = sqliteTable("targets", { diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index f6ab56ceb..470788ad5 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -13,6 +13,18 @@ import logger from "@server/logger"; import { processAlerts } from "../processAlerts"; +import { + db, + statusHistory, + targetHealthCheck, + targets, + resources +} from "@server/db"; +import { eq } from "drizzle-orm"; +import { + fireResourceHealthyAlert, + fireResourceUnhealthyAlert +} from "./resourceEvents"; // --------------------------------------------------------------------------- // Public API @@ -33,9 +45,20 @@ export async function fireHealthCheckHealthyAlert( orgId: string, healthCheckId: number, healthCheckName?: string | null, + healthCheckTargetId?: number | null, extra?: Record ): Promise { try { + await db.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "healthy", + timestamp: Math.floor(Date.now() / 1000) + }); + + await handleResource(orgId, healthCheckTargetId); + await processAlerts({ eventType: "health_check_healthy", orgId, @@ -78,9 +101,20 @@ export async function fireHealthCheckUnhealthyAlert( orgId: string, healthCheckId: number, healthCheckName?: string | null, + healthCheckTargetId?: number | null, extra?: Record ): Promise { try { + await db.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "unhealthy", + timestamp: Math.floor(Date.now() / 1000) + }); + + await handleResource(orgId, healthCheckTargetId); + await processAlerts({ eventType: "health_check_unhealthy", orgId, @@ -107,3 +141,63 @@ export async function fireHealthCheckUnhealthyAlert( ); } } + +async function handleResource(orgId: string, healthCheckTargetId?: number | null) { + if (!healthCheckTargetId) { + return; + } + // we have resources lets get them + const [target] = await db + .select() + .from(targets) + .where(eq(targets.targetId, healthCheckTargetId)) + .limit(1); + + if (!target) { + return; + } + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, target.resourceId)) + .limit(1); + + if (!resource) { + return; + } + const otherTargets = await db + .select({ hcHealth: targetHealthCheck.hcHealth }) + .from(targets) + .where(eq(targets.resourceId, resource.resourceId)); + + let health = "healthy"; + const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); + if (!allHealthy) { + logger.debug( + `Not marking resource ${resource.resourceId} as healthy because not all targets are healthy` + ); + health = "unhealthy"; + } + + if (health != resource.health) { + // it changed + await db + .update(resources) + .set({ health }) + .where(eq(resources.resourceId, resource.resourceId)); + + if (health === "unhealthy") { + await fireResourceUnhealthyAlert( + orgId, + resource.resourceId, + resource.name + ); + } else if (health === "healthy") { + await fireResourceHealthyAlert( + orgId, + resource.resourceId, + resource.name + ); + } + } +} diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 280b1d0c9..9fd351047 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -13,6 +13,7 @@ import logger from "@server/logger"; import { processAlerts } from "../processAlerts"; +import { db, statusHistory } from "@server/db"; // --------------------------------------------------------------------------- // Public API @@ -36,6 +37,14 @@ export async function fireResourceHealthyAlert( extra?: Record ): Promise { try { + await db.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "healthy", + timestamp: Math.floor(Date.now() / 1000) + }); + await processAlerts({ eventType: "resource_healthy", orgId, @@ -81,6 +90,14 @@ export async function fireResourceUnhealthyAlert( extra?: Record ): Promise { try { + await db.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "unhealthy", + timestamp: Math.floor(Date.now() / 1000) + }); + await processAlerts({ eventType: "resource_unhealthy", orgId, diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index d8531a5b7..66c813ab9 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -13,6 +13,9 @@ import logger from "@server/logger"; import { processAlerts } from "../processAlerts"; +import { db, sites, statusHistory, targetHealthCheck } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents"; // --------------------------------------------------------------------------- // Public API @@ -36,6 +39,14 @@ export async function fireSiteOnlineAlert( extra?: Record ): Promise { try { + await db.insert(statusHistory).values({ + entityType: "site", + entityId: siteId, + orgId: orgId, + status: "online", + timestamp: Math.floor(Date.now() / 1000) + }); + await processAlerts({ eventType: "site_online", orgId, @@ -81,6 +92,37 @@ export async function fireSiteOfflineAlert( extra?: Record ): Promise { try { + await db.insert(statusHistory).values({ + entityType: "site", + entityId: siteId, + orgId: orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000) + }); + + const unhealthyHealthChecks = await db + .update(targetHealthCheck) + .set({ hcHealth: "unhealthy" }) + .where( + and( + eq(targetHealthCheck.orgId, orgId), + eq(targetHealthCheck.siteId, siteId) + ) + ) + .returning(); + + for (const healthCheck of unhealthyHealthChecks) { + logger.info( + `Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline` + ); + + await fireHealthCheckUnhealthyAlert( + healthCheck.orgId, + healthCheck.targetHealthCheckId, + healthCheck.name + ); + } + await processAlerts({ eventType: "site_offline", orgId, diff --git a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts index 94202b0b2..0590c2bcd 100644 --- a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts +++ b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts @@ -91,14 +91,6 @@ export async function triggerHealthCheckAlert( ); } - await db.insert(statusHistory).values({ - entityType: "healthCheck", - entityId: healthCheckId, - orgId, - status: eventType === "health_check_healthy" ? "healthy" : "unhealthy", - timestamp: Math.floor(Date.now() / 1000) - }); - if (eventType === "health_check_healthy") { await fireHealthCheckHealthyAlert( orgId, diff --git a/server/private/routers/alertEvents/triggerResourceAlert.ts b/server/private/routers/alertEvents/triggerResourceAlert.ts index 61b81d900..42e63b288 100644 --- a/server/private/routers/alertEvents/triggerResourceAlert.ts +++ b/server/private/routers/alertEvents/triggerResourceAlert.ts @@ -89,16 +89,6 @@ export async function triggerResourceAlert( ); } - if (eventType === "resource_healthy" || eventType === "resource_unhealthy") { - await db.insert(statusHistory).values({ - entityType: "resource", - entityId: resourceId, - orgId, - status: eventType === "resource_healthy" ? "healthy" : "unhealthy", - timestamp: Math.floor(Date.now() / 1000) - }); - } - if (eventType === "resource_healthy") { await fireResourceHealthyAlert( orgId, @@ -132,4 +122,4 @@ export async function triggerResourceAlert( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/alertEvents/triggerSiteAlert.ts b/server/private/routers/alertEvents/triggerSiteAlert.ts index 084fbc758..a7fa0cafc 100644 --- a/server/private/routers/alertEvents/triggerSiteAlert.ts +++ b/server/private/routers/alertEvents/triggerSiteAlert.ts @@ -83,14 +83,6 @@ export async function triggerSiteAlert( ); } - await db.insert(statusHistory).values({ - entityType: "site", - entityId: siteId, - orgId, - status: eventType === "site_online" ? "online" : "offline", - timestamp: Math.floor(Date.now() / 1000) - }); - if (eventType === "site_online") { await fireSiteOnlineAlert(orgId, siteId, site.name ?? undefined); } else { @@ -110,4 +102,4 @@ export async function triggerSiteAlert( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts index 302738f19..82af2e6a3 100644 --- a/server/routers/newt/handleNewtDisconnectingMessage.ts +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -1,5 +1,12 @@ import { MessageHandler } from "@server/routers/ws"; -import { db, Newt, sites } from "@server/db"; +import { + db, + Newt, + sites, + statusHistory, + targetHealthCheck, + targets +} from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; import { fireSiteOfflineAlert } from "@server/lib/alerts"; diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts index 426d80323..fe6253581 100644 --- a/server/routers/newt/offlineChecker.ts +++ b/server/routers/newt/offlineChecker.ts @@ -1,8 +1,13 @@ -import { db, newts, sites, targetHealthCheck, targets, statusHistory } from "@server/db"; import { - hasActiveConnections, -} from "#dynamic/routers/ws"; -import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm"; + db, + newts, + sites, + targetHealthCheck, + targets, + statusHistory +} from "@server/db"; +import { hasActiveConnections } from "#dynamic/routers/ws"; +import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "#dynamic/lib/alerts"; @@ -77,43 +82,11 @@ export const startNewtOfflineChecker = (): void => { .set({ online: false }) .where(eq(sites.siteId, staleSite.siteId)); - await db.insert(statusHistory).values({ - entityType: "site", - entityId: staleSite.siteId, - orgId: staleSite.orgId, - status: "offline", - timestamp: Math.floor(Date.now() / 1000), - }).execute(); - - const healthChecksOnSite = await db - .select() - .from(targetHealthCheck) - .innerJoin( - targets, - eq(targets.targetId, targetHealthCheck.targetId) - ) - .innerJoin(sites, eq(sites.siteId, targets.siteId)) - .where(eq(sites.siteId, staleSite.siteId)); - - for (const healthCheck of healthChecksOnSite) { - logger.info( - `Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline` - ); - await db - .update(targetHealthCheck) - .set({ hcHealth: "unknown" }) - .where( - eq( - targetHealthCheck.targetHealthCheckId, - healthCheck.targetHealthCheck - .targetHealthCheckId - ) - ); - - // TODO: should we be firing an alert here when the health check goes to unknown? - } - - await fireSiteOfflineAlert(staleSite.orgId, staleSite.siteId, staleSite.name); + await fireSiteOfflineAlert( + staleSite.orgId, + staleSite.siteId, + staleSite.name + ); } // this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites @@ -155,15 +128,11 @@ export const startNewtOfflineChecker = (): void => { .set({ online: false }) .where(eq(sites.siteId, site.siteId)); - await db.insert(statusHistory).values({ - entityType: "site", - entityId: site.siteId, - orgId: site.orgId, - status: "offline", - timestamp: Math.floor(Date.now() / 1000), - }).execute(); - - await fireSiteOfflineAlert(site.orgId, site.siteId, site.name); + await fireSiteOfflineAlert( + site.orgId, + site.siteId, + site.name + ); } else if ( lastBandwidthUpdate >= wireguardOfflineThreshold && !site.online @@ -177,15 +146,11 @@ export const startNewtOfflineChecker = (): void => { .set({ online: true }) .where(eq(sites.siteId, site.siteId)); - await db.insert(statusHistory).values({ - entityType: "site", - entityId: site.siteId, - orgId: site.orgId, - status: "online", - timestamp: Math.floor(Date.now() / 1000), - }).execute(); - - await fireSiteOnlineAlert(site.orgId, site.siteId, site.name); + await fireSiteOnlineAlert( + site.orgId, + site.siteId, + site.name + ); } } } catch (error) { diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index 9b2f04c8e..70d8afa9f 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -1,5 +1,5 @@ import { db } from "@server/db"; -import { sites, clients, olms, statusHistory } from "@server/db"; +import { sites, clients, olms } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { fireSiteOnlineAlert } from "#dynamic/lib/alerts"; @@ -147,13 +147,6 @@ async function flushSitePingsToDb(): Promise { }, "flushSitePingsToDb"); for (const site of newlyOnlineSites) { - await db.insert(statusHistory).values({ - entityType: "site", - entityId: site.siteId, - orgId: site.orgId, - status: "online", - timestamp: Math.floor(Date.now() / 1000), - }).execute(); await fireSiteOnlineAlert(site.orgId, site.siteId, site.name); } } catch (error) { diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 2f5a167c8..c6f2863f8 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -94,26 +94,13 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( const [targetCheck] = await db .select({ - targetId: targets.targetId, - siteId: targets.siteId, + targetId: targetHealthCheck.targetId, orgId: targetHealthCheck.orgId, targetHealthCheckId: targetHealthCheck.targetHealthCheckId, - resourceOrgId: resources.orgId, - resourceId: resources.resourceId, - resourceName: resources.name, name: targetHealthCheck.name, hcHealth: targetHealthCheck.hcHealth }) .from(targetHealthCheck) - .innerJoin(sites, eq(targetHealthCheck.siteId, sites.siteId)) - .innerJoin( - targets, - eq(targetHealthCheck.targetId, targets.targetId) - ) - .innerJoin( - resources, - eq(targets.resourceId, resources.resourceId) - ) .where( and( eq(targetHealthCheck.targetHealthCheckId, targetIdNum), @@ -147,92 +134,22 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( | "healthy" | "unhealthy" }) - .where(eq(targetHealthCheck.targetId, targetCheck.targetId)); - - const orgId = targetCheck.orgId || targetCheck.resourceOrgId; // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId - if (!orgId) { - logger.warn( - `No org ID found for target ${targetId}, skipping status history logging` - ); - continue; - } - - // Log the state change to status history - await db.insert(statusHistory).values({ - entityType: "healthCheck", - entityId: targetCheck.targetHealthCheckId, - orgId: orgId, - status: healthStatus.status, - timestamp: Math.floor(Date.now() / 1000) - }); - - if (targetCheck.resourceId) { - // Log the state change to status history for the resource as well - // so we can show the resource status along with the site - - // if the status is healthy we should check if ALL of the targets on the resource are currently healthy and if not then dont mark the resource as healthy yet, we want to wait until all targets are healthy to mark the resource as healthy - let status = healthStatus.status; - if (healthStatus.status === "healthy") { - const otherTargets = await db - .select({ hcHealth: targetHealthCheck.hcHealth }) - .from(targets) - .innerJoin( - targetHealthCheck, - eq(targets.targetId, targetHealthCheck.targetId) - ) - .where( - and( - eq(targets.resourceId, targetCheck.resourceId), - ne(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated - ) - ); - - const allHealthy = otherTargets.every( - (t) => t.hcHealth === "healthy" - ); - if (!allHealthy) { - logger.debug( - `Not marking resource ${targetCheck.resourceId} as healthy because not all targets are healthy` - ); - status = "unhealthy"; - } - } - - await db.insert(statusHistory).values({ - entityType: "resource", - entityId: targetCheck.resourceId, - orgId: orgId, - status: status, - timestamp: Math.floor(Date.now() / 1000) - }); - - if (status === "unhealthy") { - await fireResourceUnhealthyAlert( - orgId, - targetCheck.resourceId, - targetCheck.resourceName - ); - } else if (status === "healthy") { - await fireResourceHealthyAlert( - orgId, - targetCheck.resourceId, - targetCheck.resourceName - ); - } - } + .where(eq(targetHealthCheck.targetHealthCheckId, targetCheck.targetHealthCheckId)); // because we are checking above if there was a change we can fire the alert here because it changed if (healthStatus.status === "unhealthy") { await fireHealthCheckUnhealthyAlert( - orgId, + targetCheck.orgId, targetCheck.targetHealthCheckId, - targetCheck.name ?? undefined + targetCheck.name ?? undefined, + targetCheck.targetId ); } else if (healthStatus.status === "healthy") { await fireHealthCheckHealthyAlert( - orgId, + targetCheck.orgId, targetCheck.targetHealthCheckId, - targetCheck.name ?? undefined + targetCheck.name ?? undefined, + targetCheck.targetId ); } From 245755a140b6c1b442a5a59ae509da864222ff27 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 18:13:02 -0700 Subject: [PATCH 020/107] Use transactions --- server/lib/alerts/events/healthCheckEvents.ts | 10 ++- server/lib/alerts/events/resourceEvents.ts | 11 ++-- server/lib/alerts/events/siteEvents.ts | 8 ++- .../lib/alerts/events/healthCheckEvents.ts | 35 ++++++---- .../lib/alerts/events/resourceEvents.ts | 15 +++-- .../private/lib/alerts/events/siteEvents.ts | 19 ++++-- .../newt/handleNewtDisconnectingMessage.ts | 23 ++++--- server/routers/newt/offlineChecker.ts | 66 +++++++++++-------- server/routers/newt/pingAccumulator.ts | 4 +- .../target/handleHealthcheckStatusMessage.ts | 63 +++++++++--------- 10 files changed, 147 insertions(+), 107 deletions(-) diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts index 500c535c9..ba79feb4b 100644 --- a/server/lib/alerts/events/healthCheckEvents.ts +++ b/server/lib/alerts/events/healthCheckEvents.ts @@ -4,7 +4,9 @@ export async function fireHealthCheckHealthyAlert( orgId: string, healthCheckId: number, healthCheckName?: string, - extra?: Record + healthCheckTargetId?: number | null, + extra?: Record, + trx?: unknown ): Promise { return; } @@ -13,7 +15,9 @@ export async function fireHealthCheckUnhealthyAlert( orgId: string, healthCheckId: number, healthCheckName?: string, - extra?: Record + healthCheckTargetId?: number | null, + extra?: Record, + trx?: unknown ): Promise { return; -} +} \ No newline at end of file diff --git a/server/lib/alerts/events/resourceEvents.ts b/server/lib/alerts/events/resourceEvents.ts index 2e90057d1..09dd7d8cf 100644 --- a/server/lib/alerts/events/resourceEvents.ts +++ b/server/lib/alerts/events/resourceEvents.ts @@ -2,19 +2,22 @@ export async function fireResourceHealthyAlert( orgId: string, resourceId: number, resourceName?: string | null, - extra?: Record + extra?: Record, + trx?: unknown ): Promise {} export async function fireResourceUnhealthyAlert( orgId: string, resourceId: number, resourceName?: string | null, - extra?: Record + extra?: Record, + trx?: unknown ): Promise {} export async function fireResourceToggleAlert( orgId: string, resourceId: number, resourceName?: string | null, - extra?: Record -): Promise {} + extra?: Record, + trx?: unknown +): Promise {} \ No newline at end of file diff --git a/server/lib/alerts/events/siteEvents.ts b/server/lib/alerts/events/siteEvents.ts index 8426fa9c2..1e96951cc 100644 --- a/server/lib/alerts/events/siteEvents.ts +++ b/server/lib/alerts/events/siteEvents.ts @@ -4,7 +4,8 @@ export async function fireSiteOnlineAlert( orgId: string, siteId: number, siteName?: string, - extra?: Record + extra?: Record, + trx?: unknown ): Promise { return; } @@ -13,7 +14,8 @@ export async function fireSiteOfflineAlert( orgId: string, siteId: number, siteName?: string, - extra?: Record + extra?: Record, + trx?: unknown ): Promise { return; -} +} \ No newline at end of file diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 470788ad5..c2ba25b28 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -18,7 +18,8 @@ import { statusHistory, targetHealthCheck, targets, - resources + resources, + Transaction } from "@server/db"; import { eq } from "drizzle-orm"; import { @@ -46,10 +47,11 @@ export async function fireHealthCheckHealthyAlert( healthCheckId: number, healthCheckName?: string | null, healthCheckTargetId?: number | null, - extra?: Record + extra?: Record, + trx: Transaction | typeof db = db ): Promise { try { - await db.insert(statusHistory).values({ + await trx.insert(statusHistory).values({ entityType: "health_check", entityId: healthCheckId, orgId: orgId, @@ -57,7 +59,7 @@ export async function fireHealthCheckHealthyAlert( timestamp: Math.floor(Date.now() / 1000) }); - await handleResource(orgId, healthCheckTargetId); + await handleResource(orgId, healthCheckTargetId, trx); await processAlerts({ eventType: "health_check_healthy", @@ -102,10 +104,11 @@ export async function fireHealthCheckUnhealthyAlert( healthCheckId: number, healthCheckName?: string | null, healthCheckTargetId?: number | null, - extra?: Record + extra?: Record, + trx: Transaction | typeof db = db ): Promise { try { - await db.insert(statusHistory).values({ + await trx.insert(statusHistory).values({ entityType: "health_check", entityId: healthCheckId, orgId: orgId, @@ -113,7 +116,7 @@ export async function fireHealthCheckUnhealthyAlert( timestamp: Math.floor(Date.now() / 1000) }); - await handleResource(orgId, healthCheckTargetId); + await handleResource(orgId, healthCheckTargetId, trx); await processAlerts({ eventType: "health_check_unhealthy", @@ -142,12 +145,12 @@ export async function fireHealthCheckUnhealthyAlert( } } -async function handleResource(orgId: string, healthCheckTargetId?: number | null) { +async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) { if (!healthCheckTargetId) { return; } // we have resources lets get them - const [target] = await db + const [target] = await trx .select() .from(targets) .where(eq(targets.targetId, healthCheckTargetId)) @@ -156,7 +159,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null if (!target) { return; } - const [resource] = await db + const [resource] = await trx .select() .from(resources) .where(eq(resources.resourceId, target.resourceId)) @@ -165,7 +168,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null if (!resource) { return; } - const otherTargets = await db + const otherTargets = await trx .select({ hcHealth: targetHealthCheck.hcHealth }) .from(targets) .where(eq(targets.resourceId, resource.resourceId)); @@ -181,7 +184,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null if (health != resource.health) { // it changed - await db + await trx .update(resources) .set({ health }) .where(eq(resources.resourceId, resource.resourceId)); @@ -190,13 +193,17 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null await fireResourceUnhealthyAlert( orgId, resource.resourceId, - resource.name + resource.name, + undefined, + trx ); } else if (health === "healthy") { await fireResourceHealthyAlert( orgId, resource.resourceId, - resource.name + resource.name, + undefined, + trx ); } } diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 9fd351047..c2d6d3725 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -13,7 +13,7 @@ import logger from "@server/logger"; import { processAlerts } from "../processAlerts"; -import { db, statusHistory } from "@server/db"; +import { db, statusHistory, Transaction } from "@server/db"; // --------------------------------------------------------------------------- // Public API @@ -34,10 +34,11 @@ export async function fireResourceHealthyAlert( orgId: string, resourceId: number, resourceName?: string | null, - extra?: Record + extra?: Record, + trx: Transaction | typeof db = db ): Promise { try { - await db.insert(statusHistory).values({ + await trx.insert(statusHistory).values({ entityType: "resource", entityId: resourceId, orgId: orgId, @@ -87,10 +88,11 @@ export async function fireResourceUnhealthyAlert( orgId: string, resourceId: number, resourceName?: string | null, - extra?: Record + extra?: Record, + trx: Transaction | typeof db = db ): Promise { try { - await db.insert(statusHistory).values({ + await trx.insert(statusHistory).values({ entityType: "resource", entityId: resourceId, orgId: orgId, @@ -140,7 +142,8 @@ export async function fireResourceToggleAlert( orgId: string, resourceId: number, resourceName?: string | null, - extra?: Record + extra?: Record, + trx: Transaction | typeof db = db ): Promise { try { await processAlerts({ diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index 66c813ab9..580e00848 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -13,7 +13,7 @@ import logger from "@server/logger"; import { processAlerts } from "../processAlerts"; -import { db, sites, statusHistory, targetHealthCheck } from "@server/db"; +import { db, sites, statusHistory, targetHealthCheck, Transaction } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents"; @@ -36,10 +36,11 @@ export async function fireSiteOnlineAlert( orgId: string, siteId: number, siteName?: string, - extra?: Record + extra?: Record, + trx: Transaction | typeof db = db ): Promise { try { - await db.insert(statusHistory).values({ + await trx.insert(statusHistory).values({ entityType: "site", entityId: siteId, orgId: orgId, @@ -89,10 +90,11 @@ export async function fireSiteOfflineAlert( orgId: string, siteId: number, siteName?: string, - extra?: Record + extra?: Record, + trx: Transaction | typeof db = db ): Promise { try { - await db.insert(statusHistory).values({ + await trx.insert(statusHistory).values({ entityType: "site", entityId: siteId, orgId: orgId, @@ -100,7 +102,7 @@ export async function fireSiteOfflineAlert( timestamp: Math.floor(Date.now() / 1000) }); - const unhealthyHealthChecks = await db + const unhealthyHealthChecks = await trx .update(targetHealthCheck) .set({ hcHealth: "unhealthy" }) .where( @@ -119,7 +121,10 @@ export async function fireSiteOfflineAlert( await fireHealthCheckUnhealthyAlert( healthCheck.orgId, healthCheck.targetHealthCheckId, - healthCheck.name + healthCheck.name, + undefined, + undefined, + trx ); } diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts index 82af2e6a3..15c7d3662 100644 --- a/server/routers/newt/handleNewtDisconnectingMessage.ts +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -2,10 +2,7 @@ import { MessageHandler } from "@server/routers/ws"; import { db, Newt, - sites, - statusHistory, - targetHealthCheck, - targets + sites } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; @@ -32,15 +29,17 @@ export const handleNewtDisconnectingMessage: MessageHandler = async ( try { // Update the client's last ping timestamp - const [site] = await db - .update(sites) - .set({ - online: false - }) - .where(eq(sites.siteId, newt.siteId)) - .returning(); + await db.transaction(async (trx) => { + const [site] = await trx + .update(sites) + .set({ + online: false + }) + .where(eq(sites.siteId, newt.siteId!)) + .returning(); - await fireSiteOfflineAlert(site.orgId, site.siteId, site.name); + await fireSiteOfflineAlert(site.orgId, site.siteId, site.name, undefined, trx); + }); } catch (error) { logger.error("Error handling disconnecting message", { error }); } diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts index fe6253581..1dc51d5da 100644 --- a/server/routers/newt/offlineChecker.ts +++ b/server/routers/newt/offlineChecker.ts @@ -77,16 +77,20 @@ export const startNewtOfflineChecker = (): void => { `Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection` ); - await db - .update(sites) - .set({ online: false }) - .where(eq(sites.siteId, staleSite.siteId)); + await db.transaction(async (trx) => { + await trx + .update(sites) + .set({ online: false }) + .where(eq(sites.siteId, staleSite.siteId)); - await fireSiteOfflineAlert( - staleSite.orgId, - staleSite.siteId, - staleSite.name - ); + await fireSiteOfflineAlert( + staleSite.orgId, + staleSite.siteId, + staleSite.name, + undefined, + trx + ); + }); } // this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites @@ -123,16 +127,20 @@ export const startNewtOfflineChecker = (): void => { `Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes` ); - await db - .update(sites) - .set({ online: false }) - .where(eq(sites.siteId, site.siteId)); + await db.transaction(async (trx) => { + await trx + .update(sites) + .set({ online: false }) + .where(eq(sites.siteId, site.siteId)); - await fireSiteOfflineAlert( - site.orgId, - site.siteId, - site.name - ); + await fireSiteOfflineAlert( + site.orgId, + site.siteId, + site.name, + undefined, + trx + ); + }); } else if ( lastBandwidthUpdate >= wireguardOfflineThreshold && !site.online @@ -141,16 +149,20 @@ export const startNewtOfflineChecker = (): void => { `Marking wireguard site ${site.siteId} online: recent bandwidth update` ); - await db - .update(sites) - .set({ online: true }) - .where(eq(sites.siteId, site.siteId)); + await db.transaction(async (trx) => { + await trx + .update(sites) + .set({ online: true }) + .where(eq(sites.siteId, site.siteId)); - await fireSiteOnlineAlert( - site.orgId, - site.siteId, - site.name - ); + await fireSiteOnlineAlert( + site.orgId, + site.siteId, + site.name, + undefined, + trx + ); + }); } } } catch (error) { diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts index 70d8afa9f..307565723 100644 --- a/server/routers/newt/pingAccumulator.ts +++ b/server/routers/newt/pingAccumulator.ts @@ -147,7 +147,9 @@ async function flushSitePingsToDb(): Promise { }, "flushSitePingsToDb"); for (const site of newlyOnlineSites) { - await fireSiteOnlineAlert(site.orgId, site.siteId, site.name); + await db.transaction(async (trx) => { + await fireSiteOnlineAlert(site.orgId, site.siteId, site.name, undefined, trx); + }); } } catch (error) { logger.error( diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index c6f2863f8..50331deb8 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -14,10 +14,7 @@ import { fireHealthCheckHealthyAlert, fireHealthCheckUnhealthyAlert } from "#dynamic/lib/alerts"; -import { - fireResourceHealthyAlert, - fireResourceUnhealthyAlert -} from "#dynamic/lib/alerts"; + interface TargetHealthStatus { status: string; @@ -125,33 +122,39 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( continue; } - // Update the target's health status in the database - await db - .update(targetHealthCheck) - .set({ - hcHealth: healthStatus.status as - | "unknown" - | "healthy" - | "unhealthy" - }) - .where(eq(targetHealthCheck.targetHealthCheckId, targetCheck.targetHealthCheckId)); + // Update the target's health status in the database and fire alert in a transaction + await db.transaction(async (trx) => { + await trx + .update(targetHealthCheck) + .set({ + hcHealth: healthStatus.status as + | "unknown" + | "healthy" + | "unhealthy" + }) + .where(eq(targetHealthCheck.targetHealthCheckId, targetCheck.targetHealthCheckId)); - // because we are checking above if there was a change we can fire the alert here because it changed - if (healthStatus.status === "unhealthy") { - await fireHealthCheckUnhealthyAlert( - targetCheck.orgId, - targetCheck.targetHealthCheckId, - targetCheck.name ?? undefined, - targetCheck.targetId - ); - } else if (healthStatus.status === "healthy") { - await fireHealthCheckHealthyAlert( - targetCheck.orgId, - targetCheck.targetHealthCheckId, - targetCheck.name ?? undefined, - targetCheck.targetId - ); - } + // because we are checking above if there was a change we can fire the alert here because it changed + if (healthStatus.status === "unhealthy") { + await fireHealthCheckUnhealthyAlert( + targetCheck.orgId, + targetCheck.targetHealthCheckId, + targetCheck.name ?? undefined, + targetCheck.targetId, + undefined, + trx + ); + } else if (healthStatus.status === "healthy") { + await fireHealthCheckHealthyAlert( + targetCheck.orgId, + targetCheck.targetHealthCheckId, + targetCheck.name ?? undefined, + targetCheck.targetId, + undefined, + trx + ); + } + }); logger.debug( `Updated health status for target ${targetId} to ${healthStatus.status}` From fc69364feba0439c89231a2aace05f0be9e965ea Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 20:36:00 -0700 Subject: [PATCH 021/107] Show cert status --- src/components/InternalResourceForm.tsx | 63 ++++++++++++++++--------- src/components/ResourceInfoBox.tsx | 10 ++-- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 3a693d82b..6d7fd1537 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -54,6 +54,7 @@ import { CaretSortIcon } from "@radix-ui/react-icons"; import { MachinesSelector } from "./machines-selector"; import DomainPicker from "@app/components/DomainPicker"; import { SwitchInput } from "@app/components/SwitchInput"; +import CertificateStatus from "@app/components/CertificateStatus"; // --- Helpers (shared) --- @@ -1072,28 +1073,48 @@ export function InternalResourceForm({ }} />
- ( - - - + ( + + + + + + )} + /> + {variant === "edit" && + resource?.domainId && + httpConfigFullDomain && + form.watch("ssl") && ( +
+ + {t("certificateStatus")}: + + - - - )} - /> +
+ )} +
) : (
diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index 8006612a9..ad3cb5f34 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -30,7 +30,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {/* 4 cols because of the certs */} {t("identifier")} @@ -43,7 +43,10 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { URL - + @@ -133,8 +136,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {/* Certificate Status Column */} {resource.http && resource.domainId && - resource.fullDomain && - env.flags.usePangolinDns && ( + resource.fullDomain && ( {t("certificateStatus", { From 90a2ed2f106c3acda45a57d4f7913c20fcb28a66 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 20:39:04 -0700 Subject: [PATCH 022/107] Create pending cert --- server/routers/siteResource/createSiteResource.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 29fc8c213..242bbf860 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -31,6 +31,8 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { build } from "@server/build"; const createSiteResourceParamsSchema = z.strictObject({ orgId: z.string() @@ -494,6 +496,10 @@ export async function createSiteResource( `Created site resource ${newSiteResource.siteResourceId} for org ${orgId}` ); + if (ssl && mode === "http" && domainId && fullDomain && build != "oss") { + await createCertificate(domainId, fullDomain, db); + } + return response(res, { data: newSiteResource, success: true, From bcb5b7b4a7103708e5f7ad94da52977b09339908 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 20:44:35 -0700 Subject: [PATCH 023/107] Show status in messages --- server/emails/templates/AlertNotification.tsx | 69 ++++++++++++++----- .../lib/alerts/events/healthCheckEvents.ts | 2 + .../lib/alerts/events/resourceEvents.ts | 2 + .../private/lib/alerts/events/siteEvents.ts | 2 + server/private/lib/alerts/sendAlertWebhook.ts | 33 +++++++++ 5 files changed, 91 insertions(+), 17 deletions(-) diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index b540142d2..5542384a9 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -36,8 +36,8 @@ function getEventMeta(eventType: AlertEventType): { heading: string; previewText: string; summary: string; - statusLabel: string; - statusColor: string; + statusLabel: string | null; + statusColor: string | null; } { switch (eventType) { case "site_online": @@ -63,8 +63,8 @@ function getEventMeta(eventType: AlertEventType): { heading: "Site Status Changed", previewText: "A site in your organization has changed status.", summary: "A site in your organization has changed status.", - statusLabel: "Status Changed", - statusColor: "#f59e0b" + statusLabel: null, + statusColor: null }; case "health_check_healthy": return { @@ -93,8 +93,8 @@ function getEventMeta(eventType: AlertEventType): { "A health check in your organization has changed status.", summary: "A health check in your organization has changed status.", - statusLabel: "Status Changed", - statusColor: "#f59e0b" + statusLabel: null, + statusColor: null }; case "resource_healthy": return { @@ -120,8 +120,8 @@ function getEventMeta(eventType: AlertEventType): { previewText: "A resource in your organization has changed status.", summary: "A resource in your organization has changed status.", - statusLabel: "Status Changed", - statusColor: "#f59e0b" + statusLabel: null, + statusColor: null }; default: return { @@ -135,11 +135,26 @@ function getEventMeta(eventType: AlertEventType): { } } +function resolveToggleStatus(status: unknown): { label: string; color: string } { + switch (String(status).toLowerCase()) { + case "online": + return { label: "Online", color: "#16a34a" }; + case "offline": + return { label: "Offline", color: "#dc2626" }; + case "healthy": + return { label: "Healthy", color: "#16a34a" }; + case "unhealthy": + return { label: "Unhealthy", color: "#dc2626" }; + default: + return { label: String(status ?? "Unknown"), color: "#f59e0b" }; + } +} + function formatDataItems( data: Record ): { label: string; value: React.ReactNode }[] { return Object.entries(data) - .filter(([key]) => key !== "orgId") + .filter(([key]) => key !== "orgId" && key !== "status") .map(([key, value]) => ({ label: key .replace(/([A-Z])/g, " $1") @@ -154,16 +169,36 @@ export const AlertNotification = (props: AlertNotificationProps) => { const meta = getEventMeta(eventType); const dataItems = formatDataItems(data); + const isToggle = + eventType === "site_toggle" || + eventType === "health_check_toggle" || + eventType === "resource_toggle"; + + const resolvedStatus = isToggle + ? resolveToggleStatus(data.status) + : meta.statusLabel != null + ? { label: meta.statusLabel, color: meta.statusColor! } + : null; + const allItems: { label: string; value: React.ReactNode }[] = [ { label: "Organization", value: orgId }, - { - label: "Status", - value: ( - - {meta.statusLabel} - - ) - }, + ...(resolvedStatus != null + ? [ + { + label: "Status", + value: ( + + {resolvedStatus.label} + + ) + } + ] + : []), { label: "Time", value: new Date().toUTCString() }, ...dataItems ]; diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index c2ba25b28..4851f08c4 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -76,6 +76,7 @@ export async function fireHealthCheckHealthyAlert( healthCheckId, data: { healthCheckId, + status: "healthy", ...(healthCheckName != null ? { healthCheckName } : {}), ...extra } @@ -133,6 +134,7 @@ export async function fireHealthCheckUnhealthyAlert( healthCheckId, data: { healthCheckId, + status: "unhealthy", ...(healthCheckName != null ? { healthCheckName } : {}), ...extra } diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index c2d6d3725..8c20bc5a1 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -61,6 +61,7 @@ export async function fireResourceHealthyAlert( resourceId, data: { resourceId, + status: "healthy", ...(resourceName != null ? { resourceName } : {}), ...extra } @@ -115,6 +116,7 @@ export async function fireResourceUnhealthyAlert( resourceId, data: { resourceId, + status: "unhealthy", ...(resourceName != null ? { resourceName } : {}), ...extra } diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index 580e00848..562accc18 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -63,6 +63,7 @@ export async function fireSiteOnlineAlert( siteId, data: { siteId, + status: "online", ...(siteName != null ? { siteName } : {}), ...extra } @@ -143,6 +144,7 @@ export async function fireSiteOfflineAlert( siteId, data: { siteId, + status: "offline", ...(siteName != null ? { siteName } : {}), ...extra } diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index 5656026bc..2dd0eb600 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -42,6 +42,7 @@ export async function sendAlertWebhook( const payload = { event: context.eventType, timestamp: new Date().toISOString(), + status: deriveStatus(context.eventType, context.data), data: { orgId: context.orgId, ...context.data @@ -117,6 +118,38 @@ export async function sendAlertWebhook( throw lastError ?? new Error(`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`); } +// --------------------------------------------------------------------------- +// Status derivation +// --------------------------------------------------------------------------- + +function deriveStatus( + eventType: AlertContext["eventType"], + data: Record +): string { + switch (eventType) { + case "site_online": + return "online"; + case "site_offline": + return "offline"; + case "site_toggle": + return String(data.status ?? "unknown"); + case "health_check_healthy": + case "resource_healthy": + return "healthy"; + case "health_check_unhealthy": + case "resource_unhealthy": + return "unhealthy"; + case "health_check_toggle": + case "resource_toggle": + return String(data.status ?? "unknown"); + default: { + const _exhaustive: never = eventType; + void _exhaustive; + return "unknown"; + } + } +} + // --------------------------------------------------------------------------- // Header construction (mirrors HttpLogDestination.buildHeaders) // --------------------------------------------------------------------------- From 230f77118a49da8aa156b4181cf30ddba3de7445 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 22 Apr 2026 21:11:52 -0700 Subject: [PATCH 024/107] Also check when getting the cert --- server/private/routers/certificates/createCertificate.ts | 5 ----- server/private/routers/certificates/getCertificate.ts | 6 ++++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/server/private/routers/certificates/createCertificate.ts b/server/private/routers/certificates/createCertificate.ts index 3aa0c6873..4f7bb7fe8 100644 --- a/server/private/routers/certificates/createCertificate.ts +++ b/server/private/routers/certificates/createCertificate.ts @@ -15,7 +15,6 @@ import { Certificate, certificates, db, domains } from "@server/db"; import logger from "@server/logger"; import { Transaction } from "@server/db"; import { eq, or, and, like } from "drizzle-orm"; -import privateConfig from "#private/lib/config"; /** * Checks if a certificate exists for the given domain. @@ -27,10 +26,6 @@ export async function createCertificate( domain: string, trx: Transaction | typeof db ) { - if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { - return; - } - const [domainRecord] = await trx .select() .from(domains) diff --git a/server/private/routers/certificates/getCertificate.ts b/server/private/routers/certificates/getCertificate.ts index c3a590193..9e434b3e0 100644 --- a/server/private/routers/certificates/getCertificate.ts +++ b/server/private/routers/certificates/getCertificate.ts @@ -41,8 +41,9 @@ async function query(domainId: string, domain: string) { } let existing: any[] = []; - if (domainRecord.type == "ns") { + if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { // the manual "wildcard" domains can have wildcard certs const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; existing = await db .select({ @@ -64,7 +65,8 @@ async function query(domainId: string, domain: string) { eq(certificates.wildcard, true), // only NS domains can have wildcard certs or( eq(certificates.domain, domain), - eq(certificates.domain, domainLevelDown) + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) ) ) ); From a7c731940736d47392bab0bb897d219e87bcaac6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Apr 2026 12:09:22 -0700 Subject: [PATCH 025/107] Deprecated sites should be optional --- server/lib/blueprints/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 913cf31ed..e017a16d1 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -329,7 +329,7 @@ export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), mode: z.enum(["host", "cidr", "http"]), - site: z.string(), // DEPRECATED IN FAVOR OF sites + site: z.string().optional(), // DEPRECATED IN FAVOR OF sites sites: z.array(z.string()).optional().default([]), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), From fa117198a063de954fd5d9eeb7d644ed40152c0e Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Apr 2026 14:05:08 -0700 Subject: [PATCH 026/107] Pass one at getting it into the db --- messages/en-US.json | 2 + server/db/pg/schema/schema.ts | 3 +- server/db/sqlite/schema/schema.ts | 3 +- server/lib/billing/tierMatrix.ts | 6 +- server/lib/blueprints/proxyResources.ts | 30 +++++- server/lib/blueprints/types.ts | 29 ++++++ server/lib/domainUtils.ts | 67 ++++++++++--- server/lib/schemas.ts | 36 +++++++ server/routers/resource/createResource.ts | 29 +++++- server/routers/resource/updateResource.ts | 36 ++++++- src/components/DomainPicker.tsx | 114 +++++++++++++++++++--- src/lib/subdomain-utils.ts | 66 ++++++++++++- 12 files changed, 377 insertions(+), 44 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index e85eff9e7..8218cf8d3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1947,6 +1947,8 @@ "httpMethod": "Scheme", "selectHttpMethod": "Select scheme", "domainPickerSubdomainLabel": "Subdomain", + "domainPickerWildcard": "Wildcard", + "domainPickerWildcardPaidOnly": "Wildcard subdomains are a paid feature. Please upgrade to access this feature.", "domainPickerBaseDomainLabel": "Base Domain", "domainPickerSearchDomains": "Search domains...", "domainPickerNoDomainsFound": "No domains found", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 30dfef353..473d0f852 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -158,7 +158,8 @@ export const resources = pgTable("resources", { maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), postAuthPath: text("postAuthPath"), - health: varchar("health") // "healthy", "unhealthy" + health: varchar("health"), // "healthy", "unhealthy" + wildcard: boolean("wildcard").notNull().default(false) }); export const targets = pgTable("targets", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 0f0844d2f..6a24ac8dd 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -179,7 +179,8 @@ export const resources = sqliteTable("resources", { maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), postAuthPath: text("postAuthPath"), - health: text("health") // "healthy", "unhealthy" + health: text("health"), // "healthy", "unhealthy" + wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false) }); export const targets = sqliteTable("targets", { diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 5ae57c8a7..3393dd324 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -23,7 +23,8 @@ export enum TierFeature { HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces StandaloneHealthChecks = "standaloneHealthChecks", - AlertingRules = "alertingRules" + AlertingRules = "alertingRules", + WildcardSubdomain = "wildcardSubdomain" } export const tierMatrix: Record = { @@ -64,5 +65,6 @@ export const tierMatrix: Record = { [TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"], [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"], - [TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"] + [TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"], + [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"] }; diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 175c8c79f..136fa1545 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -1,5 +1,6 @@ import { domains, + domainNamespaces, orgDomains, Resource, resourceHeaderAuth, @@ -236,6 +237,7 @@ export async function updateProxyResources( fullDomain: http ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, + wildcard: domain ? domain.wildcard : false, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, skipToIdpId: @@ -683,6 +685,7 @@ export async function updateProxyResources( fullDomain: http ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, + wildcard: domain ? domain.wildcard : false, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, skipToIdpId: resourceData.auth?.["auto-login-idp"] || null, @@ -1152,7 +1155,9 @@ async function getDomainId( orgId: string, fullDomain: string, trx: Transaction -): Promise<{ subdomain: string | null; domainId: string } | null> { +): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> { + const isWildcardFullDomain = fullDomain.startsWith("*."); + const possibleDomains = await trx .select() .from(domains) @@ -1165,6 +1170,11 @@ async function getDomainId( } const validDomains = possibleDomains.filter((domain) => { + // Wildcard full-domains are not allowed on CNAME domains + if (isWildcardFullDomain && domain.domains.type === "cname") { + return false; + } + if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { return ( fullDomain === domain.domains.baseDomain || @@ -1182,6 +1192,21 @@ async function getDomainId( const domainSelection = validDomains[0].domains; const baseDomain = domainSelection.baseDomain; + // Wildcard full-domains are not allowed on namespace (provided/free) domains + if (isWildcardFullDomain) { + const [namespaceDomain] = await trx + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainSelection.domainId)) + .limit(1); + + if (namespaceDomain) { + throw new Error( + `Wildcard full-domains are not supported for provided or free domains: ${fullDomain}` + ); + } + } + // remove the base domain of the domain let subdomain = null; if (fullDomain != baseDomain) { @@ -1191,6 +1216,7 @@ async function getDomainId( // Return the first valid domain return { subdomain: subdomain, - domainId: domainSelection.domainId + domainId: domainSelection.domainId, + wildcard: isWildcardFullDomain }; } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index e017a16d1..13f4caa8f 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { portRangeStringSchema } from "@server/lib/ip"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; import { isValidRegionId } from "@server/db/regions"; +import { wildcardSubdomainSchema } from "@server/lib/schemas"; export const SiteSchema = z.object({ name: z.string().min(1).max(100), @@ -319,6 +320,34 @@ export const ResourceSchema = z message: "Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)" } + ) + .refine( + (resource) => { + const fullDomain = resource["full-domain"]; + if (!fullDomain || !fullDomain.includes("*")) return true; + + // A wildcard full-domain must be of the form *.labels.basedomain + // Extract the leftmost label(s) before the first non-wildcard segment. + // e.g. "*.level1.example.com" → subdomain candidate is "*.level1" + // We do this by finding the base domain: everything after the first + // real (non-wildcard) dot-separated segment pair. + // + // Simple rule: split on ".", first token must be "*", rest must be + // valid hostname labels, and there must be at least 2 remaining labels + // (so the full domain has a real base domain). + const parts = fullDomain.split("."); + if (parts[0] !== "*") return false; // * must be the very first label + if (parts.includes("*", 1)) return false; // no further wildcards + if (parts.length < 3) return false; // need at least *.label.tld + + const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/; + return parts.slice(1).every((label) => labelRegex.test(label)); + }, + { + path: ["full-domain"], + message: + 'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.' + } ); export function isTargetsOnlyResource(resource: any): boolean { diff --git a/server/lib/domainUtils.ts b/server/lib/domainUtils.ts index 3562df683..b8dab5e4c 100644 --- a/server/lib/domainUtils.ts +++ b/server/lib/domainUtils.ts @@ -1,7 +1,7 @@ import { db } from "@server/db"; -import { domains, orgDomains } from "@server/db"; +import { domains, orgDomains, domainNamespaces } from "@server/db"; import { eq, and } from "drizzle-orm"; -import { subdomainSchema } from "@server/lib/schemas"; +import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import { fromError } from "zod-validation-error"; export type DomainValidationResult = @@ -9,6 +9,7 @@ export type DomainValidationResult = success: true; fullDomain: string; subdomain: string | null; + wildcard: boolean; } | { success: false; @@ -66,6 +67,47 @@ export async function validateAndConstructDomain( }; } + // Detect wildcard subdomain request + const isWildcard = + subdomain !== undefined && + subdomain !== null && + subdomain.includes("*"); + + // Wildcard subdomains are not allowed on CNAME domains + if (isWildcard && domainRes.domains.type === "cname") { + return { + success: false, + error: "Wildcard subdomains are not supported for CNAME domains. CNAME domains must use a specific hostname." + }; + } + + // Wildcard subdomains are not allowed on namespace (provided/free) domains + if (isWildcard) { + const [namespaceDomain] = await db + .select() + .from(domainNamespaces) + .where(eq(domainNamespaces.domainId, domainId)) + .limit(1); + + if (namespaceDomain) { + return { + success: false, + error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead." + }; + } + } + + // Validate wildcard subdomain format + if (isWildcard) { + const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain); + if (!parsedWildcard.success) { + return { + success: false, + error: fromError(parsedWildcard.error).toString() + }; + } + } + // Construct full domain based on domain type let fullDomain = ""; let finalSubdomain = subdomain; @@ -81,13 +123,15 @@ export async function validateAndConstructDomain( finalSubdomain = null; // CNAME domains don't use subdomains } else if (domainRes.domains.type === "wildcard") { if (subdomain !== undefined && subdomain !== null) { - // Validate subdomain format for wildcard domains - const parsedSubdomain = subdomainSchema.safeParse(subdomain); - if (!parsedSubdomain.success) { - return { - success: false, - error: fromError(parsedSubdomain.error).toString() - }; + if (!isWildcard) { + // Validate regular subdomain format for wildcard domains + const parsedSubdomain = subdomainSchema.safeParse(subdomain); + if (!parsedSubdomain.success) { + return { + success: false, + error: fromError(parsedSubdomain.error).toString() + }; + } } fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; } else { @@ -100,13 +144,14 @@ export async function validateAndConstructDomain( finalSubdomain = null; } - // Convert to lowercase + // Convert to lowercase (preserve * as-is) fullDomain = fullDomain.toLowerCase(); return { success: true, fullDomain, - subdomain: finalSubdomain ?? null + subdomain: isWildcard ? "*" : (finalSubdomain ?? null), + wildcard: isWildcard }; } catch (error) { return { diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index 5e2bd400b..813849d52 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -1,5 +1,41 @@ import { z } from "zod"; +/** + * Validates a wildcard subdomain passed as the leftmost component of a full domain. + * + * The value represents everything to the left of the base domain, so when combined + * with e.g. "example.com" it must produce a valid SSL-style wildcard hostname. + * + * Valid: + * "*" → *.example.com + * "*.level1" → *.level1.example.com + * + * Invalid: + * "*example" → *example.com (no dot after *) + * "level2.*.level1" → wildcard not in leftmost position + * "*.level1.*" → multiple wildcards + */ +export const wildcardSubdomainSchema = z + .string() + .refine( + (val) => { + // Must start with "*."; the remainder (if any) must be valid hostname labels. + // A bare "*" is also valid (becomes *.baseDomain directly). + if (val === "*") return true; + if (!val.startsWith("*.")) return false; + const rest = val.slice(2); // everything after "*." + // rest must not be empty, must not contain another "*", + // and every label must be a valid hostname label. + if (!rest || rest.includes("*")) return false; + const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/; + return rest.split(".").every((label) => labelRegex.test(label)); + }, + { + message: + 'Invalid wildcard subdomain. The wildcard "*" must be the leftmost label followed by a dot and valid hostname labels (e.g. "*" or "*.level1"). Patterns like "*example", "level2.*.level1", or multiple wildcards are not supported.' + } + ); + export const subdomainSchema = z .string() .regex( diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index f026166a6..d3ace5adc 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -17,7 +17,7 @@ import createHttpError from "http-errors"; import { eq, and } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { subdomainSchema } from "@server/lib/schemas"; +import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; @@ -25,6 +25,7 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica import { getUniqueResourceName } from "@server/db/names"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; const createResourceParamsSchema = z.strictObject({ @@ -44,7 +45,10 @@ const createHttpResourceSchema = z .refine( (data) => { if (data.subdomain) { - return subdomainSchema.safeParse(data.subdomain).success; + return ( + subdomainSchema.safeParse(data.subdomain).success || + wildcardSubdomainSchema.safeParse(data.subdomain).success + ); } return true; }, @@ -198,6 +202,22 @@ async function createHttpResource( const subdomain = parsedBody.data.subdomain; const stickySession = parsedBody.data.stickySession; + // Wildcard subdomains are a paid feature + if (subdomain && subdomain.includes("*")) { + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.wildcardSubdomain + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + } + if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) { // grandfather in existing users const lastAllowedDate = new Date("2026-04-13"); @@ -232,7 +252,7 @@ async function createHttpResource( return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error)); } - const { fullDomain, subdomain: finalSubdomain } = domainResult; + const { fullDomain, subdomain: finalSubdomain, wildcard } = domainResult; logger.debug(`Full domain: ${fullDomain}`); @@ -299,7 +319,8 @@ async function createHttpResource( protocol: "tcp", ssl: true, stickySession: stickySession, - postAuthPath: postAuthPath + postAuthPath: postAuthPath, + wildcard }) .returning(); diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 21a923704..feb12e90e 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -16,8 +16,11 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import config from "@server/lib/config"; -import { tlsNameSchema } from "@server/lib/schemas"; -import { subdomainSchema } from "@server/lib/schemas"; +import { + tlsNameSchema, + subdomainSchema, + wildcardSubdomainSchema +} from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; @@ -73,7 +76,10 @@ const updateHttpResourceBodySchema = z .refine( (data) => { if (data.subdomain) { - return subdomainSchema.safeParse(data.subdomain).success; + return ( + subdomainSchema.safeParse(data.subdomain).success || + wildcardSubdomainSchema.safeParse(data.subdomain).success + ); } return true; }, @@ -318,6 +324,22 @@ async function updateHttpResource( } } + // Wildcard subdomains are a paid feature + if (updateData.subdomain && updateData.subdomain.includes("*")) { + const isLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.wildcardSubdomain + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature." + ) + ); + } + } + if (updateData.domainId) { const domainId = updateData.domainId; @@ -362,7 +384,11 @@ async function updateHttpResource( ); } - const { fullDomain, subdomain: finalSubdomain } = domainResult; + const { + fullDomain, + subdomain: finalSubdomain, + wildcard + } = domainResult; logger.debug(`Full domain: ${fullDomain}`); @@ -419,7 +445,7 @@ async function updateHttpResource( if (fullDomain && fullDomain !== resource.fullDomain) { await db .update(resources) - .set({ fullDomain }) + .set({ fullDomain, wildcard }) .where(eq(resources.resourceId, resource.resourceId)); } diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 7a90dfa67..281248327 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -27,6 +27,7 @@ import { cn } from "@/lib/cn"; import { finalizeSubdomainSanitize, isValidSubdomainStructure, + isWildcardSubdomain, sanitizeInputRaw, validateByDomainType } from "@/lib/subdomain-utils"; @@ -77,6 +78,7 @@ interface DomainPickerProps { subdomain?: string; fullDomain: string; baseDomain: string; + wildcard?: boolean; } | null ) => void; cols?: number; @@ -85,6 +87,7 @@ interface DomainPickerProps { defaultSubdomain?: string | null; defaultDomainId?: string | null; warnOnProvidedDomain?: boolean; + allowWildcard?: boolean; } export default function DomainPicker({ @@ -95,23 +98,33 @@ export default function DomainPicker({ defaultSubdomain, defaultFullDomain, defaultDomainId, - warnOnProvidedDomain = false + warnOnProvidedDomain = false, + allowWildcard = false }: DomainPickerProps) { const { env } = useEnvContext(); const { user } = useUserContext(); const api = createApiClient({ env }); const t = useTranslations(); - const { hasSaasSubscription } = usePaidStatus(); + const { hasSaasSubscription, isPaidUser } = usePaidStatus(); const requiresPaywall = build === "saas" && !hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) && new Date(user.dateCreated) > new Date("2026-04-13"); + const wildcardAllowed = + allowWildcard && isPaidUser(tierMatrix[TierFeature.WildcardSubdomain]); + const { data = [], isLoading: loadingDomains } = useQuery( orgQueries.domains({ orgId }) ); + // Wildcard mode toggle — only relevant when allowWildcard is true, the + // user has a paid plan, and the selected base domain supports it. + const [wildcardMode, setWildcardMode] = useState( + wildcardAllowed && !!defaultSubdomain && isWildcardSubdomain(defaultSubdomain) + ); + if (!env.flags.usePangolinDns) { hideFreeDomain = true; } @@ -180,13 +193,15 @@ export default function DomainPicker({ firstOrExistingDomain.type !== "cname" ? defaultSubdomain?.trim() || undefined : undefined; + const isWc = allowWildcard && !!sub && isWildcardSubdomain(sub); onDomainChange?.({ domainId: firstOrExistingDomain.domainId, type: "organization", subdomain: sub, fullDomain: sub ? `${sub}.${base}` : base, - baseDomain: base + baseDomain: base, + wildcard: isWc }); } } @@ -285,7 +300,7 @@ export default function DomainPicker({ }, [userInput, debouncedCheckAvailability, selectedBaseDomain]); const finalizeSubdomain = (sub: string, base: DomainOption): string => { - const sanitized = finalizeSubdomainSanitize(sub); + const sanitized = finalizeSubdomainSanitize(sub, wildcardAllowed && wildcardMode); if (!sanitized) { toast({ @@ -301,7 +316,8 @@ export default function DomainPicker({ base.type === "provided-search" ? "provided-search" : "organization", - domainType: base.domainType + domainType: base.domainType, + allowWildcard: wildcardAllowed && wildcardMode }); if (!ok) { @@ -330,7 +346,7 @@ export default function DomainPicker({ }; const handleSubdomainChange = (value: string) => { - const raw = sanitizeInputRaw(value); + const raw = sanitizeInputRaw(value, wildcardAllowed && wildcardMode); setSubdomainInput(raw); setSelectedProvidedDomain(null); @@ -338,13 +354,15 @@ export default function DomainPicker({ const fullDomain = raw ? `${raw}.${selectedBaseDomain.domain}` : selectedBaseDomain.domain; + const isWc = wildcardAllowed && wildcardMode && isWildcardSubdomain(raw); onDomainChange?.({ domainId: selectedBaseDomain.domainId!, type: "organization", subdomain: raw || undefined, fullDomain, - baseDomain: selectedBaseDomain.domain + baseDomain: selectedBaseDomain.domain, + wildcard: isWc }); } }; @@ -366,6 +384,18 @@ export default function DomainPicker({ const handleBaseDomainSelect = (option: DomainOption) => { let sub = subdomainInput; + // Wildcard mode is not applicable for cname or provided-search domains, + // or when the user doesn't have a paid plan. + const newWildcardMode = + wildcardAllowed && + wildcardMode && + option.type === "organization" && + option.domainType !== "cname"; + + if (newWildcardMode !== wildcardMode) { + setWildcardMode(newWildcardMode); + } + if (sub && sub.trim() !== "") { sub = finalizeSubdomain(sub, option) || ""; setSubdomainInput(sub); @@ -389,6 +419,7 @@ export default function DomainPicker({ } const fullDomain = sub ? `${sub}.${option.domain}` : option.domain; + const isWc = newWildcardMode && !!sub && isWildcardSubdomain(sub); if (option.type === "provided-search") { onDomainChange?.(null); // prevent the modal from closing with `.Free Provided domain` @@ -402,7 +433,8 @@ export default function DomainPicker({ ? sub || undefined : undefined, fullDomain, - baseDomain: option.domain + baseDomain: option.domain, + wildcard: isWc }); } }; @@ -431,7 +463,8 @@ export default function DomainPicker({ selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization", - domainType: selectedBaseDomain.domainType + domainType: selectedBaseDomain.domainType, + allowWildcard: wildcardAllowed && wildcardMode }) : true; @@ -439,6 +472,31 @@ export default function DomainPicker({ selectedBaseDomain && selectedBaseDomain.type === "organization" && selectedBaseDomain.domainType !== "cname"; + + // Wildcard toggle is shown when the caller opts in and the selected domain + // supports it (ns or wildcard type). Shown even when unpaid so we can + // render a disabled state explaining it's a paid feature. + const showWildcardToggle = + allowWildcard && + showSubdomainInput && + (selectedBaseDomain?.domainType === "ns" || + selectedBaseDomain?.domainType === "wildcard"); + + const handleWildcardModeChange = (enabled: boolean) => { + setWildcardMode(enabled); + // Reset subdomain input when toggling modes to avoid invalid state + setSubdomainInput(""); + if (selectedBaseDomain?.type === "organization") { + onDomainChange?.({ + domainId: selectedBaseDomain.domainId!, + type: "organization", + subdomain: undefined, + fullDomain: selectedBaseDomain.domain, + baseDomain: selectedBaseDomain.domain, + wildcard: enabled ? true : false + }); + } + }; const showProvidedDomainSearch = selectedBaseDomain?.type === "provided-search"; @@ -463,9 +521,35 @@ export default function DomainPicker({
- +
+ + {showWildcardToggle && ( + + )} +
{showSubdomainInput && subdomainInput && - !isValidSubdomainStructure(subdomainInput) && ( + !isValidSubdomainStructure(subdomainInput, wildcardAllowed && wildcardMode) && (

{t("domainPickerInvalidSubdomainStructure")}

diff --git a/src/lib/subdomain-utils.ts b/src/lib/subdomain-utils.ts index 10c027a89..1c5e79207 100644 --- a/src/lib/subdomain-utils.ts +++ b/src/lib/subdomain-utils.ts @@ -5,16 +5,58 @@ export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wild export const SINGLE_LABEL_STRICT_RE = /^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum -export function sanitizeInputRaw(input: string): string { +/** + * A wildcard subdomain is either bare "*" or "*.label1.label2…" where every + * label after the dot is a valid hostname label. This mirrors the shape that + * the server's `wildcardSubdomainSchema` accepts. + */ +export const WILDCARD_SUBDOMAIN_RE = + /^\*(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/; + +export function isWildcardSubdomain(input: string): boolean { + return WILDCARD_SUBDOMAIN_RE.test(input); +} + +export function sanitizeInputRaw(input: string, allowWildcard = false): string { if (!input) return ""; + // When wildcard mode is active, preserve a leading "* " / "*." prefix and + // only sanitize the remainder so the user can type "*.level1" naturally. + if (allowWildcard && input.startsWith("*")) { + const rest = input.slice(1); + const sanitizedRest = rest + .toLowerCase() + .normalize("NFC") + .replace(/[^\p{L}\p{N}.-]/gu, ""); + return "*" + sanitizedRest; + } return input .toLowerCase() .normalize("NFC") // normalize Unicode .replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen } -export function finalizeSubdomainSanitize(input: string): string { +export function finalizeSubdomainSanitize( + input: string, + allowWildcard = false +): string { if (!input) return ""; + + // If the input is a valid wildcard and the caller permits it, keep it as-is + // (just lowercase the non-wildcard labels). + if (allowWildcard && input.startsWith("*")) { + const rest = input.slice(1); // everything after the leading "*" + const sanitizedRest = rest + .toLowerCase() + .normalize("NFC") + .replace(/[^\p{L}\p{N}.-]/gu, "") + .replace(/\.{2,}/g, ".") + .replace(/^-+|-+$/g, "") + .replace(/(\.-)|(-\.)/g, "."); + const candidate = "*" + sanitizedRest; + // Return only if it still forms a valid wildcard after sanitizing + return isWildcardSubdomain(candidate) ? candidate : ""; + } + return input .toLowerCase() .normalize("NFC") @@ -30,6 +72,7 @@ export function validateByDomainType( domainType: { type: "provided-search" | "organization"; domainType?: "ns" | "cname" | "wildcard"; + allowWildcard?: boolean; } ): boolean { if (!domainType) return false; @@ -46,6 +89,12 @@ export function validateByDomainType( domainType.domainType === "wildcard" ) { if (subdomain === "") return true; + + // Wildcard subdomain validation (only when caller opts in) + if (domainType.allowWildcard && subdomain.startsWith("*")) { + return isWildcardSubdomain(subdomain); + } + if (!MULTI_LABEL_RE.test(subdomain)) return false; const labels = subdomain.split("."); return labels.every( @@ -57,10 +106,19 @@ export function validateByDomainType( return false; } -export const isValidSubdomainStructure = (input: string): boolean => { +export const isValidSubdomainStructure = ( + input: string, + allowWildcard = false +): boolean => { + if (!input) return false; + + // A valid wildcard subdomain is structurally valid when the caller allows it + if (allowWildcard && input.startsWith("*")) { + return isWildcardSubdomain(input); + } + const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(? regex.test(label)); From e7a9a19816d744d1ca83db081884ddf3fe42b7d2 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Apr 2026 15:01:43 -0700 Subject: [PATCH 027/107] Basic crud working? --- messages/en-US.json | 3 +- server/lib/domainUtils.ts | 2 +- server/routers/resource/updateResource.ts | 2 +- .../resources/proxy/[niceId]/general/page.tsx | 1 + .../settings/resources/proxy/create/page.tsx | 1 + src/components/DomainPicker.tsx | 169 ++++++++---------- src/components/ShowTrialCard.tsx | 2 +- src/hooks/useSubscriptionStatusContext.ts | 4 +- 8 files changed, 80 insertions(+), 104 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 8218cf8d3..ba0f1c004 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3175,5 +3175,6 @@ "webhookUrlLabel": "URL", "webhookHeaderKeyPlaceholder": "Key", "webhookHeaderValuePlaceholder": "Value", - "alertLabel": "Alert" + "alertLabel": "Alert", + "domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed." } diff --git a/server/lib/domainUtils.ts b/server/lib/domainUtils.ts index b8dab5e4c..b147e79b8 100644 --- a/server/lib/domainUtils.ts +++ b/server/lib/domainUtils.ts @@ -150,7 +150,7 @@ export async function validateAndConstructDomain( return { success: true, fullDomain, - subdomain: isWildcard ? "*" : (finalSubdomain ?? null), + subdomain: finalSubdomain ?? null, wildcard: isWildcard }; } catch (error) { diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index feb12e90e..7c6a46d8f 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -46,7 +46,7 @@ const updateHttpResourceBodySchema = z "niceId can only contain letters, numbers, and dashes" ) .optional(), - subdomain: subdomainSchema.nullable().optional(), + subdomain: z.string().nullable().optional(), ssl: z.boolean().optional(), sso: z.boolean().optional(), blockAccess: z.boolean().optional(), diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 2b87da745..e1c5b6ac2 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -670,6 +670,7 @@ export default function GeneralForm() {
= 1 diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 281248327..73926448b 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -46,6 +46,7 @@ import { Zap } from "lucide-react"; import { useTranslations } from "next-intl"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@/hooks/usePaidStatus"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { toUnicode } from "punycode"; @@ -119,11 +120,8 @@ export default function DomainPicker({ orgQueries.domains({ orgId }) ); - // Wildcard mode toggle — only relevant when allowWildcard is true, the - // user has a paid plan, and the selected base domain supports it. - const [wildcardMode, setWildcardMode] = useState( - wildcardAllowed && !!defaultSubdomain && isWildcardSubdomain(defaultSubdomain) - ); + // Wildcard mode is derived from the input itself — if the user types a + // wildcard subdomain (e.g. *.foo) and allowWildcard is enabled, it's active. if (!env.flags.usePangolinDns) { hideFreeDomain = true; @@ -193,7 +191,8 @@ export default function DomainPicker({ firstOrExistingDomain.type !== "cname" ? defaultSubdomain?.trim() || undefined : undefined; - const isWc = allowWildcard && !!sub && isWildcardSubdomain(sub); + const isWc = + allowWildcard && !!sub && isWildcardSubdomain(sub); onDomainChange?.({ domainId: firstOrExistingDomain.domainId, @@ -300,7 +299,8 @@ export default function DomainPicker({ }, [userInput, debouncedCheckAvailability, selectedBaseDomain]); const finalizeSubdomain = (sub: string, base: DomainOption): string => { - const sanitized = finalizeSubdomainSanitize(sub, wildcardAllowed && wildcardMode); + const wildcardMode = wildcardAllowed && isWildcardSubdomain(sub); + const sanitized = finalizeSubdomainSanitize(sub, wildcardMode); if (!sanitized) { toast({ @@ -317,7 +317,7 @@ export default function DomainPicker({ ? "provided-search" : "organization", domainType: base.domainType, - allowWildcard: wildcardAllowed && wildcardMode + allowWildcard: wildcardMode }); if (!ok) { @@ -346,7 +346,7 @@ export default function DomainPicker({ }; const handleSubdomainChange = (value: string) => { - const raw = sanitizeInputRaw(value, wildcardAllowed && wildcardMode); + const raw = sanitizeInputRaw(value, allowWildcard); setSubdomainInput(raw); setSelectedProvidedDomain(null); @@ -354,7 +354,7 @@ export default function DomainPicker({ const fullDomain = raw ? `${raw}.${selectedBaseDomain.domain}` : selectedBaseDomain.domain; - const isWc = wildcardAllowed && wildcardMode && isWildcardSubdomain(raw); + const isWc = wildcardAllowed && isWildcardSubdomain(raw); onDomainChange?.({ domainId: selectedBaseDomain.domainId!, @@ -384,16 +384,15 @@ export default function DomainPicker({ const handleBaseDomainSelect = (option: DomainOption) => { let sub = subdomainInput; - // Wildcard mode is not applicable for cname or provided-search domains, - // or when the user doesn't have a paid plan. - const newWildcardMode = + // If the selected domain doesn't support wildcards, strip any wildcard prefix. + const supportsWildcard = wildcardAllowed && - wildcardMode && option.type === "organization" && option.domainType !== "cname"; - if (newWildcardMode !== wildcardMode) { - setWildcardMode(newWildcardMode); + if (!supportsWildcard && isWildcardSubdomain(sub)) { + sub = sub.replace(/^\*\./, ""); + setSubdomainInput(sub); } if (sub && sub.trim() !== "") { @@ -419,7 +418,7 @@ export default function DomainPicker({ } const fullDomain = sub ? `${sub}.${option.domain}` : option.domain; - const isWc = newWildcardMode && !!sub && isWildcardSubdomain(sub); + const isWc = wildcardAllowed && !!sub && isWildcardSubdomain(sub); if (option.type === "provided-search") { onDomainChange?.(null); // prevent the modal from closing with `.Free Provided domain` @@ -464,7 +463,8 @@ export default function DomainPicker({ ? "provided-search" : "organization", domainType: selectedBaseDomain.domainType, - allowWildcard: wildcardAllowed && wildcardMode + allowWildcard: + wildcardAllowed && isWildcardSubdomain(subdomainInput) }) : true; @@ -473,30 +473,6 @@ export default function DomainPicker({ selectedBaseDomain.type === "organization" && selectedBaseDomain.domainType !== "cname"; - // Wildcard toggle is shown when the caller opts in and the selected domain - // supports it (ns or wildcard type). Shown even when unpaid so we can - // render a disabled state explaining it's a paid feature. - const showWildcardToggle = - allowWildcard && - showSubdomainInput && - (selectedBaseDomain?.domainType === "ns" || - selectedBaseDomain?.domainType === "wildcard"); - - const handleWildcardModeChange = (enabled: boolean) => { - setWildcardMode(enabled); - // Reset subdomain input when toggling modes to avoid invalid state - setSubdomainInput(""); - if (selectedBaseDomain?.type === "organization") { - onDomainChange?.({ - domainId: selectedBaseDomain.domainId!, - type: "organization", - subdomain: undefined, - fullDomain: selectedBaseDomain.domain, - baseDomain: selectedBaseDomain.domain, - wildcard: enabled ? true : false - }); - } - }; const showProvidedDomainSearch = selectedBaseDomain?.type === "provided-search"; @@ -525,30 +501,6 @@ export default function DomainPicker({ - {showWildcardToggle && ( - - )}
{showSubdomainInput && subdomainInput && - !isValidSubdomainStructure(subdomainInput, wildcardAllowed && wildcardMode) && ( + !isValidSubdomainStructure( + subdomainInput, + wildcardAllowed && + isWildcardSubdomain(subdomainInput) + ) && (

{t("domainPickerInvalidSubdomainStructure")}

)} + {allowWildcard && + !wildcardAllowed && + showSubdomainInput && + isWildcardSubdomain(subdomainInput) && ( + <> +

+ {t( + "domainPickerWildcardSubdomainNotAllowed" + )} +

+ + + )}
@@ -678,23 +653,23 @@ export default function DomainPicker({ {orgDomain.type === - "wildcard" - ? t( - "domainPickerManual" - ) - : ( - <> - {orgDomain.type.toUpperCase()}{" "} - •{" "} - {orgDomain.verified - ? t( - "domainPickerVerified" - ) - : t( - "domainPickerUnverified" - )} - - )} + "wildcard" ? ( + t( + "domainPickerManual" + ) + ) : ( + <> + {orgDomain.type.toUpperCase()}{" "} + •{" "} + {orgDomain.verified + ? t( + "domainPickerVerified" + ) + : t( + "domainPickerUnverified" + )} + + )}
{requiresPaywall && !hideFreeDomain && ( - - -
- - - {t("domainPickerFreeDomainsPaidFeature")} - -
-
-
- )} + + +
+ + + {t("domainPickerFreeDomainsPaidFeature")} + +
+
+
+ )} {/*showProvidedDomainSearch && build === "saas" && ( diff --git a/src/components/ShowTrialCard.tsx b/src/components/ShowTrialCard.tsx index 1cc8e79f1..dc58483f8 100644 --- a/src/components/ShowTrialCard.tsx +++ b/src/components/ShowTrialCard.tsx @@ -14,7 +14,7 @@ import { } from "@app/components/ui/tooltip"; import { useTranslations } from "next-intl"; -const TRIAL_DURATION_DAYS = 14; +const TRIAL_DURATION_DAYS = 10; export default function ShowTrialCard({ isCollapsed diff --git a/src/hooks/useSubscriptionStatusContext.ts b/src/hooks/useSubscriptionStatusContext.ts index 59d6a6b9a..2fd61dd2e 100644 --- a/src/hooks/useSubscriptionStatusContext.ts +++ b/src/hooks/useSubscriptionStatusContext.ts @@ -8,9 +8,7 @@ export function useSubscriptionStatusContext() { } const context = useContext(SubscriptionStatusContext); if (context === undefined) { - throw new Error( - "useSubscriptionStatusContext must be used within an SubscriptionStatusProvider" - ); + return null; } return context; } From 1ba7fca798bf83bffdebf09be3f27e8def3d02ff Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Apr 2026 15:08:55 -0700 Subject: [PATCH 028/107] Update traefik config --- server/private/lib/traefik/getTraefikConfig.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index fb6e176b8..dba2cbffb 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -100,6 +100,7 @@ export async function getTraefikConfig( headers: resources.headers, proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, + wildcard: resources.wildcard, maintenanceModeEnabled: resources.maintenanceModeEnabled, maintenanceModeType: resources.maintenanceModeType, @@ -238,6 +239,7 @@ export async function getTraefikConfig( priority: priority, // may be null, we fallback later domainCertResolver: row.domainCertResolver, preferWildcardCert: row.preferWildcardCert, + wildcard: row.wildcard, maintenanceModeEnabled: row.maintenanceModeEnabled, maintenanceModeType: row.maintenanceModeType, @@ -376,7 +378,16 @@ export async function getTraefikConfig( ...additionalMiddlewares ]; - let rule = `Host(\`${fullDomain}\`)`; + let rule: string; + if (resource.wildcard && fullDomain.startsWith("*.")) { + // Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`) + const escaped = fullDomain + .slice(2) // remove leading "*." + .replace(/\./g, "\\."); + rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`; + } else { + rule = `Host(\`${fullDomain}\`)`; + } // priority logic let priority: number; @@ -566,7 +577,7 @@ export async function getTraefikConfig( resource.ssl ? entrypointHttps : entrypointHttp ], service: maintenanceServiceName, - rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, priority: 2001, ...(resource.ssl ? { tls } : {}) }; From 5e293e8364ff4a5b701815755c879aa4a3312cd6 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Apr 2026 17:12:56 -0700 Subject: [PATCH 029/107] Handle getting resources --- server/db/queries/verifySessionQueries.ts | 39 ++++++++++++++++++++--- server/private/routers/hybrid.ts | 34 +++++++++++++++++--- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 989e111a7..46b45b1a0 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -25,7 +25,7 @@ import { ResourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility } from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, or, sql } from "drizzle-orm"; export type ResourceWithAuth = { resource: Resource | null; @@ -47,7 +47,17 @@ export type UserSessionWithUser = { export async function getResourceByDomain( domain: string ): Promise { - const [result] = await db + // Build wildcard domain variants to match against. + // For a domain like "me.example.test.com", we want to match: + // - "*.example.test.com" (subdomain wildcard) + // - "*.test.com" (parent wildcard, i.e. just "*" subdomain on parent) + const parts = domain.split("."); + const wildcardCandidates: string[] = []; + for (let i = 1; i < parts.length; i++) { + wildcardCandidates.push(`*.${parts.slice(i).join(".")}`); + } + + const potentialResults = await db .select() .from(resources) .leftJoin( @@ -70,8 +80,29 @@ export async function getResourceByDomain( ) ) .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) - .where(eq(resources.fullDomain, domain)) - .limit(1); + .where( + or( + // Exact match + eq(resources.fullDomain, domain), + // Wildcard match: resource fullDomain is one of the wildcard candidates + wildcardCandidates.length > 0 + ? and( + eq(resources.wildcard, true), + inArray(resources.fullDomain, wildcardCandidates) + ) + : sql`false` + ) + ); + + if (!potentialResults.length) { + return null; + } + + // Prefer exact match over wildcard match + const exactMatch = potentialResults.find( + (r) => r.resources?.fullDomain === domain + ); + const result = exactMatch ?? potentialResults[0]; if (!result) { return null; diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index b3ef792d9..98e7ff671 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -50,7 +50,7 @@ import { userOrgRoles, roles } from "@server/db"; -import { eq, and, inArray, isNotNull, ne } from "drizzle-orm"; +import { eq, and, inArray, isNotNull, ne, or, sql } from "drizzle-orm"; import { response } from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; @@ -492,7 +492,15 @@ hybridRouter.get( ); } - const [result] = await db + // Build wildcard domain candidates for the requested domain. + // e.g. "me.example.test.com" -> ["*.example.test.com", "*.test.com"] + const domainParts = domain.split("."); + const wildcardCandidates: string[] = []; + for (let i = 1; i < domainParts.length; i++) { + wildcardCandidates.push(`*.${domainParts.slice(i).join(".")}`); + } + + const potentialResults = await db .select() .from(resources) .leftJoin( @@ -515,10 +523,28 @@ hybridRouter.get( ) ) .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) - .where(eq(resources.fullDomain, domain)) - .limit(1); + .where( + or( + // Exact match + eq(resources.fullDomain, domain), + // Wildcard match + wildcardCandidates.length > 0 + ? and( + eq(resources.wildcard, true), + inArray(resources.fullDomain, wildcardCandidates) + ) + : sql`false` + ) + ); + + // Prefer exact match over wildcard match + const exactMatch = potentialResults.find( + (r) => r.resources?.fullDomain === domain + ); + const result = exactMatch ?? potentialResults[0]; if ( + result && await checkExitNodeOrg( remoteExitNode.exitNodeId, result.resources.orgId From 009bac64bf24b1094fd976cca3ad3b23260b496c Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Apr 2026 18:02:32 -0700 Subject: [PATCH 030/107] Adding guiderails --- messages/en-US.json | 4 +- server/lib/domainUtils.ts | 103 +++++++++++++++++- .../private/lib/traefik/getTraefikConfig.ts | 53 ++++----- server/routers/resource/createResource.ts | 9 +- server/routers/resource/updateResource.ts | 12 +- src/components/DomainPicker.tsx | 15 +++ 6 files changed, 163 insertions(+), 33 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index ba0f1c004..97b19552d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3176,5 +3176,7 @@ "webhookHeaderKeyPlaceholder": "Key", "webhookHeaderValuePlaceholder": "Value", "alertLabel": "Alert", - "domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed." + "domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.", + "domainPickerWildcardCertWarning": "Wildcard certificates must be configured separately in Traefik.", + "domainPickerWildcardCertWarningLink": "Learn more" } diff --git a/server/lib/domainUtils.ts b/server/lib/domainUtils.ts index b147e79b8..138ffc74f 100644 --- a/server/lib/domainUtils.ts +++ b/server/lib/domainUtils.ts @@ -1,8 +1,9 @@ import { db } from "@server/db"; -import { domains, orgDomains, domainNamespaces } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { domains, orgDomains, domainNamespaces, resources } from "@server/db"; +import { eq, and, like, not } from "drizzle-orm"; import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import { fromError } from "zod-validation-error"; +import config from "./config"; export type DomainValidationResult = | { @@ -71,7 +72,8 @@ export async function validateAndConstructDomain( const isWildcard = subdomain !== undefined && subdomain !== null && - subdomain.includes("*"); + subdomain.includes("*") && + domainRes.domains.type !== "cname"; // Wildcard subdomains are not allowed on CNAME domains if (isWildcard && domainRes.domains.type === "cname") { @@ -97,6 +99,20 @@ export async function validateAndConstructDomain( } } + if ( + isWildcard && + domainRes.domains.type == "wildcard" && + !( + domainRes.domains.preferWildcardCert || + config.getRawConfig().traefik.prefer_wildcard_cert + ) + ) { + return { + success: false, + error: "Wildcard domains are not supported without configuring certificate resolver for wildcard certs and marking it as prefered." + }; + } + // Validate wildcard subdomain format if (isWildcard) { const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain); @@ -125,7 +141,8 @@ export async function validateAndConstructDomain( if (subdomain !== undefined && subdomain !== null) { if (!isWildcard) { // Validate regular subdomain format for wildcard domains - const parsedSubdomain = subdomainSchema.safeParse(subdomain); + const parsedSubdomain = + subdomainSchema.safeParse(subdomain); if (!parsedSubdomain.success) { return { success: false, @@ -160,3 +177,81 @@ export async function validateAndConstructDomain( }; } } + +/** + * Checks whether a given fullDomain conflicts with any existing wildcard resources, + * or (if the fullDomain is itself a wildcard) whether any existing resources would + * be matched by it. + * + * @param fullDomain - The fully-constructed domain to check (may contain a leading `*`) + * @param excludeResourceId - Optional resource ID to exclude from the check (for updates) + * @returns An object with `conflict: true` and a human-readable `message`, or `conflict: false` + */ +export async function checkWildcardDomainConflict( + fullDomain: string, + excludeResourceId?: number +): Promise<{ conflict: false } | { conflict: true; message: string }> { + const isWildcard = fullDomain.startsWith("*."); + + if (isWildcard) { + // e.g. fullDomain = "*.example.com" → suffix = ".example.com" + const suffix = fullDomain.slice(1); // ".example.com" + + // Find any existing non-wildcard resource whose fullDomain ends with this suffix + // e.g. "test.example.com" or "foo.example.com" + const conflicting = await db + .select({ + resourceId: resources.resourceId, + fullDomain: resources.fullDomain + }) + .from(resources) + .where(like(resources.fullDomain, `%${suffix}`)); + + const matches = conflicting.filter( + (r) => + !r.fullDomain!.startsWith("*.") && + r.fullDomain!.endsWith(suffix) && + (excludeResourceId === undefined || + r.resourceId !== excludeResourceId) + ); + + if (matches.length > 0) { + return { + conflict: true, + message: `Wildcard domain ${fullDomain} conflicts with existing resource(s): ${matches.map((r) => r.fullDomain).join(", ")}` + }; + } + } else { + // Specific domain — check if any existing wildcard would match it. + // e.g. fullDomain = "test.example.com" + // We look for a wildcard "*.example.com" which means fullDomain ends with ".example.com" + const dotIndex = fullDomain.indexOf("."); + if (dotIndex !== -1) { + const suffix = fullDomain.slice(dotIndex); // ".example.com" + const wildcardPattern = `*.${fullDomain.slice(dotIndex + 1)}`; // "*.example.com" + + const conflicting = await db + .select({ + resourceId: resources.resourceId, + fullDomain: resources.fullDomain + }) + .from(resources) + .where(eq(resources.fullDomain, wildcardPattern)); + + const matches = conflicting.filter( + (r) => + excludeResourceId === undefined || + r.resourceId !== excludeResourceId + ); + + if (matches.length > 0) { + return { + conflict: true, + message: `Domain ${fullDomain} conflicts with existing wildcard resource(s): ${matches.map((r) => r.fullDomain).join(", ")}` + }; + } + } + } + + return { conflict: false }; +} diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index dba2cbffb..6427bec4f 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -33,7 +33,15 @@ import { } from "drizzle-orm"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { orgs, resources, sites, siteNetworks, siteResources, Target, targets } from "@server/db"; +import { + orgs, + resources, + sites, + siteNetworks, + siteResources, + Target, + targets +} from "@server/db"; import { sanitize, encodePath, @@ -277,7 +285,10 @@ export async function getTraefikConfig( mode: siteResources.mode }) .from(siteResources) - .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId)) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .where( and( @@ -430,7 +441,8 @@ export async function getTraefikConfig( config.getRawConfig().traefik.prefer_wildcard_cert; const domainCertResolver = resource.domainCertResolver; - const preferWildcardCert = resource.preferWildcardCert; + const preferWildcardCert = + resource.preferWildcardCert || resource.wildcard; let resolverName: string | undefined; let preferWildcard: boolean | undefined; @@ -964,22 +976,17 @@ export async function getTraefikConfig( }; // Middleware that rewrites any path to /maintenance-screen - config_output.http.middlewares[ - siteResourceRewriteMiddlewareName - ] = { - replacePathRegex: { - regex: "^/(.*)", - replacement: "/private-maintenance-screen" - } - }; + config_output.http.middlewares[siteResourceRewriteMiddlewareName] = + { + replacePathRegex: { + regex: "^/(.*)", + replacement: "/private-maintenance-screen" + } + }; // HTTP -> HTTPS redirect so the ACME challenge can be served - config_output.http.routers[ - `${siteResourceRouterName}-redirect` - ] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], + config_output.http.routers[`${siteResourceRouterName}-redirect`] = { + entryPoints: [config.getRawConfig().traefik.http_entrypoint], middlewares: [redirectHttpsMiddlewareName], service: siteResourceServiceName, rule: `Host(\`${fullDomain}\`)`, @@ -988,9 +995,7 @@ export async function getTraefikConfig( // Determine TLS / cert-resolver configuration let tls: any = {}; - if ( - !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns - ) { + if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); const wildCard = domainParts.length <= 2 @@ -1023,9 +1028,7 @@ export async function getTraefikConfig( // HTTPS router - presence of this entry triggers cert generation config_output.http.routers[siteResourceRouterName] = { - entryPoints: [ - config.getRawConfig().traefik.https_entrypoint - ], + entryPoints: [config.getRawConfig().traefik.https_entrypoint], service: siteResourceServiceName, middlewares: [siteResourceRewriteMiddlewareName], rule: `Host(\`${fullDomain}\`)`, @@ -1035,9 +1038,7 @@ export async function getTraefikConfig( // Assets bypass router - lets Next.js static files load without rewrite config_output.http.routers[`${siteResourceRouterName}-assets`] = { - entryPoints: [ - config.getRawConfig().traefik.https_entrypoint - ], + entryPoints: [config.getRawConfig().traefik.https_entrypoint], service: siteResourceServiceName, rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, priority: 101, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index d3ace5adc..85e607211 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -23,7 +23,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { getUniqueResourceName } from "@server/db/names"; -import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -271,6 +271,13 @@ async function createHttpResource( ); } + const wildcardConflict = await checkWildcardDomainConflict(fullDomain); + if (wildcardConflict.conflict) { + return next( + createHttpError(HttpCode.CONFLICT, wildcardConflict.message) + ); + } + // Prevent creating resource with same domain as dashboard const dashboardUrl = config.getRawConfig().app.dashboard_url; if (dashboardUrl) { diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 7c6a46d8f..0a7052dce 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -24,7 +24,7 @@ import { import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; -import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -410,6 +410,16 @@ async function updateHttpResource( ); } + const wildcardConflict = await checkWildcardDomainConflict( + fullDomain, + resource.resourceId + ); + if (wildcardConflict.conflict) { + return next( + createHttpError(HttpCode.CONFLICT, wildcardConflict.message) + ); + } + // Prevent updating resource with same domain as dashboard const dashboardUrl = config.getRawConfig().app.dashboard_url; if (dashboardUrl) { diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 73926448b..1c7a117a3 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -906,6 +906,21 @@ export default function DomainPicker({ )}
)} + {selectedBaseDomain?.domainType === "wildcard" && + isWildcardSubdomain(subdomainInput) && ( +

+ {t("domainPickerWildcardCertWarning")}{" "} + + {t("domainPickerWildcardCertWarningLink")} + + . +

+ )}
); } From 07c7501669feab110b93d80c983cdce94557b09a Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Apr 2026 20:30:34 -0700 Subject: [PATCH 031/107] New columns --- server/setup/scriptsPg/1.18.0.ts | 8 ++++++++ server/setup/scriptsSqlite/1.18.0.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/server/setup/scriptsPg/1.18.0.ts b/server/setup/scriptsPg/1.18.0.ts index 2f2b3067c..8431a11c1 100644 --- a/server/setup/scriptsPg/1.18.0.ts +++ b/server/setup/scriptsPg/1.18.0.ts @@ -346,6 +346,14 @@ export default async function migration() { ALTER TABLE "siteResources" DROP COLUMN "protocol"; `); + await db.execute(sql` + ALTER TABLE "resources" ADD "health" varchar; + `); + + await db.execute(sql` + ALTER TABLE "resources" ADD "wildcard" boolean DEFAULT false NOT NULL; + `); + await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts index c9d2ddc95..d34cf1541 100644 --- a/server/setup/scriptsSqlite/1.18.0.ts +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -330,6 +330,17 @@ export default async function migration() { ALTER TABLE 'sites' ADD 'networkId' integer REFERENCES networks(networkId); ` ).run(); + db.prepare( + ` + ALTER TABLE 'resources' ADD 'health' text; + ` + ).run(); + db.prepare( + ` + ALTER TABLE 'resources' ADD 'wildcard' integer DEFAULT false NOT NULL; + ` + ).run(); + })(); db.pragma("foreign_keys = ON"); From b4f0b4e285664bf7e5c505579442c454873a0e3e Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Apr 2026 21:25:13 -0700 Subject: [PATCH 032/107] Handle matching wildcards --- server/routers/resource/getResourceAuthInfo.ts | 10 ++++++++-- src/app/auth/resource/[resourceGuid]/page.tsx | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 7def75d5b..30ff4699a 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -32,6 +32,8 @@ export type GetResourceAuthInfoResponse = { sso: boolean; blockAccess: boolean; url: string; + wildcard: boolean; + fullDomain: string | null; whitelist: boolean; skipToIdpId: number | null; orgId: string; @@ -130,7 +132,9 @@ export async function getResourceAuthInfo( const headerAuthExtendedCompatibility = result?.resourceHeaderAuthExtendedCompatibility; - const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + const url = resource.fullDomain + ? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` + : null; return response(res, { data: { @@ -145,7 +149,9 @@ export async function getResourceAuthInfo( headerAuthExtendedCompatibility !== null, sso: resource.sso, blockAccess: resource.blockAccess, - url, + url: url ?? "", + wildcard: resource.wildcard ?? false, + fullDomain: resource.fullDomain, whitelist: resource.emailWhitelistEnabled, skipToIdpId: resource.skipToIdpId, orgId: resource.orgId, diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index f22a59d6b..c78c277b6 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -106,10 +106,22 @@ export default async function ResourceAuthPage(props: { const redirectPort = new URL(searchParams.redirect).port; const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`; + const wildcardMatchesRedirect = (wildcardDomain: string, host: string): boolean => { + if (!wildcardDomain.startsWith("*.")) return false; + const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io" + return host.endsWith(suffix) && host.length > suffix.length; + }; + if (serverResourceHost === redirectHost) { redirectUrl = searchParams.redirect; } else if (serverResourceHostWithPort === redirectHost) { redirectUrl = searchParams.redirect; + } else if ( + authInfo.wildcard && + authInfo.fullDomain && + wildcardMatchesRedirect(authInfo.fullDomain, redirectHost) + ) { + redirectUrl = searchParams.redirect; } } catch (e) {} } From 6a96f743aaeacc76d7ec483157f79825c26542ba Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 23 Apr 2026 21:38:12 -0700 Subject: [PATCH 033/107] Update exchange session to support wildcards --- server/routers/badger/exchangeSession.ts | 28 ++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index bde5518b8..e6c8b70f1 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -6,7 +6,7 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { resourceAccessToken, resources, sessions } from "@server/db"; import { db } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, eq, inArray, or, sql } from "drizzle-orm"; import { createResourceSession, serializeResourceSessionCookie, @@ -65,11 +65,31 @@ export async function exchangeSession( const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined; - const [resource] = await db + const parts = cleanHost.split("."); + const wildcardCandidates: string[] = []; + for (let i = 1; i < parts.length; i++) { + wildcardCandidates.push(`*.${parts.slice(i).join(".")}`); + } + + const potentialResources = await db .select() .from(resources) - .where(eq(resources.fullDomain, cleanHost)) - .limit(1); + .where( + or( + eq(resources.fullDomain, cleanHost), + wildcardCandidates.length > 0 + ? and( + eq(resources.wildcard, true), + inArray(resources.fullDomain, wildcardCandidates) + ) + : sql`false` + ) + ); + + const exactMatch = potentialResources.find( + (r) => r.fullDomain === cleanHost + ); + const resource = exactMatch ?? potentialResources[0]; if (!resource) { return next( From d08f2767941821cc45c6e4817c24bf191d6db80a Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 11:55:09 -0700 Subject: [PATCH 034/107] Use the provided host in the cookie --- server/routers/badger/exchangeSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index e6c8b70f1..08987961d 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -198,7 +198,7 @@ export async function exchangeSession( const cookieName = `${config.getRawConfig().server.session_cookie_name}`; const cookie = serializeResourceSessionCookie( cookieName, - resource.fullDomain!, + cleanHost, token, !resource.ssl, expiresAt ? new Date(expiresAt) : undefined From 537f9ae66b9c55b6c15fe75f32c998c6f56c2413 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 12:14:06 -0700 Subject: [PATCH 035/107] Always update the domain even if wildcard changes --- server/private/lib/acmeCertSync.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index 24d612661..f5dfdeb12 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -312,8 +312,7 @@ async function syncAcmeCerts( .from(certificates) .where( and( - eq(certificates.domain, domain), - eq(certificates.wildcard, wildcard) + eq(certificates.domain, domain) ) ) .limit(1); @@ -392,6 +391,9 @@ async function syncAcmeCerts( } if (existing.length > 0) { + logger.debug( + `acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); await db .update(certificates) .set({ @@ -416,6 +418,9 @@ async function syncAcmeCerts( oldKeyPem ); } else { + logger.debug( + `acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})` + ); await db.insert(certificates).values({ domain, domainId, From 0473d5f639b3325d2c928fadc8bc9548fd03a588 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 12:18:50 -0700 Subject: [PATCH 036/107] Get the cert correctly --- server/private/routers/certificates/getCertificate.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/private/routers/certificates/getCertificate.ts b/server/private/routers/certificates/getCertificate.ts index 9e434b3e0..a8f14df68 100644 --- a/server/private/routers/certificates/getCertificate.ts +++ b/server/private/routers/certificates/getCertificate.ts @@ -62,7 +62,6 @@ async function query(domainId: string, domain: string) { .where( and( eq(certificates.domainId, domainId), - eq(certificates.wildcard, true), // only NS domains can have wildcard certs or( eq(certificates.domain, domain), eq(certificates.domain, domainLevelDown), From 48ddc700a00beda6d0b980ecfa8d6ec837cee2c5 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 13:40:31 -0700 Subject: [PATCH 037/107] Catch the domains the right way --- server/private/routers/certificates/getCertificate.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/server/private/routers/certificates/getCertificate.ts b/server/private/routers/certificates/getCertificate.ts index a8f14df68..d439011a7 100644 --- a/server/private/routers/certificates/getCertificate.ts +++ b/server/private/routers/certificates/getCertificate.ts @@ -41,7 +41,7 @@ async function query(domainId: string, domain: string) { } let existing: any[] = []; - if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { // the manual "wildcard" domains can have wildcard certs + if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { const domainLevelDown = domain.split(".").slice(1).join("."); const wildcardPrefixed = `*.${domainLevelDown}`; @@ -64,8 +64,13 @@ async function query(domainId: string, domain: string) { eq(certificates.domainId, domainId), or( eq(certificates.domain, domain), - eq(certificates.domain, domainLevelDown), - eq(certificates.domain, wildcardPrefixed) + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) + ) + ) ) ) ); From 9655f119a591aabdbb69b22f706565a03ae27935 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Apr 2026 13:47:54 -0700 Subject: [PATCH 038/107] fix text --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 97b19552d..d6a39168d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3158,7 +3158,7 @@ "uptimeNoData": "No data", "uptimeMiniBarDown": "Down", "uptimeSectionTitle": "Uptime", - "uptimeSectionDescription": "Site availability over the last {days} days.", + "uptimeSectionDescription": "Availability over the last {days} days", "uptimeAddAlert": "Add Alert", "uptimeViewAlerts": "View Alerts", "uptimeCreateEmailAlert": "Create Email Alert", From 99f9b68efe9d7b4aa2cb71face414dbd6707fdd1 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Apr 2026 14:53:11 -0700 Subject: [PATCH 039/107] fix full sudo mode calculation --- server/private/routers/ssh/signSshKey.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index 82044c0ad..e29493a01 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -368,8 +368,8 @@ export async function signSshKey( const parsedSudoCommands: string[] = []; const parsedGroupsSet = new Set(); let homedir: boolean | null = null; - const sudoModeOrder = { none: 0, commands: 1, all: 2 }; - let sudoMode: "none" | "commands" | "all" = "none"; + const sudoModeOrder = { none: 0, commands: 1, full: 2 }; + let sudoMode: "none" | "commands" | "full" = "none"; for (const roleRow of roleRows) { try { const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); @@ -386,7 +386,7 @@ export async function signSshKey( if (roleRow?.sshCreateHomeDir === true) homedir = true; const m = roleRow?.sshSudoMode ?? "none"; if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) { - sudoMode = m as "none" | "commands" | "all"; + sudoMode = m as "none" | "commands" | "full"; } } const parsedGroups = Array.from(parsedGroupsSet); From 2a5d8367477404b322510d1622cf4198e6f9268f Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 16:06:04 -0700 Subject: [PATCH 040/107] Fix gear icon --- .../settings/resources/proxy/create/page.tsx | 37 ++++--------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index 5f65b8578..a2cf36de6 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -694,19 +694,6 @@ export default function Page() { header: () => {t("healthCheck")}, cell: ({ row }) => { const status = row.original.hcHealth || "unknown"; - const isEnabled = row.original.hcEnabled; - - const getStatusColor = (status: string) => { - switch (status) { - case "healthy": - return "green"; - case "unhealthy": - return "red"; - case "unknown": - default: - return "secondary"; - } - }; const getStatusText = (status: string) => { switch (status) { @@ -720,19 +707,7 @@ export default function Page() { } }; - const getStatusIcon = (status: string) => { - switch (status) { - case "healthy": - return ; - case "unhealthy": - return ; - case "unknown": - default: - return null; - } - }; - - return ( + return (
{row.original.siteType === "newt" ? ( + ) : ( - )} From 15f02cf79a1dc890b87e33f2e0e4f52a4d5f1ee8 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 16:06:19 -0700 Subject: [PATCH 041/107] Quiet up messages --- server/routers/newt/handleSocketMessages.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 383ab5541..5d5497ee1 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -8,26 +8,26 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; - logger.info("Handling Docker socket check response"); + logger.debug("Handling Docker socket check response"); if (!newt) { logger.warn("Newt not found"); return; } - logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); + logger.debug(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); const { available, socketPath } = message.data; - logger.info( + logger.debug( `Docker socket availability for Newt ${newt.newtId}: available=${available}, socketPath=${socketPath}` ); if (available) { - logger.info(`Newt ${newt.newtId} has Docker socket access`); + logger.debug(`Newt ${newt.newtId} has Docker socket access`); await cache.set(`${newt.newtId}:socketPath`, socketPath, 0); await cache.set(`${newt.newtId}:isAvailable`, available, 0); } else { - logger.warn(`Newt ${newt.newtId} does not have Docker socket access`); + logger.debug(`Newt ${newt.newtId} does not have Docker socket access`); } return; @@ -39,28 +39,28 @@ export const handleDockerContainersMessage: MessageHandler = async ( const { message, client, sendToClient } = context; const newt = client as Newt; - logger.info("Handling Docker containers response"); + logger.debug("Handling Docker containers response"); if (!newt) { logger.warn("Newt not found"); return; } - logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); + logger.debug(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); const { containers } = message.data; - logger.info( + logger.debug( `Docker containers for Newt ${newt.newtId}: ${containers ? containers.length : 0}` ); if (containers && containers.length > 0) { await cache.set(`${newt.newtId}:dockerContainers`, containers, 0); } else { - logger.warn(`Newt ${newt.newtId} does not have Docker containers`); + logger.debug(`Newt ${newt.newtId} does not have Docker containers`); } if (!newt.siteId) { - logger.warn("Newt has no site!"); + logger.debug("Newt has no site!"); return; } From 29f26021dfdecb8fd9ff07144abd0798d0133e44 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 16:07:44 -0700 Subject: [PATCH 042/107] Add the right pending record --- .../routers/certificates/createCertificate.ts | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/server/private/routers/certificates/createCertificate.ts b/server/private/routers/certificates/createCertificate.ts index 4f7bb7fe8..60ca2072a 100644 --- a/server/private/routers/certificates/createCertificate.ts +++ b/server/private/routers/certificates/createCertificate.ts @@ -37,18 +37,25 @@ export async function createCertificate( } let existing: Certificate[] = []; - if (domainRecord.type == "ns") { + if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { const domainLevelDown = domain.split(".").slice(1).join("."); + const wildcardPrefixed = `*.${domainLevelDown}`; + existing = await trx .select() .from(certificates) .where( and( eq(certificates.domainId, domainId), - eq(certificates.wildcard, true), // only NS domains can have wildcard certs or( eq(certificates.domain, domain), - eq(certificates.domain, domainLevelDown) + and( + eq(certificates.wildcard, true), + or( + eq(certificates.domain, domainLevelDown), + eq(certificates.domain, wildcardPrefixed) + ) + ) ) ) ); @@ -70,11 +77,28 @@ export async function createCertificate( return; } + let domainToWrite = domain; + if ( + domainRecord.type == "wildcard" && + domainRecord.preferWildcardCert && + !domain.startsWith("*.") + ) { + // in this case traefik is going to generate a domain one level down so we need to store it that way + const parts = domain.split("."); + if (parts.length > 2) { + domainToWrite = parts.slice(1).join("."); + domainToWrite = `*.${domainToWrite}`; + } + } + // No cert found, create a new one in pending state await trx.insert(certificates).values({ - domain, + domain: domainToWrite, domainId, - wildcard: domainRecord.type == "ns", // we can only create wildcard certs for NS domains + wildcard: + domainRecord.type == "ns" || + (domainRecord.type == "wildcard" && + domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains status: "pending", updatedAt: Math.floor(Date.now() / 1000), createdAt: Math.floor(Date.now() / 1000) From 33f1662c91a5ff740edaae0165ff575461b9741b Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Apr 2026 16:11:37 -0700 Subject: [PATCH 043/107] support site filter in private resources table --- .../siteResource/listAllSiteResourcesByOrg.ts | 35 ++++++- .../settings/resources/client/page.tsx | 36 ++++++++ src/components/ClientResourcesTable.tsx | 91 ++++++++++++++++++- 3 files changed, 157 insertions(+), 5 deletions(-) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 8750e7516..0a6166755 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -4,7 +4,7 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; import type { PaginatedResponse } from "@server/types/Pagination"; -import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -68,6 +68,16 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({ enum: ["asc", "desc"], default: "asc", description: "Sort order" + }), + siteId: z.coerce + .number() + .int() + .positive() + .optional() + .openapi({ + type: "integer", + description: + "When set, only site resources associated with this site (via network) are returned" }) }); @@ -199,10 +209,31 @@ export async function listAllSiteResourcesByOrg( } const { orgId } = parsedParams.data; - const { page, pageSize, query, mode, sort_by, order } = + const { page, pageSize, query, mode, sort_by, order, siteId } = parsedQuery.data; const conditions = [and(eq(siteResources.orgId, orgId))]; + + if (siteId != null) { + const resourcesForSite = db + .select({ id: siteResources.siteResourceId }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) + .where( + and( + eq(siteResources.orgId, orgId), + eq(sites.orgId, orgId), + eq(sites.siteId, siteId) + ) + ); + conditions.push( + inArray(siteResources.siteResourceId, resourcesForSite) + ); + } if (query) { conditions.push( or( diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index da967feea..42d4e69eb 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -7,7 +7,9 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; import type { ListResourcesResponse } from "@server/routers/resource"; +import { GetSiteResponse } from "@server/routers/site/getSite"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; +import type ResponseT from "@server/types/Response"; import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import type { Metadata } from "next"; @@ -22,6 +24,13 @@ export interface ClientResourcesPageProps { searchParams: Promise>; } +function parsePositiveInt(s: string | undefined): number | undefined { + if (!s) return undefined; + const n = Number(s); + if (!Number.isInteger(n) || n <= 0) return undefined; + return n; +} + export default async function ClientResourcesPage( props: ClientResourcesPageProps ) { @@ -47,6 +56,32 @@ export default async function ClientResourcesPage( pagination = responseData.pagination; } catch (e) {} + const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined); + + let initialFilterSite: { + siteId: number; + name: string; + type: string; + } | null = null; + if (siteIdParam) { + try { + const siteRes = await internal.get( + `/site/${siteIdParam}`, + await authCookieHeader() + ); + const s = (siteRes.data as ResponseT).data; + if (s && s.orgId === params.orgId) { + initialFilterSite = { + siteId: s.siteId, + name: s.name, + type: s.type + }; + } + } catch { + // leave null + } + } + let org = null; try { const res = await getCachedOrg(params.orgId); @@ -114,6 +149,7 @@ export default async function ClientResourcesPage( pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} + initialFilterSite={initialFilterSite} /> diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 36f8caa78..b43cf1ac1 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -4,6 +4,7 @@ import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { DataTable } from "@app/components/ui/data-table"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import { DropdownMenu, @@ -12,6 +13,11 @@ import { DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; @@ -23,12 +29,14 @@ import { ArrowUpRight, ChevronDown, ChevronsUpDownIcon, + Funnel, MoreHorizontal } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState, useTransition } from "react"; +import { Selectedsite, SitesSelector } from "@app/components/site-selector"; +import { useMemo, useState, useTransition } from "react"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; @@ -219,13 +227,15 @@ type ClientResourcesTableProps = { orgId: string; pagination: PaginationState; rowCount: number; + initialFilterSite?: Selectedsite | null; }; export default function ClientResourcesTable({ internalResources, orgId, pagination, - rowCount + rowCount, + initialFilterSite = null }: ClientResourcesTableProps) { const router = useRouter(); const { @@ -247,9 +257,26 @@ export default function ClientResourcesTable({ const [editingResource, setEditingResource] = useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [siteFilterOpen, setSiteFilterOpen] = useState(false); const [isRefreshing, startTransition] = useTransition(); + const siteIdQ = searchParams.get("siteId"); + const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; + const selectedSite: Selectedsite | null = useMemo(() => { + if (!siteIdQ || !Number.isInteger(siteIdNum) || siteIdNum <= 0) { + return null; + } + if (initialFilterSite && initialFilterSite.siteId === siteIdNum) { + return initialFilterSite; + } + return { + siteId: siteIdNum, + name: t("standaloneHcFilterSiteIdFallback", { id: siteIdNum }), + type: "newt" + }; + }, [initialFilterSite, siteIdQ, siteIdNum, t]); + const refreshData = () => { startTransition(() => { try { @@ -391,7 +418,55 @@ export default function ClientResourcesTable({ id: "sites", accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), friendlyName: t("sites"), - header: () => {t("sites")}, + header: () => ( + + + + + +
+ +
+ +
+
+ ), cell: ({ row }) => { const resourceRow = row.original; return ( @@ -576,6 +651,16 @@ export default function ClientResourcesTable({ }); } + const clearSiteFilter = () => { + handleFilterChange("siteId", undefined); + setSiteFilterOpen(false); + }; + + const onPickSite = (site: Selectedsite) => { + handleFilterChange("siteId", String(site.siteId)); + setSiteFilterOpen(false); + }; + function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); From 34296e5f404fb846d3fa4d5c430216e1eab03608 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 16:19:35 -0700 Subject: [PATCH 044/107] Fix health check status --- server/routers/target/handleHealthcheckStatusMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 50331deb8..c3bcb6d8e 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -101,7 +101,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( .where( and( eq(targetHealthCheck.targetHealthCheckId, targetIdNum), - eq(sites.siteId, newt.siteId) + eq(targetHealthCheck.siteId, newt.siteId) ) ) .limit(1); From 960ada4d66b15a337ddaeb17c8603e6388b1dd18 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Apr 2026 16:24:26 -0700 Subject: [PATCH 045/107] add site column and filter to public resources --- server/routers/resource/listResources.ts | 67 +++++++++- .../[orgId]/settings/resources/proxy/page.tsx | 46 +++++-- src/components/ClientResourcesTable.tsx | 125 ++---------------- src/components/ProxyResourcesTable.tsx | 109 ++++++++++++++- src/components/ResourceSitesStatusCell.tsx | 123 +++++++++++++++++ 5 files changed, 339 insertions(+), 131 deletions(-) create mode 100644 src/components/ResourceSitesStatusCell.tsx diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index d1accfc9d..652c9112e 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -113,6 +113,16 @@ const listResourcesSchema = z.object({ enum: ["no_targets", "healthy", "degraded", "offline", "unknown"], description: "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets." + }), + siteId: z.coerce + .number() + .int() + .positive() + .optional() + .openapi({ + type: "integer", + description: + "When set, only resources that have at least one target on this site are returned" }) }); @@ -141,6 +151,12 @@ export type ResourceWithTargets = { healthStatus: "healthy" | "unhealthy" | "unknown" | null; siteName: string | null; }>; + sites: Array<{ + siteId: number; + siteName: string; + siteNiceId: string; + online: boolean; + }>; }; // Aggregate filters @@ -260,7 +276,8 @@ export async function listResources( query, healthStatus, sort_by, - order + order, + siteId } = parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); @@ -380,6 +397,19 @@ export async function listResources( } } + if (siteId != null) { + const resourcesWithSite = db + .select({ resourceId: targets.resourceId }) + .from(targets) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .where( + and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)) + ); + conditions.push( + inArray(resources.resourceId, resourcesWithSite) + ); + } + let aggregateFilters: SQL | undefined = sql`1 = 1`; if (typeof healthStatus !== "undefined") { @@ -444,12 +474,15 @@ export async function listResources( .select({ targetId: targets.targetId, resourceId: targets.resourceId, + siteId: targets.siteId, ip: targets.ip, port: targets.port, enabled: targets.enabled, healthStatus: targetHealthCheck.hcHealth, hcEnabled: targetHealthCheck.hcEnabled, - siteName: sites.name + siteName: sites.name, + siteNiceId: sites.niceId, + siteOnline: sites.online }) .from(targets) .where(inArray(targets.resourceId, resourceIdList)) @@ -481,7 +514,8 @@ export async function listResources( enabled: row.enabled, domainId: row.domainId, headerAuthId: row.headerAuthId, - targets: [] + targets: [], + sites: [] }; map.set(row.resourceId, entry); } @@ -491,6 +525,33 @@ export async function listResources( ); } + for (const entry of map.values()) { + const raw = allResourceTargets.filter( + (t) => t.resourceId === entry.resourceId + ); + const siteById = new Map< + number, + { + siteId: number; + siteName: string; + siteNiceId: string; + online: boolean; + } + >(); + for (const t of raw) { + if (typeof t.siteId !== "number" || siteById.has(t.siteId)) { + continue; + } + siteById.set(t.siteId, { + siteId: t.siteId, + siteName: t.siteName ?? "", + siteNiceId: t.siteNiceId ?? "", + online: Boolean(t.siteOnline) + }); + } + entry.sites = Array.from(siteById.values()); + } + const resourcesList: ResourceWithTargets[] = Array.from(map.values()); return response(res, { diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index cdbf959f4..ed76aafb7 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -7,7 +7,8 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import OrgProvider from "@app/providers/OrgProvider"; import type { GetOrgResponse } from "@server/routers/org"; import type { ListResourcesResponse } from "@server/routers/resource"; -import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; +import { GetSiteResponse } from "@server/routers/site/getSite"; +import type ResponseT from "@server/types/Response"; import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; @@ -24,6 +25,13 @@ export interface ProxyResourcesPageProps { searchParams: Promise>; } +function parsePositiveInt(s: string | undefined): number | undefined { + if (!s) return undefined; + const n = Number(s); + if (!Number.isInteger(n) || n <= 0) return undefined; + return n; +} + export default async function ProxyResourcesPage( props: ProxyResourcesPageProps ) { @@ -47,13 +55,31 @@ export default async function ProxyResourcesPage( pagination = responseData.pagination; } 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) {} + const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined); + + let initialFilterSite: { + siteId: number; + name: string; + type: string; + } | null = null; + if (siteIdParam) { + try { + const siteRes = await internal.get( + `/site/${siteIdParam}`, + await authCookieHeader() + ); + const s = (siteRes.data as ResponseT).data; + if (s && s.orgId === params.orgId) { + initialFilterSite = { + siteId: s.siteId, + name: s.name, + type: s.type + }; + } + } catch { + // leave null + } + } let org = null; try { @@ -102,7 +128,8 @@ export default async function ProxyResourcesPage( enabled: target.enabled, healthStatus: target.healthStatus, siteName: target.siteName - })) + })), + sites: resource.sites ?? [] }; }); return ( @@ -123,6 +150,7 @@ export default async function ProxyResourcesPage( pageIndex: pagination.page - 1, pageSize: pagination.pageSize }} + initialFilterSite={initialFilterSite} /> diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index b43cf1ac1..9866e444f 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -48,13 +48,12 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { cn } from "@app/lib/cn"; +import { + ResourceSitesStatusCell, + type ResourceSiteRow +} from "@app/components/ResourceSitesStatusCell"; -export type InternalResourceSiteRow = { - siteId: number; - siteName: string; - siteNiceId: string; - online: boolean; -}; +export type InternalResourceSiteRow = ResourceSiteRow; export type InternalResourceRow = { id: number; @@ -119,109 +118,6 @@ function isSafeUrlForLink(href: string): boolean { } } -type AggregateSitesStatus = "allOnline" | "partial" | "allOffline"; - -function aggregateSitesStatus( - resourceSites: InternalResourceSiteRow[] -): AggregateSitesStatus { - if (resourceSites.length === 0) { - return "allOffline"; - } - const onlineCount = resourceSites.filter((rs) => rs.online).length; - if (onlineCount === resourceSites.length) return "allOnline"; - if (onlineCount > 0) return "partial"; - return "allOffline"; -} - -function aggregateStatusDotClass(status: AggregateSitesStatus): string { - switch (status) { - case "allOnline": - return "bg-green-500"; - case "partial": - return "bg-yellow-500"; - case "allOffline": - default: - return "bg-neutral-500"; - } -} - -function ClientResourceSitesStatusCell({ - orgId, - resourceSites -}: { - orgId: string; - resourceSites: InternalResourceSiteRow[]; -}) { - const t = useTranslations(); - - if (resourceSites.length === 0) { - return -; - } - - const aggregate = aggregateSitesStatus(resourceSites); - const countLabel = t("multiSitesSelectorSitesCount", { - count: resourceSites.length - }); - - return ( - - - - - - {resourceSites.map((site) => { - const isOnline = site.online; - return ( - - -
-
- - {site.siteName} - -
- - {isOnline ? t("online") : t("offline")} - - - - ); - })} - - - ); -} - type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; @@ -321,9 +217,7 @@ export default function ClientResourcesTable({ if (siteNames.length === 1) { return ( - + + + +
+ +
+ +
+ + ), + cell: ({ row }) => ( + + ) + }, { accessorKey: "protocol", friendlyName: t("protocol"), @@ -620,6 +715,16 @@ export default function ProxyResourcesTable({ }); } + const clearSiteFilter = () => { + handleFilterChange("siteId", undefined); + setSiteFilterOpen(false); + }; + + const onPickSite = (site: Selectedsite) => { + handleFilterChange("siteId", String(site.siteId)); + setSiteFilterOpen(false); + }; + function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); diff --git a/src/components/ResourceSitesStatusCell.tsx b/src/components/ResourceSitesStatusCell.tsx new file mode 100644 index 000000000..3c940c6b0 --- /dev/null +++ b/src/components/ResourceSitesStatusCell.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { cn } from "@app/lib/cn"; +import { ChevronDown } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; + +export type ResourceSiteRow = { + siteId: number; + siteName: string; + siteNiceId: string; + online: boolean; +}; + +type AggregateSitesStatus = "allOnline" | "partial" | "allOffline"; + +function aggregateSitesStatus( + resourceSites: ResourceSiteRow[] +): AggregateSitesStatus { + if (resourceSites.length === 0) { + return "allOffline"; + } + const onlineCount = resourceSites.filter((rs) => rs.online).length; + if (onlineCount === resourceSites.length) return "allOnline"; + if (onlineCount > 0) return "partial"; + return "allOffline"; +} + +function aggregateStatusDotClass(status: AggregateSitesStatus): string { + switch (status) { + case "allOnline": + return "bg-green-500"; + case "partial": + return "bg-yellow-500"; + case "allOffline": + default: + return "bg-neutral-500"; + } +} + +export function ResourceSitesStatusCell({ + orgId, + resourceSites +}: { + orgId: string; + resourceSites: ResourceSiteRow[]; +}) { + const t = useTranslations(); + + if (resourceSites.length === 0) { + return -; + } + + const aggregate = aggregateSitesStatus(resourceSites); + const countLabel = t("multiSitesSelectorSitesCount", { + count: resourceSites.length + }); + + return ( + + + + + + {resourceSites.map((site) => { + const isOnline = site.online; + return ( + + +
+
+ + {site.siteName} + +
+ + {isOnline ? t("online") : t("offline")} + + + + ); + })} + + + ); +} From a2c76cbb24ddfec5024ffa21c5f661f06fe905a4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Apr 2026 16:44:57 -0700 Subject: [PATCH 046/107] set standard filter popover width --- src/components/ClientResourcesTable.tsx | 3 ++- src/components/ColumnFilter.tsx | 6 +++++- src/components/ColumnFilterButton.tsx | 6 +++++- src/components/ColumnMultiFilterButton.tsx | 6 +++++- src/components/HealthChecksTable.tsx | 5 +++-- src/components/ProxyResourcesTable.tsx | 3 ++- src/components/ui/controlled-data-table.tsx | 5 ++++- src/components/ui/data-table.tsx | 5 ++++- src/lib/dataTableFilterPopover.ts | 5 +++++ 9 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 src/lib/dataTableFilterPopover.ts diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 9866e444f..f10f6414e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -48,6 +48,7 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { ResourceSitesStatusCell, type ResourceSiteRow @@ -336,7 +337,7 @@ export default function ClientResourcesTable({
diff --git a/src/components/ColumnFilter.tsx b/src/components/ColumnFilter.tsx index 3e7b585b8..5a944cd88 100644 --- a/src/components/ColumnFilter.tsx +++ b/src/components/ColumnFilter.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { Badge } from "./ui/badge"; interface FilterOption { @@ -74,7 +75,10 @@ export function ColumnFilter({ - + diff --git a/src/components/ColumnFilterButton.tsx b/src/components/ColumnFilterButton.tsx index 7d17066cb..689f78983 100644 --- a/src/components/ColumnFilterButton.tsx +++ b/src/components/ColumnFilterButton.tsx @@ -15,6 +15,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, ChevronDownIcon, Funnel } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { Badge } from "./ui/badge"; interface FilterOption { @@ -75,7 +76,10 @@ export function ColumnFilterButton({
- + diff --git a/src/components/ColumnMultiFilterButton.tsx b/src/components/ColumnMultiFilterButton.tsx index ee386461d..787a306b2 100644 --- a/src/components/ColumnMultiFilterButton.tsx +++ b/src/components/ColumnMultiFilterButton.tsx @@ -18,6 +18,7 @@ import { } from "@app/components/ui/command"; import { CheckIcon, Funnel } from "lucide-react"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { Badge } from "./ui/badge"; type FilterOption = { @@ -101,7 +102,10 @@ export function ColumnMultiFilterButton({
- + diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 404ade547..9545cbb7d 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -50,6 +50,7 @@ import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; type StandaloneHealthChecksTableProps = { orgId: string; @@ -376,7 +377,7 @@ export default function HealthChecksTable({
@@ -445,7 +446,7 @@ export default function HealthChecksTable({
diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index b5daa5850..efbd2db01 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -26,6 +26,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; import { Selectedsite, SitesSelector } from "@app/components/site-selector"; import { cn } from "@app/lib/cn"; +import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; @@ -441,7 +442,7 @@ export default function ProxyResourcesTable({
diff --git a/src/components/ui/controlled-data-table.tsx b/src/components/ui/controlled-data-table.tsx index 58081783a..1f7683adc 100644 --- a/src/components/ui/controlled-data-table.tsx +++ b/src/components/ui/controlled-data-table.tsx @@ -33,6 +33,7 @@ import { } from "@app/components/ui/dropdown-menu"; import { Input } from "@app/components/ui/input"; import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; +import { dataTableFilterDropdownContentClassName } from "@app/lib/dataTableFilterPopover"; import { ChevronDown, @@ -345,7 +346,9 @@ export function ControlledDataTable({ {filter.label} diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 2b2861bb5..673388454 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -34,6 +34,7 @@ import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; +import { dataTableFilterDropdownContentClassName } from "@app/lib/dataTableFilterPopover"; import { ChevronDown, Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; import { Card, @@ -603,7 +604,9 @@ export function DataTable({ {filter.label} diff --git a/src/lib/dataTableFilterPopover.ts b/src/lib/dataTableFilterPopover.ts new file mode 100644 index 000000000..606527209 --- /dev/null +++ b/src/lib/dataTableFilterPopover.ts @@ -0,0 +1,5 @@ +export const dataTableFilterPopoverContentClassName = + "w-[min(16rem,calc(100vw-2rem))] p-0"; + +export const dataTableFilterDropdownContentClassName = + "w-[min(16rem,calc(100vw-2rem))]"; From b509c8aeecac72bc9b895508d0a0af3624cba4eb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Apr 2026 16:57:53 -0700 Subject: [PATCH 047/107] dont distribute info section cols --- src/components/InfoSection.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/InfoSection.tsx b/src/components/InfoSection.tsx index b00503c3d..7203236e1 100644 --- a/src/components/InfoSection.tsx +++ b/src/components/InfoSection.tsx @@ -4,18 +4,29 @@ import { cn } from "@app/lib/cn"; export function InfoSections({ children, - cols + cols, + columnSizing = "content" }: { children: React.ReactNode; cols?: number; + /** content (default): fixed gap, columns hug content, left-aligned; fill: equal-width columns across the row */ + columnSizing?: "fill" | "content"; }) { + const n = cols || 1; + const track = + columnSizing === "fill" ? "minmax(0, 1fr)" : "minmax(0, max-content)"; + return (
{children} From 07154d2a16011e627c09ee79ba2b7957acf16eec Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 24 Apr 2026 17:07:11 -0700 Subject: [PATCH 048/107] add links to view resources on site --- .cursor/rules/Localization.mdc | 5 +++++ .cursor/rules/Nomenclature.mdc | 6 ++++++ messages/en-US.json | 2 ++ src/components/SitesTable.tsx | 20 +++++++++++++++++--- 4 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 .cursor/rules/Localization.mdc create mode 100644 .cursor/rules/Nomenclature.mdc diff --git a/.cursor/rules/Localization.mdc b/.cursor/rules/Localization.mdc new file mode 100644 index 000000000..3014c6177 --- /dev/null +++ b/.cursor/rules/Localization.mdc @@ -0,0 +1,5 @@ +--- +alwaysApply: true +--- + +Always localize strings and use the `t` function to convert keys to strings. Add the keys to the en-us.json file. Never edit the other language files, as en-us.json is the single source of truth. diff --git a/.cursor/rules/Nomenclature.mdc b/.cursor/rules/Nomenclature.mdc new file mode 100644 index 000000000..33c4af797 --- /dev/null +++ b/.cursor/rules/Nomenclature.mdc @@ -0,0 +1,6 @@ +--- +alwaysApply: true +--- + +Proxy resources = public resources +Private resources = client resources diff --git a/messages/en-US.json b/messages/en-US.json index d6a39168d..5a616f07a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "I have copied the config", "searchSitesProgress": "Search sites...", "siteAdd": "Add Site", + "sitesTableViewPublicResources": "View Public Resources", + "sitesTableViewPrivateResources": "View Private Resources", "siteInstallNewt": "Install Site", "siteInstallNewtDescription": "Install the site connector for your system", "WgConfiguration": "WireGuard Configuration", diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index d59244552..45c0d9a0b 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -239,9 +239,7 @@ export default function SitesTable({ if (originalRow.type == "local") { return -; } - return ( - - ); + return ; } }, { @@ -437,6 +435,22 @@ export default function SitesTable({ {t("viewSettings")} + + + {t("sitesTableViewPublicResources")} + + + + + {t("sitesTableViewPrivateResources")} + + { setSelectedSite(siteRow); From cca7cea2f1afa429addcff355664d79bb2746029 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 17:30:54 -0700 Subject: [PATCH 049/107] Handeling the different health status --- messages/en-US.json | 3 +- .../lib/alerts/events/healthCheckEvents.ts | 14 +++- .../routers/healthChecks/createHealthCheck.ts | 3 +- .../routers/healthChecks/updateHealthCheck.ts | 31 ++++++++ server/routers/resource/createResource.ts | 3 +- server/routers/resource/listResources.ts | 66 ++--------------- server/routers/target/createTarget.ts | 2 +- server/routers/target/updateTarget.ts | 66 ++++++++--------- src/components/ProxyResourcesTable.tsx | 72 +++++++------------ 9 files changed, 112 insertions(+), 148 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index d6a39168d..99e4d09e5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1974,10 +1974,9 @@ "resourcesTableAliasAddressInfo": "This address is part of the organization's utility subnet. It's used to resolve alias records using internal DNS resolution.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.", - "resourcesTableNoTargets": "No targets", "resourcesTableHealthy": "Healthy", "resourcesTableDegraded": "Degraded", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Unhealthy", "resourcesTableUnknown": "Unknown", "resourcesTableNotMonitored": "Not monitored", "editInternalResourceDialogEditClientResource": "Edit Private Resource", diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 4851f08c4..9b5c3104b 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -173,15 +173,25 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null const otherTargets = await trx .select({ hcHealth: targetHealthCheck.hcHealth }) .from(targets) + .innerJoin(targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId)) .where(eq(targets.resourceId, resource.resourceId)); let health = "healthy"; const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); - if (!allHealthy) { + const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); + + if (allHealthy) { + health = "healthy"; + } else if (allUnhealthy) { logger.debug( - `Not marking resource ${resource.resourceId} as healthy because not all targets are healthy` + `Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy` ); health = "unhealthy"; + } else { + logger.debug( + `Marking resource ${resource.resourceId} as degraded because some targets are unhealthy` + ); + health = "degraded"; } if (health != resource.health) { diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index 374ec4ba4..ada583a70 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -141,7 +141,8 @@ export async function createHealthCheck( hcStatus: hcStatus ?? null, hcTlsServerName: hcTlsServerName ?? null, hcHealthyThreshold, - hcUnhealthyThreshold + hcUnhealthyThreshold, + hcHealth: "unhealthy" }) .returning(); diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index 713bf1e03..47a9518a9 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -166,6 +166,17 @@ export async function updateHealthCheck( const updateData: Record = {}; + const [existingHealthCheck] = await db + .select() + .from(targetHealthCheck) + .where( + and( + eq(targetHealthCheck.targetHealthCheckId, healthCheckId), + eq(targetHealthCheck.orgId, orgId) + ) + ) + .limit(1); + if (name !== undefined) updateData.name = name; if (siteId !== undefined) updateData.siteId = siteId; if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; @@ -190,6 +201,26 @@ export async function updateHealthCheck( if (hcUnhealthyThreshold !== undefined) updateData.hcUnhealthyThreshold = hcUnhealthyThreshold; + const hcEnabledTurnedOn = + parsedBody.data.hcEnabled === true && + existingHealthCheck.hcEnabled === false; + + let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; + if ( + parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null + ) { + hcHealthValue = "unknown"; + } else if (hcEnabledTurnedOn) { + hcHealthValue = "unhealthy"; + } else { + hcHealthValue = undefined; + } + + if (hcHealthValue) { + updateData.hcHealth = hcHealthValue; + } + const [updated] = await db .update(targetHealthCheck) .set(updateData) diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 85e607211..f8b7551e9 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -327,7 +327,8 @@ async function createHttpResource( ssl: true, stickySession: stickySession, postAuthPath: postAuthPath, - wildcard + wildcard, + health: "unknown" }) .returning(); diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index d1accfc9d..e6889d285 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -105,7 +105,7 @@ const listResourcesSchema = z.object({ "Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)." }), healthStatus: z - .enum(["no_targets", "healthy", "degraded", "offline", "unknown"]) + .enum(["healthy", "degraded", "unhealthy", "unknown"]) .optional() .catch(undefined) .openapi({ @@ -143,27 +143,6 @@ export type ResourceWithTargets = { }>; }; -// Aggregate filters -const total_targets = count(targets.targetId); -const healthy_targets = sql`SUM( - CASE - WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1 - ELSE 0 - END - ) `; -const unknown_targets = sql`SUM( - CASE - WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1 - ELSE 0 - END - ) `; -const unhealthy_targets = sql`SUM( - CASE - WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1 - ELSE 0 - END - ) `; - function queryResourcesBase() { return db .select({ @@ -183,7 +162,8 @@ function queryResourcesBase() { niceId: resources.niceId, headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthExtendedCompatibilityId: - resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, + health: resources.health }) .from(resources) .leftJoin( @@ -378,46 +358,12 @@ export async function listResources( ); break; } - } - - let aggregateFilters: SQL | undefined = sql`1 = 1`; - - if (typeof healthStatus !== "undefined") { - switch (healthStatus) { - case "healthy": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${healthy_targets} = ${total_targets}` - ); - break; - case "degraded": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${unhealthy_targets} > 0` - ); - break; - case "no_targets": - aggregateFilters = sql`${total_targets} = 0`; - break; - case "offline": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${healthy_targets} = 0`, - sql`${unhealthy_targets} = ${total_targets}` - ); - break; - case "unknown": - aggregateFilters = and( - sql`${total_targets} > 0`, - sql`${unknown_targets} = ${total_targets}` - ); - break; + if (typeof healthStatus !== "undefined") { + conditions.push(eq(resources.health, healthStatus)); } } - const baseQuery = queryResourcesBase() - .where(and(...conditions)) - .having(aggregateFilters); + const baseQuery = queryResourcesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery const countQuery = db.$count(baseQuery.as("filtered_resources")); diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index e5c1f246e..e37f12490 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -245,7 +245,7 @@ export async function createTarget( hcFollowRedirects: targetData.hcFollowRedirects ?? null, hcMethod: targetData.hcMethod ?? null, hcStatus: targetData.hcStatus ?? null, - hcHealth: "unknown", + hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown", hcTlsServerName: targetData.hcTlsServerName ?? null, hcHealthyThreshold: targetData.hcHealthyThreshold ?? null, hcUnhealthyThreshold: targetData.hcUnhealthyThreshold ?? null diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index a633deb4d..dad302de8 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -13,7 +13,7 @@ import { addTargets } from "../newt/targets"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { vs } from "@react-email/components"; + const updateTargetParamsSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) @@ -153,32 +153,6 @@ export async function updateTarget( ); } - const targetData = { - ...target, - ...parsedBody.data - }; - - const existingTargets = await db - .select() - .from(targets) - .where(eq(targets.resourceId, target.resourceId)); - - const foundTarget = existingTargets.find( - (target) => - target.targetId !== targetId && // Exclude the current target being updated - target.ip === targetData.ip && - target.port === targetData.port && - target.method === targetData.method && - target.siteId === targetData.siteId - ); - - if (foundTarget) { - // 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); if (!internalPort) { @@ -210,20 +184,46 @@ export async function updateTarget( .where(eq(targets.targetId, targetId)) .returning(); + const [existingHc] = await db + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .limit(1); + + if (!existingHc) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check for target with ID ${targetId} not found` + ) + ); + } + let hcHeaders = null; if (parsedBody.data.hcHeaders) { hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); } // When health check is disabled, reset hcHealth to "unknown" - // to prevent previously unhealthy targets from being excluded - // Also when the site is not a newt, set hcHealth to "unknown" - const hcHealthValue = + // to prevent previously unhealthy targets from being excluded. + // Also when the site is not a newt, set hcHealth to "unknown". + // If hcEnabled is being turned on (was false, now true), set to "unhealthy" + // so the target must pass a health check before being considered healthy. + const hcEnabledTurnedOn = + parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false; + + let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; + if ( parsedBody.data.hcEnabled === false || parsedBody.data.hcEnabled === null || site.type !== "newt" - ? "unknown" - : undefined; + ) { + hcHealthValue = "unknown"; + } else if (hcEnabledTurnedOn) { + hcHealthValue = "unhealthy"; + } else { + hcHealthValue = undefined; + } const [updatedHc] = await db .update(targetHealthCheck) @@ -245,7 +245,7 @@ export async function updateTarget( hcTlsServerName: parsedBody.data.hcTlsServerName, hcHealthyThreshold: parsedBody.data.hcHealthyThreshold, hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold, - ...(hcHealthValue !== undefined && { hcHealth: hcHealthValue }) + hcHealth: hcHealthValue }) .where(eq(targetHealthCheck.targetId, targetId)) .returning(); diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index dddf1312c..ff0a6ad5b 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -83,54 +83,24 @@ export type ResourceRow = { targetHost?: string; targetPort?: number; targets?: TargetHealth[]; + health?: "online" | "degraded" | "unhealthy" | "unknown"; }; -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"; + status: string | undefined | null; className?: string; }) { const iconClass = `h-4 w-4 ${className}`; switch (status) { - case "online": + case "healthy": return ; case "degraded": return ; - case "offline": + case "unhealthy": return ; case "unknown": return ; @@ -231,12 +201,18 @@ export default function ProxyResourcesTable({ } } - function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { - const overallStatus = getOverallHealthStatus(targets); + function TargetStatusCell({ + targets, + healthStatus + }: { + targets?: TargetHealth[]; + healthStatus?: string; + }) { + const overallStatus = healthStatus; if (!targets || targets.length === 0) { return ( -
+
{t("resourcesTableNoTargets")} @@ -266,8 +242,8 @@ export default function ProxyResourcesTable({ t("resourcesTableHealthy")} {overallStatus === "degraded" && t("resourcesTableDegraded")} - {overallStatus === "offline" && - t("resourcesTableOffline")} + {overallStatus === "unhealthy" && + t("resourcesTableUnhealthy")} {overallStatus === "unknown" && t("resourcesTableUnknown")} @@ -405,10 +381,9 @@ export default function ProxyResourcesTable({ value: "degraded", label: t("resourcesTableDegraded") }, - { value: "offline", label: t("resourcesTableOffline") }, { - value: "no_targets", - label: t("resourcesTableNoTargets") + value: "unhealty", + label: t("resourcesTableUnhealthy") }, { value: "unknown", label: t("resourcesTableUnknown") } ]} @@ -429,12 +404,15 @@ export default function ProxyResourcesTable({ return ; }, sortingFn: (rowA, rowB) => { - const statusA = getOverallHealthStatus(rowA.original.targets); - const statusB = getOverallHealthStatus(rowB.original.targets); + const statusA = rowA.original.health; + const statusB = rowB.original.health; + if (!statusA && !statusB) return 0; + if (!statusA) return 1; + if (!statusB) return -1; const statusOrder = { online: 3, degraded: 2, - offline: 1, + unhealthy: 1, unknown: 0 }; return statusOrder[statusA] - statusOrder[statusB]; @@ -446,9 +424,7 @@ export default function ProxyResourcesTable({ header: () => {t("uptime30d")}, cell: ({ row }) => { const resourceRow = row.original; - return ( - - ); + return ; } }, { From 477712b73c3c2eac4675a1666d4be911193301b1 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 25 Apr 2026 15:07:59 -0700 Subject: [PATCH 050/107] show site resources --- .cursor/rules/Nomenclature.mdc | 3 +- messages/en-US.json | 15 + server/routers/resource/getUserResources.ts | 50 +- server/routers/site/listSites.ts | 16 +- .../settings/provisioning/pending/page.tsx | 1 + .../settings/sites/[niceId]/layout.tsx | 4 + .../sites/[niceId]/resources/page.tsx | 64 +++ src/app/[orgId]/settings/sites/page.tsx | 1 + src/components/ClientResourcesTable.tsx | 29 +- src/components/MemberResourcesPortal.tsx | 266 ++++++---- src/components/SiteResourcesOverview.tsx | 480 ++++++++++++++++++ src/components/SitesTable.tsx | 74 +++ src/lib/formatSiteResourceAccess.ts | 32 ++ 13 files changed, 885 insertions(+), 150 deletions(-) create mode 100644 src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx create mode 100644 src/components/SiteResourcesOverview.tsx create mode 100644 src/lib/formatSiteResourceAccess.ts diff --git a/.cursor/rules/Nomenclature.mdc b/.cursor/rules/Nomenclature.mdc index 33c4af797..d290f212e 100644 --- a/.cursor/rules/Nomenclature.mdc +++ b/.cursor/rules/Nomenclature.mdc @@ -1,6 +1,7 @@ --- +description: alwaysApply: true --- Proxy resources = public resources -Private resources = client resources +Private resources = client resources = site resources diff --git a/messages/en-US.json b/messages/en-US.json index c8e76add4..5492768f1 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -112,6 +112,21 @@ "siteUpdatedDescription": "The site has been updated.", "siteGeneralDescription": "Configure the general settings for this site", "siteSettingDescription": "Configure the settings on the site", + "siteResourcesTab": "Resources", + "siteResourcesNoneOnSite": "This site has no public or private resources yet.", + "siteResourcesSectionPublic": "Public Resources", + "siteResourcesSectionPrivate": "Private Resources", + "siteResourcesSectionPublicDescription": "Resources exposed externally through domains or ports.", + "siteResourcesSectionPrivateDescription": "Resources available on your private network through the site.", + "siteResourcesViewAllPublic": "View all resources", + "siteResourcesViewAllPrivate": "View all resources", + "siteResourcesDialogDescription": "Overview of public and private resources associated with this site.", + "siteResourcesShowMore": "Show more", + "siteResourcesPermissionDenied": "You do not have permission to list these resources.", + "siteResourcesEmptyPublic": "No public resources target this site yet.", + "siteResourcesEmptyPrivate": "No private resources are associated with this site yet.", + "siteResourcesHowToAccess": "How to access", + "siteResourcesTargetsOnSite": "Targets on this site", "siteSetting": "{siteName} Settings", "siteNewtTunnel": "Newt Site (Recommended)", "siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.", diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index 1722a7993..c0f21a440 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -86,7 +86,12 @@ export async function getUserResources( .where(inArray(roleSiteResources.roleId, userRoleIds)) : Promise.resolve([]); - const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ + const [ + directResources, + roleResourceResults, + directSiteResourceResults, + roleSiteResourceResults + ] = await Promise.all([ directResourcesQuery, roleResourcesQuery, directSiteResourcesQuery, @@ -118,24 +123,24 @@ export async function getUserResources( }> = []; if (accessibleResourceIds.length > 0) { resourcesData = await db - .select({ - resourceId: resources.resourceId, - name: resources.name, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - enabled: resources.enabled, - sso: resources.sso, - protocol: resources.protocol, - emailWhitelistEnabled: resources.emailWhitelistEnabled - }) - .from(resources) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.orgId, orgId), - eq(resources.enabled, true) - ) - ); + .select({ + resourceId: resources.resourceId, + name: resources.name, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + enabled: resources.enabled, + sso: resources.sso, + protocol: resources.protocol, + emailWhitelistEnabled: resources.emailWhitelistEnabled + }) + .from(resources) + .where( + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId), + eq(resources.enabled, true) + ) + ); } // Get site resource details for accessible site resources @@ -166,7 +171,10 @@ export async function getUserResources( .from(siteResources) .where( and( - inArray(siteResources.siteResourceId, accessibleSiteResourceIds), + inArray( + siteResources.siteResourceId, + accessibleSiteResourceIds + ), eq(siteResources.orgId, orgId), eq(siteResources.enabled, true) ) @@ -246,7 +254,7 @@ export async function getUserResources( enabled: siteResource.enabled, alias: siteResource.alias, aliasAddress: siteResource.aliasAddress, - type: 'site' as const + type: "site" as const }; }); diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 88bb233ed..10f5ac0f1 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -5,6 +5,9 @@ import { orgs, remoteExitNodes, roleSites, + siteNetworks, + siteResources, + targets, sites, userSites } from "@server/db"; @@ -199,6 +202,18 @@ function querySitesBase() { exitNodeName: exitNodes.name, exitNodeEndpoint: exitNodes.endpoint, remoteExitNodeId: remoteExitNodes.remoteExitNodeId, + resourceCount: sql`( + SELECT COUNT(DISTINCT ${targets.resourceId}) + FROM ${targets} + WHERE ${targets.siteId} = ${sites.siteId} + ) + ( + SELECT COUNT(DISTINCT ${siteResources.siteResourceId}) + FROM ${siteResources} + INNER JOIN ${siteNetworks} + ON ${siteResources.networkId} = ${siteNetworks.networkId} + WHERE ${siteNetworks.siteId} = ${sites.siteId} + AND ${siteResources.orgId} = ${sites.orgId} + )`, status: sites.status }) .from(sites) @@ -319,7 +334,6 @@ export async function listSites( if (typeof status !== "undefined") { conditions.push(eq(sites.status, status)); } - const baseQuery = querySitesBase().where(and(...conditions)); // we need to add `as` so that drizzle filters the result as a subquery diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx index ee7246821..a85b0d7d9 100644 --- a/src/app/[orgId]/settings/provisioning/pending/page.tsx +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -69,6 +69,7 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) { address: site.address?.split("/")[0], mbIn: formatSize(site.megabytesIn || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type), + resourceCount: Number(site.resourceCount ?? 0), orgId: params.orgId, type: site.type as any, online: site.online, diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index d5e11e9bc..ba65d06e0 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -42,6 +42,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { title: t("general"), href: `/${params.orgId}/settings/sites/${params.niceId}/general` }, + { + title: t("siteResourcesTab"), + href: `/${params.orgId}/settings/sites/${params.niceId}/resources` + }, ...(site.type !== "local" ? [ { diff --git a/src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx new file mode 100644 index 000000000..fcb460b87 --- /dev/null +++ b/src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx @@ -0,0 +1,64 @@ +import SiteResourcesOverview from "@app/components/SiteResourcesOverview"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import type { ListResourcesResponse } from "@server/routers/resource"; +import type { GetSiteResponse } from "@server/routers/site"; +import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; +import type { AxiosResponse } from "axios"; + +type SiteResourcesPageProps = { + params: Promise<{ orgId: string; niceId: string }>; +}; + +export default async function SiteResourcesPage(props: SiteResourcesPageProps) { + const { orgId, niceId } = await props.params; + + const siteRes = await internal.get>( + `/org/${orgId}/site/${niceId}`, + await authCookieHeader() + ); + const site = siteRes.data.data; + + const baseSearch = new URLSearchParams({ + page: "1", + pageSize: "5", + siteId: String(site.siteId) + }); + + let initialPublicData: ListResourcesResponse | null = null; + let initialPrivateData: ListAllSiteResourcesByOrgResponse | null = null; + let initialPublicForbidden = false; + let initialPrivateForbidden = false; + + try { + const res = await internal.get>( + `/org/${orgId}/resources?${baseSearch.toString()}`, + await authCookieHeader() + ); + initialPublicData = res.data.data; + } catch (e: any) { + initialPublicForbidden = e?.response?.status === 403; + } + + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${orgId}/site-resources?${baseSearch.toString()}`, + await authCookieHeader() + ); + initialPrivateData = res.data.data; + } catch (e: any) { + initialPrivateForbidden = e?.response?.status === 403; + } + + return ( + + ); +} diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index d78666d78..631baee41 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -64,6 +64,7 @@ export default async function SitesPage(props: SitesPageProps) { address: site.address?.split("/")[0], mbIn: formatSize(site.megabytesIn || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type), + resourceCount: Number(site.resourceCount ?? 0), orgId: params.orgId, type: site.type as any, online: site.online, diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index f10f6414e..d60d58d76 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -49,6 +49,7 @@ import { useDebouncedCallback } from "use-debounce"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { cn } from "@app/lib/cn"; import { dataTableFilterPopoverContentClassName } from "@app/lib/dataTableFilterPopover"; +import { formatSiteResourceDestinationDisplay } from "@app/lib/formatSiteResourceAccess"; import { ResourceSitesStatusCell, type ResourceSiteRow @@ -86,28 +87,13 @@ export type InternalResourceRow = { fullDomain?: string | null; }; -function resolveHttpHttpsDisplayPort( - mode: "http", - httpHttpsPort: number | null -): number { - if (httpHttpsPort != null) { - return httpHttpsPort; - } - return 80; -} - function formatDestinationDisplay(row: InternalResourceRow): string { - const { mode, destination, httpHttpsPort, scheme } = row; - if (mode !== "http") { - return destination; - } - const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort); - const downstreamScheme = scheme ?? "http"; - const hostPart = - destination.includes(":") && !destination.startsWith("[") - ? `[${destination}]` - : destination; - return `${downstreamScheme}://${hostPart}:${port}`; + return formatSiteResourceDestinationDisplay({ + mode: row.mode, + destination: row.destination, + httpHttpsPort: row.httpHttpsPort, + scheme: row.scheme + }); } function isSafeUrlForLink(href: string): boolean { @@ -609,6 +595,7 @@ export default function ClientResourcesTable({ rows={internalResources} tableId="internal-resources" searchPlaceholder={t("resourcesSearch")} + searchQuery={searchParams.get("query") ?? ""} onAdd={() => setIsCreateDialogOpen(true)} addButtonText={t("resourceAdd")} onSearch={handleSearchChange} diff --git a/src/components/MemberResourcesPortal.tsx b/src/components/MemberResourcesPortal.tsx index 8ce721c88..602735e56 100644 --- a/src/components/MemberResourcesPortal.tsx +++ b/src/components/MemberResourcesPortal.tsx @@ -67,7 +67,7 @@ type SiteResource = { enabled: boolean; alias: string | null; aliasAddress: string | null; - type: 'site'; + type: "site"; }; type MemberResourcesPortalProps = { @@ -130,7 +130,9 @@ const ResourceInfo = ({ resource }: { resource: Resource }) => { resource.whitelist; const hasAnyInfo = - Boolean(resource.siteName) || Boolean(hasAuthMethods) || !resource.enabled; + Boolean(resource.siteName) || + Boolean(hasAuthMethods) || + !resource.enabled; if (!hasAnyInfo) return null; @@ -353,7 +355,9 @@ export default function MemberResourcesPortal({ const [resources, setResources] = useState([]); const [siteResources, setSiteResources] = useState([]); const [filteredResources, setFilteredResources] = useState([]); - const [filteredSiteResources, setFilteredSiteResources] = useState([]); + const [filteredSiteResources, setFilteredSiteResources] = useState< + SiteResource[] + >([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); @@ -381,7 +385,9 @@ export default function MemberResourcesPortal({ setResources(response.data.data.resources); setSiteResources(response.data.data.siteResources || []); setFilteredResources(response.data.data.resources); - setFilteredSiteResources(response.data.data.siteResources || []); + setFilteredSiteResources( + response.data.data.siteResources || [] + ); } else { setError("Failed to load resources"); } @@ -459,9 +465,10 @@ export default function MemberResourcesPortal({ case "domain-asc": case "domain-desc": // Sort by destination for site resources - const destCompare = sortBy === "domain-asc" - ? a.destination.localeCompare(b.destination) - : b.destination.localeCompare(a.destination); + const destCompare = + sortBy === "domain-asc" + ? a.destination.localeCompare(b.destination) + : b.destination.localeCompare(a.destination); return destCompare; case "status-enabled": return b.enabled ? 1 : -1; @@ -487,12 +494,14 @@ export default function MemberResourcesPortal({ startIndex + itemsPerPage ); const remainingSlots = itemsPerPage - paginatedResources.length; - const paginatedSiteResources = remainingSlots > 0 - ? filteredSiteResources.slice( - Math.max(0, startIndex - filteredResources.length), - Math.max(0, startIndex - filteredResources.length) + remainingSlots - ) - : []; + const paginatedSiteResources = + remainingSlots > 0 + ? filteredSiteResources.slice( + Math.max(0, startIndex - filteredResources.length), + Math.max(0, startIndex - filteredResources.length) + + remainingSlots + ) + : []; const handleOpenResource = (resource: Resource) => { // Open the resource in a new tab @@ -640,7 +649,8 @@ export default function MemberResourcesPortal({
{/* Resources Content */} - {filteredResources.length === 0 && filteredSiteResources.length === 0 ? ( + {filteredResources.length === 0 && + filteredSiteResources.length === 0 ? ( /* Enhanced Empty State */ @@ -697,87 +707,96 @@ export default function MemberResourcesPortal({ Public Resources

- Web applications and services accessible via browser + Web applications and services accessible via + browser

{paginatedResources.map((resource) => ( - -
-
-
- - - - - {resource.name} - - - -

- {resource.name} -

-
-
-
+ +
+
+
+ + + + + { + resource.name + } + + + +

+ { + resource.name + } +

+
+
+
+
+ +
+ +
+
+ +
+ + +
-
- +
+
-
- -
- - -
-
- -
- -
- - ))} -
+ + ))} +
)} @@ -790,7 +809,8 @@ export default function MemberResourcesPortal({ Private Resources

- Internal network resources accessible via client + Internal network resources accessible via + client

@@ -803,12 +823,16 @@ export default function MemberResourcesPortal({ - {siteResource.name} + { + siteResource.name + }

- {siteResource.name} + { + siteResource.name + }

@@ -818,39 +842,63 @@ export default function MemberResourcesPortal({
-
Resource Details
+
+ Resource Details +
- Mode: + + Mode: + - {siteResource.mode} + { + siteResource.mode + }
{siteResource.protocol && (
- Protocol: + + Protocol: + - {siteResource.protocol} + { + siteResource.protocol + }
)}
- Destination: + + Destination: + - {siteResource.destination} + { + siteResource.destination + }
{siteResource.alias && (
- Alias: + + Alias: + - {siteResource.alias} + { + siteResource.alias + }
)}
- Status: - - {siteResource.enabled ? 'Enabled' : 'Disabled'} + + Status: + + + {siteResource.enabled + ? "Enabled" + : "Disabled"}
@@ -864,7 +912,9 @@ export default function MemberResourcesPortal({ {/* Alias as primary */}
- {siteResource.alias} + { + siteResource.alias + }
+ ); +} + +function PrivateResourceMeta({ row }: { row: SiteResourceRow }) { + const t = useTranslations(); + const modeLabel: Record = { + host: t("editInternalResourceDialogModeHost"), + cidr: t("editInternalResourceDialogModeCidr"), + http: t("editInternalResourceDialogModeHttp") + }; + const dest = formatSiteResourceDestinationDisplay({ + mode: row.mode, + destination: row.destination, + httpHttpsPort: row.destinationPort ?? null, + scheme: row.scheme + }); + return ( +
+
+ {modeLabel[row.mode]} +
+
+ ); +} + +function PublicAccessMethod({ resource: r }: { resource: PublicResourceRow }) { + const t = useTranslations(); + if (!r.http) { + return ( + + ); + } + if (!r.domainId) { + return ( + + ); + } + const fullUrl = `${r.ssl ? "https" : "http"}://${toUnicode(r.fullDomain || "")}`; + return ( + + ); +} + +function PrivateAccessMethod({ row }: { row: SiteResourceRow }) { + if (row.mode === "http" && row.fullDomain) { + const url = `${row.ssl ? "https" : "http"}://${toUnicode(row.fullDomain)}`; + return ( + + ); + } + if (row.mode === "host" && row.alias) { + return ( + + ); + } + const fromAlias = row.alias?.trim(); + if (fromAlias) { + return ( + + ); + } + const dest = formatSiteResourceDestinationDisplay({ + mode: row.mode, + destination: row.destination, + httpHttpsPort: row.destinationPort, + scheme: row.scheme + }); + return ( + + ); +} + +type OverviewRow = { + key: number; + meta: ReactNode; + name: string; + access: ReactNode; + editHref: string; +}; + +type OverviewColumnProps = { + title: string; + description: string; + viewAllHref: string; + viewAllLabel: string; + emptyLabel: string; + isForbidden: boolean; + isFetching: boolean; + rows: OverviewRow[]; + canShowMore: boolean; + onShowMore: () => void; +}; + +function OverviewColumn({ + title, + description, + viewAllHref, + viewAllLabel, + emptyLabel, + isForbidden, + isFetching, + rows, + canShowMore, + onShowMore +}: OverviewColumnProps) { + const t = useTranslations(); + + const header = ( +
+
+
+

+ {title} +

+

+ {description} +

+
+ + {viewAllLabel} + +
+
+ ); + + if (isForbidden) { + return ( +
+ {header} +

+ {t("siteResourcesPermissionDenied")} +

+
+ ); + } + + return ( +
+ {header} + {rows.length === 0 ? ( +
+

+ {emptyLabel} +

+
+ ) : ( + <> +
+
+
    + {rows.map((row) => ( +
  • +
    + {row.meta} +
    +
    +
    + {row.name} +
    +
    + {row.access} +
    +
    +
    + +
    +
  • + ))} +
+
+ {canShowMore ? ( +
+ +
+ ) : null} + + )} +
+ ); +} + +type SiteResourcesOverviewProps = { + siteId: number; + initialPublicData: ListResourcesResponse | null; + initialPrivateData: ListAllSiteResourcesByOrgResponse | null; + initialPublicForbidden: boolean; + initialPrivateForbidden: boolean; + /** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */ + orgIdOverride?: string; +}; + +export default function SiteResourcesOverview({ + siteId, + initialPublicData, + initialPrivateData, + initialPublicForbidden, + initialPrivateForbidden, + orgIdOverride +}: SiteResourcesOverviewProps) { + const t = useTranslations(); + const params = useParams<{ orgId: string }>(); + const orgId = orgIdOverride ?? params.orgId; + const { env } = useEnvContext(); + const api = useMemo(() => createApiClient({ env }), [env]); + + const enabled = Boolean(orgId && siteId); + + const [publicPageSize, setPublicPageSize] = useState(INITIAL_PAGE_SIZE); + const [privatePageSize, setPrivatePageSize] = useState(INITIAL_PAGE_SIZE); + + const publicQuery = useQuery({ + queryKey: [ + "siteResourcesOverview", + "public", + orgId, + siteId, + publicPageSize + ] as const, + enabled: enabled && !initialPublicForbidden, + initialData: initialPublicData ?? undefined, + queryFn: async (): Promise => { + const sp = new URLSearchParams({ + page: "1", + pageSize: String(publicPageSize), + siteId: String(siteId) + }); + const res = await api.get( + `/org/${orgId}/resources?${sp.toString()}` + ); + const envelope = res.data as ResponseT; + const payload = envelope.data; + if (!payload) { + throw new Error("No data"); + } + return payload; + } + }); + + const privateQuery = useQuery({ + queryKey: [ + "siteResourcesOverview", + "private", + orgId, + siteId, + privatePageSize + ] as const, + enabled: enabled && !initialPrivateForbidden, + initialData: initialPrivateData ?? undefined, + queryFn: async (): Promise => { + const sp = new URLSearchParams({ + page: "1", + pageSize: String(privatePageSize), + siteId: String(siteId) + }); + const res = await api.get( + `/org/${orgId}/site-resources?${sp.toString()}` + ); + const envelope = + res.data as ResponseT; + const payload = envelope.data; + if (!payload) { + throw new Error("No data"); + } + return payload; + } + }); + + const publicList = publicQuery.data?.resources ?? []; + const publicTotal = publicQuery.data?.pagination.total ?? 0; + const privateList = privateQuery.data?.siteResources ?? []; + const privateTotal = privateQuery.data?.pagination.total ?? 0; + + const publicForbidden = + initialPublicForbidden || + (publicQuery.isError && isForbidden(publicQuery.error)); + const privateForbidden = + initialPrivateForbidden || + (privateQuery.isError && isForbidden(privateQuery.error)); + + const showEmptyPlaceholder = + !publicForbidden && + !privateForbidden && + publicList.length === 0 && + privateList.length === 0; + + const publicViewAllHref = `/${orgId}/settings/resources/proxy?siteId=${siteId}`; + const privateViewAllHref = `/${orgId}/settings/resources/client?siteId=${siteId}`; + + const publicRows = publicList.map((r) => ({ + key: r.resourceId, + meta: , + name: r.name, + access: , + editHref: `/${orgId}/settings/resources/proxy/${r.niceId}` + })); + + const privateRows = privateList.map((row) => { + const qs = new URLSearchParams({ + siteId: String(siteId), + query: row.niceId + }); + return { + key: row.siteResourceId, + meta: , + name: row.name, + access: , + editHref: `/${orgId}/settings/resources/client?${qs.toString()}` + }; + }); + + if (showEmptyPlaceholder) { + return ( + +

+ {t("siteResourcesNoneOnSite")} +

+
+ ); + } + + /** Left column = whichever side has a greater total; ties default to public first. */ + const publicOnLeft = publicTotal >= privateTotal; + + const publicColumn = ( + setPublicPageSize((n) => n + LOAD_MORE_INCREMENT)} + /> + ); + + const privateColumn = ( + + setPrivatePageSize((n) => n + LOAD_MORE_INCREMENT) + } + /> + ); + + return ( + +
+ {publicOnLeft + ? [publicColumn, privateColumn] + : [privateColumn, publicColumn]} +
+
+ ); +} diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 45c0d9a0b..b221f3f19 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -24,6 +24,7 @@ import { ArrowRight, ArrowUp10Icon, ArrowUpRight, + ChevronDown, ChevronsUpDownIcon, MoreHorizontal } from "lucide-react"; @@ -34,6 +35,16 @@ import { useState, useTransition, useEffect } from "react"; import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; +import SiteResourcesOverview from "@app/components/SiteResourcesOverview"; +import { + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; import { ControlledDataTable, type ExtendedColumnDef @@ -54,6 +65,7 @@ export type SiteRow = { exitNodeName?: string; exitNodeEndpoint?: string; remoteExitNodeId?: string; + resourceCount: number; }; type SitesTableProps = { @@ -79,6 +91,8 @@ export default function SitesTable({ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); + const [resourcesDialogSite, setResourcesDialogSite] = + useState(null); const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); @@ -293,6 +307,29 @@ export default function SitesTable({ ); } }, + { + id: "resources", + accessorKey: "resourceCount", + friendlyName: t("resources"), + header: () => {t("resources")}, + cell: ({ row }) => { + const siteRow = row.original; + return ( + + ); + } + }, { accessorKey: "type", friendlyName: t("type"), @@ -503,6 +540,43 @@ export default function SitesTable({ return ( <> + { + if (!open) setResourcesDialogSite(null); + }} + > + + + {t("siteResourcesTab")} + + {t("siteResourcesDialogDescription")} + + + + {resourcesDialogSite != null && ( + + )} + + + + + + + {selectedSite && ( Date: Sat, 25 Apr 2026 15:17:39 -0700 Subject: [PATCH 051/107] change column order on sites table --- src/components/SiteResourcesOverview.tsx | 8 ++--- src/components/SitesTable.tsx | 46 ++++++++++++------------ 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/src/components/SiteResourcesOverview.tsx b/src/components/SiteResourcesOverview.tsx index b774b22b8..9ff6b9686 100644 --- a/src/components/SiteResourcesOverview.tsx +++ b/src/components/SiteResourcesOverview.tsx @@ -431,9 +431,6 @@ export default function SiteResourcesOverview({ ); } - /** Left column = whichever side has a greater total; ties default to public first. */ - const publicOnLeft = publicTotal >= privateTotal; - const publicColumn = (
- {publicOnLeft - ? [publicColumn, privateColumn] - : [privateColumn, publicColumn]} + {publicColumn} + {privateColumn}
); diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index b221f3f19..8eba5cee5 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -307,29 +307,6 @@ export default function SitesTable({ ); } }, - { - id: "resources", - accessorKey: "resourceCount", - friendlyName: t("resources"), - header: () => {t("resources")}, - cell: ({ row }) => { - const siteRow = row.original; - return ( - - ); - } - }, { accessorKey: "type", friendlyName: t("type"), @@ -376,6 +353,29 @@ export default function SitesTable({ } } }, + { + id: "resources", + accessorKey: "resourceCount", + friendlyName: t("resources"), + header: () => {t("resources")}, + cell: ({ row }) => { + const siteRow = row.original; + return ( + + ); + } + }, { accessorKey: "exitNode", friendlyName: t("exitNode"), From 8e16ff07a9b6c25785c966f59acfad7c26213391 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 25 Apr 2026 15:23:22 -0700 Subject: [PATCH 052/107] move switch toggle above tabs on health check dialog --- messages/en-US.json | 1 + src/components/HealthCheckCredenza.tsx | 239 +++++++++++++------------ 2 files changed, 121 insertions(+), 119 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 5492768f1..13b47d135 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1903,6 +1903,7 @@ "configureHealthCheck": "Configure Health Check", "configureHealthCheckDescription": "Set up health monitoring for {target}", "enableHealthChecks": "Enable Health Checks", + "healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.", "enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.", "healthScheme": "Method", "healthSelectScheme": "Select Method", diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index 671a16e7d..c5ab59064 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -46,6 +46,7 @@ import { SitesSelector } from "@app/components/site-selector"; import type { Selectedsite } from "@app/components/site-selector"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { cn } from "@app/lib/cn"; +import { SwitchInput } from "@app/components/SwitchInput"; export type HealthCheckConfig = { hcEnabled: boolean; @@ -407,7 +408,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { }) : t("standaloneHcDescription"); - const showFields = mode === "submit" || watchedEnabled; + const disableTabInputs = mode === "autoSave" && !watchedEnabled; const isSnmpOrIcmp = watchedMode === "snmp" || watchedMode === "icmp"; const isTcp = watchedMode === "tcp"; @@ -491,6 +492,40 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) {
)} + {mode === "autoSave" && ( +
+ ( + + + + handleChange( + "hcEnabled", + value, + field.onChange + ) + } + /> + + + )} + /> +
+ )} +
{/* ── Strategy tab ──────────────────────── */} -
- {/* Enable toggle (autoSave mode only) */} - {mode === "autoSave" && ( - ( - -
- - {t( - "enableHealthChecks" - )} - -
- - - handleChange( - "hcEnabled", - value, - field.onChange - ) - } - /> - -
- )} - /> - )} - +
+
{/* Strategy picker */} - {showFields && ( - ( - - - - handleChange( - "hcMode", - value, - field.onChange + ( + + + - - - - )} - /> - )} + ]} + value={field.value} + onChange={(value) => + handleChange( + "hcMode", + value, + field.onChange + ) + } + /> + + + + )} + /> +
{/* ── Connection tab ────────────────────── */} -
- {!showFields && ( -

- {t("enableHealthChecks")} -

- )} - +
+
{/* Contact-sales banner for SNMP / ICMP */} - {showFields && isSnmpOrIcmp && ( - - )} + {isSnmpOrIcmp && } - {showFields && !isSnmpOrIcmp && ( + {!isSnmpOrIcmp && ( <> {/* Scheme / Hostname / Port */} {isTcp ? ( @@ -1021,22 +1020,23 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { )} )} +
{/* ── Advanced tab ──────────────────────── */} -
- {!showFields && ( -

- {t("enableHealthChecks")} -

- )} - +
+
{/* Contact-sales banner for SNMP / ICMP */} - {showFields && isSnmpOrIcmp && ( - - )} + {isSnmpOrIcmp && } - {showFields && !isSnmpOrIcmp && ( + {!isSnmpOrIcmp && ( <> {/* Healthy interval + threshold */}
@@ -1350,6 +1350,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { )} )} +
From 82212af643a2710a894613e55aa760db767de341 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 24 Apr 2026 17:47:08 -0700 Subject: [PATCH 053/107] Add resource degraded --- messages/en-US.json | 1 + server/db/pg/schema/privateSchema.ts | 1 + server/db/sqlite/schema/privateSchema.ts | 43 ++++++++++++++----- server/emails/templates/AlertNotification.tsx | 15 ++++++- .../lib/alerts/events/healthCheckEvents.ts | 9 ++++ .../lib/alerts/events/resourceEvents.ts | 29 ++++++++++--- server/private/lib/alerts/sendAlertEmail.ts | 2 + server/private/lib/alerts/sendAlertWebhook.ts | 31 ++++++++++--- .../alertEvents/triggerResourceAlert.ts | 14 ++++-- .../routers/alertRule/createAlertRule.ts | 41 ++++++++++++------ .../routers/alertRule/listAlertRules.ts | 1 + server/routers/alertRule/types.ts | 2 + src/components/AlertingRulesTable.tsx | 2 + src/components/ProxyResourcesTable.tsx | 1 - .../alert-rule-editor/AlertRuleFields.tsx | 4 ++ src/lib/alertRuleForm.ts | 8 +++- 16 files changed, 162 insertions(+), 42 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 13b47d135..c566d500e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1432,6 +1432,7 @@ "alertingTriggerHcToggle": "Health check status changes", "alertingTriggerResourceHealthy": "Resource healthy", "alertingTriggerResourceUnhealthy": "Resource unhealthy", + "alertingTriggerResourceDegraded": "Resource degraded", "alertingSearchHealthChecks": "Search health checks…", "alertingHealthChecksEmpty": "No health checks available.", "alertingTriggerResourceToggle": "Resource status changes", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index d930b69d0..1aa2a1ef7 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -484,6 +484,7 @@ export const alertRules = pgTable("alertRules", { | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle" >() .notNull(), diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 9a33e2049..25a7b5bf5 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -425,10 +425,18 @@ export const eventStreamingDestinations = sqliteTable( orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), - sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false), - sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false), - sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false), - sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }).notNull().default(false), + sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }) + .notNull() + .default(false), + sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }) + .notNull() + .default(false), + sendActionLogs: integer("sendActionLogs", { mode: "boolean" }) + .notNull() + .default(false), + sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }) + .notNull() + .default(false), type: text("type").notNull(), // e.g. "http", "kafka", etc. config: text("config").notNull(), // JSON string with the configuration for the destination enabled: integer("enabled", { mode: "boolean" }) @@ -476,14 +484,19 @@ export const alertRules = sqliteTable("alertRules", { | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle" >() .notNull(), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), cooldownSeconds: integer("cooldownSeconds").notNull().default(300), allSites: integer("allSites", { mode: "boolean" }).notNull().default(false), - allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false), - allResources: integer("allResources", { mode: "boolean" }).notNull().default(false), + allHealthChecks: integer("allHealthChecks", { mode: "boolean" }) + .notNull() + .default(false), + allResources: integer("allResources", { mode: "boolean" }) + .notNull() + .default(false), lastTriggeredAt: integer("lastTriggeredAt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt").notNull() @@ -531,19 +544,27 @@ export const alertEmailRecipients = sqliteTable("alertEmailRecipients", { recipientId: integer("recipientId").primaryKey({ autoIncrement: true }), emailActionId: integer("emailActionId") .notNull() - .references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }), - userId: text("userId").references(() => users.userId, { onDelete: "cascade" }), - roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }), + .references(() => alertEmailActions.emailActionId, { + onDelete: "cascade" + }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + roleId: integer("roleId").references(() => roles.roleId, { + onDelete: "cascade" + }), email: text("email") }); export const alertWebhookActions = sqliteTable("alertWebhookActions", { - webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }), + webhookActionId: integer("webhookActionId").primaryKey({ + autoIncrement: true + }), alertRuleId: integer("alertRuleId") .notNull() .references(() => alertRules.alertRuleId, { onDelete: "cascade" }), webhookUrl: text("webhookUrl").notNull(), - config: text("config"), // encrypted JSON with auth config (authType, credentials) + config: text("config"), // encrypted JSON with auth config (authType, credentials) enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), lastSentAt: integer("lastSentAt") }); diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index 5542384a9..899c4a82f 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -23,6 +23,7 @@ export type AlertEventType = | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle"; export type AlertNotificationProps = { @@ -114,6 +115,15 @@ function getEventMeta(eventType: AlertEventType): { statusLabel: "Unhealthy", statusColor: "#dc2626" }; + case "resource_degraded": + return { + heading: "Resource Unhealthy", + previewText: "A resource in your organization is not healthy.", + summary: + "A resource in your organization is currently unhealthy.", + statusLabel: "Unhealthy", + statusColor: "#dc2626" + }; case "resource_toggle": return { heading: "Resource Status Changed", @@ -135,7 +145,10 @@ function getEventMeta(eventType: AlertEventType): { } } -function resolveToggleStatus(status: unknown): { label: string; color: string } { +function resolveToggleStatus(status: unknown): { + label: string; + color: string; +} { switch (String(status).toLowerCase()) { case "online": return { label: "Online", color: "#16a34a" }; diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 9b5c3104b..9f675f4c0 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -23,6 +23,7 @@ import { } from "@server/db"; import { eq } from "drizzle-orm"; import { + fireResourceDegradedAlert, fireResourceHealthyAlert, fireResourceUnhealthyAlert } from "./resourceEvents"; @@ -217,6 +218,14 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null undefined, trx ); + } else if (health === "degraded") { + await fireResourceDegradedAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + trx + ); } } } diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 8c20bc5a1..41c77dc11 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -130,9 +130,9 @@ export async function fireResourceUnhealthyAlert( } /** - * Fire a `resource_toggle` alert for the given resource. + * Fire a `resource_degraded` alert for the given resource. * - * Call this when a resource's enabled/disabled status is toggled so that any + * Call this after a resource has been detected as degraded so that any * matching `alertRules` can dispatch their email and webhook actions. * * @param orgId - Organisation that owns the resource. @@ -140,7 +140,7 @@ export async function fireResourceUnhealthyAlert( * @param resourceName - Human-readable name shown in notifications (optional). * @param extra - Any additional key/value pairs to include in the payload. */ -export async function fireResourceToggleAlert( +export async function fireResourceDegradedAlert( orgId: string, resourceId: number, resourceName?: string | null, @@ -148,8 +148,16 @@ export async function fireResourceToggleAlert( trx: Transaction | typeof db = db ): Promise { try { + await trx.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "degraded", + timestamp: Math.floor(Date.now() / 1000) + }); + await processAlerts({ - eventType: "resource_toggle", + eventType: "resource_degraded", orgId, resourceId, data: { @@ -157,9 +165,20 @@ export async function fireResourceToggleAlert( ...extra } }); + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "degraded", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); } catch (err) { logger.error( - `fireResourceToggleAlert: unexpected error for resourceId ${resourceId}`, + `fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`, err ); } diff --git a/server/private/lib/alerts/sendAlertEmail.ts b/server/private/lib/alerts/sendAlertEmail.ts index 598262e38..6f99b102c 100644 --- a/server/private/lib/alerts/sendAlertEmail.ts +++ b/server/private/lib/alerts/sendAlertEmail.ts @@ -88,6 +88,8 @@ function buildSubject(context: AlertContext): string { return "[Alert] Resource Healthy"; case "resource_unhealthy": return "[Alert] Resource Unhealthy"; + case "resource_degraded": + return "[Alert] Resource Degraded"; case "resource_toggle": return "[Alert] Resource Status Changed"; default: { diff --git a/server/private/lib/alerts/sendAlertWebhook.ts b/server/private/lib/alerts/sendAlertWebhook.ts index 2dd0eb600..3975eb09f 100644 --- a/server/private/lib/alerts/sendAlertWebhook.ts +++ b/server/private/lib/alerts/sendAlertWebhook.ts @@ -12,7 +12,10 @@ */ import logger from "@server/logger"; -import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types"; +import { + AlertContext, + WebhookAlertConfig +} from "@server/routers/alertRule/types"; const REQUEST_TIMEOUT_MS = 15_000; const MAX_RETRIES = 3; @@ -56,7 +59,10 @@ export async function sendAlertWebhook( for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { const controller = new AbortController(); - const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const timeoutHandle = setTimeout( + () => controller.abort(), + REQUEST_TIMEOUT_MS + ); let response: Response; try { @@ -75,7 +81,9 @@ export async function sendAlertWebhook( ); } else { const msg = err instanceof Error ? err.message : String(err); - lastError = new Error(`Alert webhook: request to "${url}" failed – ${msg}`); + lastError = new Error( + `Alert webhook: request to "${url}" failed – ${msg}` + ); } if (attempt < MAX_RETRIES) { const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); @@ -111,11 +119,18 @@ export async function sendAlertWebhook( continue; } - logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}" (attempt ${attempt}/${MAX_RETRIES})`); + logger.debug( + `Alert webhook sent successfully to "${url}" for event "${context.eventType}" (attempt ${attempt}/${MAX_RETRIES})` + ); return; } - throw lastError ?? new Error(`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`); + throw ( + lastError ?? + new Error( + `Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"` + ) + ); } // --------------------------------------------------------------------------- @@ -139,6 +154,8 @@ function deriveStatus( case "health_check_unhealthy": case "resource_unhealthy": return "unhealthy"; + case "resource_degraded": + return "degraded"; case "health_check_toggle": case "resource_toggle": return String(data.status ?? "unknown"); @@ -154,7 +171,9 @@ function deriveStatus( // Header construction (mirrors HttpLogDestination.buildHeaders) // --------------------------------------------------------------------------- -function buildHeaders(webhookConfig: WebhookAlertConfig): Record { +function buildHeaders( + webhookConfig: WebhookAlertConfig +): Record { const headers: Record = { "Content-Type": "application/json" }; diff --git a/server/private/routers/alertEvents/triggerResourceAlert.ts b/server/private/routers/alertEvents/triggerResourceAlert.ts index 42e63b288..a43b8e201 100644 --- a/server/private/routers/alertEvents/triggerResourceAlert.ts +++ b/server/private/routers/alertEvents/triggerResourceAlert.ts @@ -24,7 +24,8 @@ import { eq, and } from "drizzle-orm"; import { fireResourceHealthyAlert, fireResourceUnhealthyAlert, - fireResourceToggleAlert + fireResourceToggleAlert, + fireResourceDegradedAlert } from "#private/lib/alerts/events/resourceEvents"; const paramsSchema = z.strictObject({ @@ -33,7 +34,12 @@ const paramsSchema = z.strictObject({ }); const bodySchema = z.strictObject({ - eventType: z.enum(["resource_healthy", "resource_unhealthy", "resource_toggle"]) + eventType: z.enum([ + "resource_healthy", + "resource_unhealthy", + "resource_degraded", + "resource_toggle" + ]) }); export type TriggerResourceAlertResponse = { @@ -101,8 +107,8 @@ export async function triggerResourceAlert( resourceId, resource.name ?? undefined ); - } else { - await fireResourceToggleAlert( + } else if (eventType === "resource_degraded") { + await fireResourceDegradedAlert( orgId, resourceId, resource.name ?? undefined diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index ceaacf73c..f9b84ebab 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -33,7 +33,11 @@ import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import { CreateAlertRuleResponse } from "@server/routers/alertRule/types"; -export const SITE_EVENT_TYPES = ["site_online", "site_offline", "site_toggle"] as const; +export const SITE_EVENT_TYPES = [ + "site_online", + "site_offline", + "site_toggle" +] as const; export const HC_EVENT_TYPES = [ "health_check_healthy", "health_check_unhealthy", @@ -42,6 +46,7 @@ export const HC_EVENT_TYPES = [ export const RESOURCE_EVENT_TYPES = [ "resource_healthy", "resource_unhealthy", + "resource_degraded", "resource_toggle" ] as const; @@ -92,19 +97,24 @@ const bodySchema = z const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes( val.eventType ); - const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes( - val.eventType - ); + const isResourceEvent = ( + RESOURCE_EVENT_TYPES as readonly string[] + ).includes(val.eventType); if (isSiteEvent && !val.allSites && val.siteIds.length === 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "At least one siteId is required for site event types when allSites is false", + message: + "At least one siteId is required for site event types when allSites is false", path: ["siteIds"] }); } - if (isHcEvent && !val.allHealthChecks && val.healthCheckIds.length === 0) { + if ( + isHcEvent && + !val.allHealthChecks && + val.healthCheckIds.length === 0 + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: @@ -129,10 +139,15 @@ const bodySchema = z }); } - if (isResourceEvent && !val.allResources && val.resourceIds.length === 0) { + if ( + isResourceEvent && + !val.allResources && + val.resourceIds.length === 0 + ) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "At least one resourceId is required for resource event types when allResources is false", + message: + "At least one resourceId is required for resource event types when allResources is false", path: ["resourceIds"] }); } @@ -148,7 +163,8 @@ const bodySchema = z if (isResourceEvent && val.healthCheckIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "healthCheckIds must not be set for resource event types", + message: + "healthCheckIds must not be set for resource event types", path: ["healthCheckIds"] }); } @@ -164,7 +180,8 @@ const bodySchema = z if (isHcEvent && val.resourceIds.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: "resourceIds must not be set for health check event types", + message: + "resourceIds must not be set for health check event types", path: ["resourceIds"] }); } @@ -284,9 +301,7 @@ export async function createAlertRule( // Create the email action pivot row and recipients if any recipients // were supplied (userIds, roleIds, or raw emails). const hasRecipients = - userIds.length > 0 || - roleIds.length > 0 || - emails.length > 0; + userIds.length > 0 || roleIds.length > 0 || emails.length > 0; if (hasRecipients) { const [emailActionRow] = await db diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index a31a4d119..9684b88a4 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -76,6 +76,7 @@ const SITE_ALERT_EVENT_TYPES = [ const RESOURCE_ALERT_EVENT_TYPES = [ "resource_healthy", "resource_unhealthy", + "resource_degraded", "resource_toggle" ] as const; diff --git a/server/routers/alertRule/types.ts b/server/routers/alertRule/types.ts index a9e66350e..e3f92591d 100644 --- a/server/routers/alertRule/types.ts +++ b/server/routers/alertRule/types.ts @@ -37,6 +37,7 @@ export type GetAlertRuleResponse = { | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle"; enabled: boolean; cooldownSeconds: number; @@ -94,6 +95,7 @@ export type AlertEventType = | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle"; // --------------------------------------------------------------------------- diff --git a/src/components/AlertingRulesTable.tsx b/src/components/AlertingRulesTable.tsx index 52ff3b609..f8fcf468d 100644 --- a/src/components/AlertingRulesTable.tsx +++ b/src/components/AlertingRulesTable.tsx @@ -118,6 +118,8 @@ function triggerLabel(rule: AlertRuleRow, t: (k: string) => string) { return t("alertingTriggerResourceHealthy"); case "resource_unhealthy": return t("alertingTriggerResourceUnhealthy"); + case "resource_degraded": + return t("alertingTriggerResourceDegraded"); case "resource_toggle": return t("alertingTriggerResourceToggle"); default: diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 324f29552..2b56eb98d 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -32,7 +32,6 @@ import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { UpdateResourceResponse } from "@server/routers/resource"; import type { PaginationState } from "@tanstack/react-table"; -import { useQuery } from "@tanstack/react-query"; import { AxiosResponse } from "axios"; import { ArrowDown01Icon, diff --git a/src/components/alert-rule-editor/AlertRuleFields.tsx b/src/components/alert-rule-editor/AlertRuleFields.tsx index 1d420f433..887fbaa5a 100644 --- a/src/components/alert-rule-editor/AlertRuleFields.tsx +++ b/src/components/alert-rule-editor/AlertRuleFields.tsx @@ -1147,6 +1147,7 @@ export function AlertRuleSourceFields({ if ( curTrigger !== "resource_healthy" && curTrigger !== "resource_unhealthy" && + curTrigger !== "resource_degraded" && curTrigger !== "resource_toggle" ) { setValue("trigger", "resource_toggle", { @@ -1367,6 +1368,9 @@ export function AlertRuleTriggerFields({ {t("alertingTriggerResourceUnhealthy")} + + {t("alertingTriggerResourceDegraded")} + ) : ( <> diff --git a/src/lib/alertRuleForm.ts b/src/lib/alertRuleForm.ts index 111487c48..039c367b6 100644 --- a/src/lib/alertRuleForm.ts +++ b/src/lib/alertRuleForm.ts @@ -25,6 +25,7 @@ export type AlertTrigger = | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle"; export type AlertRuleFormAction = @@ -77,6 +78,7 @@ export type AlertRuleApiPayload = { | "health_check_toggle" | "resource_healthy" | "resource_unhealthy" + | "resource_degraded" | "resource_toggle"; enabled: boolean; allSites: boolean; @@ -160,6 +162,7 @@ export function buildFormSchema(t: (k: string) => string) { "health_check_toggle", "resource_healthy", "resource_unhealthy", + "resource_degraded", "resource_toggle" ]), actions: z.array( @@ -243,6 +246,7 @@ export function buildFormSchema(t: (k: string) => string) { const resourceTriggers: AlertTrigger[] = [ "resource_healthy", "resource_unhealthy", + "resource_degraded", "resource_toggle" ]; if ( @@ -344,7 +348,9 @@ export function alertRuleAllResourcesSelected( eventType: string, resourceIds: number[] | undefined ): boolean { - return eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0; + return ( + eventType.startsWith("resource_") && (resourceIds?.length ?? 0) === 0 + ); } export function alertRuleAllHealthChecksSelected( From 7c7d1f641e174b94fd228d6d2f4173d824e79635 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 25 Apr 2026 15:29:59 -0700 Subject: [PATCH 054/107] Support unknown and degraded status --- server/lib/alerts/events/healthCheckEvents.ts | 11 + server/lib/blueprints/proxyResources.ts | 27 ++ server/lib/statusHistory.ts | 51 +++- .../lib/alerts/events/healthCheckEvents.ts | 47 +++- .../lib/alerts/events/resourceEvents.ts | 46 +++ server/private/lib/alerts/types.ts | 63 +++++ server/routers/target/updateTarget.ts | 15 + src/app/admin/users/AdminUsersTable.tsx | 264 ++++++++++++++++++ src/components/AdminUsersDataTable.tsx | 37 +++ src/components/RolesDataTable.tsx | 41 +++ src/components/UptimeBar.tsx | 7 +- src/components/UptimeMiniBar.tsx | 5 +- src/components/UsersDataTable.tsx | 41 +++ src/lib/alertRulesLocalStorage.ts | 129 +++++++++ 14 files changed, 766 insertions(+), 18 deletions(-) create mode 100644 server/private/lib/alerts/types.ts create mode 100644 src/app/admin/users/AdminUsersTable.tsx create mode 100644 src/components/AdminUsersDataTable.tsx create mode 100644 src/components/RolesDataTable.tsx create mode 100644 src/components/UsersDataTable.tsx create mode 100644 src/lib/alertRulesLocalStorage.ts diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts index ba79feb4b..0a6d40f16 100644 --- a/server/lib/alerts/events/healthCheckEvents.ts +++ b/server/lib/alerts/events/healthCheckEvents.ts @@ -20,4 +20,15 @@ export async function fireHealthCheckUnhealthyAlert( trx?: unknown ): Promise { return; +} + +export async function fireHealthCheckUnknownAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + healthCheckTargetId?: number | null, + extra?: Record, + trx?: unknown +): Promise { + return; } \ No newline at end of file diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 136fa1545..fc1fee5b0 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -34,6 +34,7 @@ import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isValidRegionId } from "@server/db/regions"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; import { tierMatrix } from "../billing/tierMatrix"; export type ProxyResourcesResults = { @@ -169,6 +170,18 @@ export async function updateProxyResources( .returning(); healthchecksToUpdate.push(newHealthcheck); + + // Insert unknown status history when HC is created in disabled state + if (!healthcheckData?.enabled) { + await fireHealthCheckUnknownAlert( + orgId, + newHealthcheck.targetHealthCheckId, + newHealthcheck.name, + newHealthcheck.targetId, + undefined, + trx + ); + } } // Find existing resource by niceId and orgId @@ -557,6 +570,20 @@ export async function updateProxyResources( targetsToUpdate.push(updatedTarget); } } + + // Insert unknown status history when HC is disabled + const isDisablingHc = + !healthcheckData?.enabled && oldHealthcheck?.hcEnabled; + if (isDisablingHc) { + await fireHealthCheckUnknownAlert( + orgId, + newHealthcheck.targetHealthCheckId, + newHealthcheck.name, + newHealthcheck.targetId, + undefined, + trx + ); + } } else { await createTarget(existingResource.resourceId, targetData); } diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 001a0b93b..896a5e302 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -18,7 +18,7 @@ export interface StatusHistoryDayBucket { uptimePercent: number; // 0-100 totalDowntimeSeconds: number; downtimeWindows: { start: number; end: number | null; status: string }[]; - status: "good" | "degraded" | "bad" | "no_data"; + status: "good" | "degraded" | "bad" | "no_data" | "unknown"; } export interface StatusHistoryResponse { @@ -54,6 +54,7 @@ export function computeBuckets( const windows: { start: number; end: number | null; status: string }[] = []; let dayDowntime = 0; + let dayDegradedTime = 0; let windowStart = dayStartSec; let windowStatus = currentStatus; @@ -63,8 +64,8 @@ export function computeBuckets( const windowEnd = evt.timestamp; const isDown = windowStatus === "offline" || - windowStatus === "unhealthy" || - windowStatus === "unknown"; + windowStatus === "unhealthy"; + const isDegraded = windowStatus === "degraded"; if (isDown) { dayDowntime += windowEnd - windowStart; windows.push({ @@ -72,6 +73,13 @@ export function computeBuckets( end: windowEnd, status: windowStatus, }); + } else if (isDegraded) { + dayDegradedTime += windowEnd - windowStart; + windows.push({ + start: windowStart, + end: windowEnd, + status: windowStatus, + }); } } windowStart = evt.timestamp; @@ -83,8 +91,8 @@ export function computeBuckets( const finalEnd = Math.min(dayEndSec, nowSec); const isDown = windowStatus === "offline" || - windowStatus === "unhealthy" || - windowStatus === "unknown"; + windowStatus === "unhealthy"; + const isDegraded = windowStatus === "degraded"; if (isDown && finalEnd > windowStart) { dayDowntime += finalEnd - windowStart; windows.push({ @@ -92,6 +100,13 @@ export function computeBuckets( end: finalEnd, status: windowStatus, }); + } else if (isDegraded && finalEnd > windowStart) { + dayDegradedTime += finalEnd - windowStart; + windows.push({ + start: windowStart, + end: finalEnd, + status: windowStatus, + }); } } @@ -105,7 +120,7 @@ export function computeBuckets( effectiveDayLength > 0 ? Math.max( 0, - ((effectiveDayLength - dayDowntime) / + ((effectiveDayLength - dayDowntime - dayDegradedTime) / effectiveDayLength) * 100 ) @@ -113,11 +128,27 @@ export function computeBuckets( const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); + const hasAnyData = currentStatus !== null || dayEvents.length > 0; + + // The whole observable window is "unknown" if every status we have seen is unknown + const allStatuses = [ + ...(currentStatus !== null ? [currentStatus] : []), + ...dayEvents.map((e) => e.status) + ]; + const onlyUnknownData = + hasAnyData && allStatuses.every((s) => s === "unknown"); + let status: StatusHistoryDayBucket["status"] = "no_data"; - if (currentStatus !== null || dayEvents.length > 0) { - if (uptimePct >= 99) status = "good"; - else if (uptimePct >= 50) status = "degraded"; - else status = "bad"; + if (hasAnyData) { + if (onlyUnknownData) { + status = "unknown"; + } else if (dayDowntime > 0 && uptimePct < 50) { + status = "bad"; + } else if (dayDowntime > 0 || dayDegradedTime > 0) { + status = "degraded"; + } else { + status = "good"; + } } buckets.push({ diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 9f675f4c0..04f197a8d 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -25,7 +25,8 @@ import { eq } from "drizzle-orm"; import { fireResourceDegradedAlert, fireResourceHealthyAlert, - fireResourceUnhealthyAlert + fireResourceUnhealthyAlert, + fireResourceUnknownAlert } from "./resourceEvents"; // --------------------------------------------------------------------------- @@ -148,6 +149,32 @@ export async function fireHealthCheckUnhealthyAlert( } } +export async function fireHealthCheckUnknownAlert( + orgId: string, + healthCheckId: number, + healthCheckName?: string | null, + healthCheckTargetId?: number | null, + extra?: Record, + trx: Transaction | typeof db = db +): Promise { + try { + await trx.insert(statusHistory).values({ + entityType: "health_check", + entityId: healthCheckId, + orgId: orgId, + status: "unknown", + timestamp: Math.floor(Date.now() / 1000) + }); + + await handleResource(orgId, healthCheckTargetId, trx); + } catch (err) { + logger.error( + `fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`, + err + ); + } +} + async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) { if (!healthCheckTargetId) { return; @@ -178,10 +205,16 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null .where(eq(targets.resourceId, resource.resourceId)); let health = "healthy"; + const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown"); const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy"); const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy"); - if (allHealthy) { + if (allUnknown) { + logger.debug( + `Marking resource ${resource.resourceId} as unknown because all health checks are disabled` + ); + health = "unknown"; + } else if (allHealthy) { health = "healthy"; } else if (allUnhealthy) { logger.debug( @@ -202,7 +235,15 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null .set({ health }) .where(eq(resources.resourceId, resource.resourceId)); - if (health === "unhealthy") { + if (health === "unknown") { + await fireResourceUnknownAlert( + orgId, + resource.resourceId, + resource.name, + undefined, + trx + ); + } else if (health === "unhealthy") { await fireResourceUnhealthyAlert( orgId, resource.resourceId, diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 41c77dc11..289b19b90 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -183,3 +183,49 @@ export async function fireResourceDegradedAlert( ); } } + +/** + * Fire a `resource_unknown` alert for the given resource. + * + * Call this when all health checks on a resource are disabled so that the + * resource status transitions to unknown. + * + * @param orgId - Organisation that owns the resource. + * @param resourceId - Numeric primary key of the resource. + * @param resourceName - Human-readable name shown in notifications (optional). + * @param extra - Any additional key/value pairs to include in the payload. + */ +export async function fireResourceUnknownAlert( + orgId: string, + resourceId: number, + resourceName?: string | null, + extra?: Record, + trx: Transaction | typeof db = db +): Promise { + try { + await trx.insert(statusHistory).values({ + entityType: "resource", + entityId: resourceId, + orgId: orgId, + status: "unknown", + timestamp: Math.floor(Date.now() / 1000) + }); + + await processAlerts({ + eventType: "resource_toggle", + orgId, + resourceId, + data: { + resourceId, + status: "unknown", + ...(resourceName != null ? { resourceName } : {}), + ...extra + } + }); + } catch (err) { + logger.error( + `fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`, + err + ); + } +} diff --git a/server/private/lib/alerts/types.ts b/server/private/lib/alerts/types.ts new file mode 100644 index 000000000..e79db2ef5 --- /dev/null +++ b/server/private/lib/alerts/types.ts @@ -0,0 +1,63 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +// --------------------------------------------------------------------------- +// Alert event types +// --------------------------------------------------------------------------- + +export type AlertEventType = + | "site_online" + | "site_offline" + | "health_check_healthy" + | "health_check_not_healthy"; + +// --------------------------------------------------------------------------- +// Webhook authentication config (stored as encrypted JSON in the DB) +// --------------------------------------------------------------------------- + +export type WebhookAuthType = "none" | "bearer" | "basic" | "custom"; + +/** + * Stored as an encrypted JSON blob in `alertWebhookActions.config`. + */ +export interface WebhookAlertConfig { + /** Authentication strategy for the webhook endpoint */ + authType: WebhookAuthType; + /** Bearer token – used when authType === "bearer" */ + bearerToken?: string; + /** Basic credentials – "username:password" – used when authType === "basic" */ + basicCredentials?: string; + /** Custom header name – used when authType === "custom" */ + customHeaderName?: string; + /** Custom header value – used when authType === "custom" */ + customHeaderValue?: string; + /** Extra headers to send with every webhook request */ + headers?: Array<{ key: string; value: string }>; + /** HTTP method (default POST) */ + method?: string; +} + +// --------------------------------------------------------------------------- +// Internal alert event passed through the processing pipeline +// --------------------------------------------------------------------------- + +export interface AlertContext { + eventType: AlertEventType; + orgId: string; + /** Set for site_online / site_offline events */ + siteId?: number; + /** Set for health_check_* events */ + healthCheckId?: number; + /** Human-readable context data included in emails and webhook payloads */ + data: Record; +} \ No newline at end of file diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index dad302de8..21f52566f 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; +import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; @@ -225,6 +226,11 @@ export async function updateTarget( hcHealthValue = undefined; } + const isDisablingHc = + (parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null) && + existingHc.hcEnabled === true; + const [updatedHc] = await db .update(targetHealthCheck) .set({ @@ -250,6 +256,15 @@ export async function updateTarget( .where(eq(targetHealthCheck.targetId, targetId)) .returning(); + if (isDisablingHc) { + await fireHealthCheckUnknownAlert( + resource.orgId, + existingHc.targetHealthCheckId, + existingHc.name, + updatedHc.targetId + ); + } + if (site.pubKey) { if (site.type == "wireguard") { await addPeer(site.exitNodeId!, { diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx new file mode 100644 index 000000000..1c7d1b7fd --- /dev/null +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -0,0 +1,264 @@ +"use client"; + +import { UsersDataTable } from "@app/components/AdminUsersDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { + DropdownMenu, + DropdownMenuItem, + DropdownMenuContent, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; + +export type GlobalUserRow = { + id: string; + name: string | null; + username: string; + email: string | null; + type: string; + idpId: number | null; + idpName: string; + dateCreated: string; + twoFactorEnabled: boolean | null; + twoFactorSetupRequested: boolean | null; +}; + +type Props = { + users: GlobalUserRow[]; +}; + +export default function UsersTable({ users }: Props) { + const router = useRouter(); + const t = useTranslations(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(users); + + const api = createApiClient(useEnvContext()); + + const deleteUser = (id: string) => { + api.delete(`/user/${id}`) + .catch((e) => { + console.error(t("userErrorDelete"), e); + toast({ + variant: "destructive", + title: t("userErrorDelete"), + description: formatAxiosError(e, t("userErrorDelete")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== id); + + setRows(newRows); + }); + }; + + const columns: ExtendedColumnDef[] = [ + { + accessorKey: "id", + friendlyName: "ID", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "username", + friendlyName: t("username"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "email", + friendlyName: t("email"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "name", + friendlyName: t("name"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "idpName", + friendlyName: t("identityProvider"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "twoFactorEnabled", + friendlyName: t("twoFactor"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const userRow = row.original; + + return ( +
+ + {userRow.twoFactorEnabled || + userRow.twoFactorSetupRequested ? ( + + {t("enabled")} + + ) : ( + {t("disabled")} + )} + +
+ ); + } + }, + { + id: "actions", + header: () => {t("actions")}, + cell: ({ row }) => { + const r = row.original; + return ( + <> +
+ + + + + + + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + {t("delete")} + + + +
+ + ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

{t("userQuestionRemove")}

+ +

{t("userMessageRemove")}

+
+ } + buttonText={t("userDeleteConfirm")} + onConfirm={async () => deleteUser(selected!.id)} + string={ + selected.email || selected.name || selected.username + } + title={t("userDeleteServer")} + /> + )} + + + + ); +} diff --git a/src/components/AdminUsersDataTable.tsx b/src/components/AdminUsersDataTable.tsx new file mode 100644 index 000000000..afa473e86 --- /dev/null +++ b/src/components/AdminUsersDataTable.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function UsersDataTable({ + columns, + data, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/RolesDataTable.tsx b/src/components/RolesDataTable.tsx new file mode 100644 index 000000000..5a2d1cb4c --- /dev/null +++ b/src/components/RolesDataTable.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + createRole?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function RolesDataTable({ + columns, + data, + createRole, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/components/UptimeBar.tsx b/src/components/UptimeBar.tsx index 5bd5ce3f1..37e38bcaa 100644 --- a/src/components/UptimeBar.tsx +++ b/src/components/UptimeBar.tsx @@ -42,7 +42,8 @@ const barColorClass: Record = { good: "bg-green-500", degraded: "bg-yellow-500", bad: "bg-red-500", - no_data: "bg-neutral-200 dark:bg-neutral-700" + no_data: "bg-neutral-200 dark:bg-neutral-700", + unknown: "bg-neutral-200 dark:bg-neutral-700" }; type UptimeBarProps = { @@ -188,7 +189,7 @@ export default function UptimeBar({
{formatDate(day.date)}
- {day.status !== "no_data" && ( + {day.status !== "no_data" && day.status !== "unknown" && (
{t("uptimeTooltipUptimeLabel")}:{" "} @@ -224,7 +225,7 @@ export default function UptimeBar({ ))}
)} - {day.status === "no_data" && ( + {(day.status === "no_data" || day.status === "unknown") && (
{t("uptimeNoMonitoringData")}
diff --git a/src/components/UptimeMiniBar.tsx b/src/components/UptimeMiniBar.tsx index b4c8aa3bd..b7e684c8b 100644 --- a/src/components/UptimeMiniBar.tsx +++ b/src/components/UptimeMiniBar.tsx @@ -34,7 +34,8 @@ const barColorClass: Record = { good: "bg-green-500", degraded: "bg-yellow-500", bad: "bg-red-500", - no_data: "bg-neutral-200 dark:bg-neutral-700" + no_data: "bg-neutral-200 dark:bg-neutral-700", + unknown: "bg-neutral-200 dark:bg-neutral-700" }; type UptimeMiniBarProps = { @@ -137,7 +138,7 @@ export default function UptimeMiniBar({ {formatDate(day.date)}
- {day.status === "no_data" + {day.status === "no_data" || day.status === "unknown" ? t("uptimeNoData") : `${day.uptimePercent.toFixed(1)}% ${t("uptimeSuffix")}`}
diff --git a/src/components/UsersDataTable.tsx b/src/components/UsersDataTable.tsx new file mode 100644 index 000000000..ececa4c17 --- /dev/null +++ b/src/components/UsersDataTable.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + inviteUser?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function UsersDataTable({ + columns, + data, + inviteUser, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/lib/alertRulesLocalStorage.ts b/src/lib/alertRulesLocalStorage.ts new file mode 100644 index 000000000..2471219b0 --- /dev/null +++ b/src/lib/alertRulesLocalStorage.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +const STORAGE_PREFIX = "pangolin:alert-rules:"; + +export const webhookHeaderEntrySchema = z.object({ + key: z.string(), + value: z.string() +}); + +export const alertActionSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("notify"), + userIds: z.array(z.string()), + roleIds: z.array(z.number()), + emails: z.array(z.string()) + }), + z.object({ + type: z.literal("webhook"), + url: z.string().url(), + method: z.string().min(1), + headers: z.array(webhookHeaderEntrySchema), + secret: z.string().optional() + }) +]); + +export const alertSourceSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("site"), + siteIds: z.array(z.number()) + }), + z.object({ + type: z.literal("health_check"), + targetIds: z.array(z.number()) + }) +]); + +export const alertTriggerSchema = z.enum([ + "site_online", + "site_offline", + "health_check_healthy", + "health_check_unhealthy" +]); + +export const alertRuleSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(255), + enabled: z.boolean(), + createdAt: z.string(), + updatedAt: z.string(), + source: alertSourceSchema, + trigger: alertTriggerSchema, + actions: z.array(alertActionSchema).min(1) +}); + +export type AlertRule = z.infer; +export type AlertAction = z.infer; +export type AlertTrigger = z.infer; + +function storageKey(orgId: string) { + return `${STORAGE_PREFIX}${orgId}`; +} + +export function getRule(orgId: string, ruleId: string): AlertRule | undefined { + return loadRules(orgId).find((r) => r.id === ruleId); +} + +export function loadRules(orgId: string): AlertRule[] { + if (typeof window === "undefined") { + return []; + } + try { + const raw = localStorage.getItem(storageKey(orgId)); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + const out: AlertRule[] = []; + for (const item of parsed) { + const r = alertRuleSchema.safeParse(item); + if (r.success) { + out.push(r.data); + } + } + return out; + } catch { + return []; + } +} + +export function saveRules(orgId: string, rules: AlertRule[]) { + if (typeof window === "undefined") { + return; + } + localStorage.setItem(storageKey(orgId), JSON.stringify(rules)); +} + +export function upsertRule(orgId: string, rule: AlertRule) { + const rules = loadRules(orgId); + const i = rules.findIndex((r) => r.id === rule.id); + if (i >= 0) { + rules[i] = rule; + } else { + rules.push(rule); + } + saveRules(orgId, rules); +} + +export function deleteRule(orgId: string, ruleId: string) { + const rules = loadRules(orgId).filter((r) => r.id !== ruleId); + saveRules(orgId, rules); +} + +export function newRuleId() { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export function isoNow() { + return new Date().toISOString(); +} From 6f6c24b6df59cabec624aa53cca5b7639a1d0f12 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sat, 25 Apr 2026 15:42:19 -0700 Subject: [PATCH 055/107] use semibold --- server/routers/resource/listResources.ts | 17 +++--- .../routers/siteResource/listSiteResources.ts | 6 +-- .../settings/(private)/billing/page.tsx | 13 +++-- src/app/[orgId]/settings/not-found.tsx | 2 +- src/app/auth/initial-setup/page.tsx | 2 +- src/app/auth/login/device/success/page.tsx | 14 +++-- src/app/auth/login/page.tsx | 2 +- src/app/auth/signup/page.tsx | 2 +- src/app/not-found.tsx | 2 +- src/components/AccessToken.tsx | 2 +- src/components/AccessTokenUsage.tsx | 4 +- src/components/ConfirmDeleteDialog.tsx | 6 ++- src/components/DNSRecordsDataTable.tsx | 2 +- src/components/DeleteAccountConfirmDialog.tsx | 2 +- src/components/InviteStatusCard.tsx | 2 +- src/components/MemberResourcesPortal.tsx | 4 +- src/components/OptionSelect.tsx | 10 ++-- src/components/OrgSelector.tsx | 2 +- src/components/OrganizationLandingCard.tsx | 4 +- src/components/ResourceAccessDenied.tsx | 2 +- src/components/ResourceNotFound.tsx | 2 +- src/components/Settings.tsx | 2 +- src/components/SettingsSectionTitle.tsx | 2 +- src/components/SiteResourcesOverview.tsx | 2 +- src/components/newt-install-commands.tsx | 18 ++++--- src/components/olm-install-commands.tsx | 54 +++++++++---------- src/components/tags/tag.tsx | 2 +- 27 files changed, 95 insertions(+), 87 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 9127d74e6..c84a80205 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -114,16 +114,11 @@ const listResourcesSchema = z.object({ description: "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets." }), - siteId: z.coerce - .number() - .int() - .positive() - .optional() - .openapi({ - type: "integer", - description: - "When set, only resources that have at least one target on this site are returned" - }) + siteId: z.coerce.number().int().positive().optional().openapi({ + type: "integer", + description: + "When set, only resources that have at least one target on this site are returned" + }) }); // grouped by resource with targets[]) @@ -270,6 +265,8 @@ export async function listResources( ); } + await new Promise((resolve) => setTimeout(resolve, 3 * 1000)); + const orgId = parsedParams.data.orgId || req.userOrg?.orgId || diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index 8a1469f76..61460c2d0 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -112,10 +112,7 @@ export async function listSiteResources( const siteResourcesList = await db .select() .from(siteNetworks) - .innerJoin( - networks, - eq(siteNetworks.networkId, networks.networkId) - ) + .innerJoin(networks, eq(siteNetworks.networkId, networks.networkId)) .innerJoin( siteResources, eq(siteResources.networkId, networks.networkId) @@ -136,7 +133,6 @@ export async function listSiteResources( .limit(limit) .offset(offset); - return response(res, { data: { siteResources: siteResourcesList }, success: true, diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 8f714336a..778062e8e 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -836,7 +836,14 @@ export default function BillingPage() { {/* Plan Cards Grid */} -
+
{visiblePlanOptions.map((plan) => { const isCurrentPlan = plan.id === currentPlanId; const planAction = getPlanAction(plan); @@ -967,7 +974,7 @@ export default function BillingPage() { {t("billingCurrentUsage") || "Current Usage"}
- + {getUserCount()} @@ -1298,7 +1305,7 @@ export default function BillingPage() { "Current Keys"}
- + {getLicenseKeyCount()} diff --git a/src/app/[orgId]/settings/not-found.tsx b/src/app/[orgId]/settings/not-found.tsx index d3ca37ccf..680962d23 100644 --- a/src/app/[orgId]/settings/not-found.tsx +++ b/src/app/[orgId]/settings/not-found.tsx @@ -5,7 +5,7 @@ export default async function NotFound() { return (
-

404

+

404

{t("pageNotFound")}

diff --git a/src/app/auth/initial-setup/page.tsx b/src/app/auth/initial-setup/page.tsx index 4a4438964..bf38eee9e 100644 --- a/src/app/auth/initial-setup/page.tsx +++ b/src/app/auth/initial-setup/page.tsx @@ -92,7 +92,7 @@ export default function InitialSetupPage() { />
-

+

{t("initialSetupTitle")}

diff --git a/src/app/auth/login/device/success/page.tsx b/src/app/auth/login/device/success/page.tsx index dab609351..56f84c835 100644 --- a/src/app/auth/login/device/success/page.tsx +++ b/src/app/auth/login/device/success/page.tsx @@ -23,8 +23,10 @@ export default function DeviceAuthSuccessPage() { useEffect(() => { // Detect if we're on iOS or Android - const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; - const isIOS = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; + const userAgent = + navigator.userAgent || navigator.vendor || (window as any).opera; + const isIOS = + /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; const isAndroid = /android/i.test(userAgent); if (isAndroid) { @@ -32,7 +34,8 @@ export default function DeviceAuthSuccessPage() { // This explicitly tells Chrome to send an intent to the app, which will bring // SignInCodeActivity back to the foreground (it has launchMode="singleTop") setTimeout(() => { - window.location.href = "intent://auth-success#Intent;scheme=pangolin;package=net.pangolin.Pangolin;end"; + window.location.href = + "intent://auth-success#Intent;scheme=pangolin;package=net.pangolin.Pangolin;end"; }, 500); } else if (isIOS) { // Wait 500ms then attempt to open the app @@ -41,7 +44,8 @@ export default function DeviceAuthSuccessPage() { window.location.href = "pangolin://"; setTimeout(() => { - window.location.href = "https://apps.apple.com/app/pangolin/net.pangolin.Pangolin.PangoliniOS"; + window.location.href = + "https://apps.apple.com/app/pangolin/net.pangolin.Pangolin.PangoliniOS"; }, 2000); }, 500); } @@ -64,7 +68,7 @@ export default function DeviceAuthSuccessPage() {
-

+

{t("deviceConnected")}

diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 9b1639925..c2aaefaa6 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -135,7 +135,7 @@ export default async function Page(props: {

-

+

{t("inviteAlready")}

diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index be138c45d..42829acfd 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -65,7 +65,7 @@ export default async function Page(props: {

-

+

{t("inviteAlready")}

diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index d3ca37ccf..680962d23 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -5,7 +5,7 @@ export default async function NotFound() { return (

-

404

+

404

{t("pageNotFound")}

diff --git a/src/components/AccessToken.tsx b/src/components/AccessToken.tsx index 54f926433..802be769a 100644 --- a/src/components/AccessToken.tsx +++ b/src/components/AccessToken.tsx @@ -143,7 +143,7 @@ export default function AccessToken({ token, resourceId }: AccessTokenProps) { ) : ( - + {renderTitle()} diff --git a/src/components/AccessTokenUsage.tsx b/src/components/AccessTokenUsage.tsx index 4b1703717..b10f793d0 100644 --- a/src/components/AccessTokenUsage.tsx +++ b/src/components/AccessTokenUsage.tsx @@ -58,12 +58,12 @@ export default function AccessTokenSection({
-
{t("tokenId")}
+
{t("tokenId")}
-
{t("token")}
+
{t("token")}
diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx index 4c5c6ad63..32fc83179 100644 --- a/src/components/ConfirmDeleteDialog.tsx +++ b/src/components/ConfirmDeleteDialog.tsx @@ -92,7 +92,7 @@ export default function ConfirmDeleteDialog({
{dialog} -
+
{warningText || t("cannotbeUndone")}
@@ -142,7 +142,9 @@ export default function ConfirmDeleteDialog({ form="confirm-delete-form" loading={loading} disabled={loading || !isConfirmed} - className={!isConfirmed && !loading ? "opacity-50" : ""} + className={ + !isConfirmed && !loading ? "opacity-50" : "" + } > {buttonText} diff --git a/src/components/DNSRecordsDataTable.tsx b/src/components/DNSRecordsDataTable.tsx index 0a9eaa7d4..4e7839000 100644 --- a/src/components/DNSRecordsDataTable.tsx +++ b/src/components/DNSRecordsDataTable.tsx @@ -107,7 +107,7 @@ export function DNSRecordsDataTable({
-

{t("dnsRecord")}

+

{t("dnsRecord")}

{t("required")}
)}
-

+

{t("cannotbeUndone")}

diff --git a/src/components/InviteStatusCard.tsx b/src/components/InviteStatusCard.tsx index 5de8f25fd..f35f47629 100644 --- a/src/components/InviteStatusCard.tsx +++ b/src/components/InviteStatusCard.tsx @@ -204,7 +204,7 @@ export default function InviteStatusCard({
- + {loading ? t("checkingInvite") : t("inviteNotAccepted")} diff --git a/src/components/MemberResourcesPortal.tsx b/src/components/MemberResourcesPortal.tsx index 602735e56..0ca6c550b 100644 --- a/src/components/MemberResourcesPortal.tsx +++ b/src/components/MemberResourcesPortal.tsx @@ -720,7 +720,7 @@ export default function MemberResourcesPortal({ - + { resource.name } @@ -822,7 +822,7 @@ export default function MemberResourcesPortal({ - + { siteResource.name } diff --git a/src/components/OptionSelect.tsx b/src/components/OptionSelect.tsx index 2f891394b..21a95c225 100644 --- a/src/components/OptionSelect.tsx +++ b/src/components/OptionSelect.tsx @@ -32,9 +32,7 @@ export function OptionSelect({ }: OptionSelectProps) { return (
- {label && ( -

{label}

- )} + {label &&

{label}

}
({ - )} + {isPending ? ( + + ) : ( + + + {cert.status.charAt(0).toUpperCase() + cert.status.slice(1)} + {shouldShowRefreshButton(cert.status, cert.updatedAt) && ( + + )} + - + )}
); } From 06af53c4d6e6b6bc105d435b9d6c03d5e2754d01 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Sun, 26 Apr 2026 16:57:10 -0700 Subject: [PATCH 062/107] increase refresh rate --- src/components/ClientResourcesTable.tsx | 9 ++++++++- src/components/HealthChecksTable.tsx | 2 +- src/components/ProxyResourcesTable.tsx | 5 ++--- src/components/SitesTable.tsx | 2 +- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index d60d58d76..a772fb576 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -36,7 +36,7 @@ import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Selectedsite, SitesSelector } from "@app/components/site-selector"; -import { useMemo, useState, useTransition } from "react"; +import { useEffect, useMemo, useState, useTransition } from "react"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; @@ -144,6 +144,13 @@ export default function ClientResourcesTable({ const [isRefreshing, startTransition] = useTransition(); + useEffect(() => { + const interval = setInterval(() => { + router.refresh(); + }, 30_000); + return () => clearInterval(interval); + }, [router]); + const siteIdQ = searchParams.get("siteId"); const siteIdNum = siteIdQ ? parseInt(siteIdQ, 10) : NaN; const selectedSite: Selectedsite | null = useMemo(() => { diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 9545cbb7d..75aaebcff 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -166,7 +166,7 @@ export default function HealthChecksTable({ useEffect(() => { const interval = setInterval(() => { router.refresh(); - }, 10_000); + }, 30_000); return () => clearInterval(interval); }, [router]); diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 2b56eb98d..01cd17635 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -178,7 +178,7 @@ export default function ProxyResourcesTable({ useEffect(() => { const interval = setInterval(() => { router.refresh(); - }, 10_000); + }, 30_000); return () => clearInterval(interval); }, [router]); @@ -387,8 +387,7 @@ export default function ProxyResourcesTable({ }, { id: "sites", - accessorFn: (row) => - row.sites.map((s) => s.siteName).join(", "), + accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "), friendlyName: t("sites"), header: () => ( diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 8eba5cee5..4ab35359e 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -102,7 +102,7 @@ export default function SitesTable({ useEffect(() => { const interval = setInterval(() => { router.refresh(); - }, 10_000); + }, 30_000); return () => clearInterval(interval); }, []); From ca2370e31d7179781c15130dcddb14ed3e9ea7ba Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 26 Apr 2026 17:28:57 -0700 Subject: [PATCH 063/107] Add logging when manually changing the hc status --- messages/en-US.json | 3 +- server/lib/blueprints/proxyResources.ts | 2 + .../lib/alerts/events/healthCheckEvents.ts | 17 ++++++++- .../private/lib/alerts/events/siteEvents.ts | 1 + .../alertEvents/triggerHealthCheckAlert.ts | 2 +- .../alertEvents/triggerResourceAlert.ts | 3 +- .../routers/alertEvents/triggerSiteAlert.ts | 2 +- .../routers/healthChecks/createHealthCheck.ts | 10 +++++ .../routers/healthChecks/updateHealthCheck.ts | 32 ++++++++++++++++ .../newt/handleNewtDisconnectingMessage.ts | 2 +- server/routers/newt/offlineChecker.ts | 5 +-- server/routers/newt/registerNewt.ts | 10 ++++- server/routers/site/createSite.ts | 20 ++++++---- server/routers/target/createTarget.ts | 38 ++++++++++++++++++- .../target/handleHealthcheckStatusMessage.ts | 8 ++-- server/routers/target/updateTarget.ts | 34 ++++++++++++++--- .../alerting/(list)/health-checks/page.tsx | 4 +- src/components/DomainPicker.tsx | 1 + src/components/HealthChecksTable.tsx | 3 +- src/components/PaidFeaturesAlert.tsx | 7 +++- src/components/ProxyResourcesTable.tsx | 11 +----- 21 files changed, 170 insertions(+), 45 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 5d4facf1c..d2b79bd35 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3196,5 +3196,6 @@ "alertLabel": "Alert", "domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.", "domainPickerWildcardCertWarning": "Wildcard certificates must be configured separately in Traefik.", - "domainPickerWildcardCertWarningLink": "Learn more" + "domainPickerWildcardCertWarningLink": "Learn more", + "health": "Health" } diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index fc1fee5b0..ba93bc46a 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -179,6 +179,7 @@ export async function updateProxyResources( newHealthcheck.name, newHealthcheck.targetId, undefined, + true, trx ); } @@ -581,6 +582,7 @@ export async function updateProxyResources( newHealthcheck.name, newHealthcheck.targetId, undefined, + true, trx ); } diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 04f197a8d..48aef424f 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -50,7 +50,8 @@ export async function fireHealthCheckHealthyAlert( healthCheckName?: string | null, healthCheckTargetId?: number | null, extra?: Record, - trx: Transaction | typeof db = db + send: boolean = true, + trx: Transaction | typeof db = db, ): Promise { try { await trx.insert(statusHistory).values({ @@ -63,6 +64,10 @@ export async function fireHealthCheckHealthyAlert( await handleResource(orgId, healthCheckTargetId, trx); + if (!send) { + return; + } + await processAlerts({ eventType: "health_check_healthy", orgId, @@ -108,6 +113,7 @@ export async function fireHealthCheckUnhealthyAlert( healthCheckName?: string | null, healthCheckTargetId?: number | null, extra?: Record, + send: boolean = true, trx: Transaction | typeof db = db ): Promise { try { @@ -121,6 +127,10 @@ export async function fireHealthCheckUnhealthyAlert( await handleResource(orgId, healthCheckTargetId, trx); + if (!send) { + return; + } + await processAlerts({ eventType: "health_check_unhealthy", orgId, @@ -155,6 +165,7 @@ export async function fireHealthCheckUnknownAlert( healthCheckName?: string | null, healthCheckTargetId?: number | null, extra?: Record, + send: boolean = true, trx: Transaction | typeof db = db ): Promise { try { @@ -167,6 +178,10 @@ export async function fireHealthCheckUnknownAlert( }); await handleResource(orgId, healthCheckTargetId, trx); + + if (!send) { + return; + } } catch (err) { logger.error( `fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`, diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index 562accc18..36e3dacff 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -125,6 +125,7 @@ export async function fireSiteOfflineAlert( healthCheck.name, undefined, undefined, + true, trx ); } diff --git a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts index 0590c2bcd..530557463 100644 --- a/server/private/routers/alertEvents/triggerHealthCheckAlert.ts +++ b/server/private/routers/alertEvents/triggerHealthCheckAlert.ts @@ -14,7 +14,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { targetHealthCheck, statusHistory } from "@server/db"; +import { targetHealthCheck } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; diff --git a/server/private/routers/alertEvents/triggerResourceAlert.ts b/server/private/routers/alertEvents/triggerResourceAlert.ts index a43b8e201..afda63e9a 100644 --- a/server/private/routers/alertEvents/triggerResourceAlert.ts +++ b/server/private/routers/alertEvents/triggerResourceAlert.ts @@ -14,7 +14,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resources, statusHistory } from "@server/db"; +import { resources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -24,7 +24,6 @@ import { eq, and } from "drizzle-orm"; import { fireResourceHealthyAlert, fireResourceUnhealthyAlert, - fireResourceToggleAlert, fireResourceDegradedAlert } from "#private/lib/alerts/events/resourceEvents"; diff --git a/server/private/routers/alertEvents/triggerSiteAlert.ts b/server/private/routers/alertEvents/triggerSiteAlert.ts index a7fa0cafc..25b14acb9 100644 --- a/server/private/routers/alertEvents/triggerSiteAlert.ts +++ b/server/private/routers/alertEvents/triggerSiteAlert.ts @@ -14,7 +14,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { sites, statusHistory } from "@server/db"; +import { sites } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index ada583a70..ead58e996 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -22,6 +22,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; +import { fireHealthCheckUnhealthyAlert } from "#private/lib/alerts"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() @@ -146,6 +147,15 @@ export async function createHealthCheck( }) .returning(); + await fireHealthCheckUnhealthyAlert( + record.orgId, + record.targetHealthCheckId, + record.name || "", + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + // Push health check to newt if the site is a newt site if (siteId) { const [site] = await db diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index 47a9518a9..8afeca6a4 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -22,6 +22,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq, isNull } from "drizzle-orm"; import { addStandaloneHealthCheck } from "@server/routers/newt/targets"; +import { fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckHealthyAlert } from "#private/lib/alerts"; const paramsSchema = z .object({ @@ -233,6 +234,37 @@ export async function updateHealthCheck( ) .returning(); + if (updated.hcHealth === "unhealthy" && existingHealthCheck.hcHealth !== "unhealthy") { + await fireHealthCheckUnhealthyAlert( + updated.orgId, + updated.targetHealthCheckId, + updated.name || "", + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } else if (updated.hcHealth === "unknown" && existingHealthCheck.hcHealth !== "unknown") { + // if the health is unknown, we want to fire an alert to notify users to enable health checks + await fireHealthCheckUnknownAlert( + updated.orgId, + updated.targetHealthCheckId, + updated.name, + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } else if (updated.hcHealth === "healthy" && existingHealthCheck.hcHealth !== "healthy") { + await fireHealthCheckHealthyAlert( + updated.orgId, + updated.targetHealthCheckId, + updated.name, + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } + + // Push updated health check to newt if the site is a newt site const [newt] = await db .select() diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts index 15c7d3662..a05d410c8 100644 --- a/server/routers/newt/handleNewtDisconnectingMessage.ts +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -6,7 +6,7 @@ import { } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; -import { fireSiteOfflineAlert } from "@server/lib/alerts"; +import { fireSiteOfflineAlert } from "#dynamic/lib/alerts"; /** * Handles disconnecting messages from sites to show disconnected in the ui diff --git a/server/routers/newt/offlineChecker.ts b/server/routers/newt/offlineChecker.ts index 1dc51d5da..6ff43688a 100644 --- a/server/routers/newt/offlineChecker.ts +++ b/server/routers/newt/offlineChecker.ts @@ -1,10 +1,7 @@ import { db, newts, - sites, - targetHealthCheck, - targets, - statusHistory + sites } from "@server/db"; import { hasActiveConnections } from "#dynamic/routers/ws"; import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm"; diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts index cc53e48df..440a62198 100644 --- a/server/routers/newt/registerNewt.ts +++ b/server/routers/newt/registerNewt.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, statusHistory } from "@server/db"; import { siteProvisioningKeys, siteProvisioningKeyOrg, @@ -223,6 +223,14 @@ export async function registerNewt( }) .returning(); + await trx.insert(statusHistory).values({ + entityType: "site", + entityId: newSite.siteId, + orgId: orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000) + }); + newSiteId = newSite.siteId; // Grant admin role access to the new site diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index f9b26799e..0ac0de3d7 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { clients, db, exitNodes } from "@server/db"; +import { clients, db, exitNodes, statusHistory } from "@server/db"; import { roles, userSites, sites, roleSites, Site, orgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -321,12 +321,7 @@ export async function createSite( const existingSite = await db .select() .from(sites) - .where( - and( - eq(sites.niceId, niceId), - eq(sites.orgId, orgId) - ) - ) + .where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId))) .limit(1); if (existingSite.length > 0) { @@ -344,7 +339,8 @@ export async function createSite( if (type == "newt") { [newSite] = await trx .insert(sites) - .values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT + .values({ + // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT orgId, name, niceId: updatedNiceId!, @@ -354,6 +350,14 @@ export async function createSite( status: "approved" }) .returning(); + + await trx.insert(statusHistory).values({ + entityType: "site", + entityId: newSite.siteId, + orgId: orgId, + status: "offline", + timestamp: Math.floor(Date.now() / 1000) + }); } else if (type == "wireguard") { // we are creating a site with an exit node (tunneled) if (!subnet) { diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index e37f12490..c58157e75 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -1,6 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, TargetHealthCheck, targetHealthCheck } from "@server/db"; +import { + db, + statusHistory, + TargetHealthCheck, + targetHealthCheck +} from "@server/db"; import { newts, resources, sites, Target, targets } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -14,6 +19,7 @@ import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { fireHealthCheckHealthyAlert, fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; const createTargetParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -252,6 +258,36 @@ export async function createTarget( }) .returning(); + if (healthCheck[0].hcHealth === "unhealthy") { + await fireHealthCheckUnhealthyAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name, + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } else if (healthCheck[0].hcHealth === "unknown") { + // if the health is unknown, we want to fire an alert to notify users to enable health checks + await fireHealthCheckUnknownAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name, + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } else if (healthCheck[0].hcHealth === "healthy") { + await fireHealthCheckHealthyAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name, + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } + if (site.pubKey) { if (site.type == "wireguard") { await addPeer(site.exitNodeId!, { diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index c3bcb6d8e..0fe5caf7b 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -1,10 +1,6 @@ import { db, - targets, - resources, - sites, - targetHealthCheck, - statusHistory + targetHealthCheck } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; @@ -142,6 +138,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( targetCheck.name ?? undefined, targetCheck.targetId, undefined, + true, trx ); } else if (healthStatus.status === "healthy") { @@ -151,6 +148,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( targetCheck.name ?? undefined, targetCheck.targetId, undefined, + true, trx ); } diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 21f52566f..0766f87b5 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,10 +10,11 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; -import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; +import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts"; const updateTargetParamsSchema = z.strictObject({ @@ -256,12 +257,33 @@ export async function updateTarget( .where(eq(targetHealthCheck.targetId, targetId)) .returning(); - if (isDisablingHc) { + if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") { + await fireHealthCheckUnhealthyAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name || "", + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") { + // if the health is unknown, we want to fire an alert to notify users to enable health checks await fireHealthCheckUnknownAlert( - resource.orgId, - existingHc.targetHealthCheckId, - existingHc.name, - updatedHc.targetId + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name, + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet + ); + } else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") { + await fireHealthCheckHealthyAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name, + undefined, + undefined, + false // dont send the alert because we just want to create the alert, not notify users yet ); } diff --git a/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx index 5cbb9ea3d..f3cf0160f 100644 --- a/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx +++ b/src/app/[orgId]/settings/alerting/(list)/health-checks/page.tsx @@ -151,6 +151,7 @@ export default async function AlertingHealthChecksPage( fullDomain: string | null; niceId: string; ssl: boolean; + wildcard: boolean; } | null = null; if (resourceIdParam) { try { @@ -165,7 +166,8 @@ export default async function AlertingHealthChecksPage( resourceId: r.resourceId, fullDomain: r.fullDomain, niceId: r.niceId, - ssl: r.ssl + ssl: r.ssl, + wildcard: r.wildcard }; } } catch { diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 555bcb264..89cc4fed9 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -557,6 +557,7 @@ export default function DomainPicker({ )}

null; if (env.flags.disableEnterpriseFeatures) { return null; diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 01cd17635..525b28809 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -63,13 +63,6 @@ import { useDebouncedCallback } from "use-debounce"; import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger -} from "@app/components/ui/tooltip"; -import type { StatusHistoryResponse } from "@server/lib/statusHistory"; import UptimeMiniBar from "./UptimeMiniBar"; export type TargetHealth = { @@ -466,7 +459,7 @@ export default function ProxyResourcesTable({ { id: "status", accessorKey: "status", - friendlyName: t("status"), + friendlyName: t("health"), header: () => ( ), From 4ff811c5bddf0b97f01d3f5c610930fb0d1167af Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 26 Apr 2026 17:38:24 -0700 Subject: [PATCH 064/107] Use http by default --- src/components/HealthCheckCredenza.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index c5ab59064..7fa64ba80 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -119,7 +119,7 @@ const DEFAULT_VALUES = { name: "", hcEnabled: true, hcMode: "http", - hcScheme: "https", + hcScheme: "http", hcMethod: "GET", hcHostname: "", hcPort: "", @@ -271,7 +271,7 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { name: initialValues.name, hcEnabled: initialValues.hcEnabled, hcMode: initialValues.hcMode ?? "http", - hcScheme: initialValues.hcScheme ?? "https", + hcScheme: initialValues.hcScheme ?? "http", hcMethod: initialValues.hcMethod ?? "GET", hcHostname: initialValues.hcHostname ?? "", hcPort: initialValues.hcPort From 8ca72a39da5b5ad41bef466828db443bcc07b9d4 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 26 Apr 2026 17:55:26 -0700 Subject: [PATCH 065/107] Handle deleting targets --- server/routers/newt/targets.ts | 31 ++------ server/routers/resource/deleteResource.ts | 86 ++++++++++++----------- server/routers/target/deleteTarget.ts | 71 ++++++++++--------- 3 files changed, 92 insertions(+), 96 deletions(-) diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 25b520854..5c717fa94 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -28,36 +28,18 @@ export async function addTargets( { incrementConfigVersion: true, compress: canCompress(version, "newt") } ); - // Create a map for quick lookup - const healthCheckMap = new Map(); - healthCheckData.forEach((hc) => { - if (hc.targetId !== null) { - healthCheckMap.set(hc.targetId, hc); - } - }); - - const healthCheckTargets = targets.map((target) => { - const hc = healthCheckMap.get(target.targetId); - - // If no health check data found, skip this target - if (!hc) { - logger.warn( - `No health check configuration found for target ${target.targetId}` - ); - return null; - } - + const healthCheckTargets = healthCheckData.map((hc) => { // Ensure all necessary fields are present const isTCP = hc.hcMode?.toLowerCase() === "tcp"; if (!hc.hcHostname || !hc.hcPort || !hc.hcInterval) { logger.debug( - `Skipping target ${target.targetId} due to missing health check fields` + `Skipping hc ${hc.targetHealthCheckId} due to missing health check fields` ); return null; } if (!isTCP && (!hc.hcPath || !hc.hcMethod)) { logger.debug( - `Skipping target ${target.targetId} due to missing HTTP health check fields` + `Skipping hc ${hc.targetHealthCheckId} due to missing HTTP health check fields` ); return null; } @@ -105,7 +87,7 @@ export async function addTargets( // Filter out any null values from health check targets const validHealthCheckTargets = healthCheckTargets.filter( - (target) => target !== null + (hc) => hc !== null ); await sendToClient( @@ -213,6 +195,7 @@ export async function removeStandaloneHealthCheck( export async function removeTargets( newtId: string, targets: Target[], + healthCheckData: TargetHealthCheck[], protocol: string, version?: string | null ) { @@ -234,8 +217,8 @@ export async function removeTargets( { incrementConfigVersion: true } ); - const healthCheckTargets = targets.map((target) => { - return target.targetId; + const healthCheckTargets = healthCheckData.map((hc) => { + return hc.targetHealthCheckId; }); await sendToClient( diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index e63301867..b29f7732d 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, targetHealthCheck } from "@server/db"; import { newts, resources, sites, targets } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -52,6 +52,16 @@ export async function deleteResource( .from(targets) .where(eq(targets.resourceId, resourceId)); + const targetHealthChecksToBeRemoved = await db + .select() + .from(targetHealthCheck) + .where( + inArray( + targetHealthCheck.targetId, + targetsToBeRemoved.map((t) => t.targetId) + ) + ); + const [deletedResource] = await db .delete(resources) .where(eq(resources.resourceId, resourceId)) @@ -66,44 +76,40 @@ export async function deleteResource( ); } - // const [site] = await db - // .select() - // .from(sites) - // .where(eq(sites.siteId, deletedResource.siteId!)) - // .limit(1); - // - // if (!site) { - // return next( - // createHttpError( - // HttpCode.NOT_FOUND, - // `Site with ID ${deletedResource.siteId} not found` - // ) - // ); - // } - // - // if (site.pubKey) { - // if (site.type == "wireguard") { - // await addPeer(site.exitNodeId!, { - // publicKey: site.pubKey, - // allowedIps: await getAllowedIps(site.siteId) - // }); - // } else if (site.type == "newt") { - // // get the newt on the site by querying the newt table for siteId - // const [newt] = await db - // .select() - // .from(newts) - // .where(eq(newts.siteId, site.siteId)) - // .limit(1); - // - // removeTargets( - // newt.newtId, - // targetsToBeRemoved, - // deletedResource.protocol, - // deletedResource.proxyPort - // ); - // } - // } - // + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, targets.siteId)) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${targets.siteId} not found` + ) + ); + } + + if (site.pubKey) { + if (site.type == "newt") { + // get the newt on the site by querying the newt table for siteId + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + await removeTargets( + newt.newtId, + targetsToBeRemoved, + targetHealthChecksToBeRemoved, + deletedResource.protocol, + newt.version + ); + } + } + return response(res, { data: null, success: true, diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 606d86351..754a047ff 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -12,6 +12,7 @@ import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; import { getAllowedIps } from "./helpers"; import { OpenAPITags, registry } from "@server/openApi"; +import { targetHealthCheck } from "@server/db/pg"; const deleteTargetSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) @@ -46,6 +47,11 @@ export async function deleteTarget( const { targetId } = parsedParams.data; + const [deletedHealthCheck] = await db + .delete(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .returning(); + const [deletedTarget] = await db .delete(targets) .where(eq(targets.targetId, targetId)) @@ -74,38 +80,39 @@ export async function deleteTarget( ); } - // const [site] = await db - // .select() - // .from(sites) - // .where(eq(sites.siteId, resource.siteId!)) - // .limit(1); - // - // if (!site) { - // return next( - // createHttpError( - // HttpCode.NOT_FOUND, - // `Site with ID ${resource.siteId} not found` - // ) - // ); - // } - // - // if (site.pubKey) { - // if (site.type == "wireguard") { - // await addPeer(site.exitNodeId!, { - // publicKey: site.pubKey, - // allowedIps: await getAllowedIps(site.siteId) - // }); - // } else if (site.type == "newt") { - // // get the newt on the site by querying the newt table for siteId - // const [newt] = await db - // .select() - // .from(newts) - // .where(eq(newts.siteId, site.siteId)) - // .limit(1); - // - // removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort); - // } - // } + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, targets.siteId)) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${targets.siteId} not found` + ) + ); + } + + if (site.pubKey) { + if (site.type == "newt") { + // get the newt on the site by querying the newt table for siteId + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + await removeTargets( + newt.newtId, + [deletedTarget], + [deletedHealthCheck], + resource.protocol, + newt.version + ); + } + } return response(res, { data: null, From 467cd70b72d5eeb9cc09144cd8218a756ddc1f5f Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 26 Apr 2026 20:26:03 -0700 Subject: [PATCH 066/107] Handle delete correctly --- server/routers/resource/deleteResource.ts | 75 +++++++++++------------ 1 file changed, 36 insertions(+), 39 deletions(-) diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index b29f7732d..6d885b01f 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -52,16 +52,6 @@ export async function deleteResource( .from(targets) .where(eq(targets.resourceId, resourceId)); - const targetHealthChecksToBeRemoved = await db - .select() - .from(targetHealthCheck) - .where( - inArray( - targetHealthCheck.targetId, - targetsToBeRemoved.map((t) => t.targetId) - ) - ); - const [deletedResource] = await db .delete(resources) .where(eq(resources.resourceId, resourceId)) @@ -76,38 +66,45 @@ export async function deleteResource( ); } - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, targets.siteId)) - .limit(1); + for (const target of targetsToBeRemoved) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, target.siteId)) + .limit(1); - if (!site) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${targets.siteId} not found` - ) - ); - } - - if (site.pubKey) { - if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - await removeTargets( - newt.newtId, - targetsToBeRemoved, - targetHealthChecksToBeRemoved, - deletedResource.protocol, - newt.version + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${target.siteId} not found` + ) ); } + + if (site.pubKey) { + if (site.type == "newt") { + // get the newt on the site by querying the newt table for siteId + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + const [healthCheck] = await db + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, target.targetId)); + + await removeTargets( + newt.newtId, + [target], + [healthCheck], + deletedResource.protocol, + newt.version + ); + } + } } return response(res, { From 7318c86cca7718686bc883e5b10ea53542255393 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 26 Apr 2026 20:33:58 -0700 Subject: [PATCH 067/107] Fix display and query issues --- messages/en-US.json | 1 + server/routers/resource/listResources.ts | 33 ++++++++++++------------ src/components/HealthChecksTable.tsx | 2 +- src/components/ProxyResourcesTable.tsx | 12 ++++----- 4 files changed, 25 insertions(+), 23 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index d2b79bd35..9347235ef 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1998,6 +1998,7 @@ "resourcesTableUnhealthy": "Unhealthy", "resourcesTableUnknown": "Unknown", "resourcesTableNotMonitored": "Not monitored", + "resourcesTableNoTargets": "No targets", "editInternalResourceDialogEditClientResource": "Edit Private Resource", "editInternalResourceDialogUpdateResourceProperties": "Update the resource configuration and access controls for {resourceName}", "editInternalResourceDialogResourceProperties": "Resource Properties", diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 78ef30559..d0c30aaa2 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -110,9 +110,9 @@ const listResourcesSchema = z.object({ .catch(undefined) .openapi({ type: "string", - enum: ["no_targets", "healthy", "degraded", "offline", "unknown"], + enum: ["healthy", "degraded", "offline", "unknown"], description: - "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets." + "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status." }), siteId: z.coerce.number().int().positive().optional().openapi({ type: "integer", @@ -374,21 +374,22 @@ export async function listResources( ); break; } - if (typeof healthStatus !== "undefined") { - conditions.push(eq(resources.health, healthStatus)); - } - if (siteId != null) { - const resourcesWithSite = db - .select({ resourceId: targets.resourceId }) - .from(targets) - .innerJoin(sites, eq(targets.siteId, sites.siteId)) - .where( - and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)) - ); - conditions.push( - inArray(resources.resourceId, resourcesWithSite) + } + + if (typeof healthStatus !== "undefined") { + conditions.push(eq(resources.health, healthStatus)); + } + if (siteId != null) { + const resourcesWithSite = db + .select({ resourceId: targets.resourceId }) + .from(targets) + .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .where( + and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)) ); - } + conditions.push( + inArray(resources.resourceId, resourcesWithSite) + ); } const baseQuery = queryResourcesBase().where(and(...conditions)); diff --git a/src/components/HealthChecksTable.tsx b/src/components/HealthChecksTable.tsx index 47a1c8cd9..68976bf40 100644 --- a/src/components/HealthChecksTable.tsx +++ b/src/components/HealthChecksTable.tsx @@ -586,7 +586,7 @@ export default function HealthChecksTable({ handleToggleEnabled(r, v)} /> diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 525b28809..384ad35c6 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -90,7 +90,7 @@ export type ResourceRow = { targetHost?: string; targetPort?: number; targets?: TargetHealth[]; - health?: "online" | "degraded" | "unhealthy" | "unknown"; + health?: "healthy" | "degraded" | "unhealthy" | "unknown"; sites: ResourceSiteRow[]; }; @@ -265,8 +265,8 @@ export default function ProxyResourcesTable({ > - {overallStatus === "online" && - t("resourcesTableHealthy")} + {overallStatus === "healthy" && + t("resourcesTableHealthy")} {overallStatus === "degraded" && t("resourcesTableDegraded")} {overallStatus === "unhealthy" && @@ -469,7 +469,7 @@ export default function ProxyResourcesTable({ label: t("resourcesTableDegraded") }, { - value: "unhealty", + value: "unhealthy", label: t("resourcesTableUnhealthy") }, { value: "unknown", label: t("resourcesTableUnknown") } @@ -488,7 +488,7 @@ export default function ProxyResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - return ; + return ; }, sortingFn: (rowA, rowB) => { const statusA = rowA.original.health; @@ -497,7 +497,7 @@ export default function ProxyResourcesTable({ if (!statusA) return 1; if (!statusB) return -1; const statusOrder = { - online: 3, + healthy: 3, degraded: 2, unhealthy: 1, unknown: 0 From 7563b37cd0e77da9aa83adacfd4c3a288af0862e Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 26 Apr 2026 21:25:14 -0700 Subject: [PATCH 068/107] Add missing health column --- server/routers/resource/listResources.ts | 2 ++ src/app/[orgId]/settings/resources/proxy/page.tsx | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index d0c30aaa2..36a424831 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -139,6 +139,7 @@ export type ResourceWithTargets = { niceId: string; headerAuthId: number | null; wildcard: boolean; + health: string | null; targets: Array<{ targetId: number; ip: string; @@ -460,6 +461,7 @@ export async function listResources( enabled: row.enabled, domainId: row.domainId, headerAuthId: row.headerAuthId, + health: row.health ?? null, targets: [], sites: [] }; diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx index ed76aafb7..5d727d905 100644 --- a/src/app/[orgId]/settings/resources/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -129,7 +129,8 @@ export default async function ProxyResourcesPage( healthStatus: target.healthStatus, siteName: target.siteName })), - sites: resource.sites ?? [] + sites: resource.sites ?? [], + health: (resource.health as ResourceRow["health"]) ?? undefined }; }); return ( From 17631599a2d601cc7a551596f4892c917e71a970 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 26 Apr 2026 21:25:53 -0700 Subject: [PATCH 069/107] Remove delay --- server/routers/resource/listResources.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 36a424831..1ca0ef918 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -268,8 +268,6 @@ export async function listResources( ); } - await new Promise((resolve) => setTimeout(resolve, 3 * 1000)); - const orgId = parsedParams.data.orgId || req.userOrg?.orgId || From 1cdb261f7e2406c41cb1bc757c2eea4bfcbc6a32 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 27 Apr 2026 12:31:31 -0700 Subject: [PATCH 070/107] add loading indicator to resources --- src/components/SiteResourcesOverview.tsx | 45 +++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/components/SiteResourcesOverview.tsx b/src/components/SiteResourcesOverview.tsx index b547fffbc..080074d87 100644 --- a/src/components/SiteResourcesOverview.tsx +++ b/src/components/SiteResourcesOverview.tsx @@ -12,6 +12,7 @@ import type { ListResourcesResponse } from "@server/routers/resource"; import type ResponseT from "@server/types/Response"; import { useQuery } from "@tanstack/react-query"; import { isAxiosError } from "axios"; +import { Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useParams } from "next/navigation"; @@ -176,6 +177,8 @@ type OverviewColumnProps = { emptyLabel: string; isForbidden: boolean; isFetching: boolean; + /** When there are no rows and the first fetch (no SSR initial data) is in flight. */ + isLoading: boolean; rows: OverviewRow[]; canShowMore: boolean; onShowMore: () => void; @@ -189,6 +192,7 @@ function OverviewColumn({ emptyLabel, isForbidden, isFetching, + isLoading, rows, canShowMore, onShowMore @@ -231,10 +235,23 @@ function OverviewColumn({
{header} {rows.length === 0 ? ( -
-

- {emptyLabel} -

+
+ {isLoading ? ( +
+ + {t("loading")} +
+ ) : ( +

+ {emptyLabel} +

+ )}
) : ( <> @@ -390,7 +407,14 @@ export default function SiteResourcesOverview({ initialPrivateForbidden || (privateQuery.isError && isForbidden(privateQuery.error)); + const waitingOnPublicList = + enabled && !publicForbidden && publicQuery.isPending; + const waitingOnPrivateList = + enabled && !privateForbidden && privateQuery.isPending; + const showEmptyPlaceholder = + !waitingOnPublicList && + !waitingOnPrivateList && !publicForbidden && !privateForbidden && publicList.length === 0 && @@ -431,6 +455,17 @@ export default function SiteResourcesOverview({ ); } + const publicEmptyLoading = + enabled && + !publicForbidden && + publicRows.length === 0 && + publicQuery.isPending; + const privateEmptyLoading = + enabled && + !privateForbidden && + privateRows.length === 0 && + privateQuery.isPending; + const publicColumn = ( setPublicPageSize((n) => n + LOAD_MORE_INCREMENT)} @@ -457,6 +493,7 @@ export default function SiteResourcesOverview({ emptyLabel={t("siteResourcesEmptyPrivate")} isForbidden={privateForbidden} isFetching={privateQuery.isFetching} + isLoading={privateEmptyLoading} rows={privateRows} canShowMore={privateList.length < privateTotal} onShowMore={() => From d1f7a9c6dfb302e5363b2d49655b712646aacd3d Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 11:37:53 -0700 Subject: [PATCH 071/107] Add health to the resource --- src/components/CertificateStatus.tsx | 4 ++- src/components/ResourceInfoBox.tsx | 38 +++++++++++++++++++++++----- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/components/CertificateStatus.tsx b/src/components/CertificateStatus.tsx index daf373783..8271ccb60 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -12,6 +12,7 @@ type CertificateStatusProps = { autoFetch?: boolean; showLabel?: boolean; className?: string; + disableRestartButton?: boolean; onRefresh?: () => void; polling?: boolean; pollingInterval?: number; @@ -23,6 +24,7 @@ export default function CertificateStatus({ fullDomain, autoFetch = true, showLabel = true, + disableRestartButton = false, className = "", onRefresh, polling = false, @@ -153,7 +155,7 @@ export default function CertificateStatus({ variant="ghost" className="p-0 w-3 h-auto align-middle" onClick={handleRefresh} - disabled={refreshing} + disabled={refreshing || disableRestartButton} title={t("restartCertificate", { defaultValue: "Restart Certificate" })} diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index ad3cb5f34..4ceadb4f7 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -1,7 +1,7 @@ "use client"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { ShieldCheck, ShieldOff, Eye, EyeOff } from "lucide-react"; +import { ShieldCheck, ShieldOff, Eye, EyeOff, CheckCircle2, XCircle, Clock } from "lucide-react"; import { useResourceContext } from "@app/hooks/useResourceContext"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { @@ -18,8 +18,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; type ResourceInfoBoxType = {}; export default function ResourceInfoBox({}: ResourceInfoBoxType) { - const { resource, authInfo, updateResource } = useResourceContext(); - const { env } = useEnvContext(); + const { resource, authInfo } = useResourceContext(); const t = useTranslations(); @@ -29,9 +28,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {/* 4 cols because of the certs */} - + {t("identifier")} @@ -155,6 +152,35 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { )} + + {t("health")} + + {resource.health === "healthy" && ( +
+ + {t("resourcesTableHealthy")} +
+ )} + {resource.health === "degraded" && ( +
+ + {t("resourcesTableDegraded")} +
+ )} + {resource.health === "unhealthy" && ( +
+ + {t("resourcesTableUnhealthy")} +
+ )} + {(!resource.health || resource.health === "unknown") && ( +
+ + {t("resourcesTableUnknown")} +
+ )} +
+
{t("visibility")} From 512ba2150ba3262be8c0327d11cf52f3e2957be2 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 11:50:57 -0700 Subject: [PATCH 072/107] Fix sanitizing the domain causing problems --- .../[orgId]/settings/resources/proxy/[niceId]/general/page.tsx | 3 ++- src/app/[orgId]/settings/resources/proxy/create/page.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index e1c5b6ac2..62a6b9fed 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -29,6 +29,7 @@ import { Label } from "@app/components/ui/label"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { UpdateResourceResponse } from "@server/routers/resource"; import { AxiosResponse } from "axios"; import { AlertCircle } from "lucide-react"; @@ -506,7 +507,7 @@ export default function GeneralForm() { name: data.name, niceId: data.niceId, subdomain: data.subdomain - ? toASCII(data.subdomain) + ? toASCII(finalizeSubdomainSanitize(data.subdomain, true)) : undefined, domainId: data.domainId, proxyPort: data.proxyPort diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index a2cf36de6..98151cc36 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -488,7 +488,7 @@ export default function Page() { const httpData = httpForm.getValues(); sanitizedSubdomain = httpData.subdomain - ? finalizeSubdomainSanitize(httpData.subdomain) + ? finalizeSubdomainSanitize(httpData.subdomain, true) : undefined; Object.assign(payload, { From 61aaa5a8322ad9a93197151633703cabba861b10 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 13:46:01 -0700 Subject: [PATCH 073/107] Wrap in transactions --- server/routers/newt/targets.ts | 45 +-- server/routers/resource/deleteResource.ts | 3 +- server/routers/target/createTarget.ts | 276 +++++++++--------- server/routers/target/deleteTarget.ts | 28 +- server/routers/target/updateTarget.ts | 232 ++++++++------- .../resources/proxy/[niceId]/proxy/page.tsx | 13 + .../settings/resources/proxy/create/page.tsx | 12 + 7 files changed, 337 insertions(+), 272 deletions(-) diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 5c717fa94..ac25fb27d 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -17,16 +17,21 @@ export async function addTargets( }:${target.port}`; }); - await sendToClient( - newtId, - { - type: `newt/${protocol}/add`, - data: { - targets: payloadTargets + if (payloadTargets.length > 0) { + await sendToClient( + newtId, + { + type: `newt/${protocol}/add`, + data: { + targets: payloadTargets + } + }, + { + incrementConfigVersion: true, + compress: canCompress(version, "newt") } - }, - { incrementConfigVersion: true, compress: canCompress(version, "newt") } - ); + ); + } const healthCheckTargets = healthCheckData.map((hc) => { // Ensure all necessary fields are present @@ -206,16 +211,18 @@ export async function removeTargets( }:${target.port}`; }); - await sendToClient( - newtId, - { - type: `newt/${protocol}/remove`, - data: { - targets: payloadTargets - } - }, - { incrementConfigVersion: true } - ); + if (payloadTargets.length > 0) { + await sendToClient( + newtId, + { + type: `newt/${protocol}/remove`, + data: { + targets: payloadTargets + } + }, + { incrementConfigVersion: true } + ); + } const healthCheckTargets = healthCheckData.map((hc) => { return hc.targetHealthCheckId; diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index 6d885b01f..a578c3841 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -98,7 +98,8 @@ export async function deleteResource( await removeTargets( newt.newtId, - [target], + // [target], + [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this [healthCheck], deletedResource.protocol, newt.version diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index c58157e75..a44a1a1fb 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -19,7 +19,11 @@ import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { fireHealthCheckHealthyAlert, fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; +import { + fireHealthCheckHealthyAlert, + fireHealthCheckUnhealthyAlert, + fireHealthCheckUnknownAlert +} from "#dynamic/lib/alerts"; const createTargetParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -142,151 +146,155 @@ export async function createTarget( ); } - const existingTargets = await db - .select() - .from(targets) - .where(eq(targets.resourceId, resourceId)); - - const existingTarget = existingTargets.find( - (target) => - target.ip === targetData.ip && - target.port === targetData.port && - target.method === targetData.method && - target.siteId === targetData.siteId - ); - - if (existingTarget) { - // 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[] = []; - let healthCheck: TargetHealthCheck[] = []; let targetIps: string[] = []; - if (site.type == "local") { - newTarget = await db - .insert(targets) - .values({ - resourceId, - ...targetData, - priority: targetData.priority || 100 - }) - .returning(); - } else { - // make sure the target is within the site subnet - if ( - site.type == "wireguard" && - !isIpInCidr(targetData.ip, site.subnet!) - ) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `Target IP is not within the site subnet` - ) + let healthCheck: TargetHealthCheck[] = []; + await db.transaction(async (trx) => { + const existingTargets = await trx + .select() + .from(targets) + .where(eq(targets.resourceId, resourceId)); + + const existingTarget = existingTargets.find( + (target) => + target.ip === targetData.ip && + target.port === targetData.port && + target.method === targetData.method && + target.siteId === targetData.siteId + ); + + if (existingTarget) { + // log a warning + logger.warn( + `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}` ); } - const { internalPort, targetIps: newTargetIps } = await pickPort( - site.siteId!, - db - ); + if (site.type == "local") { + newTarget = await trx + .insert(targets) + .values({ + resourceId, + ...targetData, + priority: targetData.priority || 100 + }) + .returning(); + } else { + // make sure the target is within the site subnet + if ( + site.type == "wireguard" && + !isIpInCidr(targetData.ip, site.subnet!) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Target IP is not within the site subnet` + ) + ); + } - if (!internalPort) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `No available internal port` - ) - ); + const { internalPort, targetIps: newTargetIps } = + await pickPort(site.siteId!, trx); + + if (!internalPort) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `No available internal port` + ) + ); + } + + newTarget = await trx + .insert(targets) + .values({ + resourceId, + siteId: site.siteId, + ip: targetData.ip, + method: targetData.method, + port: targetData.port, + internalPort, + enabled: targetData.enabled, + path: targetData.path, + pathMatchType: targetData.pathMatchType, + rewritePath: targetData.rewritePath, + rewritePathType: targetData.rewritePathType, + priority: targetData.priority || 100 + }) + .returning(); + + // add the new target to the targetIps array + newTargetIps.push(`${targetData.ip}/32`); + + targetIps = newTargetIps; } - newTarget = await db - .insert(targets) + let hcHeaders = null; + if (targetData.hcHeaders) { + hcHeaders = JSON.stringify(targetData.hcHeaders); + } + + healthCheck = await trx + .insert(targetHealthCheck) .values({ - resourceId, - siteId: site.siteId, - ip: targetData.ip, - method: targetData.method, - port: targetData.port, - internalPort, - enabled: targetData.enabled, - path: targetData.path, - pathMatchType: targetData.pathMatchType, - rewritePath: targetData.rewritePath, - rewritePathType: targetData.rewritePathType, - priority: targetData.priority || 100 + orgId: resource.orgId, + targetId: newTarget[0].targetId, + siteId: targetData.siteId, + name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, + hcEnabled: targetData.hcEnabled ?? false, + hcPath: targetData.hcPath ?? null, + hcScheme: targetData.hcScheme ?? null, + hcMode: targetData.hcMode ?? null, + hcHostname: targetData.hcHostname ?? null, + hcPort: targetData.hcPort ?? null, + hcInterval: targetData.hcInterval ?? null, + hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null, + hcTimeout: targetData.hcTimeout ?? null, + hcHeaders: hcHeaders, + hcFollowRedirects: targetData.hcFollowRedirects ?? null, + hcMethod: targetData.hcMethod ?? null, + hcStatus: targetData.hcStatus ?? null, + hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown", + hcTlsServerName: targetData.hcTlsServerName ?? null, + hcHealthyThreshold: targetData.hcHealthyThreshold ?? null, + hcUnhealthyThreshold: + targetData.hcUnhealthyThreshold ?? null }) .returning(); - // add the new target to the targetIps array - newTargetIps.push(`${targetData.ip}/32`); - - targetIps = newTargetIps; - } - - let hcHeaders = null; - if (targetData.hcHeaders) { - hcHeaders = JSON.stringify(targetData.hcHeaders); - } - - healthCheck = await db - .insert(targetHealthCheck) - .values({ - orgId: resource.orgId, - targetId: newTarget[0].targetId, - siteId: targetData.siteId, - name: `Resource ${resource.name} - ${targetData.ip}:${targetData.port}`, - hcEnabled: targetData.hcEnabled ?? false, - hcPath: targetData.hcPath ?? null, - hcScheme: targetData.hcScheme ?? null, - hcMode: targetData.hcMode ?? null, - hcHostname: targetData.hcHostname ?? null, - hcPort: targetData.hcPort ?? null, - hcInterval: targetData.hcInterval ?? null, - hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null, - hcTimeout: targetData.hcTimeout ?? null, - hcHeaders: hcHeaders, - hcFollowRedirects: targetData.hcFollowRedirects ?? null, - hcMethod: targetData.hcMethod ?? null, - hcStatus: targetData.hcStatus ?? null, - hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown", - hcTlsServerName: targetData.hcTlsServerName ?? null, - hcHealthyThreshold: targetData.hcHealthyThreshold ?? null, - hcUnhealthyThreshold: targetData.hcUnhealthyThreshold ?? null - }) - .returning(); - - if (healthCheck[0].hcHealth === "unhealthy") { - await fireHealthCheckUnhealthyAlert( - healthCheck[0].orgId, - healthCheck[0].targetHealthCheckId, - healthCheck[0].name, - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } else if (healthCheck[0].hcHealth === "unknown") { - // if the health is unknown, we want to fire an alert to notify users to enable health checks - await fireHealthCheckUnknownAlert( - healthCheck[0].orgId, - healthCheck[0].targetHealthCheckId, - healthCheck[0].name, - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } else if (healthCheck[0].hcHealth === "healthy") { - await fireHealthCheckHealthyAlert( - healthCheck[0].orgId, - healthCheck[0].targetHealthCheckId, - healthCheck[0].name, - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } + if (healthCheck[0].hcHealth === "unhealthy") { + await fireHealthCheckUnhealthyAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name, + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if (healthCheck[0].hcHealth === "unknown") { + // if the health is unknown, we want to fire an alert to notify users to enable health checks + await fireHealthCheckUnknownAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name, + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if (healthCheck[0].hcHealth === "healthy") { + await fireHealthCheckHealthyAlert( + healthCheck[0].orgId, + healthCheck[0].targetHealthCheckId, + healthCheck[0].name, + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } + }); if (site.pubKey) { if (site.type == "wireguard") { diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 754a047ff..b6cea7139 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -2,15 +2,13 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { newts, resources, sites, targets } from "@server/db"; -import { eq } from "drizzle-orm"; +import { eq, ne, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; -import { getAllowedIps } from "./helpers"; import { OpenAPITags, registry } from "@server/openApi"; import { targetHealthCheck } from "@server/db/pg"; @@ -80,10 +78,29 @@ export async function deleteTarget( ); } + // check if there are other targets on the resource + const otherTargets = await db + .select() + .from(targets) + .where( + and( + eq(targets.resourceId, resource.resourceId), + ne(targets.targetId, targetId) + ) + ); + + if (otherTargets.length == 0) { + // set the resource status + await db + .update(resources) + .set({ health: "unknown" }) + .where(eq(resources.resourceId, resource.resourceId)); + } + const [site] = await db .select() .from(sites) - .where(eq(sites.siteId, targets.siteId)) + .where(eq(sites.siteId, deletedTarget.siteId)) .limit(1); if (!site) { @@ -106,7 +123,8 @@ export async function deleteTarget( await removeTargets( newt.newtId, - [deletedTarget], + // [deletedTarget], + [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this [deletedHealthCheck], resource.protocol, newt.version diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 0766f87b5..99f1acdeb 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -10,12 +10,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; -import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts"; +import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckUnhealthyAlert } from "#dynamic/lib/alerts"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; -import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts"; - const updateTargetParamsSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) @@ -168,124 +166,131 @@ export async function updateTarget( const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; - const [updatedTarget] = await db - .update(targets) - .set({ - siteId: parsedBody.data.siteId, - ip: parsedBody.data.ip, - method: parsedBody.data.method, - port: parsedBody.data.port, - internalPort, - enabled: parsedBody.data.enabled, - path: parsedBody.data.path, - pathMatchType: parsedBody.data.pathMatchType, - priority: parsedBody.data.priority, - rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, - rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType - }) - .where(eq(targets.targetId, targetId)) - .returning(); + let updatedTarget: any; + let updatedHc: any; + await db.transaction(async (trx) => { + [updatedTarget] = await trx + .update(targets) + .set({ + siteId: parsedBody.data.siteId, + ip: parsedBody.data.ip, + method: parsedBody.data.method, + port: parsedBody.data.port, + internalPort, + enabled: parsedBody.data.enabled, + path: parsedBody.data.path, + pathMatchType: parsedBody.data.pathMatchType, + priority: parsedBody.data.priority, + rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, + rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType + }) + .where(eq(targets.targetId, targetId)) + .returning(); - const [existingHc] = await db - .select() - .from(targetHealthCheck) - .where(eq(targetHealthCheck.targetId, targetId)) - .limit(1); + const [existingHc] = await trx + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .limit(1); - if (!existingHc) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Health check for target with ID ${targetId} not found` - ) - ); - } + if (!existingHc) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check for target with ID ${targetId} not found` + ) + ); + } - let hcHeaders = null; - if (parsedBody.data.hcHeaders) { - hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); - } + let hcHeaders = null; + if (parsedBody.data.hcHeaders) { + hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); + } - // When health check is disabled, reset hcHealth to "unknown" - // to prevent previously unhealthy targets from being excluded. - // Also when the site is not a newt, set hcHealth to "unknown". - // If hcEnabled is being turned on (was false, now true), set to "unhealthy" - // so the target must pass a health check before being considered healthy. - const hcEnabledTurnedOn = - parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false; + // When health check is disabled, reset hcHealth to "unknown" + // to prevent previously unhealthy targets from being excluded. + // Also when the site is not a newt, set hcHealth to "unknown". + // If hcEnabled is being turned on (was false, now true), set to "unhealthy" + // so the target must pass a health check before being considered healthy. + const hcEnabledTurnedOn = + parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false; - let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; - if ( - parsedBody.data.hcEnabled === false || - parsedBody.data.hcEnabled === null || - site.type !== "newt" - ) { - hcHealthValue = "unknown"; - } else if (hcEnabledTurnedOn) { - hcHealthValue = "unhealthy"; - } else { - hcHealthValue = undefined; - } + let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined; + if ( + parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null || + site.type !== "newt" + ) { + hcHealthValue = "unknown"; + } else if (hcEnabledTurnedOn) { + hcHealthValue = "unhealthy"; + } else { + hcHealthValue = undefined; + } - const isDisablingHc = - (parsedBody.data.hcEnabled === false || - parsedBody.data.hcEnabled === null) && - existingHc.hcEnabled === true; + const isDisablingHc = + (parsedBody.data.hcEnabled === false || + parsedBody.data.hcEnabled === null) && + existingHc.hcEnabled === true; - const [updatedHc] = await db - .update(targetHealthCheck) - .set({ - siteId: parsedBody.data.siteId, - hcEnabled: parsedBody.data.hcEnabled || false, - hcPath: parsedBody.data.hcPath, - hcScheme: parsedBody.data.hcScheme, - hcMode: parsedBody.data.hcMode, - hcHostname: parsedBody.data.hcHostname, - hcPort: parsedBody.data.hcPort, - hcInterval: parsedBody.data.hcInterval, - hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval, - hcTimeout: parsedBody.data.hcTimeout, - hcHeaders: hcHeaders, - hcFollowRedirects: parsedBody.data.hcFollowRedirects, - hcMethod: parsedBody.data.hcMethod, - hcStatus: parsedBody.data.hcStatus, - hcTlsServerName: parsedBody.data.hcTlsServerName, - hcHealthyThreshold: parsedBody.data.hcHealthyThreshold, - hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold, - hcHealth: hcHealthValue - }) - .where(eq(targetHealthCheck.targetId, targetId)) - .returning(); + const [updatedHc] = await trx + .update(targetHealthCheck) + .set({ + siteId: parsedBody.data.siteId, + hcEnabled: parsedBody.data.hcEnabled || false, + hcPath: parsedBody.data.hcPath, + hcScheme: parsedBody.data.hcScheme, + hcMode: parsedBody.data.hcMode, + hcHostname: parsedBody.data.hcHostname, + hcPort: parsedBody.data.hcPort, + hcInterval: parsedBody.data.hcInterval, + hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval, + hcTimeout: parsedBody.data.hcTimeout, + hcHeaders: hcHeaders, + hcFollowRedirects: parsedBody.data.hcFollowRedirects, + hcMethod: parsedBody.data.hcMethod, + hcStatus: parsedBody.data.hcStatus, + hcTlsServerName: parsedBody.data.hcTlsServerName, + hcHealthyThreshold: parsedBody.data.hcHealthyThreshold, + hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold, + hcHealth: hcHealthValue + }) + .where(eq(targetHealthCheck.targetId, targetId)) + .returning(); - if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") { - await fireHealthCheckUnhealthyAlert( - updatedHc.orgId, - updatedHc.targetHealthCheckId, - updatedHc.name || "", - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") { - // if the health is unknown, we want to fire an alert to notify users to enable health checks - await fireHealthCheckUnknownAlert( - updatedHc.orgId, - updatedHc.targetHealthCheckId, - updatedHc.name, - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") { - await fireHealthCheckHealthyAlert( - updatedHc.orgId, - updatedHc.targetHealthCheckId, - updatedHc.name, - undefined, - undefined, - false // dont send the alert because we just want to create the alert, not notify users yet - ); - } + if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") { + await fireHealthCheckUnhealthyAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name || "", + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") { + // if the health is unknown, we want to fire an alert to notify users to enable health checks + await fireHealthCheckUnknownAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name, + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") { + await fireHealthCheckHealthyAlert( + updatedHc.orgId, + updatedHc.targetHealthCheckId, + updatedHc.name, + undefined, + undefined, + false, // dont send the alert because we just want to create the alert, not notify users yet + trx + ); + } + }); if (site.pubKey) { if (site.type == "wireguard") { @@ -310,6 +315,7 @@ export async function updateTarget( ); } } + return response(res, { data: { ...updatedTarget, diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx index 03426ef1f..0846fc896 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/proxy/page.tsx @@ -62,6 +62,7 @@ import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { DockerManager, DockerState } from "@app/lib/docker"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; import { tlsNameSchema } from "@server/lib/schemas"; import { type GetResourceResponse } from "@server/routers/resource"; import type { ListSitesResponse } from "@server/routers/site"; @@ -953,6 +954,18 @@ function ProxyResourceTargetsForm({
)} + {build === "saas" && + targets.length > 1 && + new Set(targets.map((t) => t.siteId)).size > 1 && ( +

+ + + Round robin routing will not work between + sites that are not connected to the same + node, but failover will work. + +

+ )} diff --git a/src/app/[orgId]/settings/resources/proxy/create/page.tsx b/src/app/[orgId]/settings/resources/proxy/create/page.tsx index 98151cc36..8eae652cd 100644 --- a/src/app/[orgId]/settings/resources/proxy/create/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/create/page.tsx @@ -1419,6 +1419,18 @@ export default function Page() {
)} + {build === "enterprise" && + targets.length > 1 && + new Set(targets.map((t) => t.siteId)).size > 1 && ( +

+ + + Round robin routing will not work between + sites that are not connected to the same + node, but failover will work. + +

+ )} From 28dd06c41f0815950bea28582fbc2a79153c679e Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 14:29:57 -0700 Subject: [PATCH 074/107] Add caching to the hc and fix resource stuff --- server/lib/statusHistory.ts | 69 +++++++++++++++++++ .../lib/alerts/events/healthCheckEvents.ts | 20 ++++-- .../lib/alerts/events/resourceEvents.ts | 25 +++++++ .../private/lib/alerts/events/siteEvents.ts | 3 + .../routers/healthChecks/getStatusHistory.ts | 39 ++--------- server/routers/resource/getStatusHistory.ts | 39 ++--------- server/routers/site/getStatusHistory.ts | 39 ++--------- server/routers/target/createTarget.ts | 6 +- server/routers/target/updateTarget.ts | 22 +++--- 9 files changed, 140 insertions(+), 122 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 896a5e302..b0ef0c927 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -1,4 +1,73 @@ import { z } from "zod"; +import { db, statusHistory } from "@server/db"; +import { and, eq, gte, asc } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +const STATUS_HISTORY_CACHE_TTL = 60; // seconds + +function statusHistoryCacheKey( + entityType: string, + entityId: number, + days: number +): string { + return `statusHistory:${entityType}:${entityId}:${days}`; +} + +export async function getCachedStatusHistory( + entityType: string, + entityId: number, + days: number +): Promise { + const cacheKey = statusHistoryCacheKey(entityType, entityId, days); + const cached = await cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const nowSec = Math.floor(Date.now() / 1000); + const startSec = nowSec - days * 86400; + + const events = await db + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + eq(statusHistory.entityId, entityId), + gte(statusHistory.timestamp, startSec) + ) + ) + .orderBy(asc(statusHistory.timestamp)); + + const { buckets, totalDowntime } = computeBuckets(events, days); + const totalWindow = days * 86400; + const overallUptime = + totalWindow > 0 + ? Math.max(0, ((totalWindow - totalDowntime) / totalWindow) * 100) + : 100; + + const result: StatusHistoryResponse = { + entityType, + entityId, + days: buckets, + overallUptimePercent: Math.round(overallUptime * 100) / 100, + totalDowntimeSeconds: totalDowntime + }; + + await cache.set(cacheKey, result, STATUS_HISTORY_CACHE_TTL); + return result; +} + +export async function invalidateStatusHistoryCache( + entityType: string, + entityId: number +): Promise { + const prefix = `statusHistory:${entityType}:${entityId}:`; + const keys = cache.keys().filter((k) => k.startsWith(prefix)); + if (keys.length > 0) { + await cache.del(keys); + } +} export const statusHistoryQuerySchema = z .object({ diff --git a/server/private/lib/alerts/events/healthCheckEvents.ts b/server/private/lib/alerts/events/healthCheckEvents.ts index 48aef424f..abb1e4c2b 100644 --- a/server/private/lib/alerts/events/healthCheckEvents.ts +++ b/server/private/lib/alerts/events/healthCheckEvents.ts @@ -22,6 +22,7 @@ import { Transaction } from "@server/db"; import { eq } from "drizzle-orm"; +import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; import { fireResourceDegradedAlert, fireResourceHealthyAlert, @@ -61,8 +62,9 @@ export async function fireHealthCheckHealthyAlert( status: "healthy", timestamp: Math.floor(Date.now() / 1000) }); + await invalidateStatusHistoryCache("health_check", healthCheckId); - await handleResource(orgId, healthCheckTargetId, trx); + await handleResource(orgId, healthCheckTargetId, send, trx); if (!send) { return; @@ -124,8 +126,9 @@ export async function fireHealthCheckUnhealthyAlert( status: "unhealthy", timestamp: Math.floor(Date.now() / 1000) }); + await invalidateStatusHistoryCache("health_check", healthCheckId); - await handleResource(orgId, healthCheckTargetId, trx); + await handleResource(orgId, healthCheckTargetId, send, trx); if (!send) { return; @@ -176,8 +179,9 @@ export async function fireHealthCheckUnknownAlert( status: "unknown", timestamp: Math.floor(Date.now() / 1000) }); + await invalidateStatusHistoryCache("health_check", healthCheckId); - await handleResource(orgId, healthCheckTargetId, trx); + await handleResource(orgId, healthCheckTargetId, send, trx); if (!send) { return; @@ -190,11 +194,11 @@ export async function fireHealthCheckUnknownAlert( } } -async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) { +async function handleResource(orgId: string, healthCheckTargetId?: number | null, send: boolean = true, trx: Transaction | typeof db = db) { if (!healthCheckTargetId) { return; } - // we have resources lets get them + // we have targets lets get them const [target] = await trx .select() .from(targets) @@ -204,6 +208,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null if (!target) { return; } + const [resource] = await trx .select() .from(resources) @@ -213,6 +218,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null if (!resource) { return; } + const otherTargets = await trx .select({ hcHealth: targetHealthCheck.hcHealth }) .from(targets) @@ -256,6 +262,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null resource.resourceId, resource.name, undefined, + send, trx ); } else if (health === "unhealthy") { @@ -264,6 +271,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null resource.resourceId, resource.name, undefined, + send, trx ); } else if (health === "healthy") { @@ -272,6 +280,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null resource.resourceId, resource.name, undefined, + send, trx ); } else if (health === "degraded") { @@ -280,6 +289,7 @@ async function handleResource(orgId: string, healthCheckTargetId?: number | null resource.resourceId, resource.name, undefined, + send, trx ); } diff --git a/server/private/lib/alerts/events/resourceEvents.ts b/server/private/lib/alerts/events/resourceEvents.ts index 289b19b90..006c8f622 100644 --- a/server/private/lib/alerts/events/resourceEvents.ts +++ b/server/private/lib/alerts/events/resourceEvents.ts @@ -14,6 +14,7 @@ import logger from "@server/logger"; import { processAlerts } from "../processAlerts"; import { db, statusHistory, Transaction } from "@server/db"; +import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; // --------------------------------------------------------------------------- // Public API @@ -35,6 +36,7 @@ export async function fireResourceHealthyAlert( resourceId: number, resourceName?: string | null, extra?: Record, + send: boolean = true, trx: Transaction | typeof db = db ): Promise { try { @@ -45,6 +47,11 @@ export async function fireResourceHealthyAlert( status: "healthy", timestamp: Math.floor(Date.now() / 1000) }); + await invalidateStatusHistoryCache("resource", resourceId); + + if (!send) { + return; + } await processAlerts({ eventType: "resource_healthy", @@ -90,6 +97,7 @@ export async function fireResourceUnhealthyAlert( resourceId: number, resourceName?: string | null, extra?: Record, + send: boolean = true, trx: Transaction | typeof db = db ): Promise { try { @@ -100,6 +108,11 @@ export async function fireResourceUnhealthyAlert( status: "unhealthy", timestamp: Math.floor(Date.now() / 1000) }); + await invalidateStatusHistoryCache("resource", resourceId); + + if (!send) { + return; + } await processAlerts({ eventType: "resource_unhealthy", @@ -145,6 +158,7 @@ export async function fireResourceDegradedAlert( resourceId: number, resourceName?: string | null, extra?: Record, + send: boolean = true, trx: Transaction | typeof db = db ): Promise { try { @@ -155,6 +169,11 @@ export async function fireResourceDegradedAlert( status: "degraded", timestamp: Math.floor(Date.now() / 1000) }); + await invalidateStatusHistoryCache("resource", resourceId); + + if (!send) { + return; + } await processAlerts({ eventType: "resource_degraded", @@ -200,6 +219,7 @@ export async function fireResourceUnknownAlert( resourceId: number, resourceName?: string | null, extra?: Record, + send: boolean = true, trx: Transaction | typeof db = db ): Promise { try { @@ -210,6 +230,11 @@ export async function fireResourceUnknownAlert( status: "unknown", timestamp: Math.floor(Date.now() / 1000) }); + await invalidateStatusHistoryCache("resource", resourceId); + + if (!send) { + return; + } await processAlerts({ eventType: "resource_toggle", diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index 36e3dacff..76939b537 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -14,6 +14,7 @@ import logger from "@server/logger"; import { processAlerts } from "../processAlerts"; import { db, sites, statusHistory, targetHealthCheck, Transaction } from "@server/db"; +import { invalidateStatusHistoryCache } from "@server/lib/statusHistory"; import { and, eq, inArray } from "drizzle-orm"; import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents"; @@ -47,6 +48,7 @@ export async function fireSiteOnlineAlert( status: "online", timestamp: Math.floor(Date.now() / 1000) }); + await invalidateStatusHistoryCache("site", siteId); await processAlerts({ eventType: "site_online", @@ -102,6 +104,7 @@ export async function fireSiteOfflineAlert( status: "offline", timestamp: Math.floor(Date.now() / 1000) }); + await invalidateStatusHistoryCache("site", siteId); const unhealthyHealthChecks = await trx .update(targetHealthCheck) diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts index 2fa596950..d2ef1ec26 100644 --- a/server/private/routers/healthChecks/getStatusHistory.ts +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -13,15 +13,13 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, statusHistory } from "@server/db"; -import { and, eq, gte, asc } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { - computeBuckets, + getCachedStatusHistory, statusHistoryQuerySchema, StatusHistoryResponse } from "@server/lib/statusHistory"; @@ -59,39 +57,10 @@ export async function getHealthCheckStatusHistory( const entityId = parsedParams.data.healthCheckId; const { days } = parsedQuery.data; - const nowSec = Math.floor(Date.now() / 1000); - const startSec = nowSec - days * 86400; - - const events = await db - .select() - .from(statusHistory) - .where( - and( - eq(statusHistory.entityType, entityType), - eq(statusHistory.entityId, entityId), - gte(statusHistory.timestamp, startSec) - ) - ) - .orderBy(asc(statusHistory.timestamp)); - - const { buckets, totalDowntime } = computeBuckets(events, days); - const totalWindow = days * 86400; - const overallUptime = - totalWindow > 0 - ? Math.max( - 0, - ((totalWindow - totalDowntime) / totalWindow) * 100 - ) - : 100; + const data = await getCachedStatusHistory(entityType, entityId, days); return response(res, { - data: { - entityType, - entityId, - days: buckets, - overallUptimePercent: Math.round(overallUptime * 100) / 100, - totalDowntimeSeconds: totalDowntime - }, + data, success: true, error: false, message: "Status history retrieved successfully", @@ -103,4 +72,4 @@ export async function getHealthCheckStatusHistory( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} +} \ No newline at end of file diff --git a/server/routers/resource/getStatusHistory.ts b/server/routers/resource/getStatusHistory.ts index 9aa548624..c3dcf6c88 100644 --- a/server/routers/resource/getStatusHistory.ts +++ b/server/routers/resource/getStatusHistory.ts @@ -1,14 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, statusHistory } from "@server/db"; -import { and, eq, gte, asc } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { - computeBuckets, + getCachedStatusHistory, statusHistoryQuerySchema, StatusHistoryResponse } from "@server/lib/statusHistory"; @@ -46,39 +44,10 @@ export async function getResourceStatusHistory( const entityId = parsedParams.data.resourceId; const { days } = parsedQuery.data; - const nowSec = Math.floor(Date.now() / 1000); - const startSec = nowSec - days * 86400; - - const events = await db - .select() - .from(statusHistory) - .where( - and( - eq(statusHistory.entityType, entityType), - eq(statusHistory.entityId, entityId), - gte(statusHistory.timestamp, startSec) - ) - ) - .orderBy(asc(statusHistory.timestamp)); - - const { buckets, totalDowntime } = computeBuckets(events, days); - const totalWindow = days * 86400; - const overallUptime = - totalWindow > 0 - ? Math.max( - 0, - ((totalWindow - totalDowntime) / totalWindow) * 100 - ) - : 100; + const data = await getCachedStatusHistory(entityType, entityId, days); return response(res, { - data: { - entityType, - entityId, - days: buckets, - overallUptimePercent: Math.round(overallUptime * 100) / 100, - totalDowntimeSeconds: totalDowntime - }, + data, success: true, error: false, message: "Status history retrieved successfully", @@ -90,4 +59,4 @@ export async function getResourceStatusHistory( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} +} \ No newline at end of file diff --git a/server/routers/site/getStatusHistory.ts b/server/routers/site/getStatusHistory.ts index f1717c8a9..26f1dbbd2 100644 --- a/server/routers/site/getStatusHistory.ts +++ b/server/routers/site/getStatusHistory.ts @@ -1,14 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, statusHistory } from "@server/db"; -import { and, eq, gte, asc } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { - computeBuckets, + getCachedStatusHistory, statusHistoryQuerySchema, StatusHistoryResponse } from "@server/lib/statusHistory"; @@ -46,39 +44,10 @@ export async function getSiteStatusHistory( const entityId = parsedParams.data.siteId; const { days } = parsedQuery.data; - const nowSec = Math.floor(Date.now() / 1000); - const startSec = nowSec - days * 86400; - - const events = await db - .select() - .from(statusHistory) - .where( - and( - eq(statusHistory.entityType, entityType), - eq(statusHistory.entityId, entityId), - gte(statusHistory.timestamp, startSec) - ) - ) - .orderBy(asc(statusHistory.timestamp)); - - const { buckets, totalDowntime } = computeBuckets(events, days); - const totalWindow = days * 86400; - const overallUptime = - totalWindow > 0 - ? Math.max( - 0, - ((totalWindow - totalDowntime) / totalWindow) * 100 - ) - : 100; + const data = await getCachedStatusHistory(entityType, entityId, days); return response(res, { - data: { - entityType, - entityId, - days: buckets, - overallUptimePercent: Math.round(overallUptime * 100) / 100, - totalDowntimeSeconds: totalDowntime - }, + data, success: true, error: false, message: "Status history retrieved successfully", @@ -90,4 +59,4 @@ export async function getSiteStatusHistory( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} +} \ No newline at end of file diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index a44a1a1fb..96df0260d 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -267,7 +267,7 @@ export async function createTarget( healthCheck[0].orgId, healthCheck[0].targetHealthCheckId, healthCheck[0].name, - undefined, + healthCheck[0].targetId, undefined, false, // dont send the alert because we just want to create the alert, not notify users yet trx @@ -278,7 +278,7 @@ export async function createTarget( healthCheck[0].orgId, healthCheck[0].targetHealthCheckId, healthCheck[0].name, - undefined, + healthCheck[0].targetId, undefined, false, // dont send the alert because we just want to create the alert, not notify users yet trx @@ -288,7 +288,7 @@ export async function createTarget( healthCheck[0].orgId, healthCheck[0].targetHealthCheckId, healthCheck[0].name, - undefined, + healthCheck[0].targetId, undefined, false, // dont send the alert because we just want to create the alert, not notify users yet trx diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 99f1acdeb..92c434a19 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -228,12 +228,7 @@ export async function updateTarget( hcHealthValue = undefined; } - const isDisablingHc = - (parsedBody.data.hcEnabled === false || - parsedBody.data.hcEnabled === null) && - existingHc.hcEnabled === true; - - const [updatedHc] = await trx + [updatedHc] = await trx .update(targetHealthCheck) .set({ siteId: parsedBody.data.siteId, @@ -259,32 +254,41 @@ export async function updateTarget( .returning(); if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") { + logger.debug( + `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unhealthy, firing alert` + ); await fireHealthCheckUnhealthyAlert( updatedHc.orgId, updatedHc.targetHealthCheckId, updatedHc.name || "", - undefined, + updatedHc.targetId, undefined, false, // dont send the alert because we just want to create the alert, not notify users yet trx ); } else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") { + logger.debug( + `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now unknown, firing alert` + ); // if the health is unknown, we want to fire an alert to notify users to enable health checks await fireHealthCheckUnknownAlert( updatedHc.orgId, updatedHc.targetHealthCheckId, updatedHc.name, - undefined, + updatedHc.targetId, undefined, false, // dont send the alert because we just want to create the alert, not notify users yet trx ); } else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") { + logger.debug( + `Health check ${updatedHc.targetHealthCheckId} for target ${targetId} is now healthy, firing alert` + ); await fireHealthCheckHealthyAlert( updatedHc.orgId, updatedHc.targetHealthCheckId, updatedHc.name, - undefined, + updatedHc.targetId, undefined, false, // dont send the alert because we just want to create the alert, not notify users yet trx From c5072bed80b759fec8a82dcceed616160445ca52 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 14:33:28 -0700 Subject: [PATCH 075/107] Fix healthcheck not showing data --- server/private/routers/healthChecks/getStatusHistory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/routers/healthChecks/getStatusHistory.ts b/server/private/routers/healthChecks/getStatusHistory.ts index d2ef1ec26..51a59e2e1 100644 --- a/server/private/routers/healthChecks/getStatusHistory.ts +++ b/server/private/routers/healthChecks/getStatusHistory.ts @@ -53,7 +53,7 @@ export async function getHealthCheckStatusHistory( ); } - const entityType = "healthCheck"; + const entityType = "health_check"; const entityId = parsedParams.data.healthCheckId; const { days } = parsedQuery.data; From e57312593416982626ae89afc64fd096fff1af8a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 27 Apr 2026 15:14:18 -0700 Subject: [PATCH 076/107] update wildcard resources link --- messages/en-US.json | 4 ++-- src/components/DomainPicker.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9347235ef..c78d3e1be 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2339,7 +2339,7 @@ "domainPickerVerified": "Verified", "domainPickerUnverified": "Unverified", "domainPickerManual": "Manual", - "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", + "domainPickerInvalidSubdomainStructure": "Invalid characters will be sanitized when saved.", "domainPickerError": "Error", "domainPickerErrorLoadDomains": "Failed to load organization domains", "domainPickerErrorCheckAvailability": "Failed to check domain availability", @@ -3196,7 +3196,7 @@ "webhookHeaderValuePlaceholder": "Value", "alertLabel": "Alert", "domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.", - "domainPickerWildcardCertWarning": "Wildcard certificates must be configured separately in Traefik.", + "domainPickerWildcardCertWarning": "Wildcard resources may require additional configuration to work properly.", "domainPickerWildcardCertWarningLink": "Learn more", "health": "Health" } diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 89cc4fed9..daacf892b 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -913,7 +913,7 @@ export default function DomainPicker({

{t("domainPickerWildcardCertWarning")}{" "} Date: Mon, 27 Apr 2026 15:08:17 -0700 Subject: [PATCH 077/107] Hide the icmp and snmp for now --- src/components/HealthCheckCredenza.tsx | 29 +++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src/components/HealthCheckCredenza.tsx b/src/components/HealthCheckCredenza.tsx index 7fa64ba80..c0b78018c 100644 --- a/src/components/HealthCheckCredenza.tsx +++ b/src/components/HealthCheckCredenza.tsx @@ -581,20 +581,21 @@ export function HealthCheckCredenza(props: HealthCheckCredenzaProps) { "healthCheckStrategyTcp" ) }, - { - id: "snmp", - title: "SNMP", - description: t( - "healthCheckStrategySnmp" - ) - }, - { - id: "icmp", - title: "Ping (ICMP)", - description: t( - "healthCheckStrategyIcmp" - ) - } + // lets hide these for now until they are implemented + // { + // id: "snmp", + // title: "SNMP", + // description: t( + // "healthCheckStrategySnmp" + // ) + // }, + // { + // id: "icmp", + // title: "Ping (ICMP)", + // description: t( + // "healthCheckStrategyIcmp" + // ) + // } ]} value={field.value} onChange={(value) => From 3439a3690f9e3de8b6f2da38e520a842c1a2620f Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 15:17:12 -0700 Subject: [PATCH 078/107] Fix site offline not respecting hc enabled --- .../private/lib/alerts/events/siteEvents.ts | 5 ++-- .../target/handleHealthcheckStatusMessage.ts | 27 ++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/server/private/lib/alerts/events/siteEvents.ts b/server/private/lib/alerts/events/siteEvents.ts index 76939b537..afb53f25f 100644 --- a/server/private/lib/alerts/events/siteEvents.ts +++ b/server/private/lib/alerts/events/siteEvents.ts @@ -112,7 +112,8 @@ export async function fireSiteOfflineAlert( .where( and( eq(targetHealthCheck.orgId, orgId), - eq(targetHealthCheck.siteId, siteId) + eq(targetHealthCheck.siteId, siteId), + eq(targetHealthCheck.hcEnabled, true) // only effect the ones that are enabled ) ) .returning(); @@ -126,7 +127,7 @@ export async function fireSiteOfflineAlert( healthCheck.orgId, healthCheck.targetHealthCheckId, healthCheck.name, - undefined, + healthCheck.targetId, // for the resource if we have one undefined, true, trx diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 0fe5caf7b..e5f286524 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -1,7 +1,4 @@ -import { - db, - targetHealthCheck -} from "@server/db"; +import { db, primaryDb, targetHealthCheck } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; import { eq, and, ne } from "drizzle-orm"; @@ -11,7 +8,6 @@ import { fireHealthCheckUnhealthyAlert } from "#dynamic/lib/alerts"; - interface TargetHealthStatus { status: string; lastCheck: string; @@ -85,13 +81,14 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( continue; } - const [targetCheck] = await db + const [targetCheck] = await primaryDb // using the primary db here in case it has just been updated and we are getting the immediate status back and it has not made it out to the repliacs yet .select({ targetId: targetHealthCheck.targetId, orgId: targetHealthCheck.orgId, targetHealthCheckId: targetHealthCheck.targetHealthCheckId, name: targetHealthCheck.name, - hcHealth: targetHealthCheck.hcHealth + hcHealth: targetHealthCheck.hcHealth, + hcEnabled: targetHealthCheck.hcEnabled }) .from(targetHealthCheck) .where( @@ -103,13 +100,20 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( .limit(1); if (!targetCheck) { - logger.warn( + logger.debug( `Target ${targetId} not found or does not belong to site ${newt.siteId}` ); errorCount++; continue; } + if (!targetCheck.hcEnabled) { + logger.debug( + `Health check for target ${targetId} is not enabled, skipping update` + ); + continue; + } + // check if the status has changed if (targetCheck.hcHealth === healthStatus.status) { logger.debug( @@ -128,7 +132,12 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( | "healthy" | "unhealthy" }) - .where(eq(targetHealthCheck.targetHealthCheckId, targetCheck.targetHealthCheckId)); + .where( + eq( + targetHealthCheck.targetHealthCheckId, + targetCheck.targetHealthCheckId + ) + ); // because we are checking above if there was a change we can fire the alert here because it changed if (healthStatus.status === "unhealthy") { From 24f437e260123109b3b143e9bb819fc391bbe475 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 15:24:03 -0700 Subject: [PATCH 079/107] Cap degraded in the mail --- server/emails/templates/AlertNotification.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/server/emails/templates/AlertNotification.tsx b/server/emails/templates/AlertNotification.tsx index 899c4a82f..ce30753da 100644 --- a/server/emails/templates/AlertNotification.tsx +++ b/server/emails/templates/AlertNotification.tsx @@ -117,11 +117,11 @@ function getEventMeta(eventType: AlertEventType): { }; case "resource_degraded": return { - heading: "Resource Unhealthy", - previewText: "A resource in your organization is not healthy.", + heading: "Resource Degraded", + previewText: "A resource in your organization is degraded.", summary: - "A resource in your organization is currently unhealthy.", - statusLabel: "Unhealthy", + "A resource in your organization is currently degraded.", + statusLabel: "Degraded", statusColor: "#dc2626" }; case "resource_toggle": @@ -158,6 +158,8 @@ function resolveToggleStatus(status: unknown): { return { label: "Healthy", color: "#16a34a" }; case "unhealthy": return { label: "Unhealthy", color: "#dc2626" }; + case "degraded": + return { label: "Degraded", color: "#dc2626" }; default: return { label: String(status ?? "Unknown"), color: "#f59e0b" }; } From cbb2388a46793d5b4fdf595e0fc4fd961740f866 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 27 Apr 2026 15:38:08 -0700 Subject: [PATCH 080/107] add multi site help link --- messages/en-US.json | 2 + src/components/InternalResourceForm.tsx | 215 +++++++++++++----------- 2 files changed, 120 insertions(+), 97 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index c78d3e1be..1b7e446fb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2884,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.", + "internalResourceFormMultiSiteRoutingHelp": "Selecting multiple sites enables resilient routing and failover for high availability.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Learn more", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", "createInternalResourceDialogHttpConfiguration": "HTTP configuration", "createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.", diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 6d7fd1537..76a614207 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -749,108 +749,129 @@ export function InternalResourceForm({ )}

-
-
- ( - - - {t("sites")} - - - - - - - - - { - setSelectedSites( - sites - ); - field.onChange( - sites.map( - (s) => - s.siteId - ) - ); - }} - /> - - - - - )} - /> -
-
- { - const modeOptions: OptionSelectOption[] = - [ - { - value: "host", - label: t(modeHostKey) - }, - { - value: "cidr", - label: t(modeCidrKey) - }, - { - value: "http", - label: t(modeHttpKey) - } - ]; - return ( - +
+
+
+ ( + - {t(modeLabelKey)} + {t("sites")} - - options={modeOptions} - value={field.value} - onChange={ - field.onChange - } - cols={3} - /> + + + + + + + + { + setSelectedSites( + sites + ); + field.onChange( + sites.map( + (s) => + s.siteId + ) + ); + }} + /> + + - ); - }} - /> + )} + /> +
+
+ { + const modeOptions: OptionSelectOption[] = + [ + { + value: "host", + label: t(modeHostKey) + }, + { + value: "cidr", + label: t(modeCidrKey) + }, + { + value: "http", + label: t(modeHttpKey) + } + ]; + return ( + + + {t(modeLabelKey)} + + + options={modeOptions} + value={field.value} + onChange={ + field.onChange + } + cols={3} + /> + + + ); + }} + /> +
+ {selectedSites.length > 1 && ( +

+ {t( + "internalResourceFormMultiSiteRoutingHelp" + )}{" "} + + {t( + "internalResourceFormMultiSiteRoutingHelpLearnMore" + )} + + + . +

+ )}
Date: Mon, 27 Apr 2026 15:38:32 -0700 Subject: [PATCH 081/107] Fix deleting resource --- server/routers/resource/deleteResource.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index a578c3841..682fd6aa9 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -52,6 +52,16 @@ export async function deleteResource( .from(targets) .where(eq(targets.resourceId, resourceId)); + const healthChecksToBeRemoved = await db + .select() + .from(targetHealthCheck) + .where( + inArray( + targetHealthCheck.targetId, + targetsToBeRemoved.map((t) => t.targetId) + ) + ); + const [deletedResource] = await db .delete(resources) .where(eq(resources.resourceId, resourceId)) @@ -91,16 +101,11 @@ export async function deleteResource( .where(eq(newts.siteId, site.siteId)) .limit(1); - const [healthCheck] = await db - .select() - .from(targetHealthCheck) - .where(eq(targetHealthCheck.targetId, target.targetId)); - await removeTargets( newt.newtId, // [target], [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this - [healthCheck], + healthChecksToBeRemoved, deletedResource.protocol, newt.version ); From 7affaf63d0246dc36f761f66439f7596cab644bf Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 16:14:20 -0700 Subject: [PATCH 082/107] Update get cert to now allow restarting --- server/private/routers/certificates/getCertificate.ts | 4 +++- server/routers/certificates/types.ts | 3 ++- src/components/CertificateStatus.tsx | 5 ++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/server/private/routers/certificates/getCertificate.ts b/server/private/routers/certificates/getCertificate.ts index d439011a7..fca53e9bb 100644 --- a/server/private/routers/certificates/getCertificate.ts +++ b/server/private/routers/certificates/getCertificate.ts @@ -40,6 +40,8 @@ async function query(domainId: string, domain: string) { throw new Error(`Domain with ID ${domainId} not found`); } + const domainType = domainRecord.type; + let existing: any[] = []; if (domainRecord.type == "ns" || domainRecord.type == "wildcard") { const domainLevelDown = domain.split(".").slice(1).join("."); @@ -98,7 +100,7 @@ async function query(domainId: string, domain: string) { ); } - return existing.length > 0 ? existing[0] : null; + return existing.length > 0 ? { ...existing[0], domainType } : null; } registry.registerPath({ diff --git a/server/routers/certificates/types.ts b/server/routers/certificates/types.ts index bca9412c4..e6aeecdf8 100644 --- a/server/routers/certificates/types.ts +++ b/server/routers/certificates/types.ts @@ -3,6 +3,7 @@ export type GetCertificateResponse = { domain: string; domainId: string; wildcard: boolean; + domainType: string; status: string; // pending, requested, valid, expired, failed expiresAt: string | null; lastRenewalAttempt: Date | null; @@ -10,4 +11,4 @@ export type GetCertificateResponse = { updatedAt: number; errorMessage?: string | null; renewalCount: number; -}; +}; \ No newline at end of file diff --git a/src/components/CertificateStatus.tsx b/src/components/CertificateStatus.tsx index 8271ccb60..9b08aedfe 100644 --- a/src/components/CertificateStatus.tsx +++ b/src/components/CertificateStatus.tsx @@ -12,7 +12,6 @@ type CertificateStatusProps = { autoFetch?: boolean; showLabel?: boolean; className?: string; - disableRestartButton?: boolean; onRefresh?: () => void; polling?: boolean; pollingInterval?: number; @@ -24,7 +23,6 @@ export default function CertificateStatus({ fullDomain, autoFetch = true, showLabel = true, - disableRestartButton = false, className = "", onRefresh, polling = false, @@ -120,6 +118,7 @@ export default function CertificateStatus({ } const isPending = cert.status === "pending"; + const disableRestartButton = cert.domainType === "wildcard"; return (
@@ -133,7 +132,7 @@ export default function CertificateStatus({ variant="ghost" className={`h-auto p-0 text-sm ${getStatusColor(cert.status)}`} onClick={handleRefresh} - disabled={refreshing} + disabled={refreshing || disableRestartButton} title={t("restartCertificate", { defaultValue: "Restart Certificate" })} From c03519b7f52692730de26ec99966b8ae8fcdd346 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 16:19:23 -0700 Subject: [PATCH 083/107] Send updates when the full domain changes --- server/routers/siteResource/updateSiteResource.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 4335b55d3..fd42cc465 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -730,8 +730,10 @@ export async function handleMessagingForUpdatedSiteResource( updatedSiteResource.destinationPort; const aliasChanged = existingSiteResource && - (existingSiteResource.alias !== updatedSiteResource.alias || - existingSiteResource.fullDomain !== updatedSiteResource.fullDomain); // because the full domain gets sent down to the stuff as an alias + existingSiteResource.alias !== updatedSiteResource.alias; + const fullDomainChanged = + existingSiteResource && + existingSiteResource.fullDomain !== updatedSiteResource.fullDomain; const portRangesChanged = existingSiteResource && (existingSiteResource.tcpPortRangeString !== @@ -746,6 +748,7 @@ export async function handleMessagingForUpdatedSiteResource( if ( destinationChanged || aliasChanged || + fullDomainChanged || portRangesChanged || destinationPortChanged ) { @@ -766,6 +769,7 @@ export async function handleMessagingForUpdatedSiteResource( if ( destinationChanged || portRangesChanged || + fullDomainChanged || // if the domain changes we need to update the certs and stuff destinationPortChanged ) { const oldTargets = await generateSubnetProxyTargetV2( @@ -844,7 +848,7 @@ export async function handleMessagingForUpdatedSiteResource( ]) } : undefined, - aliasChanged + aliasChanged || fullDomainChanged // the full domain is sent down as an alias ? { oldAliases: generateAliasConfig([ existingSiteResource From dbee049ac85d282556a06e72494810df2f2bc65a Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 16:30:54 -0700 Subject: [PATCH 084/107] Fix oss build issues --- server/lib/alerts/events/healthCheckEvents.ts | 5 ++++- server/lib/alerts/events/resourceEvents.ts | 5 ++++- server/routers/target/createTarget.ts | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/server/lib/alerts/events/healthCheckEvents.ts b/server/lib/alerts/events/healthCheckEvents.ts index 0a6d40f16..00afa22f0 100644 --- a/server/lib/alerts/events/healthCheckEvents.ts +++ b/server/lib/alerts/events/healthCheckEvents.ts @@ -6,6 +6,7 @@ export async function fireHealthCheckHealthyAlert( healthCheckName?: string, healthCheckTargetId?: number | null, extra?: Record, + send: boolean = true, trx?: unknown ): Promise { return; @@ -17,6 +18,7 @@ export async function fireHealthCheckUnhealthyAlert( healthCheckName?: string, healthCheckTargetId?: number | null, extra?: Record, + send: boolean = true, trx?: unknown ): Promise { return; @@ -28,7 +30,8 @@ export async function fireHealthCheckUnknownAlert( healthCheckName?: string | null, healthCheckTargetId?: number | null, extra?: Record, + send: boolean = true, trx?: unknown ): Promise { return; -} \ No newline at end of file +} diff --git a/server/lib/alerts/events/resourceEvents.ts b/server/lib/alerts/events/resourceEvents.ts index 09dd7d8cf..e7a374b44 100644 --- a/server/lib/alerts/events/resourceEvents.ts +++ b/server/lib/alerts/events/resourceEvents.ts @@ -3,6 +3,7 @@ export async function fireResourceHealthyAlert( resourceId: number, resourceName?: string | null, extra?: Record, + send: boolean = true, trx?: unknown ): Promise {} @@ -11,6 +12,7 @@ export async function fireResourceUnhealthyAlert( resourceId: number, resourceName?: string | null, extra?: Record, + send: boolean = true, trx?: unknown ): Promise {} @@ -19,5 +21,6 @@ export async function fireResourceToggleAlert( resourceId: number, resourceName?: string | null, extra?: Record, + send: boolean = true, trx?: unknown -): Promise {} \ No newline at end of file +): Promise {} diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 96df0260d..d582d06da 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -266,7 +266,7 @@ export async function createTarget( await fireHealthCheckUnhealthyAlert( healthCheck[0].orgId, healthCheck[0].targetHealthCheckId, - healthCheck[0].name, + healthCheck[0].name || "", healthCheck[0].targetId, undefined, false, // dont send the alert because we just want to create the alert, not notify users yet @@ -287,7 +287,7 @@ export async function createTarget( await fireHealthCheckHealthyAlert( healthCheck[0].orgId, healthCheck[0].targetHealthCheckId, - healthCheck[0].name, + healthCheck[0].name || "", healthCheck[0].targetId, undefined, false, // dont send the alert because we just want to create the alert, not notify users yet From 81a6fb8d0033e52e8f2af5739177087b144c8fb3 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 17:04:04 -0700 Subject: [PATCH 085/107] Dont import from postgres --- server/routers/target/deleteTarget.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index b6cea7139..685c41e7e 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -10,7 +10,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; import { OpenAPITags, registry } from "@server/openApi"; -import { targetHealthCheck } from "@server/db/pg"; +import { targetHealthCheck } from "@server/db"; const deleteTargetSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) From f89b0a17acc738384802379825775544832ea8e0 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 17:15:44 -0700 Subject: [PATCH 086/107] Set the default to unknown --- server/db/pg/schema/schema.ts | 2 +- server/db/sqlite/schema/schema.ts | 2 +- server/setup/scriptsPg/1.18.0.ts | 2 +- server/setup/scriptsSqlite/1.18.0.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 473d0f852..7fbcef621 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -158,7 +158,7 @@ export const resources = pgTable("resources", { maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), postAuthPath: text("postAuthPath"), - health: varchar("health"), // "healthy", "unhealthy" + health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: boolean("wildcard").notNull().default(false) }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 6a24ac8dd..423190420 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -179,7 +179,7 @@ export const resources = sqliteTable("resources", { maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), postAuthPath: text("postAuthPath"), - health: text("health"), // "healthy", "unhealthy" + health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false) }); diff --git a/server/setup/scriptsPg/1.18.0.ts b/server/setup/scriptsPg/1.18.0.ts index 8431a11c1..fe43112a2 100644 --- a/server/setup/scriptsPg/1.18.0.ts +++ b/server/setup/scriptsPg/1.18.0.ts @@ -347,7 +347,7 @@ export default async function migration() { `); await db.execute(sql` - ALTER TABLE "resources" ADD "health" varchar; + ALTER TABLE "resources" ADD "health" varchar DEFAULT 'unknown'; `); await db.execute(sql` diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts index d34cf1541..aba817df1 100644 --- a/server/setup/scriptsSqlite/1.18.0.ts +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -332,7 +332,7 @@ export default async function migration() { ).run(); db.prepare( ` - ALTER TABLE 'resources' ADD 'health' text; + ALTER TABLE 'resources' ADD 'health' text DEFAULT 'unknown'; ` ).run(); db.prepare( From c771722127062aa6787b2d33734a8279cde72a53 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 17:52:41 -0700 Subject: [PATCH 087/107] Dont include rewrite to --- server/lib/ip.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 3e57e8c94..929399f7b 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -700,7 +700,6 @@ export async function generateSubnetProxyTargetV2( targets.push({ sourcePrefixes: [], destPrefix: `${siteResource.aliasAddress}/32`, - rewriteTo: destination, portRange, disableIcmp, resourceId: siteResource.siteResourceId, From 85334f082cd970a03e6ebb9bd29646274b97ba21 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 27 Apr 2026 18:20:30 -0700 Subject: [PATCH 088/107] Only support alerts and newt sync on saas --- server/auth/actions.ts | 5 +- server/private/lib/acmeCertSync.ts | 2 +- server/private/routers/certificates/index.ts | 1 + .../routers/certificates/syncCertToNewts.ts | 68 +++++++++++++++++++ server/private/routers/integration.ts | 43 +++++++----- 5 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 server/private/routers/certificates/syncCertToNewts.ts diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 5ae98f965..89ccd7e37 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -154,10 +154,7 @@ export enum ActionsEnum { createHealthCheck = "createHealthCheck", updateHealthCheck = "updateHealthCheck", deleteHealthCheck = "deleteHealthCheck", - listHealthChecks = "listHealthChecks", - triggerSiteAlert = "triggerSiteAlert", - triggerResourceAlert = "triggerResourceAlert", - triggerHealthCheckAlert = "triggerHealthCheckAlert" + listHealthChecks = "listHealthChecks" } export async function checkUserActionPermission( diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index f5dfdeb12..a9e818986 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -50,7 +50,7 @@ interface AcmeJson { }; } -async function pushCertUpdateToAffectedNewts( +export async function pushCertUpdateToAffectedNewts( domain: string, domainId: string | null, oldCertPem: string | null, diff --git a/server/private/routers/certificates/index.ts b/server/private/routers/certificates/index.ts index 1ced04c31..18b942d5c 100644 --- a/server/private/routers/certificates/index.ts +++ b/server/private/routers/certificates/index.ts @@ -13,3 +13,4 @@ export * from "./getCertificate"; export * from "./restartCertificate"; +export * from "./syncCertToNewts"; diff --git a/server/private/routers/certificates/syncCertToNewts.ts b/server/private/routers/certificates/syncCertToNewts.ts new file mode 100644 index 000000000..ac6089acb --- /dev/null +++ b/server/private/routers/certificates/syncCertToNewts.ts @@ -0,0 +1,68 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { pushCertUpdateToAffectedNewts } from "#private/lib/acmeCertSync"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; + +const bodySchema = z.object({ + domain: z.string().min(1), + domainId: z.string().nullable().optional().default(null) +}); + +export async function syncCertToNewts( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsed = bodySchema.safeParse(req.body); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + + const { domain, domainId } = parsed.data; + + logger.debug( + `syncCertToNewts: received request to push cert update for domain "${domain}" (domainId: ${domainId ?? "none"})` + ); + + try { + await pushCertUpdateToAffectedNewts(domain, domainId, null, null); + + res.status(HttpCode.OK).json({ + data: null, + success: true, + error: false, + message: `Certificate update pushed to affected newts for domain "${domain}"` + }); + } catch (err) { + logger.error( + `syncCertToNewts: error pushing cert update for domain "${domain}": ${err}` + ); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to push certificate update to affected newts" + ) + ); + } +} \ No newline at end of file diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index ed97b3751..d5dac01e1 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -15,6 +15,7 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; +import * as certificates from "#private/routers/certificates"; import { verifyApiKeyHasAction, @@ -37,30 +38,36 @@ import { } from "@server/routers/integration"; import { logActionAudit } from "#private/middlewares"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { build } from "@server/build"; export const unauthenticated = ua; export const authenticated = a; -authenticated.post( - "/org/:orgId/site/:siteId/trigger-alert", - verifyApiKeyIsRoot, - verifyApiKeyHasAction(ActionsEnum.triggerSiteAlert), - alertEvents.triggerSiteAlert -); +if (build == "saas") { + authenticated.post( + "/org/:orgId/site/:siteId/trigger-alert", + verifyApiKeyIsRoot, + alertEvents.triggerSiteAlert + ); -authenticated.post( - "/org/:orgId/resource/:resourceId/trigger-alert", - verifyApiKeyIsRoot, - verifyApiKeyHasAction(ActionsEnum.triggerResourceAlert), - alertEvents.triggerResourceAlert -); + authenticated.post( + "/org/:orgId/resource/:resourceId/trigger-alert", + verifyApiKeyIsRoot, + alertEvents.triggerResourceAlert + ); -authenticated.post( - "/org/:orgId/health-check/:healthCheckId/trigger-alert", - verifyApiKeyIsRoot, - verifyApiKeyHasAction(ActionsEnum.triggerHealthCheckAlert), - alertEvents.triggerHealthCheckAlert -); + authenticated.post( + "/org/:orgId/health-check/:healthCheckId/trigger-alert", + verifyApiKeyIsRoot, + alertEvents.triggerHealthCheckAlert + ); + + authenticated.post( + "/cert/sync-to-newts", + verifyApiKeyIsRoot, + certificates.syncCertToNewts + ); +} authenticated.post( `/org/:orgId/send-usage-notification`, From e4bf2da2e5b20e5a88282ad0e56d4b301dcf85d0 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:24:58 -0700 Subject: [PATCH 089/107] New translations en-us.json (French) --- messages/fr-FR.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 8b9cd90b9..ff9cca43d 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "J'ai copié la configuration", "searchSitesProgress": "Rechercher des nœuds...", "siteAdd": "Ajouter un nœud", + "sitesTableViewPublicResources": "Voir les ressources publiques", + "sitesTableViewPrivateResources": "Voir les ressources privées", "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Faites fonctionner Newt sur votre système", "WgConfiguration": "Configuration WireGuard", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "Le nœud a été mis à jour.", "siteGeneralDescription": "Configurer les paramètres par défaut de ce nœud", "siteSettingDescription": "Configurer les paramètres du site", + "siteResourcesTab": "Ressources", + "siteResourcesNoneOnSite": "Ce site n'a pas encore de ressources publiques ou privées.", + "siteResourcesSectionPublic": "Ressources publiques", + "siteResourcesSectionPrivate": "Ressources privées", + "siteResourcesSectionPublicDescription": "Ressources exposées à l'extérieur via des domaines ou des ports.", + "siteResourcesSectionPrivateDescription": "Ressources disponibles sur votre réseau privé via le site.", + "siteResourcesViewAllPublic": "Voir toutes les ressources", + "siteResourcesViewAllPrivate": "Voir toutes les ressources", + "siteResourcesDialogDescription": "Aperçu des ressources publiques et privées associées à ce site.", + "siteResourcesShowMore": "Afficher plus", + "siteResourcesPermissionDenied": "Vous n'avez pas la permission de lister ces ressources.", + "siteResourcesEmptyPublic": "Aucune ressource publique ne cible encore ce site.", + "siteResourcesEmptyPrivate": "Aucune ressource privée n'est encore associée à ce site.", + "siteResourcesHowToAccess": "Comment accéder", + "siteResourcesTargetsOnSite": "Cibles sur ce site", "siteSetting": "Paramètres de {siteName}", "siteNewtTunnel": "Site Newt (Recommandé)", "siteNewtTunnelDescription": "La façon la plus simple de créer un point d'entrée dans n'importe quel réseau. Pas de configuration supplémentaire.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "Les changements d'état de la vérification de l'état de santé", "alertingTriggerResourceHealthy": "Ressource saine", "alertingTriggerResourceUnhealthy": "Ressource non saine", + "alertingTriggerResourceDegraded": "Ressource dégradée", "alertingSearchHealthChecks": "Rechercher des vérifications de l'état de santé…", "alertingHealthChecksEmpty": "Aucune vérification de l'état de santé disponible.", "alertingTriggerResourceToggle": "Les changements d'état de la ressource", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Créer le compte administrateur du serveur initial. Un seul administrateur serveur peut exister. Vous pouvez toujours changer ces informations d'identification plus tard.", "createAdminAccount": "Créer un compte administrateur", "setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.", - "certificateStatus": "Statut du certificat", + "certificateStatus": "Certificat", "loading": "Chargement", "loadingAnalytics": "Chargement de l'analyse", "restart": "Redémarrer", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Configurer la vérification de l'état de santé", "configureHealthCheckDescription": "Configurer la surveillance de la santé pour {target}", "enableHealthChecks": "Activer les vérifications de santé", + "healthCheckDisabledStateDescription": "Lorsqu'il est désactivé, le site ne procédera pas aux vérifications de santé et l'état sera considéré comme inconnu.", "enableHealthChecksDescription": "Surveiller la vie de cette cible. Vous pouvez surveiller un point de terminaison différent de la cible si nécessaire.", "healthScheme": "Méthode", "healthSelectScheme": "Sélectionnez la méthode", @@ -1947,6 +1966,8 @@ "httpMethod": "Méthode HTTP", "selectHttpMethod": "Sélectionnez la méthode HTTP", "domainPickerSubdomainLabel": "Sous-domaine", + "domainPickerWildcard": "Joker", + "domainPickerWildcardPaidOnly": "Les sous-domaines Joker sont une fonctionnalité payante. Veuillez mettre à niveau pour accéder à cette fonctionnalité.", "domainPickerBaseDomainLabel": "Domaine de base", "domainPickerSearchDomains": "Rechercher des domaines...", "domainPickerNoDomainsFound": "Aucun domaine trouvé", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Cette adresse fait partie du sous-réseau utilitaire de l'organisation. Elle est utilisée pour résoudre les enregistrements d'alias en utilisant une résolution DNS interne.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "et sont uniquement accessibles en interne lorsqu'elles sont connectées avec un client.", - "resourcesTableNoTargets": "Aucune cible", "resourcesTableHealthy": "Sain", "resourcesTableDegraded": "Dégradé", - "resourcesTableOffline": "Hors ligne", + "resourcesTableUnhealthy": "En mauvaise santé", "resourcesTableUnknown": "Inconnu", "resourcesTableNotMonitored": "Non-monitoré", + "resourcesTableNoTargets": "Aucune cible", "editInternalResourceDialogEditClientResource": "Modifier une ressource privée", "editInternalResourceDialogUpdateResourceProperties": "Mettre à jour la configuration de la ressource et les contrôles d'accès pour {resourceName}", "editInternalResourceDialogResourceProperties": "Propriétés de la ressource", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Vérifié", "domainPickerUnverified": "Non vérifié", "domainPickerManual": "Manuel", - "domainPickerInvalidSubdomainStructure": "Ce sous-domaine contient des caractères ou une structure non valide. Il sera automatiquement nettoyé lorsque vous enregistrez.", + "domainPickerInvalidSubdomainStructure": "Les caractères invalides seront nettoyés lors de l'enregistrement.", "domainPickerError": "Erreur", "domainPickerErrorLoadDomains": "Impossible de charger les domaines de l'organisation", "domainPickerErrorCheckAvailability": "Impossible de vérifier la disponibilité du domaine", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Ajouter des clients", "editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationDescription": "Indiquez l'adresse de destination pour la ressource interne. Cela peut être un nom d'hôte, une adresse IP ou une plage CIDR selon le mode sélectionné. Définissez éventuellement un alias DNS interne pour une identification plus facile.", + "internalResourceFormMultiSiteRoutingHelp": "La sélection de plusieurs sites permet un routage résilient et un basculement pour une haute disponibilité.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "En savoir plus", "editInternalResourceDialogPortRestrictionsDescription": "Restreindre l'accès à des ports TCP/UDP spécifiques ou autoriser/bloquer tous les ports.", "createInternalResourceDialogHttpConfiguration": "Configuration HTTP", "createInternalResourceDialogHttpConfigurationDescription": "Choisissez le domaine que les clients utiliseront pour atteindre cette ressource via HTTP ou HTTPS.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Temps d'achèvement estimé (facultatif)", "privateMaintenanceScreenTitle": "Écran de maintien de service privé", "privateMaintenanceScreenMessage": "Ce domaine est utilisé sur une ressource privée. Veuillez vous connecter à l'aide du client Pangolin pour accéder à cette ressource.", + "privateMaintenanceScreenSteps": "Une fois connecté, si vous voyez toujours ce message, le cache DNS de votre navigateur peut toujours pointer vers l'ancienne adresse. Pour résoudre cela : fermez complètement et rouvrez cet onglet, ou votre navigateur, puis retournez sur cette page.", "maintenanceTime": "par exemple, 2 heures, le 1er nov. à 17:00", "maintenanceEstimatedTimeDescription": "Quand vous attendez que la maintenance soit terminée", "editDomain": "Modifier le domaine", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Supprimer", "publicIpEndpoint": "Point de terminaison", "lastTriggeredAt": "Dernier déclenchement", - "reject": "Rejeter" + "reject": "Rejeter", + "uptimeDaysAgo": "Il y a {count} jours", + "uptimeToday": "Aujourd'hui", + "uptimeNoDataAvailable": "Aucune donnée disponible", + "uptimeSuffix": "disponibilité", + "uptimeDowntimeSuffix": "indisponibilité", + "uptimeTooltipUptimeLabel": "Disponibilité", + "uptimeTooltipDowntimeLabel": "Indisponibilité", + "uptimeOngoing": "en cours", + "uptimeNoMonitoringData": "Pas de données de surveillance", + "uptimeNoData": "Aucune donnée", + "uptimeMiniBarDown": "Non disponible", + "uptimeSectionTitle": "Disponibilité", + "uptimeSectionDescription": "Disponibilité sur les {days} derniers jours", + "uptimeAddAlert": "Ajouter une alerte", + "uptimeViewAlerts": "Voir les alertes", + "uptimeCreateEmailAlert": "Créer une alerte par e-mail", + "uptimeAlertDescriptionSite": "Recevez un e-mail lorsque ce site est hors ligne ou revient en ligne.", + "uptimeAlertDescriptionResource": "Recevez un e-mail lorsque cette ressource est hors ligne ou revient en ligne.", + "uptimeAlertNamePlaceholder": "Nom de l'alerte", + "uptimeAdditionalEmails": "E-mails supplémentaires", + "uptimeCreateAlert": "Créer une alerte", + "uptimeAlertNoRecipients": "Aucun destinataire", + "uptimeAlertNoRecipientsDescription": "Veuillez ajouter au moins un utilisateur, rôle ou e-mail à notifier.", + "uptimeAlertCreated": "Alerte créé", + "uptimeAlertCreatedDescription": "Vous serez notifié lorsque ce statut changera.", + "uptimeAlertCreateFailed": "Échec de la création de l'alerte", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Clé", + "webhookHeaderValuePlaceholder": "Valeur", + "alertLabel": "Alerte", + "domainPickerWildcardSubdomainNotAllowed": "Les sous-domaines Joker ne sont pas autorisés.", + "domainPickerWildcardCertWarning": "Les ressources Joker peuvent nécessiter une configuration supplémentaire pour fonctionner correctement.", + "domainPickerWildcardCertWarningLink": "En savoir plus", + "health": "Santé" } From c0a4541455d569e339619b6a281cc9f7b77b60dc Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:00 -0700 Subject: [PATCH 090/107] New translations en-us.json (Bulgarian) --- messages/bg-BG.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index bf953e4d4..eb129ee32 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "Копирах конфигурацията", "searchSitesProgress": "Търсене на сайтове...", "siteAdd": "Добавете сайт", + "sitesTableViewPublicResources": "Вижте публични ресурси", + "sitesTableViewPrivateResources": "Вижте частни ресурси", "siteInstallNewt": "Инсталирайте Newt", "siteInstallNewtDescription": "Пуснете Newt на вашата система", "WgConfiguration": "WireGuard конфигурация", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "Сайтът е актуализиран.", "siteGeneralDescription": "Конфигурирайте общи настройки за този сайт", "siteSettingDescription": "Конфигурирайте настройките на сайта", + "siteResourcesTab": "Ресурси", + "siteResourcesNoneOnSite": "Този сайт все още няма публични или частни ресурси.", + "siteResourcesSectionPublic": "Публични ресурси", + "siteResourcesSectionPrivate": "Частни ресурси", + "siteResourcesSectionPublicDescription": "Ресурси, които са изложени външно чрез домейни или портове.", + "siteResourcesSectionPrivateDescription": "Ресурси, които са достъпни в частната ви мрежа през сайта.", + "siteResourcesViewAllPublic": "Виж всички ресурси", + "siteResourcesViewAllPrivate": "Виж всички ресурси", + "siteResourcesDialogDescription": "Преглед на публични и частни ресурси, свързани с този сайт.", + "siteResourcesShowMore": "Покажи повече", + "siteResourcesPermissionDenied": "Нямате разрешение да изброите тези ресурси.", + "siteResourcesEmptyPublic": "Няма публични ресурси, насочени към този сайт все още.", + "siteResourcesEmptyPrivate": "Няма частни ресурси, свързани с този сайт още.", + "siteResourcesHowToAccess": "Как да получите достъп", + "siteResourcesTargetsOnSite": "Цели на този сайт", "siteSetting": "Настройки на {siteName}", "siteNewtTunnel": "Нов Сайт (Препоръчително)", "siteNewtTunnelDescription": "Най-лесният начин да създадете точка за достъп до всяка мрежа. Няма нужда от допълнителни настройки.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "Състоянието на проверката се променя", "alertingTriggerResourceHealthy": "Ресурсът е здрав", "alertingTriggerResourceUnhealthy": "Ресурсът не е здрав", + "alertingTriggerResourceDegraded": "Деградирал ресурс", "alertingSearchHealthChecks": "Търсене на проверки на състоянието…", "alertingHealthChecksEmpty": "Няма налични проверки на състоянието.", "alertingTriggerResourceToggle": "Състоянието на ресурса се променя", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Създайте администраторски акаунт на сървъра. Може да съществува само един администраторски акаунт. Винаги можете да промените тези данни по-късно.", "createAdminAccount": "Създаване на админ акаунт", "setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.", - "certificateStatus": "Статус на сертификата", + "certificateStatus": "Сертификат", "loading": "Зареждане", "loadingAnalytics": "Зареждане на анализи", "restart": "Рестарт", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Конфигуриране на проверка на здравето", "configureHealthCheckDescription": "Настройте мониторинг на здравето за {target}", "enableHealthChecks": "Разрешаване на проверки на здравето", + "healthCheckDisabledStateDescription": "Когато е деактивиран, сайтът не изпълнява проверки и състоянието се счита за неизвестно.", "enableHealthChecksDescription": "Мониторинг на здравето на тази цел. Можете да наблюдавате различен краен пункт от целта, ако е необходимо.", "healthScheme": "Метод", "healthSelectScheme": "Избор на метод", @@ -1947,6 +1966,8 @@ "httpMethod": "HTTP Метод", "selectHttpMethod": "Изберете HTTP метод", "domainPickerSubdomainLabel": "Поддомен", + "domainPickerWildcard": "Уайлдкард", + "domainPickerWildcardPaidOnly": "Уайлдкард подсайтовете са платена функция. Моля, надстройте за достъп до тази функция.", "domainPickerBaseDomainLabel": "Основен домейн", "domainPickerSearchDomains": "Търсене на домейни...", "domainPickerNoDomainsFound": "Не са намерени домейни", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Този адрес е част от подсистемата на организацията. Използва се за разрешаване на псевдонимни записи чрез вътрешно DNS разрешаване.", "resourcesTableClients": "Клиенти", "resourcesTableAndOnlyAccessibleInternally": "и са достъпни само вътрешно при свързване с клиент.", - "resourcesTableNoTargets": "Без цели", "resourcesTableHealthy": "Здрав", "resourcesTableDegraded": "Влошен", - "resourcesTableOffline": "Извън линия", + "resourcesTableUnhealthy": "Нездравословно", "resourcesTableUnknown": "Неизвестно", "resourcesTableNotMonitored": "Не е наблюдавано", + "resourcesTableNoTargets": "Няма цели", "editInternalResourceDialogEditClientResource": "Редактиране на частен ресурс", "editInternalResourceDialogUpdateResourceProperties": "Актуализирайте конфигурацията на ресурса и контрола на достъпа за {resourceName}", "editInternalResourceDialogResourceProperties": "Свойствата на ресурса", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Проверено", "domainPickerUnverified": "Непроверено", "domainPickerManual": "Ръчно", - "domainPickerInvalidSubdomainStructure": "Този поддомен съдържа невалидни знаци или структура. Ще бъде автоматично пречистен при запазване.", + "domainPickerInvalidSubdomainStructure": "Невалидните символи ще бъдат почистени при записване.", "domainPickerError": "Грешка", "domainPickerErrorLoadDomains": "Неуспешно зареждане на домейни на организацията", "domainPickerErrorCheckAvailability": "Неуспешна проверка на наличността на домейни", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Добавяне на клиенти.", "editInternalResourceDialogDestinationLabel": "Дестинация.", "editInternalResourceDialogDestinationDescription": "Посочете адреса дестинация за вътрешния ресурс. Това може да бъде име на хост, IP адрес или CIDR обхват в зависимост от избрания режим. По избор настройте вътрешен DNS алиас за по-лесно идентифициране.", + "internalResourceFormMultiSiteRoutingHelp": "Избирайки няколко сайта, се осигурява сигурен път и пренасочване при висока достъпност.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Научете повече", "editInternalResourceDialogPortRestrictionsDescription": "Ограничете достъпа до конкретни TCP/UDP портове или позволете/блокирайте всички портове.", "createInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", "createInternalResourceDialogHttpConfigurationDescription": "Изберете домейна, който клиентите ще използват, за да достигнат този ресурс чрез HTTP или HTTPS.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Очаквано време за завършване (по избор).", "privateMaintenanceScreenTitle": "Екран за поддръжка", "privateMaintenanceScreenMessage": "Този домейн се използва при частен ресурс. Моля, свържете се с клиента на Pangolin, за да получите достъп до този ресурс.", + "privateMaintenanceScreenSteps": "След свързване, ако все още виждате това съобщение, кешът на DNS на вашия браузър все още може да сочи към стария адрес. За да коригирате това: напълно затворете и отворете отново този раздел, или браузъра си, след това се върнете на тази страница.", "maintenanceTime": "например, 2 часа, 1 ноември в 17:00.", "maintenanceEstimatedTimeDescription": "Кога очаквате поддръжката да бъде завършена?", "editDomain": "Редактиране на домейна.", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Изтриване", "publicIpEndpoint": "Крайна точка", "lastTriggeredAt": "Последен тригер", - "reject": "Отхвърляне" + "reject": "Отхвърляне", + "uptimeDaysAgo": "{count} days ago", + "uptimeToday": "Днес", + "uptimeNoDataAvailable": "Няма налични данни", + "uptimeSuffix": "време без прекъсване", + "uptimeDowntimeSuffix": "време на прекъсване", + "uptimeTooltipUptimeLabel": "Време без прекъсване", + "uptimeTooltipDowntimeLabel": "Време на прекъсване", + "uptimeOngoing": "текущо", + "uptimeNoMonitoringData": "Няма данни за наблюдение", + "uptimeNoData": "Няма данни", + "uptimeMiniBarDown": "Прекъсване", + "uptimeSectionTitle": "Време без прекъсване", + "uptimeSectionDescription": "Наличност през последните {days} дни", + "uptimeAddAlert": "Добавяне на известие", + "uptimeViewAlerts": "Преглед на известията", + "uptimeCreateEmailAlert": "Създаване на електронна известие", + "uptimeAlertDescriptionSite": "Получавайте известия по електронна поща, когато този сайт се изключи или отново стане онлайн.", + "uptimeAlertDescriptionResource": "Получавайте известия по електронна поща, когато този ресурс се изключи или отново стане онлайн.", + "uptimeAlertNamePlaceholder": "Име на известието", + "uptimeAdditionalEmails": "Допълнителни имейли", + "uptimeCreateAlert": "Създаване на известие", + "uptimeAlertNoRecipients": "Няма получатели", + "uptimeAlertNoRecipientsDescription": "Моля, добавете поне един потребител, рол, или имейл за известяване.", + "uptimeAlertCreated": "Известието е създадено", + "uptimeAlertCreatedDescription": "Ще бъдете известени, когато това промени статуса си.", + "uptimeAlertCreateFailed": "Неуспешно създаване на известие", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Ключ", + "webhookHeaderValuePlaceholder": "Стойност", + "alertLabel": "Известие", + "domainPickerWildcardSubdomainNotAllowed": "Уайлдкард подсайтове не са позволени.", + "domainPickerWildcardCertWarning": "Ресурсите с уайлдкард може да изискват допълнителна конфигурация за правилна работа.", + "domainPickerWildcardCertWarningLink": "Научете повече", + "health": "Здраве" } From 68ea7d1d98d3aae27c052c65a46e82ce1b11894d Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:02 -0700 Subject: [PATCH 091/107] New translations en-us.json (Czech) --- messages/cs-CZ.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 0e43a4043..181bff665 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "Konfiguraci jsem zkopíroval", "searchSitesProgress": "Hledat lokality...", "siteAdd": "Přidat lokalitu", + "sitesTableViewPublicResources": "Zobrazit veřejné zdroje", + "sitesTableViewPrivateResources": "Zobrazit soukromé zdroje", "siteInstallNewt": "Nainstalovat Newt", "siteInstallNewtDescription": "Spustit Newt na vašem systému", "WgConfiguration": "Konfigurace WireGuard", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "Lokalita byla upravena.", "siteGeneralDescription": "Upravte obecná nastavení pro tuto lokalitu", "siteSettingDescription": "Konfigurace nastavení na webu", + "siteResourcesTab": "Zdroje", + "siteResourcesNoneOnSite": "Tento web zatím nemá veřejné ani soukromé zdroje.", + "siteResourcesSectionPublic": "Veřejné zdroje", + "siteResourcesSectionPrivate": "Soukromé zdroje", + "siteResourcesSectionPublicDescription": "Zdroje zpřístupněné externě prostřednictvím domén nebo portů.", + "siteResourcesSectionPrivateDescription": "Zdroje dostupné ve vaší soukromé síti prostřednictvím webu.", + "siteResourcesViewAllPublic": "Zobrazit všechny zdroje", + "siteResourcesViewAllPrivate": "Zobrazit všechny zdroje", + "siteResourcesDialogDescription": "Přehled veřejných a soukromých zdrojů spojených s tímto webem.", + "siteResourcesShowMore": "Ukázat více", + "siteResourcesPermissionDenied": "Nemáte oprávnění k vypsání těchto zdrojů.", + "siteResourcesEmptyPublic": "Žádné veřejné zdroje ještě necílí na tento web.", + "siteResourcesEmptyPrivate": "Žádné soukromé zdroje ještě nejsou spojené s tímto webem.", + "siteResourcesHowToAccess": "Jak získat přístup", + "siteResourcesTargetsOnSite": "Cíle na tomto webu", "siteSetting": "Nastavení {siteName}", "siteNewtTunnel": "Novinka (doporučeno)", "siteNewtTunnelDescription": "Nejjednodušší způsob, jak vytvořit vstupní bod do jakékoli sítě. Žádné další nastavení.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "Změny stavu kontroly stavu", "alertingTriggerResourceHealthy": "Zdroj je zdravý", "alertingTriggerResourceUnhealthy": "Zdroj je nezdravý", + "alertingTriggerResourceDegraded": "Zhoršený zdroj", "alertingSearchHealthChecks": "Hledat kontroly stavu…", "alertingHealthChecksEmpty": "Nejsou dostupné kontroly stavu.", "alertingTriggerResourceToggle": "Změny stavu zdroje", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Vytvořte účet správce intial serveru. Pouze jeden správce serveru může existovat. Tyto přihlašovací údaje můžete kdykoliv změnit.", "createAdminAccount": "Vytvořit účet správce", "setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.", - "certificateStatus": "Stav certifikátu", + "certificateStatus": "Certifikát", "loading": "Načítání", "loadingAnalytics": "Načítání analytiky", "restart": "Restartovat", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Konfigurace kontroly stavu", "configureHealthCheckDescription": "Nastavit sledování zdravotního stavu pro {target}", "enableHealthChecks": "Povolit kontrolu stavu", + "healthCheckDisabledStateDescription": "Pokud je zakázáno, web nebude provádět zdravotní kontroly a stav bude považován za neznámý.", "enableHealthChecksDescription": "Sledujte zdraví tohoto cíle. V případě potřeby můžete sledovat jiný cílový bod, než je cíl.", "healthScheme": "Způsob", "healthSelectScheme": "Vybrat metodu", @@ -1947,6 +1966,8 @@ "httpMethod": "HTTP metoda", "selectHttpMethod": "Vyberte HTTP metodu", "domainPickerSubdomainLabel": "Subdoména", + "domainPickerWildcard": "Zástupný znak", + "domainPickerWildcardPaidOnly": "Zástupné poddomény jsou placenou funkcí. Upgradujte, prosím, pro přístup k této funkci.", "domainPickerBaseDomainLabel": "Základní doména", "domainPickerSearchDomains": "Hledat domény...", "domainPickerNoDomainsFound": "Nebyly nalezeny žádné domény", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Tato adresa je součástí subsítě veřejných služeb organizace. Používá se k řešení záznamů aliasů pomocí interního rozlišení DNS.", "resourcesTableClients": "Klienti", "resourcesTableAndOnlyAccessibleInternally": "a jsou interně přístupné pouze v případě, že jsou propojeni s klientem.", - "resourcesTableNoTargets": "Žádné cíle", "resourcesTableHealthy": "Zdravé", "resourcesTableDegraded": "Rozklad", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Nezdravý", "resourcesTableUnknown": "Neznámý", "resourcesTableNotMonitored": "Není sledováno", + "resourcesTableNoTargets": "Žádné cíle", "editInternalResourceDialogEditClientResource": "Upravit soukromý dokument", "editInternalResourceDialogUpdateResourceProperties": "Aktualizovat konfiguraci zdroje a ovládací prvky přístupu pro {resourceName}", "editInternalResourceDialogResourceProperties": "Vlastnosti zdroje", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Ověřeno", "domainPickerUnverified": "Neověřeno", "domainPickerManual": "Ruční nastavení", - "domainPickerInvalidSubdomainStructure": "Tato subdoména obsahuje neplatné znaky nebo strukturu. Bude automaticky sanitována při uložení.", + "domainPickerInvalidSubdomainStructure": "Neplatné znaky budou při ukládání vyčištěny.", "domainPickerError": "Chyba", "domainPickerErrorLoadDomains": "Nepodařilo se načíst domény organizace", "domainPickerErrorCheckAvailability": "Kontrola dostupnosti domény se nezdařila", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Přidat klienty", "editInternalResourceDialogDestinationLabel": "Cíl", "editInternalResourceDialogDestinationDescription": "Určete cílovou adresu pro interní prostředek. Může se jednat o hostname, IP adresu, nebo rozsah CIDR v závislosti na vybraném režimu. Volitelně nastavte interní DNS alias pro snazší identifikaci.", + "internalResourceFormMultiSiteRoutingHelp": "Výběrem více webů se povolí odolné směrování a přepojení pro vysokou dostupnost.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Zjistit více", "editInternalResourceDialogPortRestrictionsDescription": "Omezte přístup na specifické TCP/UDP porty nebo povolte/blokujte všechny porty.", "createInternalResourceDialogHttpConfiguration": "Konfigurace HTTP", "createInternalResourceDialogHttpConfigurationDescription": "Zvolte doménu, kterou klienti použijí k dosažení tohoto zdroje přes HTTP nebo HTTPS.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Odhadovaný čas dokončení (volitelný)", "privateMaintenanceScreenTitle": "Soukromá obrazovka údržby", "privateMaintenanceScreenMessage": "Tato doména je používána na soukromém zdroji. Prosím, připojte se přes klienta Pangolin pro přístup k tomuto zdroji.", + "privateMaintenanceScreenSteps": "Jakmile se připojíte, pokud stále vidíte tuto zprávu, možná je mezipaměť DNS vašeho prohlížeče stále nasměrována na starou adresu. Abyste to opravili: úplně zavřete a znovu otevřete tuto záložku nebo prohlížeč, a poté se vraťte na tuto stránku.", "maintenanceTime": "např. 2 hodiny, 1. listopadu v 17:00", "maintenanceEstimatedTimeDescription": "Kdy očekáváte, že údržba bude dokončena", "editDomain": "Upravit doménu", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Odstranit", "publicIpEndpoint": "Koncový bod", "lastTriggeredAt": "Poslední spouštěč", - "reject": "Odmítnout" + "reject": "Odmítnout", + "uptimeDaysAgo": "{count} days ago", + "uptimeToday": "Dnes", + "uptimeNoDataAvailable": "Dostupná žádná data", + "uptimeSuffix": "doba dostupnosti", + "uptimeDowntimeSuffix": "doba nedostupnosti", + "uptimeTooltipUptimeLabel": "Doba dostupnosti", + "uptimeTooltipDowntimeLabel": "Doba nedostupnosti", + "uptimeOngoing": "probíhá", + "uptimeNoMonitoringData": "Žádné monitorovací údaje", + "uptimeNoData": "Žádná data", + "uptimeMiniBarDown": "Nedostupný", + "uptimeSectionTitle": "Doba dostupnosti", + "uptimeSectionDescription": "Dostupnost za posledních {days} dní", + "uptimeAddAlert": "Přidat upozornění", + "uptimeViewAlerts": "Zobrazit upozornění", + "uptimeCreateEmailAlert": "Vytvořit e-mailové upozornění", + "uptimeAlertDescriptionSite": "Pošleme vám upozornění e-mailem, když bude tento web offline nebo se vrátí online.", + "uptimeAlertDescriptionResource": "Pošleme vám upozornění e-mailem, když bude tento zdroj offline nebo se vrátí online.", + "uptimeAlertNamePlaceholder": "Název upozornění", + "uptimeAdditionalEmails": "Další e-maily", + "uptimeCreateAlert": "Vytvořit upozornění", + "uptimeAlertNoRecipients": "Žádní příjemci", + "uptimeAlertNoRecipientsDescription": "Přidejte prosím alespoň jednoho uživatele, roli nebo e-mailovou adresu pro upozornění.", + "uptimeAlertCreated": "Upozornění vytvořeno", + "uptimeAlertCreatedDescription": "Budete upozorněni, když se tento stav změní.", + "uptimeAlertCreateFailed": "Nepodařilo se vytvořit upozornění", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Klíč", + "webhookHeaderValuePlaceholder": "Hodnota", + "alertLabel": "Upozornění", + "domainPickerWildcardSubdomainNotAllowed": "Zástupné poddomény nejsou povoleny.", + "domainPickerWildcardCertWarning": "Zástupné zdroje mohou vyžadovat dodatečnou konfiguraci pro správnou funkci.", + "domainPickerWildcardCertWarningLink": "Zjistit více", + "health": "Zdraví" } From 243da6379b941ea6819382205907d9efd4559282 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:04 -0700 Subject: [PATCH 092/107] New translations en-us.json (German) --- messages/de-DE.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 07e5d93ac..30dd1509f 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "Ich habe die Konfiguration kopiert", "searchSitesProgress": "Standorte durchsuchen...", "siteAdd": "Standort hinzufügen", + "sitesTableViewPublicResources": "Öffentliche Ressourcen anzeigen", + "sitesTableViewPrivateResources": "Private Ressourcen anzeigen", "siteInstallNewt": "Newt installieren", "siteInstallNewtDescription": "Installiere Newt auf deinem System.", "WgConfiguration": "WireGuard Konfiguration", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "Der Standort wurde aktualisiert.", "siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren", "siteSettingDescription": "Standorteinstellungen konfigurieren", + "siteResourcesTab": "Ressourcen", + "siteResourcesNoneOnSite": "Diese Seite hat noch keine öffentlichen oder privaten Ressourcen.", + "siteResourcesSectionPublic": "Öffentliche Ressourcen", + "siteResourcesSectionPrivate": "Private Ressourcen", + "siteResourcesSectionPublicDescription": "Ressourcen, die extern über Domains oder Ports bereitgestellt werden.", + "siteResourcesSectionPrivateDescription": "Ressourcen, die in Ihrem privaten Netzwerk über die Seite verfügbar sind.", + "siteResourcesViewAllPublic": "Alle Ressourcen anzeigen", + "siteResourcesViewAllPrivate": "Alle Ressourcen anzeigen", + "siteResourcesDialogDescription": "Überblick über öffentliche und private Ressourcen, die mit dieser Seite verbunden sind.", + "siteResourcesShowMore": "Mehr anzeigen", + "siteResourcesPermissionDenied": "Sie haben keine Berechtigung, diese Ressourcen aufzulisten.", + "siteResourcesEmptyPublic": "Noch sind keine öffentlichen Ressourcen für diese Seite vorhanden.", + "siteResourcesEmptyPrivate": "Noch sind keine privaten Ressourcen mit dieser Seite verbunden.", + "siteResourcesHowToAccess": "Zugriffsmöglichkeiten", + "siteResourcesTargetsOnSite": "Ziele auf dieser Seite", "siteSetting": "{siteName} Einstellungen", "siteNewtTunnel": "Newt Standort (empfohlen)", "siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "Gesundheits-Check-Status ändern", "alertingTriggerResourceHealthy": "Ressource gesund", "alertingTriggerResourceUnhealthy": "Ressource ungesund", + "alertingTriggerResourceDegraded": "Resource degraded", "alertingSearchHealthChecks": "Gesundheits-Checks suchen…", "alertingHealthChecksEmpty": "Keine Gesundheits-Checks verfügbar.", "alertingTriggerResourceToggle": "Ressourcenstatus ändern", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Erstellen Sie das initiale Server-Admin-Konto. Es kann nur einen Server-Admin geben. Sie können diese Anmeldedaten später immer ändern.", "createAdminAccount": "Admin-Konto erstellen", "setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.", - "certificateStatus": "Zertifikatsstatus", + "certificateStatus": "Zertifikat", "loading": "Laden", "loadingAnalytics": "Analytik wird geladen", "restart": "Neustart", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Gesundheits-Check konfigurieren", "configureHealthCheckDescription": "Richten Sie die Gesundheitsüberwachung für {target} ein", "enableHealthChecks": "Gesundheits-Checks aktivieren", + "healthCheckDisabledStateDescription": "Wenn deaktiviert, führt die Seite keine Gesundheitsprüfungen durch und der Zustand wird als unbekannt betrachtet.", "enableHealthChecksDescription": "Überwachen Sie die Gesundheit dieses Ziels. Bei Bedarf können Sie einen anderen Endpunkt als das Ziel überwachen.", "healthScheme": "Methode", "healthSelectScheme": "Methode auswählen", @@ -1947,6 +1966,8 @@ "httpMethod": "HTTP-Methode", "selectHttpMethod": "HTTP-Methode auswählen", "domainPickerSubdomainLabel": "Subdomain", + "domainPickerWildcard": "Platzhalter", + "domainPickerWildcardPaidOnly": "Wildcard-Subdomains sind ein kostenpflichtiges Feature. Bitte upgraden Sie, um auf dieses Feature zuzugreifen.", "domainPickerBaseDomainLabel": "Basisdomain", "domainPickerSearchDomains": "Domains suchen...", "domainPickerNoDomainsFound": "Keine Domains gefunden", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Diese Adresse ist Teil des Utility-Subnetzes der Organisation. Sie wird verwendet, um Alias-Einträge mit interner DNS-Auflösung aufzulösen.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "und sind nur intern zugänglich, wenn mit einem Client verbunden.", - "resourcesTableNoTargets": "Keine Ziele", "resourcesTableHealthy": "Gesund", "resourcesTableDegraded": "Degradiert", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Ungesund", "resourcesTableUnknown": "Unbekannt", "resourcesTableNotMonitored": "Nicht überwacht", + "resourcesTableNoTargets": "Keine Ziele", "editInternalResourceDialogEditClientResource": "Private Ressource bearbeiten", "editInternalResourceDialogUpdateResourceProperties": "Ressourcen-Konfiguration und Zugriffssteuerung für {resourceName} aktualisieren", "editInternalResourceDialogResourceProperties": "Ressourceneigenschaften", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Verifiziert", "domainPickerUnverified": "Nicht verifiziert", "domainPickerManual": "Manuell", - "domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.", + "domainPickerInvalidSubdomainStructure": "Ungültige Zeichen werden beim Speichern bereinigt.", "domainPickerError": "Fehler", "domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domains", "domainPickerErrorCheckAvailability": "Fehler beim Prüfen der Domain-Verfügbarkeit", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Clients hinzufügen", "editInternalResourceDialogDestinationLabel": "Ziel", "editInternalResourceDialogDestinationDescription": "Geben Sie die Zieladresse für die interne Ressource an. Dies kann ein Hostname, eine IP-Adresse oder ein CIDR-Bereich sein, abhängig vom gewählten Modus. Legen Sie optional einen internen DNS-Alias für eine vereinfachte Identifizierung fest.", + "internalResourceFormMultiSiteRoutingHelp": "Durch die Auswahl mehrerer Seiten wird ein ausfallsicheres Routing und Failover für hohe Verfügbarkeit ermöglicht.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Mehr erfahren", "editInternalResourceDialogPortRestrictionsDescription": "Den Zugriff auf bestimmte TCP/UDP-Ports beschränken oder alle Ports erlauben/blockieren.", "createInternalResourceDialogHttpConfiguration": "HTTP-Konfiguration", "createInternalResourceDialogHttpConfigurationDescription": "Wählen Sie die Domain, die Clients verwenden, um über HTTP oder HTTPS auf diese Ressource zuzugreifen.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Geschätzte Abschlusszeit (Optional)", "privateMaintenanceScreenTitle": "Privater Platzhalterschirm", "privateMaintenanceScreenMessage": "Diese Domain wird auf einer privaten Ressource verwendet. Bitte verbinden Sie sich mit dem Pangolin-Client, um auf diese Ressource zuzugreifen.", + "privateMaintenanceScreenSteps": "Sobald verbunden, wenn Sie diese Nachricht weiterhin sehen, zeigt der DNS-Cache Ihres Browsers möglicherweise noch auf die alte Adresse. Um dies zu beheben: Schließen Sie diesen Tab vollständig und öffnen Sie ihn erneut oder starten Sie Ihren Browser neu und rufen Sie dann diese Seite erneut auf.", "maintenanceTime": "z.B.: 2 Stunden, Nov 1 um 17:00 Uhr", "maintenanceEstimatedTimeDescription": "Wann Sie den Abschluss der Wartung erwarten", "editDomain": "Domain bearbeiten", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Löschen", "publicIpEndpoint": "Endpunkt", "lastTriggeredAt": "Letzter Auslöser", - "reject": "Zurückweisen" + "reject": "Zurückweisen", + "uptimeDaysAgo": "vor {count} Tagen", + "uptimeToday": "Heute", + "uptimeNoDataAvailable": "Keine Daten verfügbar", + "uptimeSuffix": "Betriebzeit", + "uptimeDowntimeSuffix": "Ausfallzeit", + "uptimeTooltipUptimeLabel": "Betriebszeit", + "uptimeTooltipDowntimeLabel": "Ausfallzeit", + "uptimeOngoing": "im Gange", + "uptimeNoMonitoringData": "Keine Überwachungsdaten", + "uptimeNoData": "Keine Daten", + "uptimeMiniBarDown": "Unten", + "uptimeSectionTitle": "Betriebszeit", + "uptimeSectionDescription": "Verfügbarkeit in den letzten {days} Tagen", + "uptimeAddAlert": "Warnmeldung hinzufügen", + "uptimeViewAlerts": "Warnungen anzeigen", + "uptimeCreateEmailAlert": "E-Mail Alarm erstellen", + "uptimeAlertDescriptionSite": "Werde per E-Mail benachrichtigt, wenn diese Seite offline oder wieder online ist.", + "uptimeAlertDescriptionResource": "Werde per E-Mail benachrichtigt, wenn diese Ressource offline oder wieder online ist.", + "uptimeAlertNamePlaceholder": "Alarmname", + "uptimeAdditionalEmails": "Zusätzliche E-Mails", + "uptimeCreateAlert": "Alarm erstellen", + "uptimeAlertNoRecipients": "Kein Empfänger", + "uptimeAlertNoRecipientsDescription": "Bitte fügen Sie mindestens einen Benutzer, eine Rolle oder eine E-Mail zur Benachrichtigung hinzu.", + "uptimeAlertCreated": "Alarm erstellt", + "uptimeAlertCreatedDescription": "Sie werden benachrichtigt, wenn dieser Status sich ändert", + "uptimeAlertCreateFailed": "Fehler beim Erstellen der Benachrichtigung", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Schlüssel", + "webhookHeaderValuePlaceholder": "Wert", + "alertLabel": "Alarm", + "domainPickerWildcardSubdomainNotAllowed": "Wildcard-Subdomains sind nicht erlaubt.", + "domainPickerWildcardCertWarning": "Wildcard-Ressourcen erfordern möglicherweise zusätzliche Konfigurationen, um ordnungsgemäß zu funktionieren.", + "domainPickerWildcardCertWarningLink": "Mehr erfahren", + "health": "Gesundheit" } From 926fe5e474584c305b0b177a856d76fb2101c739 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:06 -0700 Subject: [PATCH 093/107] New translations en-us.json (Italian) --- messages/it-IT.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index e1e3a586e..4c5ba6f12 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "Ho copiato la configurazione", "searchSitesProgress": "Cerca siti...", "siteAdd": "Aggiungi Sito", + "sitesTableViewPublicResources": "Visualizza Risorse Pubbliche", + "sitesTableViewPrivateResources": "Visualizza Risorse Private", "siteInstallNewt": "Installa Newt", "siteInstallNewtDescription": "Esegui Newt sul tuo sistema", "WgConfiguration": "Configurazione WireGuard", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "Il sito è stato aggiornato.", "siteGeneralDescription": "Configura le impostazioni generali per questo sito", "siteSettingDescription": "Configura le impostazioni del sito", + "siteResourcesTab": "Risorse", + "siteResourcesNoneOnSite": "Questo sito non ha ancora risorse pubbliche o private.", + "siteResourcesSectionPublic": "Risorse Pubbliche", + "siteResourcesSectionPrivate": "Risorse Private", + "siteResourcesSectionPublicDescription": "Risorse esposte esternamente attraverso domini o porte.", + "siteResourcesSectionPrivateDescription": "Risorse disponibili sulla tua rete privata tramite il sito.", + "siteResourcesViewAllPublic": "Visualizza tutte le risorse", + "siteResourcesViewAllPrivate": "Visualizza tutte le risorse", + "siteResourcesDialogDescription": "Panoramica delle risorse pubbliche e private associate a questo sito.", + "siteResourcesShowMore": "Mostra Altro", + "siteResourcesPermissionDenied": "Non hai il permesso di elencare queste risorse.", + "siteResourcesEmptyPublic": "Ancora nessuna risorsa pubblica punta a questo sito.", + "siteResourcesEmptyPrivate": "Ancora nessuna risorsa privata è associata a questo sito.", + "siteResourcesHowToAccess": "Come accedere", + "siteResourcesTargetsOnSite": "Obiettivi su questo sito", "siteSetting": "Impostazioni del sito {siteName}", "siteNewtTunnel": "Nuovo Sito (Consigliato)", "siteNewtTunnelDescription": "Modo più semplice per creare un entrypoint in qualsiasi rete. Nessuna configurazione aggiuntiva.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "I cambiamenti di stato del controllo di salute", "alertingTriggerResourceHealthy": "Risorsa in buona salute", "alertingTriggerResourceUnhealthy": "Risorsa in cattiva salute", + "alertingTriggerResourceDegraded": "Risorsa degradata", "alertingSearchHealthChecks": "Cerca controlli di salute…", "alertingHealthChecksEmpty": "Nessun controllo di salute disponibile.", "alertingTriggerResourceToggle": "Variazioni di stato della risorsa", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Crea l'account amministratore del server iniziale. Può esistere solo un amministratore del server. È sempre possibile modificare queste credenziali in seguito.", "createAdminAccount": "Crea Account Admin", "setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.", - "certificateStatus": "Stato del Certificato", + "certificateStatus": "Certificato", "loading": "Caricamento", "loadingAnalytics": "Caricamento Delle Analisi", "restart": "Riavvia", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Configura Controllo Salute", "configureHealthCheckDescription": "Imposta il monitoraggio della salute per {target}", "enableHealthChecks": "Abilita i Controlli di Salute", + "healthCheckDisabledStateDescription": "Quando disabilitato, il sito non eseguirà controlli di integrità e lo stato sarà considerato sconosciuto.", "enableHealthChecksDescription": "Monitorare lo stato di salute di questo obiettivo. Se necessario, è possibile monitorare un endpoint diverso da quello del bersaglio.", "healthScheme": "Metodo", "healthSelectScheme": "Seleziona Metodo", @@ -1947,6 +1966,8 @@ "httpMethod": "Metodo HTTP", "selectHttpMethod": "Seleziona metodo HTTP", "domainPickerSubdomainLabel": "Sottodominio", + "domainPickerWildcard": "Jolly", + "domainPickerWildcardPaidOnly": "Sotto-domini wildcard sono una funzione a pagamento. Si prega di aggiornare per accedere a questa funzione.", "domainPickerBaseDomainLabel": "Dominio Base", "domainPickerSearchDomains": "Cerca domini...", "domainPickerNoDomainsFound": "Nessun dominio trovato", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Questo indirizzo fa parte della subnet di utilità dell'organizzazione. È usato per risolvere i record alias usando la risoluzione DNS interna.", "resourcesTableClients": "Client", "resourcesTableAndOnlyAccessibleInternally": "e sono accessibili solo internamente quando connessi con un client.", - "resourcesTableNoTargets": "Nessun obiettivo", "resourcesTableHealthy": "Sano", "resourcesTableDegraded": "Degraded", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Non Sano", "resourcesTableUnknown": "Sconosciuto", "resourcesTableNotMonitored": "Non monitorato", + "resourcesTableNoTargets": "Nessun obiettivo", "editInternalResourceDialogEditClientResource": "Modifica Risorse Private", "editInternalResourceDialogUpdateResourceProperties": "Aggiorna la configurazione delle risorse e i controlli di accesso per {resourceName}", "editInternalResourceDialogResourceProperties": "Proprietà della Risorsa", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Verificato", "domainPickerUnverified": "Non Verificato", "domainPickerManual": "Manuale", - "domainPickerInvalidSubdomainStructure": "Questo sottodominio contiene caratteri o struttura non validi. Sarà sanificato automaticamente quando si salva.", + "domainPickerInvalidSubdomainStructure": "I caratteri non validi saranno sanitizzati quando salvati.", "domainPickerError": "Errore", "domainPickerErrorLoadDomains": "Impossibile caricare i domini dell'organizzazione", "domainPickerErrorCheckAvailability": "Impossibile verificare la disponibilità del dominio", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Aggiungi Clienti", "editInternalResourceDialogDestinationLabel": "Destinazione", "editInternalResourceDialogDestinationDescription": "Specifica l'indirizzo di destinazione per la risorsa interna. Può essere un hostname, indirizzo IP o un intervallo CIDR a seconda della modalità selezionata. Opzionalmente imposta un alias DNS interno per una più facile identificazione.", + "internalResourceFormMultiSiteRoutingHelp": "Selezionare più siti consente un routing resiliente e Failover per alta disponibilità.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Scopri di più", "editInternalResourceDialogPortRestrictionsDescription": "Limita l'accesso a porte TCP/UDP specifiche o consenti/blocca tutte le porte.", "createInternalResourceDialogHttpConfiguration": "Configurazione HTTP", "createInternalResourceDialogHttpConfigurationDescription": "Scegli il dominio che i clienti utilizzeranno per accedere a questa risorsa tramite HTTP o HTTPS.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Tempo di Completamento Stimato (Opzionale)", "privateMaintenanceScreenTitle": "Schermo segnaposto privato", "privateMaintenanceScreenMessage": "Questo dominio è utilizzato su una risorsa privata. Connettiti usando il client Pangolin per accedere a questa risorsa.", + "privateMaintenanceScreenSteps": "Una volta connesso, se ancora visualizzi questo messaggio, la cache DNS del tuo browser potrebbe ancora puntare al vecchio indirizzo. Per risolvere: chiudi e riapri completamente questa scheda o il tuo browser, quindi torna su questa pagina.", "maintenanceTime": "es. 2 ore, 1 novembre alle 17:00", "maintenanceEstimatedTimeDescription": "Quando prevedi che la manutenzione sarà completata", "editDomain": "Modifica Dominio", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Elimina", "publicIpEndpoint": "Endpoint", "lastTriggeredAt": "Ultimo trigger", - "reject": "Rifiuta" + "reject": "Rifiuta", + "uptimeDaysAgo": "{count} giorni fa", + "uptimeToday": "Oggi", + "uptimeNoDataAvailable": "Nessun dato disponibile", + "uptimeSuffix": "tempo di attività", + "uptimeDowntimeSuffix": "tempo di inattività", + "uptimeTooltipUptimeLabel": "Tempo di attività", + "uptimeTooltipDowntimeLabel": "Tempo di inattività", + "uptimeOngoing": "in corso", + "uptimeNoMonitoringData": "Nessun dato di monitoraggio", + "uptimeNoData": "Nessun dato", + "uptimeMiniBarDown": "Giù", + "uptimeSectionTitle": "Tempo di attività", + "uptimeSectionDescription": "Disponibilità negli ultimi {days} giorni", + "uptimeAddAlert": "Aggiungi Avviso", + "uptimeViewAlerts": "Visualizza Avvisi", + "uptimeCreateEmailAlert": "Crea Avviso Email", + "uptimeAlertDescriptionSite": "Ricevi notifica via email quando questo sito va offline o torna online.", + "uptimeAlertDescriptionResource": "Ricevi notifica via email quando questa risorsa va offline o torna online.", + "uptimeAlertNamePlaceholder": "Nome avviso", + "uptimeAdditionalEmails": "Email aggiuntive", + "uptimeCreateAlert": "Crea Avviso", + "uptimeAlertNoRecipients": "Nessun destinatario", + "uptimeAlertNoRecipientsDescription": "Si prega di aggiungere almeno un utente, ruolo o e-mail da notificare.", + "uptimeAlertCreated": "Avviso creato", + "uptimeAlertCreatedDescription": "Riceverai una notifica quando questo cambia stato.", + "uptimeAlertCreateFailed": "Errore nella creazione dell'avviso", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Chiave", + "webhookHeaderValuePlaceholder": "Valore", + "alertLabel": "Avviso", + "domainPickerWildcardSubdomainNotAllowed": "I sottodomini wildcard non sono permessi.", + "domainPickerWildcardCertWarning": "Le risorse wildcard potrebbero richiedere configurazioni aggiuntive per funzionare correttamente.", + "domainPickerWildcardCertWarningLink": "Scopri di più", + "health": "Salute" } From 3cb1cd9f2fea875b27b4c4d4fbc89c10f7396dbc Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:07 -0700 Subject: [PATCH 094/107] New translations en-us.json (Korean) --- messages/ko-KR.json | 70 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index c3e08dca4..d6936cecf 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "구성을 복사했습니다.", "searchSitesProgress": "사이트 검색...", "siteAdd": "사이트 추가", + "sitesTableViewPublicResources": "공용 리소스 보기", + "sitesTableViewPrivateResources": "개인 리소스 보기", "siteInstallNewt": "Newt 설치", "siteInstallNewtDescription": "시스템에서 Newt 실행하기", "WgConfiguration": "WireGuard 구성", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "사이트가 업데이트되었습니다.", "siteGeneralDescription": "이 사이트에 대한 일반 설정을 구성하세요.", "siteSettingDescription": "사이트에서 설정을 구성하세요.", + "siteResourcesTab": "리소스", + "siteResourcesNoneOnSite": "이 사이트에는 아직 공용 또는 개인 리소스가 없습니다.", + "siteResourcesSectionPublic": "공용 리소스", + "siteResourcesSectionPrivate": "개인 리소스", + "siteResourcesSectionPublicDescription": "도메인이나 포트를 통해 외부에 노출되는 리소스.", + "siteResourcesSectionPrivateDescription": "사이트를 통해 개인 네트워크에서 사용할 수 있는 리소스.", + "siteResourcesViewAllPublic": "모든 리소스 보기", + "siteResourcesViewAllPrivate": "모든 리소스 보기", + "siteResourcesDialogDescription": "이 사이트와 연관된 공용 및 개인 리소스의 개요.", + "siteResourcesShowMore": "더 보기", + "siteResourcesPermissionDenied": "이 리소스를 나열할 권한이 없습니다.", + "siteResourcesEmptyPublic": "이 사이트에는 아직 대상 공용 리소스가 없습니다.", + "siteResourcesEmptyPrivate": "이 사이트와 연결된 개인 리소스가 아직 없습니다.", + "siteResourcesHowToAccess": "액세스 방법", + "siteResourcesTargetsOnSite": "이 사이트의 대상", "siteSetting": "{siteName} 설정", "siteNewtTunnel": "뉴트 사이트 (추천)", "siteNewtTunnelDescription": "네트워크의 진입점을 생성하는 가장 쉬운 방법입니다. 추가 설정이 필요 없습니다.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "상태 확인 상태 변경", "alertingTriggerResourceHealthy": "리소스 정상", "alertingTriggerResourceUnhealthy": "리소스 비정상", + "alertingTriggerResourceDegraded": "리소스 열화", "alertingSearchHealthChecks": "상태 확인 검색…", "alertingHealthChecksEmpty": "사용 가능한 상태 확인이 없습니다.", "alertingTriggerResourceToggle": "리소스 상태 변경", @@ -1493,7 +1511,7 @@ "standaloneHcEditTitle": "상태 확인 편집", "standaloneHcDescription": "알림 규칙에 사용할 HTTP 또는 TCP 상태 확인을 구성하세요.", "standaloneHcNameLabel": "이름", - "standaloneHcNamePlaceholder": "My HTTP Monitor", + "standaloneHcNamePlaceholder": "나의 HTTP 모니터", "standaloneHcDeleteTitle": "상태 확인 삭제", "standaloneHcDeleteQuestion": "이 상태 확인을 삭제하겠습니까.", "standaloneHcDeleted": "상태 확인 삭제됨", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "초기 서버 관리자 계정을 생성하세요. 서버 관리자 계정은 하나만 존재할 수 있습니다. 이러한 자격 증명은 나중에 언제든지 변경할 수 있습니다.", "createAdminAccount": "관리자 계정 생성", "setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.", - "certificateStatus": "인증서 상태", + "certificateStatus": "인증서", "loading": "로딩 중", "loadingAnalytics": "분석 로딩 중", "restart": "재시작", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "상태 확인 설정", "configureHealthCheckDescription": "{target}에 대한 상태 모니터링 설정", "enableHealthChecks": "상태 확인 활성화", + "healthCheckDisabledStateDescription": "비활성화되면 이 사이트가 상태 확인을 수행하지 않으며 상태가 알 수 없는 것으로 간주됩니다.", "enableHealthChecksDescription": "이 대상을 모니터링하여 건강 상태를 확인하세요. 필요에 따라 대상과 다른 엔드포인트를 모니터링할 수 있습니다.", "healthScheme": "방법", "healthSelectScheme": "방법 선택", @@ -1947,6 +1966,8 @@ "httpMethod": "HTTP 메소드", "selectHttpMethod": "HTTP 메소드 선택", "domainPickerSubdomainLabel": "서브도메인", + "domainPickerWildcard": "와일드카드", + "domainPickerWildcardPaidOnly": "와일드카드 서브도메인은 유료 기능입니다. 이 기능에 액세스하려면 업그레이드하세요.", "domainPickerBaseDomainLabel": "기본 도메인", "domainPickerSearchDomains": "도메인 검색...", "domainPickerNoDomainsFound": "찾을 수 없는 도메인이 없습니다", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "이 주소는 조직의 유틸리티 서브넷의 일부로, 내부 DNS 해석을 사용하여 별칭 레코드를 해석하는 데 사용됩니다.", "resourcesTableClients": "클라이언트", "resourcesTableAndOnlyAccessibleInternally": "클라이언트와 연결되었을 때만 내부적으로 접근 가능합니다.", - "resourcesTableNoTargets": "대상 없음", "resourcesTableHealthy": "정상", "resourcesTableDegraded": "저하됨", - "resourcesTableOffline": "오프라인", + "resourcesTableUnhealthy": "비정상", "resourcesTableUnknown": "알 수 없음", "resourcesTableNotMonitored": "모니터링되지 않음", + "resourcesTableNoTargets": "대상 없음", "editInternalResourceDialogEditClientResource": "비공개 리소스 수정", "editInternalResourceDialogUpdateResourceProperties": "{resourceName}의 리소스 속성과 대상 구성을 업데이트하세요", "editInternalResourceDialogResourceProperties": "리소스 속성", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "검증됨", "domainPickerUnverified": "검증되지 않음", "domainPickerManual": "수동", - "domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.", + "domainPickerInvalidSubdomainStructure": "잘못된 문자는 저장 시 새니타이즈됩니다.", "domainPickerError": "오류", "domainPickerErrorLoadDomains": "조직 도메인 로드 실패", "domainPickerErrorCheckAvailability": "도메인 가용성 확인 실패", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "클라이언트 추가", "editInternalResourceDialogDestinationLabel": "대상지", "editInternalResourceDialogDestinationDescription": "내부 리소스의 목적지 주소를 지정하세요. 선택한 모드에 따라 이 주소는 호스트명, IP 주소, 또는 CIDR 범위가 될 수 있습니다. 더욱 쉽게 식별할 수 있도록 내부 DNS 별칭을 설정할 수 있습니다.", + "internalResourceFormMultiSiteRoutingHelp": "다중 사이트를 선택하면 높은 가용성을 위해 회복력 있는 라우팅 및 페일오버가 가능해집니다.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "자세히 알아보기", "editInternalResourceDialogPortRestrictionsDescription": "특정 TCP/UDP 포트에 대한 접근을 제한하거나 모든 포트를 허용/차단하십시오.", "createInternalResourceDialogHttpConfiguration": "HTTP 구성", "createInternalResourceDialogHttpConfigurationDescription": "이 리소스에 HTTP 또는 HTTPS로 도달하기 위한 도메인을 선택하세요.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "예상 완료 시간(선택 사항)", "privateMaintenanceScreenTitle": "프라이빗 플레이스홀더 화면", "privateMaintenanceScreenMessage": "이 도메인은 개인 리소스에서 사용 중입니다. Pangolin 클라이언트를 사용하여 이 리소스에 액세스하세요.", + "privateMaintenanceScreenSteps": "연결된 후에도 이 메시지가 보이면 브라우저의 DNS 캐시가 여전히 이전 주소를 가리킬 수 있습니다. 이를 해결하려면 이 탭이나 브라우저를 완전히 닫고 다시 열고 이 페이지로 돌아가세요.", "maintenanceTime": "예: 2시간, 11월 1일 오후 5시", "maintenanceEstimatedTimeDescription": "유지보수가 완료될 것으로 예상되는 시간", "editDomain": "도메인 수정", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "삭제", "publicIpEndpoint": "엔드포인트", "lastTriggeredAt": "마지막 트리거", - "reject": "거부" + "reject": "거부", + "uptimeDaysAgo": "{count}일 전", + "uptimeToday": "오늘", + "uptimeNoDataAvailable": "데이터가 없습니다", + "uptimeSuffix": "가동 시간", + "uptimeDowntimeSuffix": "다운타임", + "uptimeTooltipUptimeLabel": "가동 시간", + "uptimeTooltipDowntimeLabel": "다운타임", + "uptimeOngoing": "진행 중", + "uptimeNoMonitoringData": "모니터링 데이터 없음", + "uptimeNoData": "데이터 없음", + "uptimeMiniBarDown": "중단됨", + "uptimeSectionTitle": "가동 시간", + "uptimeSectionDescription": "지난 {days}일 동안의 가용성", + "uptimeAddAlert": "알림 추가", + "uptimeViewAlerts": "알림 보기", + "uptimeCreateEmailAlert": "이메일 알림 생성", + "uptimeAlertDescriptionSite": "이 사이트가 오프라인 되거나 다시 온라인 될 때 이메일로 알림을 받습니다.", + "uptimeAlertDescriptionResource": "이 리소스가 오프라인 되거나 다시 온라인 될 때 이메일로 알림을 받습니다.", + "uptimeAlertNamePlaceholder": "알림 이름", + "uptimeAdditionalEmails": "추가 이메일", + "uptimeCreateAlert": "알림 생성", + "uptimeAlertNoRecipients": "수신자 없음", + "uptimeAlertNoRecipientsDescription": "통지를 받을 사용자, 역할 또는 이메일을 최소 한 개 추가하세요.", + "uptimeAlertCreated": "알림 생성됨", + "uptimeAlertCreatedDescription": "상태가 변경되면 통지를 받습니다.", + "uptimeAlertCreateFailed": "알림 생성 실패", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "키", + "webhookHeaderValuePlaceholder": "값", + "alertLabel": "알림", + "domainPickerWildcardSubdomainNotAllowed": "와일드카드 서브도메인은 허용되지 않습니다.", + "domainPickerWildcardCertWarning": "와일드카드 리소스는 올바르게 작동하려면 추가 구성이 필요할 수 있습니다.", + "domainPickerWildcardCertWarningLink": "자세히 알아보기", + "health": "건강" } From d33c704f76a99b459144976fb367e984b90491f7 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:09 -0700 Subject: [PATCH 095/107] New translations en-us.json (Dutch) --- messages/nl-NL.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 855ae603d..6dc3cd158 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "Ik heb de configuratie gekopieerd", "searchSitesProgress": "Sites zoeken...", "siteAdd": "Site toevoegen", + "sitesTableViewPublicResources": "Openbare bronnen bekijken", + "sitesTableViewPrivateResources": "Privébronnen bekijken", "siteInstallNewt": "Installeer Newt", "siteInstallNewtDescription": "Laat Newt draaien op uw systeem", "WgConfiguration": "WireGuard Configuratie", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "De site is bijgewerkt.", "siteGeneralDescription": "Algemene instellingen voor deze site configureren", "siteSettingDescription": "Configureer de instellingen van de site", + "siteResourcesTab": "Bronnen", + "siteResourcesNoneOnSite": "Deze site heeft nog geen openbare of privébronnen.", + "siteResourcesSectionPublic": "Openbare bronnen", + "siteResourcesSectionPrivate": "Privébronnen", + "siteResourcesSectionPublicDescription": "Bronnen extern blootgesteld via domeinen of poorten.", + "siteResourcesSectionPrivateDescription": "Bronnen beschikbaar op uw privénetwerk via de site.", + "siteResourcesViewAllPublic": "Bekijk alle bronnen", + "siteResourcesViewAllPrivate": "Bekijk alle bronnen", + "siteResourcesDialogDescription": "Overzicht van openbare en privébronnen die geassocieerd zijn met deze site.", + "siteResourcesShowMore": "Meer weergeven", + "siteResourcesPermissionDenied": "U heeft geen toestemming om deze bronnen te vermelden.", + "siteResourcesEmptyPublic": "Geen openbare bronnen richten zich nog op deze site.", + "siteResourcesEmptyPrivate": "Er zijn nog geen privébronnen gekoppeld aan deze site.", + "siteResourcesHowToAccess": "Hoe te openen", + "siteResourcesTargetsOnSite": "Doelen op deze site", "siteSetting": "{siteName} instellingen", "siteNewtTunnel": "Nieuwste site (Aanbevolen)", "siteNewtTunnelDescription": "Makkelijkste manier om een ingangspunt in een netwerk te maken. Geen extra opzet.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "Gezondheidscontrole status verandert", "alertingTriggerResourceHealthy": "Bron gezond", "alertingTriggerResourceUnhealthy": "Bron ongezond", + "alertingTriggerResourceDegraded": "Bron gedegradeerd", "alertingSearchHealthChecks": "Zoek gezondheidscontroles…", "alertingHealthChecksEmpty": "Geen gezondheidscontroles beschikbaar.", "alertingTriggerResourceToggle": "Bronstatus wijzigt", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Maak het eerste serverbeheeraccount aan. Er kan slechts één serverbeheerder bestaan. U kunt deze inloggegevens later altijd wijzigen.", "createAdminAccount": "Maak een beheeraccount aan", "setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.", - "certificateStatus": "Certificaatstatus", + "certificateStatus": "Certificaat", "loading": "Bezig met laden", "loadingAnalytics": "Laden van Analytics", "restart": "Herstarten", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Configureer Gezondheidscontrole", "configureHealthCheckDescription": "Stel gezondheid monitor voor {target} in", "enableHealthChecks": "Inschakelen Gezondheidscontroles", + "healthCheckDisabledStateDescription": "Wanneer uitgeschakeld, zal de site geen gezondheidscontroles uitvoeren en wordt de staat als onbekend beschouwd.", "enableHealthChecksDescription": "Controleer de gezondheid van dit doel. U kunt een ander eindpunt monitoren dan het doel indien vereist.", "healthScheme": "Methode", "healthSelectScheme": "Selecteer methode", @@ -1947,6 +1966,8 @@ "httpMethod": "HTTP-methode", "selectHttpMethod": "Selecteer HTTP-methode", "domainPickerSubdomainLabel": "Subdomein", + "domainPickerWildcard": "Wildcard", + "domainPickerWildcardPaidOnly": "Wildcard-subdomeinen zijn een betaalde functie. Upgrade om deze functie te gebruiken.", "domainPickerBaseDomainLabel": "Basisdomein", "domainPickerSearchDomains": "Zoek domeinen...", "domainPickerNoDomainsFound": "Geen domeinen gevonden", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Dit adres is onderdeel van het hulpprogramma subnet van de organisatie. Het wordt gebruikt om aliasrecords op te lossen met behulp van interne DNS-resolutie.", "resourcesTableClients": "Clienten", "resourcesTableAndOnlyAccessibleInternally": "en zijn alleen intern toegankelijk wanneer verbonden met een client.", - "resourcesTableNoTargets": "Geen doelen", "resourcesTableHealthy": "Gezond", "resourcesTableDegraded": "Verminderde", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Ongezond", "resourcesTableUnknown": "onbekend", "resourcesTableNotMonitored": "Niet gecontroleerd", + "resourcesTableNoTargets": "Geen doelen", "editInternalResourceDialogEditClientResource": "Privépagina bewerken", "editInternalResourceDialogUpdateResourceProperties": "Update de resource configuratie en access control voor {resourceName}", "editInternalResourceDialogResourceProperties": "Bron eigenschappen", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Geverifieerd", "domainPickerUnverified": "Ongeverifieerd", "domainPickerManual": "Handleiding", - "domainPickerInvalidSubdomainStructure": "Dit subdomein bevat ongeldige tekens of structuur. Het zal automatisch worden gesaneerd wanneer u opslaat.", + "domainPickerInvalidSubdomainStructure": "Ongeldige tekens worden gesaneerd bij het opslaan.", "domainPickerError": "Foutmelding", "domainPickerErrorLoadDomains": "Fout bij het laden van organisatiedomeinen", "domainPickerErrorCheckAvailability": "Kan domein beschikbaarheid niet controleren", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Clienten toevoegen", "editInternalResourceDialogDestinationLabel": "Bestemming", "editInternalResourceDialogDestinationDescription": "Specificeer het bestemmingsadres voor de interne bron. Dit kan een hostnaam, IP-adres of CIDR-bereik zijn, afhankelijk van de geselecteerde modus. Stel optioneel een interne DNS-alias in voor eenvoudigere identificatie.", + "internalResourceFormMultiSiteRoutingHelp": "Selecteren van meerdere sites maakt veerkrachtige routing en failover mogelijk voor hoge beschikbaarheid.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Meer informatie", "editInternalResourceDialogPortRestrictionsDescription": "Beperk toegang tot specifieke TCP/UDP-poorten of sta alle poorten toe/blokkeer.", "createInternalResourceDialogHttpConfiguration": "HTTP-configuratie", "createInternalResourceDialogHttpConfigurationDescription": "Kies het domein dat cliënten zullen gebruiken om deze bron via HTTP of HTTPS te bereiken.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Geschatte voltooiingstijd (optioneel)", "privateMaintenanceScreenTitle": "Privéscherm maintenance screen", "privateMaintenanceScreenMessage": "Dit domein wordt gebruikt op een privébron. Verbind met de Pangolin client om toegang te krijgen tot deze bron.", + "privateMaintenanceScreenSteps": "Eenmaal verbonden, als u dit bericht nog steeds ziet, kan het DNS-cache van uw browser nog steeds naar het oude adres wijzen. Om dit te corrigeren: sluit en heropen dit tabblad, of uw browser, dan navigeer weer naar deze pagina.", "maintenanceTime": "bijv. 2 uur, 1 nov om 17:00", "maintenanceEstimatedTimeDescription": "Wanneer u verwacht dat het onderhoud voltooid is", "editDomain": "Domein bewerken", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Verwijderen", "publicIpEndpoint": "Eindpunt", "lastTriggeredAt": "Laatste Trigger", - "reject": "Afwijzen" + "reject": "Afwijzen", + "uptimeDaysAgo": "{count} days ago", + "uptimeToday": "Vandaag", + "uptimeNoDataAvailable": "Geen gegevens beschikbaar", + "uptimeSuffix": "werktijd", + "uptimeDowntimeSuffix": "uitvaltijd", + "uptimeTooltipUptimeLabel": "Werktijd", + "uptimeTooltipDowntimeLabel": "Uitvaltijd", + "uptimeOngoing": "lopend", + "uptimeNoMonitoringData": "Geen monitoringgegevens", + "uptimeNoData": "Geen gegevens", + "uptimeMiniBarDown": "Onder", + "uptimeSectionTitle": "Werktijd", + "uptimeSectionDescription": "Beschikbaarheid over de laatste {days} dagen", + "uptimeAddAlert": "Alarm toevoegen", + "uptimeViewAlerts": "Meldingen bekijken", + "uptimeCreateEmailAlert": "E-mailalert aanmaken", + "uptimeAlertDescriptionSite": "Ontvang een e-mailbericht wanneer deze site offline gaat of weer online komt.", + "uptimeAlertDescriptionResource": "Ontvang een e-mailbericht wanneer deze bron offline gaat of weer online komt.", + "uptimeAlertNamePlaceholder": "Waarschuwingsnaam", + "uptimeAdditionalEmails": "Extra e-mails", + "uptimeCreateAlert": "Alarm aanmaken", + "uptimeAlertNoRecipients": "Geen ontvangers", + "uptimeAlertNoRecipientsDescription": "Voeg ten minste één gebruiker, rol of e-mail toe om te melden.", + "uptimeAlertCreated": "Alarm aangemaakt", + "uptimeAlertCreatedDescription": "U wordt op de hoogte gebracht wanneer dit van status verandert.", + "uptimeAlertCreateFailed": "Kon alarm niet aanmaken", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Sleutel", + "webhookHeaderValuePlaceholder": "Waarde", + "alertLabel": "Waarschuwing", + "domainPickerWildcardSubdomainNotAllowed": "Wildcard-subdomeinen zijn niet toegestaan.", + "domainPickerWildcardCertWarning": "Wildcard-bronnen hebben mogelijk extra configuratie nodig om correct te werken.", + "domainPickerWildcardCertWarningLink": "Meer informatie", + "health": "Gezondheid" } From 5e6171263bdd3a80359c02d7c89846239d833525 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:11 -0700 Subject: [PATCH 096/107] New translations en-us.json (Polish) --- messages/pl-PL.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 1fc66fadb..9d9968001 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "Skopiowałem konfigurację", "searchSitesProgress": "Szukaj witryn...", "siteAdd": "Dodaj witrynę", + "sitesTableViewPublicResources": "Zobacz zasoby publiczne", + "sitesTableViewPrivateResources": "Zobacz zasoby prywatne", "siteInstallNewt": "Zainstaluj Newt", "siteInstallNewtDescription": "Uruchom Newt w swoim systemie", "WgConfiguration": "Konfiguracja WireGuard", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "Strona została zaktualizowana.", "siteGeneralDescription": "Skonfiguruj ustawienia ogólne dla tej witryny", "siteSettingDescription": "Skonfiguruj ustawienia na stronie", + "siteResourcesTab": "Zasoby", + "siteResourcesNoneOnSite": "Ta strona nie ma jeszcze żadnych zasobów publicznych ani prywatnych.", + "siteResourcesSectionPublic": "Zasoby publiczne", + "siteResourcesSectionPrivate": "Zasoby prywatne", + "siteResourcesSectionPublicDescription": "Zasoby eksponowane zewnętrznie przez domeny lub porty.", + "siteResourcesSectionPrivateDescription": "Zasoby dostępne w twojej prywatnej sieci przez stronę.", + "siteResourcesViewAllPublic": "Zobacz wszystkie zasoby", + "siteResourcesViewAllPrivate": "Zobacz wszystkie zasoby", + "siteResourcesDialogDescription": "Przegląd zasobów publicznych i prywatnych związanych z tą stroną.", + "siteResourcesShowMore": "Pokaż więcej", + "siteResourcesPermissionDenied": "Nie masz uprawnień do wyświetlania tych zasobów.", + "siteResourcesEmptyPublic": "Brak publicznych zasobów powiązanych z tą stroną.", + "siteResourcesEmptyPrivate": "Brak prywatnych zasobów powiązanych z tą stroną.", + "siteResourcesHowToAccess": "Jak uzyskać dostęp", + "siteResourcesTargetsOnSite": "Cele na tej stronie", "siteSetting": "Ustawienia {siteName}", "siteNewtTunnel": "Newt Site (Rekomendowane)", "siteNewtTunnelDescription": "Najprostszy sposób na stworzenie punktu wejścia w sieci. Nie ma dodatkowej konfiguracji.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "Status kontroli zdrowia zmienia się", "alertingTriggerResourceHealthy": "Zasób zdrowy", "alertingTriggerResourceUnhealthy": "Zasób niezdrowy", + "alertingTriggerResourceDegraded": "Zasób pogorszony", "alertingSearchHealthChecks": "Szukaj kontroli zdrowia…", "alertingHealthChecksEmpty": "Brak dostępnych kontroli zdrowia.", "alertingTriggerResourceToggle": "Zmiany statusu zasobu", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Utwórz początkowe konto administratora serwera. Może istnieć tylko jeden administrator serwera. Zawsze można zmienić te dane uwierzytelniające.", "createAdminAccount": "Utwórz konto administratora", "setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.", - "certificateStatus": "Status certyfikatu", + "certificateStatus": "Certyfikat", "loading": "Ładowanie", "loadingAnalytics": "Ładowanie Analityki", "restart": "Uruchom ponownie", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Skonfiguruj Kontrolę Zdrowia", "configureHealthCheckDescription": "Skonfiguruj monitorowanie zdrowia dla {target}", "enableHealthChecks": "Włącz Kontrole Zdrowia", + "healthCheckDisabledStateDescription": "Gdy wyłączone, strona nie będzie wykonywać kontroli zdrowia, a stan zostanie uznany za nieznany.", "enableHealthChecksDescription": "Monitoruj zdrowie tego celu. Możesz monitorować inny punkt końcowy niż docelowy w razie potrzeby.", "healthScheme": "Metoda", "healthSelectScheme": "Wybierz metodę", @@ -1947,6 +1966,8 @@ "httpMethod": "Metoda HTTP", "selectHttpMethod": "Wybierz metodę HTTP", "domainPickerSubdomainLabel": "Poddomena", + "domainPickerWildcard": "Uniwersalny", + "domainPickerWildcardPaidOnly": "Uniwersalne subdomeny są płatną funkcją. Proszę dokonać aktualizacji, aby uzyskać dostęp do tej funkcji.", "domainPickerBaseDomainLabel": "Domen bazowa", "domainPickerSearchDomains": "Szukaj domen...", "domainPickerNoDomainsFound": "Nie znaleziono domen", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Ten adres jest częścią podsieci użyteczności organizacji. Jest używany do rozwiązywania rekordów aliasu przy użyciu wewnętrznej rozdzielczości DNS.", "resourcesTableClients": "Klientami", "resourcesTableAndOnlyAccessibleInternally": "i są dostępne tylko wewnętrznie po połączeniu z klientem.", - "resourcesTableNoTargets": "Brak celów", "resourcesTableHealthy": "Zdrowe", "resourcesTableDegraded": "Degradacja", - "resourcesTableOffline": "Offline", + "resourcesTableUnhealthy": "Niezdrowy", "resourcesTableUnknown": "Nieznane", "resourcesTableNotMonitored": "Nie monitorowano", + "resourcesTableNoTargets": "Brak celów", "editInternalResourceDialogEditClientResource": "Edytuj Zasoby Prywatne", "editInternalResourceDialogUpdateResourceProperties": "Aktualizuj konfigurację zasobów i kontrolę dostępu dla {resourceName}", "editInternalResourceDialogResourceProperties": "Właściwości zasobów", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Zweryfikowano", "domainPickerUnverified": "Niezweryfikowane", "domainPickerManual": "Podręcznik", - "domainPickerInvalidSubdomainStructure": "Ta subdomena zawiera nieprawidłowe znaki lub strukturę. Zostanie ona automatycznie oczyszczona po zapisaniu.", + "domainPickerInvalidSubdomainStructure": "Nieprawidłowe znaki zostaną zsanitowane, gdy zostaną zapisane.", "domainPickerError": "Błąd", "domainPickerErrorLoadDomains": "Nie udało się załadować domen organizacji", "domainPickerErrorCheckAvailability": "Nie udało się sprawdzić dostępności domeny", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Dodaj klientów", "editInternalResourceDialogDestinationLabel": "Miejsce docelowe", "editInternalResourceDialogDestinationDescription": "Określ adres docelowy dla wewnętrznego zasobu. Może to być nazwa hosta, adres IP lub zakres CIDR, w zależności od wybranego trybu. Opcjonalnie ustaw wewnętrzny alias DNS dla łatwiejszej identyfikacji.", + "internalResourceFormMultiSiteRoutingHelp": "Wybór wielu stron umożliwia odporne trasowanie i awarię dla wysokiej dostępności.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Dowiedz się więcej", "editInternalResourceDialogPortRestrictionsDescription": "Ogranicz dostęp do konkretnych portów TCP/UDP lub zezwól/zablokuj wszystkie porty.", "createInternalResourceDialogHttpConfiguration": "Konfiguracja HTTP", "createInternalResourceDialogHttpConfigurationDescription": "Wybierz domenę, której klienci będą używać, aby dotrzeć do tego zasobu przez HTTP lub HTTPS.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Szacowany czas zakończenia (opcjonalnie)", "privateMaintenanceScreenTitle": "Ekraan prywatnego utrzymania", "privateMaintenanceScreenMessage": "Ta domena jest wykorzystywana na prywatnym zasobie. Połącz się za pomocą klienta Pangolin, aby uzyskać dostęp do tego zasobu.", + "privateMaintenanceScreenSteps": "Po połączeniu, jeśli nadal widzisz tę wiadomość, pamięć podręczna DNS przeglądarki może nadal wskazywać na stary adres. Aby to naprawić: zamknij i otwórz ponownie tę kartę lub przeglądarkę, a następnie przejdź z powrotem na tę stronę.", "maintenanceTime": "np. 2 godziny, 1 listopad o 17:00", "maintenanceEstimatedTimeDescription": "Kiedy oczekujesz zakończenia konserwacji", "editDomain": "Edytuj domenę", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Usuń", "publicIpEndpoint": "Koniec punktu pracy", "lastTriggeredAt": "Ostatnie Wyzwolenie", - "reject": "Odrzuć" + "reject": "Odrzuć", + "uptimeDaysAgo": "{count} dni temu", + "uptimeToday": "Dzisiaj", + "uptimeNoDataAvailable": "Brak danych dostępnych", + "uptimeSuffix": "czas pracy", + "uptimeDowntimeSuffix": "czas przestoju", + "uptimeTooltipUptimeLabel": "Czas pracy", + "uptimeTooltipDowntimeLabel": "Czas przestoju", + "uptimeOngoing": "w toku", + "uptimeNoMonitoringData": "Brak danych monitorowania", + "uptimeNoData": "Brak danych", + "uptimeMiniBarDown": "Nieaktywny", + "uptimeSectionTitle": "Czas pracy", + "uptimeSectionDescription": "Dostępność za ostatnie {days} dni", + "uptimeAddAlert": "Dodaj Alert", + "uptimeViewAlerts": "Zobacz Alerty", + "uptimeCreateEmailAlert": "Utwórz Alert Email", + "uptimeAlertDescriptionSite": "Otrzymuj powiadomienia e-mail, gdy ta strona jest offline lub wraca online.", + "uptimeAlertDescriptionResource": "Otrzymuj powiadomienia e-mail, gdy to zasób jest offline lub wraca online.", + "uptimeAlertNamePlaceholder": "Nazwa alertu", + "uptimeAdditionalEmails": "Dodatkowe adresy e-mail", + "uptimeCreateAlert": "Utwórz Alert", + "uptimeAlertNoRecipients": "Brak odbiorców", + "uptimeAlertNoRecipientsDescription": "Proszę dodać przynajmniej jednego użytkownika, rolę lub adres email do powiadomienia.", + "uptimeAlertCreated": "Alert utworzony", + "uptimeAlertCreatedDescription": "Zostaniesz powiadomiony, gdy status się zmieni.", + "uptimeAlertCreateFailed": "Nie udało się utworzyć alertu", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Klucz", + "webhookHeaderValuePlaceholder": "Wartość", + "alertLabel": "Alert", + "domainPickerWildcardSubdomainNotAllowed": "Uniwersalne subdomeny nie są dozwolone.", + "domainPickerWildcardCertWarning": "Uniwersalne zasoby mogą wymagać dodatkowej konfiguracji, aby działać poprawnie.", + "domainPickerWildcardCertWarningLink": "Dowiedz się więcej", + "health": "Zdrowie" } From 6c2dd4331a2705f26dbae2af9f5a95fd71117c3f Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:13 -0700 Subject: [PATCH 097/107] New translations en-us.json (Portuguese) --- messages/pt-PT.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 949f06ac6..6d0d751a4 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "Eu copiei a configuração", "searchSitesProgress": "Procurar sites...", "siteAdd": "Adicionar Site", + "sitesTableViewPublicResources": "Visualizar Recursos Públicos", + "sitesTableViewPrivateResources": "Visualizar Recursos Privados", "siteInstallNewt": "Instalar Novo", "siteInstallNewtDescription": "Novo item em execução no seu sistema", "WgConfiguration": "Configuração do WireGuard", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "O site foi atualizado.", "siteGeneralDescription": "Configurar as configurações gerais para este site", "siteSettingDescription": "Configurar as configurações no site", + "siteResourcesTab": "Recursos", + "siteResourcesNoneOnSite": "Este site ainda não possui recursos públicos ou privados.", + "siteResourcesSectionPublic": "Recursos Públicos", + "siteResourcesSectionPrivate": "Recursos Privados", + "siteResourcesSectionPublicDescription": "Recursos expostos externamente por meio de domínios ou portas.", + "siteResourcesSectionPrivateDescription": "Recursos disponíveis na sua rede privada por meio do site.", + "siteResourcesViewAllPublic": "Ver todos os recursos", + "siteResourcesViewAllPrivate": "Ver todos os recursos", + "siteResourcesDialogDescription": "Visão geral dos recursos públicos e privados associados a este site.", + "siteResourcesShowMore": "Mostrar Mais", + "siteResourcesPermissionDenied": "Você não tem permissão para listar estes recursos.", + "siteResourcesEmptyPublic": "Ainda não há recursos públicos direcionados para este site.", + "siteResourcesEmptyPrivate": "Ainda não há recursos privados associados a este site.", + "siteResourcesHowToAccess": "Como acessar", + "siteResourcesTargetsOnSite": "Alvos neste site", "siteSetting": "Configurações do {siteName}", "siteNewtTunnel": "Novo Site (Recomendado)", "siteNewtTunnelDescription": "Maneira mais fácil de criar um ponto de entrada em qualquer rede. Nenhuma configuração extra.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "Status da verificação de saúde muda", "alertingTriggerResourceHealthy": "Recurso saudável", "alertingTriggerResourceUnhealthy": "Recurso não saudável", + "alertingTriggerResourceDegraded": "Recurso degradado", "alertingSearchHealthChecks": "Pesquisar verificações de saúde…", "alertingHealthChecksEmpty": "Nenhuma verificação de saúde disponível.", "alertingTriggerResourceToggle": "Status do recurso muda", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Crie a conta de administrador inicial do servidor. Apenas um administrador do servidor pode existir. Você sempre pode alterar essas credenciais posteriormente.", "createAdminAccount": "Criar Conta de Administrador", "setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.", - "certificateStatus": "Status do Certificado", + "certificateStatus": "Certificado", "loading": "Carregando", "loadingAnalytics": "Carregando Analytics", "restart": "Reiniciar", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Configurar Verificação de Saúde", "configureHealthCheckDescription": "Configure a monitorização de saúde para {target}", "enableHealthChecks": "Ativar Verificações de Saúde", + "healthCheckDisabledStateDescription": "Quando desativado, o site não realizará verificações de saúde e o estado será considerado desconhecido.", "enableHealthChecksDescription": "Monitore a saúde deste alvo. Você pode monitorar um ponto de extremidade diferente do alvo, se necessário.", "healthScheme": "Método", "healthSelectScheme": "Selecione o Método", @@ -1947,6 +1966,8 @@ "httpMethod": "Método HTTP", "selectHttpMethod": "Selecionar método HTTP", "domainPickerSubdomainLabel": "Subdomínio", + "domainPickerWildcard": "Coringa", + "domainPickerWildcardPaidOnly": "Subdomínios curinga são um recurso pago. Por favor, atualize para acessar este recurso.", "domainPickerBaseDomainLabel": "Domínio Base", "domainPickerSearchDomains": "Buscar domínios...", "domainPickerNoDomainsFound": "Nenhum domínio encontrado", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Este endereço faz parte da sub-rede de utilitários da organização. É usado para resolver registros de alias usando resolução de DNS interno.", "resourcesTableClients": "Clientes", "resourcesTableAndOnlyAccessibleInternally": "e são acessíveis apenas internamente quando conectados com um cliente.", - "resourcesTableNoTargets": "Nenhum alvo", "resourcesTableHealthy": "Saudável", "resourcesTableDegraded": "Degradado", - "resourcesTableOffline": "Desconectado", + "resourcesTableUnhealthy": "Não Saudável", "resourcesTableUnknown": "Desconhecido", "resourcesTableNotMonitored": "Não monitorado", + "resourcesTableNoTargets": "Sem alvos", "editInternalResourceDialogEditClientResource": "Editar Recurso Privado", "editInternalResourceDialogUpdateResourceProperties": "Atualizar as configurações de recursos e controles de acesso para {resourceName}", "editInternalResourceDialogResourceProperties": "Propriedades do Recurso", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Verificada", "domainPickerUnverified": "Não verificado", "domainPickerManual": "Manual", - "domainPickerInvalidSubdomainStructure": "Este subdomínio contém caracteres ou estrutura inválidos. Ele será eliminado automaticamente quando você salvar.", + "domainPickerInvalidSubdomainStructure": "Caracteres inválidos serão sanitizados ao serem salvos.", "domainPickerError": "ERRO", "domainPickerErrorLoadDomains": "Falha ao carregar domínios da organização", "domainPickerErrorCheckAvailability": "Não foi possível verificar a disponibilidade do domínio", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Adicionar Clientes", "editInternalResourceDialogDestinationLabel": "Destino", "editInternalResourceDialogDestinationDescription": "Especifique o endereço de destino para o recurso interno. Isso pode ser um nome de host, endereço IP ou intervalo CIDR, dependendo do modo selecionado. Opcionalmente, defina um alias interno de DNS para facilitar a identificação.", + "internalResourceFormMultiSiteRoutingHelp": "Selecionar múltiplos sites permite roteamento resiliente e failover para alta disponibilidade.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Saiba mais", "editInternalResourceDialogPortRestrictionsDescription": "Restrinja o acesso a portas TCP/UDP específicas ou permita/bloqueie todas as portas.", "createInternalResourceDialogHttpConfiguration": "Configuração HTTP", "createInternalResourceDialogHttpConfigurationDescription": "Escolha o domínio que os clientes usarão para acessar este recurso via HTTP ou HTTPS.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Hora de Conclusão Estimada (Opcional)", "privateMaintenanceScreenTitle": "Tela de Placeholder Privada", "privateMaintenanceScreenMessage": "Este domínio está sendo usado em um recurso privado. Por favor, conecte-se usando o cliente Pangolin para acessar este recurso.", + "privateMaintenanceScreenSteps": "Depois de conectado, se você ainda estiver vendo esta mensagem, o cache DNS do seu navegador pode ainda apontar para o antigo endereço. Para corrigir isso: feche completamente e reabra esta aba, ou o seu navegador, e então navegue de volta para esta página.", "maintenanceTime": "por exemplo, 2 horas, 1 de Nov às 17h00", "maintenanceEstimatedTimeDescription": "Quando você espera que a manutenção seja concluída", "editDomain": "Editar Domínio", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Excluir", "publicIpEndpoint": "Endpoint", "lastTriggeredAt": "Último Gatilho", - "reject": "Rejeitar" + "reject": "Rejeitar", + "uptimeDaysAgo": "há {count} dias", + "uptimeToday": "Hoje", + "uptimeNoDataAvailable": "Sem dados disponíveis", + "uptimeSuffix": "tempo de atividade", + "uptimeDowntimeSuffix": "tempo de inatividade", + "uptimeTooltipUptimeLabel": "Tempo de Atividade", + "uptimeTooltipDowntimeLabel": "Tempo de Inatividade", + "uptimeOngoing": "em andamento", + "uptimeNoMonitoringData": "Sem dados de monitoramento", + "uptimeNoData": "Sem dados", + "uptimeMiniBarDown": "Inativo", + "uptimeSectionTitle": "Tempo de Atividade", + "uptimeSectionDescription": "Disponibilidade nos últimos {days} dias", + "uptimeAddAlert": "Adicionar Alerta", + "uptimeViewAlerts": "Visualizar Alertas", + "uptimeCreateEmailAlert": "Criar Alerta por Email", + "uptimeAlertDescriptionSite": "Seja notificado por email quando este site sair do ar ou voltar online.", + "uptimeAlertDescriptionResource": "Seja notificado por email quando este recurso sair do ar ou voltar online.", + "uptimeAlertNamePlaceholder": "Nome do alerta", + "uptimeAdditionalEmails": "Emails Adicionais", + "uptimeCreateAlert": "Criar Alerta", + "uptimeAlertNoRecipients": "Sem destinatários", + "uptimeAlertNoRecipientsDescription": "Por favor, adicione pelo menos um usuário, função ou email para notificar.", + "uptimeAlertCreated": "Alerta criado", + "uptimeAlertCreatedDescription": "Você será notificado quando isso mudar de status.", + "uptimeAlertCreateFailed": "Falha ao criar alerta", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Chave", + "webhookHeaderValuePlaceholder": "Valor", + "alertLabel": "Alerta", + "domainPickerWildcardSubdomainNotAllowed": "Subdomínios curinga não são permitidos.", + "domainPickerWildcardCertWarning": "Recursos curinga podem exigir configurações adicionais para funcionarem corretamente.", + "domainPickerWildcardCertWarningLink": "Saiba mais", + "health": "Saúde" } From a0619868be75b743cfed8c7089973c92d9d27e62 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:15 -0700 Subject: [PATCH 098/107] New translations en-us.json (Russian) --- messages/ru-RU.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index d5496e660..6490040cb 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "Я скопировал(а) конфигурацию", "searchSitesProgress": "Поиск сайтов...", "siteAdd": "Добавить сайт", + "sitesTableViewPublicResources": "Просмотр публичных ресурсов", + "sitesTableViewPrivateResources": "Просмотр частных ресурсов", "siteInstallNewt": "Установить Newt", "siteInstallNewtDescription": "Запустите Newt в вашей системе", "WgConfiguration": "Конфигурация WireGuard", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "Сайт был успешно обновлён.", "siteGeneralDescription": "Настройте общие параметры для этого сайта", "siteSettingDescription": "Настройка параметров на сайте", + "siteResourcesTab": "Ресурсы", + "siteResourcesNoneOnSite": "На этом сайте пока нет публичных или частных ресурсов.", + "siteResourcesSectionPublic": "Публичные ресурсы", + "siteResourcesSectionPrivate": "Частные ресурсы", + "siteResourcesSectionPublicDescription": "Ресурсы, доступные извне через домены или порты.", + "siteResourcesSectionPrivateDescription": "Ресурсы доступны на вашем частном сетевом ресурсе через сайт.", + "siteResourcesViewAllPublic": "Просмотреть все ресурсы", + "siteResourcesViewAllPrivate": "Просмотреть все ресурсы", + "siteResourcesDialogDescription": "Обзор публичных и частных ресурсов, связанных с этим сайтом.", + "siteResourcesShowMore": "Показать еще", + "siteResourcesPermissionDenied": "У вас нет разрешения на просмотр этих ресурсов.", + "siteResourcesEmptyPublic": "Ни один публичный ресурс еще не нацелен на этот сайт.", + "siteResourcesEmptyPrivate": "С этим сайтом еще не связано ни одного частного ресурса.", + "siteResourcesHowToAccess": "Как получить доступ", + "siteResourcesTargetsOnSite": "Цели на этом сайте", "siteSetting": "Настройки {siteName}", "siteNewtTunnel": "Новый сайт (рекомендуется)", "siteNewtTunnelDescription": "Самый простой способ создать точку входа в любую сеть. Дополнительная настройка не требуется.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "Статус проверки здоровья изменяется", "alertingTriggerResourceHealthy": "Ресурс в нормальном состоянии", "alertingTriggerResourceUnhealthy": "Ресурс в ненормальном состоянии", + "alertingTriggerResourceDegraded": "Ресурс ухудшен", "alertingSearchHealthChecks": "Поиск проверок здоровья…", "alertingHealthChecksEmpty": "Нет доступных проверок здоровья.", "alertingTriggerResourceToggle": "Статус ресурса изменяется", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Создайте первоначальную учётную запись администратора сервера. Может существовать только один администратор сервера. Вы всегда можете изменить эти учётные данные позже.", "createAdminAccount": "Создать учётную запись администратора", "setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.", - "certificateStatus": "Статус сертификата", + "certificateStatus": "Сертификат", "loading": "Загрузка", "loadingAnalytics": "Загрузка аналитики", "restart": "Перезагрузка", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Настроить проверку здоровья", "configureHealthCheckDescription": "Настройте мониторинг состояния для {target}", "enableHealthChecks": "Включить проверки здоровья", + "healthCheckDisabledStateDescription": "Когда отключен, сайт не будет выполнять проверки состояния и состояние будет считаться неизвестным.", "enableHealthChecksDescription": "Мониторинг здоровья этой цели. При необходимости можно контролировать другую конечную точку.", "healthScheme": "Метод", "healthSelectScheme": "Выберите метод", @@ -1947,6 +1966,8 @@ "httpMethod": "HTTP метод", "selectHttpMethod": "Выберите HTTP метод", "domainPickerSubdomainLabel": "Поддомен", + "domainPickerWildcard": "Подстановочный знак", + "domainPickerWildcardPaidOnly": "Wildcard поддомены являются платной функцией. Пожалуйста, обновите подписку, чтобы воспользоваться этой функцией.", "domainPickerBaseDomainLabel": "Основной домен", "domainPickerSearchDomains": "Поиск доменов...", "domainPickerNoDomainsFound": "Доменов не найдено", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Этот адрес является частью вспомогательной подсети организации. Он используется для разрешения псевдонимов с использованием внутреннего разрешения DNS.", "resourcesTableClients": "Клиенты", "resourcesTableAndOnlyAccessibleInternally": "и доступны только внутренне при подключении с клиентом.", - "resourcesTableNoTargets": "Нет ярлыков", "resourcesTableHealthy": "Здоровые", "resourcesTableDegraded": "Ухудшение", - "resourcesTableOffline": "Оффлайн", + "resourcesTableUnhealthy": "Проблемные", "resourcesTableUnknown": "Неизвестен", "resourcesTableNotMonitored": "Не отслеживается", + "resourcesTableNoTargets": "Нет целей", "editInternalResourceDialogEditClientResource": "Изменить приватный ресурс", "editInternalResourceDialogUpdateResourceProperties": "Обновить настройки ресурса и элементы управления доступом для {resourceName}", "editInternalResourceDialogResourceProperties": "Свойства ресурса", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Подтверждено", "domainPickerUnverified": "Не подтверждено", "domainPickerManual": "Ручной", - "domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.", + "domainPickerInvalidSubdomainStructure": "Недопустимые символы будут очищены при сохранении.", "domainPickerError": "Ошибка", "domainPickerErrorLoadDomains": "Не удалось загрузить домены организации", "domainPickerErrorCheckAvailability": "Не удалось проверить доступность домена", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Добавить клиентов", "editInternalResourceDialogDestinationLabel": "Пункт назначения", "editInternalResourceDialogDestinationDescription": "Укажите адрес назначения для внутреннего ресурса. Это может быть имя хоста, IP-адрес или диапазон CIDR в зависимости от выбранного режима. При необходимости установите внутренний DNS-алиас для облегчения идентификации.", + "internalResourceFormMultiSiteRoutingHelp": "Выбор нескольких сайтов позволяет обеспечить отказоустойчивую маршрутизацию и фейловер для высокой доступности.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Узнать больше", "editInternalResourceDialogPortRestrictionsDescription": "Ограничьте доступ к определенным TCP/UDP-портам или разрешите/заблокируйте все порты.", "createInternalResourceDialogHttpConfiguration": "Конфигурация HTTP", "createInternalResourceDialogHttpConfigurationDescription": "Выберите домен, который клиенты будут использовать для доступа к этому ресурсу через HTTP или HTTPS.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Предполагаемое время завершения (необязательно)", "privateMaintenanceScreenTitle": "Экраны частной заглушки", "privateMaintenanceScreenMessage": "Этот домен используется на частном ресурсе. Пожалуйста, подключитесь с помощью клиента Pangolin для доступа к этому ресурсу.", + "privateMaintenanceScreenSteps": "После подключения, если это сообщение по-прежнему отображается, кэш DNS вашего браузера может указывать на старый адрес. Чтобы исправить эту неисправность: полностью закройте и снова откройте эту вкладку или браузер, затем вернитесь на эту страницу.", "maintenanceTime": "например, 2 часа, 1 ноября в 5:00 вечера", "maintenanceEstimatedTimeDescription": "Когда вы ожидаете завершения обслуживания", "editDomain": "Редактировать домен", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Удалить", "publicIpEndpoint": "Конечная точка", "lastTriggeredAt": "Последний триггер", - "reject": "Отклонить" + "reject": "Отклонить", + "uptimeDaysAgo": "{count} дней назад", + "uptimeToday": "Сегодня", + "uptimeNoDataAvailable": "Нет доступных данных", + "uptimeSuffix": "время работы", + "uptimeDowntimeSuffix": "время простоя", + "uptimeTooltipUptimeLabel": "Время работы", + "uptimeTooltipDowntimeLabel": "Время простоя", + "uptimeOngoing": "в процессе", + "uptimeNoMonitoringData": "Отсутствуют данные мониторинга", + "uptimeNoData": "Нет данных", + "uptimeMiniBarDown": "Не работает", + "uptimeSectionTitle": "Время работы", + "uptimeSectionDescription": "Доступность за последние {days} дней", + "uptimeAddAlert": "Добавить предупреждение", + "uptimeViewAlerts": "Просмотр предупреждений", + "uptimeCreateEmailAlert": "Создать оповещение по электронной почте", + "uptimeAlertDescriptionSite": "Получайте уведомления по электронной почте, когда этот сайт выходит из сети или снова подключается.", + "uptimeAlertDescriptionResource": "Получайте уведомления по электронной почте, когда этот ресурс выходит из сети или снова подключается.", + "uptimeAlertNamePlaceholder": "Название предупреждения", + "uptimeAdditionalEmails": "Дополнительные адреса электронной почты", + "uptimeCreateAlert": "Создать предупреждение", + "uptimeAlertNoRecipients": "Нет получателей", + "uptimeAlertNoRecipientsDescription": "Пожалуйста, добавьте хотя бы одного пользователя, роль или email для уведомления.", + "uptimeAlertCreated": "Предупреждение создано", + "uptimeAlertCreatedDescription": "Вы будете уведомлены, когда статус изменится.", + "uptimeAlertCreateFailed": "Не удалось создать предупреждение", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Ключ", + "webhookHeaderValuePlaceholder": "Значение", + "alertLabel": "Предупреждение", + "domainPickerWildcardSubdomainNotAllowed": "Wildcard поддомены не допускаются.", + "domainPickerWildcardCertWarning": "Wildcard ресурсы могут потребовать дополнительной настройки для правильной работы.", + "domainPickerWildcardCertWarningLink": "Узнать больше", + "health": "Состояние" } From 1c6cd57c3180a4bc941b7eedda09b591aa93b196 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:16 -0700 Subject: [PATCH 099/107] New translations en-us.json (Turkish) --- messages/tr-TR.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index b7b3c877c..2676d2fba 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "Yapılandırmayı kopyaladım", "searchSitesProgress": "Siteleri ara...", "siteAdd": "Site Ekle", + "sitesTableViewPublicResources": "Genel Kaynakları Görüntüle", + "sitesTableViewPrivateResources": "Özel Kaynakları Görüntüle", "siteInstallNewt": "Newt Yükle", "siteInstallNewtDescription": "Newt'i sisteminizde çalıştırma", "WgConfiguration": "WireGuard Yapılandırması", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "Site güncellendi.", "siteGeneralDescription": "Bu site için genel ayarları yapılandırın", "siteSettingDescription": "Sitenizdeki ayarları yapılandırın", + "siteResourcesTab": "Kaynaklar", + "siteResourcesNoneOnSite": "Bu sitede henüz genel veya özel kaynak yok.", + "siteResourcesSectionPublic": "Genel Kaynaklar", + "siteResourcesSectionPrivate": "Özel Kaynaklar", + "siteResourcesSectionPublicDescription": "Alanlar veya portlar üzerinden dışarıdan açığa çıkan kaynaklar.", + "siteResourcesSectionPrivateDescription": "Site aracılığıyla özel ağınızda mevcut olan kaynaklar.", + "siteResourcesViewAllPublic": "Tüm kaynakları görüntüle", + "siteResourcesViewAllPrivate": "Tüm kaynakları görüntüle", + "siteResourcesDialogDescription": "Bu siteyle ilişkili genel ve özel kaynakların genel bakışı.", + "siteResourcesShowMore": "Daha fazla göster", + "siteResourcesPermissionDenied": "Bu kaynakları listeleme izniniz yok.", + "siteResourcesEmptyPublic": "Bu siteyi hedefleyen herhangi bir genel kaynak yok.", + "siteResourcesEmptyPrivate": "Bu siteyle ilişkilendirilmiş özel kaynak yok.", + "siteResourcesHowToAccess": "Nasıl erişilir", + "siteResourcesTargetsOnSite": "Bu sitedeki hedefler", "siteSetting": "{siteName} Ayarları", "siteNewtTunnel": "Newt Site (Önerilen)", "siteNewtTunnelDescription": "Ağınıza giriş noktası oluşturmanın en kolay yolu. Ekstra kurulum gerekmez.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "Sağlık kontrolü durumu değişiyor", "alertingTriggerResourceHealthy": "Kaynak sağlıklı", "alertingTriggerResourceUnhealthy": "Kaynak sağlıksız", + "alertingTriggerResourceDegraded": "Kaynak bozuk", "alertingSearchHealthChecks": "Sağlık kontrollerini ara…", "alertingHealthChecksEmpty": "Mevcut sağlık kontrolü yok.", "alertingTriggerResourceToggle": "Kaynak durumu değişiyor", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "İlk sunucu yönetici hesabını oluşturun. Yalnızca bir sunucu yöneticisi olabilir. Bu kimlik bilgilerini daha sonra her zaman değiştirebilirsiniz.", "createAdminAccount": "Yönetici Hesabı Oluştur", "setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.", - "certificateStatus": "Sertifika Durumu", + "certificateStatus": "Sertifika", "loading": "Yükleniyor", "loadingAnalytics": "Analiz Yükleniyor", "restart": "Yeniden Başlat", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Sağlık Kontrolünü Yapılandır", "configureHealthCheckDescription": "{hedef} için sağlık izleme kurun", "enableHealthChecks": "Sağlık Kontrollerini Etkinleştir", + "healthCheckDisabledStateDescription": "Devre dışı bırakıldığında, site sağlık kontrolleri yapmaz ve durum bilinmeyen olarak kabul edilecektir.", "enableHealthChecksDescription": "Bu hedefin sağlığını izleyin. Gerekirse hedef dışındaki bir son noktayı izleyebilirsiniz.", "healthScheme": "Yöntem", "healthSelectScheme": "Yöntem Seç", @@ -1947,6 +1966,8 @@ "httpMethod": "HTTP Yöntemi", "selectHttpMethod": "HTTP yöntemini seçin", "domainPickerSubdomainLabel": "Alt Alan Adı", + "domainPickerWildcard": "Genel karakter", + "domainPickerWildcardPaidOnly": "Genel alt alanlar ücretli bir özelliktir. Bu özelliğe erişmek için lütfen yükseltin.", "domainPickerBaseDomainLabel": "Temel Alan Adı", "domainPickerSearchDomains": "Alan adlarını ara...", "domainPickerNoDomainsFound": "Hiçbir alan adı bulunamadı", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Bu adres, kuruluşun yardımcı ağ alt bantının bir parçasıdır. Alias kayıtlarını çözümlemek için dahili DNS çözümlemesi kullanılır.", "resourcesTableClients": "İstemciler", "resourcesTableAndOnlyAccessibleInternally": "veyalnızca bir istemci ile bağlandığında dahili olarak erişilebilir.", - "resourcesTableNoTargets": "Hedef yok", "resourcesTableHealthy": "Sağlıklı", "resourcesTableDegraded": "Düşük Performanslı", - "resourcesTableOffline": "Çevrimdışı", + "resourcesTableUnhealthy": "Sağlıksız", "resourcesTableUnknown": "Bilinmiyor", "resourcesTableNotMonitored": "İzlenmiyor", + "resourcesTableNoTargets": "Hedef yok", "editInternalResourceDialogEditClientResource": "Özel Kaynak Düzenleyin", "editInternalResourceDialogUpdateResourceProperties": "{resourceName} için kaynak ayarlarını ve erişim kontrollerini güncelleyin", "editInternalResourceDialogResourceProperties": "Kaynak Özellikleri", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Doğrulandı", "domainPickerUnverified": "Doğrulanmadı", "domainPickerManual": "Manuel", - "domainPickerInvalidSubdomainStructure": "Bu alt alan adı geçersiz karakterler veya yapı içeriyor. Kaydettiğinizde otomatik olarak temizlenecektir.", + "domainPickerInvalidSubdomainStructure": "Geçersiz karakterler kaydedildiğinde temizlenecektir.", "domainPickerError": "Hata", "domainPickerErrorLoadDomains": "Organizasyon alan adları yüklenemedi", "domainPickerErrorCheckAvailability": "Alan adı kullanılabilirliği kontrol edilemedi", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Müşteriler Ekle", "editInternalResourceDialogDestinationLabel": "Hedef", "editInternalResourceDialogDestinationDescription": "Dahili kaynak için hedef adresi belirtin. Seçilen moda bağlı olarak bu bir ana bilgisayar adı, IP adresi veya CIDR aralığı olabilir. Daha kolay tanımlama için isteğe bağlı olarak dahili bir DNS takma adı ayarlayın.", + "internalResourceFormMultiSiteRoutingHelp": "Birden fazla site seçmek, yüksek kullanılabilirlik için dirençli yönlendirme ve yedeklik sağlar.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Daha fazla bilgi", "editInternalResourceDialogPortRestrictionsDescription": "Belirtilen TCP/UDP portlarına erişimi kısıtlayın veya tüm portlara izin/engelleme verin.", "createInternalResourceDialogHttpConfiguration": "HTTP yapılandırması", "createInternalResourceDialogHttpConfigurationDescription": "HTTP veya HTTPS üzerinden bu kaynağa ulaşmak için istemcilerin kullanacağı alan adını seçin.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Tahmini Tamamlanma Süresi (İsteğe Bağlı)", "privateMaintenanceScreenTitle": "Özel Yer Tutucu Ekran", "privateMaintenanceScreenMessage": "Bu alan adı özel bir kaynak üzerinde kullanılmaktadır. Bu kaynağa erişmek için Pangolin istemcisini kullanarak bağlanın.", + "privateMaintenanceScreenSteps": "Bağlanıldıktan sonra, hâlâ bu mesajı görüyorsanız tarayıcınızın DNS önbelleği eski adrese işaret ediyor olabilir. Bunu düzeltmek için: bu sekmeyi veya tarayıcınızı tamamen kapatıp tekrar açın, ardından bu sayfaya geri dönün.", "maintenanceTime": "ör. 2 saat, 1 Kasım saat 17:00", "maintenanceEstimatedTimeDescription": "Bakımın ne zaman tamamlanmasını bekliyorsunuz", "editDomain": "Alan Adını Düzenle", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Sil", "publicIpEndpoint": "Uç Nokta", "lastTriggeredAt": "Son Tetikleyici", - "reject": "Reddet" + "reject": "Reddet", + "uptimeDaysAgo": "{count} gün önce", + "uptimeToday": "Bugün", + "uptimeNoDataAvailable": "Veri yok", + "uptimeSuffix": "çalışma süresi", + "uptimeDowntimeSuffix": "çalışma dışı", + "uptimeTooltipUptimeLabel": "Çalışma süresi", + "uptimeTooltipDowntimeLabel": "Çalışma dışı", + "uptimeOngoing": "devam eden", + "uptimeNoMonitoringData": "İzleme verisi yok", + "uptimeNoData": "Veri yok", + "uptimeMiniBarDown": "Kapalı", + "uptimeSectionTitle": "Çalışma Süresi", + "uptimeSectionDescription": "Son {days} gün boyunca kullanılabilirlik", + "uptimeAddAlert": "Uyarı Ekle", + "uptimeViewAlerts": "Uyarıları Görüntüle", + "uptimeCreateEmailAlert": "E-posta Uyarısı Oluştur", + "uptimeAlertDescriptionSite": "Bu site çevrimdışıyken veya yeniden çevrimiçi olduğunda e-posta ile bildirim alın.", + "uptimeAlertDescriptionResource": "Bu kaynak çevrimdışıyken veya yeniden çevrimiçi olduğunda e-posta ile bildirim alın.", + "uptimeAlertNamePlaceholder": "Uyarı adı", + "uptimeAdditionalEmails": "Ek E-postalar", + "uptimeCreateAlert": "Uyarı Oluştur", + "uptimeAlertNoRecipients": "Alıcı yok", + "uptimeAlertNoRecipientsDescription": "Lütfen en az bir kullanıcı, rol veya e-posta ekleyin.", + "uptimeAlertCreated": "Uyarı oluşturuldu", + "uptimeAlertCreatedDescription": "Durum değiştiğinde haberdar edileceksiniz.", + "uptimeAlertCreateFailed": "Uyarı oluşturulamadı", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Anahtar", + "webhookHeaderValuePlaceholder": "Değer", + "alertLabel": "Uyarı", + "domainPickerWildcardSubdomainNotAllowed": "Genel alt alanlara izin verilmiyor.", + "domainPickerWildcardCertWarning": "Genel kaynaklar düzgün çalışmak için ek yapılandırma gerektirebilir.", + "domainPickerWildcardCertWarningLink": "Daha fazla bilgi", + "health": "Sağlık" } From bd866a5fd2fdcab9ca5293f987da03c371980ce9 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:18 -0700 Subject: [PATCH 100/107] New translations en-us.json (Chinese Simplified) --- messages/zh-CN.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 1a79d3c35..58ee66d44 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "我已经复制了配置信息", "searchSitesProgress": "搜索站点...", "siteAdd": "添加站点", + "sitesTableViewPublicResources": "查看公共资源", + "sitesTableViewPrivateResources": "查看私有资源", "siteInstallNewt": "安装 Newt", "siteInstallNewtDescription": "在您的系统中运行 Newt", "WgConfiguration": "WireGuard 配置", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "网站已更新。", "siteGeneralDescription": "配置此站点的常规设置", "siteSettingDescription": "配置站点设置", + "siteResourcesTab": "资源", + "siteResourcesNoneOnSite": "此站点尚无公开或私人资源。", + "siteResourcesSectionPublic": "公共资源", + "siteResourcesSectionPrivate": "私有资源", + "siteResourcesSectionPublicDescription": "通过域名或端口公开的资源。", + "siteResourcesSectionPrivateDescription": "通过站点可在您的私有网络上访问的资源。", + "siteResourcesViewAllPublic": "查看所有资源", + "siteResourcesViewAllPrivate": "查看所有资源", + "siteResourcesDialogDescription": "此站点的公开和私有资源概览。", + "siteResourcesShowMore": "显示更多", + "siteResourcesPermissionDenied": "您无权列出这些资源。", + "siteResourcesEmptyPublic": "尚无针对该站点的公共资源。", + "siteResourcesEmptyPrivate": "尚无与此站点关联的私有资源。", + "siteResourcesHowToAccess": "如何访问", + "siteResourcesTargetsOnSite": "此站点上的目标", "siteSetting": "{siteName} 设置", "siteNewtTunnel": "新站点 (推荐)", "siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "健康检查状态变更", "alertingTriggerResourceHealthy": "资源正常", "alertingTriggerResourceUnhealthy": "资源不正常", + "alertingTriggerResourceDegraded": "资源降级", "alertingSearchHealthChecks": "搜索健康检查…", "alertingHealthChecksEmpty": "无可用健康检查。", "alertingTriggerResourceToggle": "资源状态变更", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", "createAdminAccount": "创建管理员帐户", "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", - "certificateStatus": "证书状态", + "certificateStatus": "证书", "loading": "加载中", "loadingAnalytics": "加载分析", "restart": "重启", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "配置健康检查", "configureHealthCheckDescription": "为 {target} 设置健康监控", "enableHealthChecks": "启用健康检查", + "healthCheckDisabledStateDescription": "禁用后,站点不会进行健康检查,状态将被视为未知。", "enableHealthChecksDescription": "监视此目标的健康状况。如果需要,您可以监视一个不同的终点。", "healthScheme": "方法", "healthSelectScheme": "选择方法", @@ -1947,6 +1966,8 @@ "httpMethod": "HTTP 方法", "selectHttpMethod": "选择 HTTP 方法", "domainPickerSubdomainLabel": "子域名", + "domainPickerWildcard": "通配符", + "domainPickerWildcardPaidOnly": "通配符子域是付费功能。请升级以使用此功能。", "domainPickerBaseDomainLabel": "根域名", "domainPickerSearchDomains": "搜索域名...", "domainPickerNoDomainsFound": "未找到域名", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "此地址是组织实用子网的一部分。它用来使用内部DNS解析来解析别名记录。", "resourcesTableClients": "客户端", "resourcesTableAndOnlyAccessibleInternally": "且仅在与客户端连接时可内部访问。", - "resourcesTableNoTargets": "没有目标", "resourcesTableHealthy": "健康的", "resourcesTableDegraded": "降级", - "resourcesTableOffline": "离线的", + "resourcesTableUnhealthy": "不健康", "resourcesTableUnknown": "未知的", "resourcesTableNotMonitored": "未监视的", + "resourcesTableNoTargets": "无目标", "editInternalResourceDialogEditClientResource": "编辑私有资源", "editInternalResourceDialogUpdateResourceProperties": "更新{resourceName}的资源配置和访问控制。", "editInternalResourceDialogResourceProperties": "资源属性", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "已验证", "domainPickerUnverified": "未验证", "domainPickerManual": "手动", - "domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。", + "domainPickerInvalidSubdomainStructure": "保存时将清除无效字符。", "domainPickerError": "错误", "domainPickerErrorLoadDomains": "加载组织域名失败", "domainPickerErrorCheckAvailability": "检查域可用性失败", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "添加客户端", "editInternalResourceDialogDestinationLabel": "目标", "editInternalResourceDialogDestinationDescription": "指定内部资源的目标地址。根据选择的模式,这可以是主机名、IP地址或CIDR范围。可选的,设置一个内部DNS别名以便于识别。", + "internalResourceFormMultiSiteRoutingHelp": "选择多个站点可以实现高可用性的弹性路由和故障转移。", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "了解更多", "editInternalResourceDialogPortRestrictionsDescription": "限制访问特定的TCP/UDP端口或允许/阻止所有端口。", "createInternalResourceDialogHttpConfiguration": "HTTP 配置", "createInternalResourceDialogHttpConfigurationDescription": "选择客户将使用的域名通过 HTTP 或 HTTPS 访问此资源。", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "预计完成时间(可选)", "privateMaintenanceScreenTitle": "私有占位符界面", "privateMaintenanceScreenMessage": "此域名正在私有资源上使用。请连接 Pangolin 客户端以访问此资源。", + "privateMaintenanceScreenSteps": "连接后,如果您仍然看到此消息,说明您的浏览器的DNS缓存可能仍指向旧地址。解决方法:完全关闭并重新打开此标签页或浏览器,然后返回此页面。", "maintenanceTime": "例如,2小时,11月1日下午5:00", "maintenanceEstimatedTimeDescription": "您期望维护完成的时间", "editDomain": "编辑域名", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "删除", "publicIpEndpoint": "终端", "lastTriggeredAt": "最后触发", - "reject": "拒绝" + "reject": "拒绝", + "uptimeDaysAgo": "{count} 天前", + "uptimeToday": "今天", + "uptimeNoDataAvailable": "暂无数据", + "uptimeSuffix": "正常运行时间", + "uptimeDowntimeSuffix": "停机时间", + "uptimeTooltipUptimeLabel": "正常运行", + "uptimeTooltipDowntimeLabel": "停机", + "uptimeOngoing": "正在进行", + "uptimeNoMonitoringData": "无监控数据", + "uptimeNoData": "无数据", + "uptimeMiniBarDown": "停机", + "uptimeSectionTitle": "正常运行时间", + "uptimeSectionDescription": "过去 {days} 天的可用性", + "uptimeAddAlert": "添加警报", + "uptimeViewAlerts": "查看警报", + "uptimeCreateEmailAlert": "创建电子邮件警报", + "uptimeAlertDescriptionSite": "当此站点下线或恢复上线时,将通过电子邮件通知您。", + "uptimeAlertDescriptionResource": "当此资源下线或恢复上线时,将通过电子邮件通知您。", + "uptimeAlertNamePlaceholder": "警报名称", + "uptimeAdditionalEmails": "附加电子邮件", + "uptimeCreateAlert": "创建警报", + "uptimeAlertNoRecipients": "无收件人", + "uptimeAlertNoRecipientsDescription": "请至少添加一个用户、角色或电子邮件进行通知。", + "uptimeAlertCreated": "警报已创建", + "uptimeAlertCreatedDescription": "状态变化时将通知您。", + "uptimeAlertCreateFailed": "创建警报失败", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "关键字", + "webhookHeaderValuePlaceholder": "值", + "alertLabel": "警报", + "domainPickerWildcardSubdomainNotAllowed": "不允许使用通配符子域。", + "domainPickerWildcardCertWarning": "通配符资源可能需要额外配置才能正常工作。", + "domainPickerWildcardCertWarningLink": "了解更多", + "health": "健康" } From 4a3035d5974075d32313ba3eaa25b2a1b824dcfd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:20 -0700 Subject: [PATCH 101/107] New translations en-us.json (Norwegian Bokmal) --- messages/nb-NO.json | 70 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 4cf5159d8..e44022572 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "Jeg har kopiert konfigurasjonen", "searchSitesProgress": "Søker i områder...", "siteAdd": "Legg til område", + "sitesTableViewPublicResources": "Vis offentlige ressurser", + "sitesTableViewPrivateResources": "Vis private ressurser", "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Få Newt til å kjøre på systemet ditt", "WgConfiguration": "WireGuard Konfigurasjon", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "Området har blitt oppdatert.", "siteGeneralDescription": "Konfigurer de generelle innstillingene for dette området", "siteSettingDescription": "Konfigurere innstillingene på nettstedet", + "siteResourcesTab": "Ressurser", + "siteResourcesNoneOnSite": "Dette nettstedet har ingen offentlige eller private ressurser enda.", + "siteResourcesSectionPublic": "Offentlige ressurser", + "siteResourcesSectionPrivate": "Private ressurser", + "siteResourcesSectionPublicDescription": "Ressurser eksponert eksternt gjennom domener eller porter.", + "siteResourcesSectionPrivateDescription": "Ressurser tilgjengelig på ditt private nettverk gjennom nettstedet.", + "siteResourcesViewAllPublic": "Vis alle ressurser", + "siteResourcesViewAllPrivate": "Vis alle ressurser", + "siteResourcesDialogDescription": "Oversikt over offentlige og private ressurser assosiert med dette nettstedet.", + "siteResourcesShowMore": "Vis mer", + "siteResourcesPermissionDenied": "Du har ikke tillatelse til å liste opp disse ressursene.", + "siteResourcesEmptyPublic": "Ingen offentlige ressurser retter seg mot dette nettstedet enda.", + "siteResourcesEmptyPrivate": "Ingen private ressurser er assosiert med dette nettstedet enda.", + "siteResourcesHowToAccess": "Hvordan få tilgang", + "siteResourcesTargetsOnSite": "Mål på dette nettstedet", "siteSetting": "{siteName} Innstillinger", "siteNewtTunnel": "Nyhetsnettsted (anbefalt)", "siteNewtTunnelDescription": "Lekkeste måte å lage et inngangspunkt til ethvert nettverk. Ingen ekstra oppsett på.", @@ -1404,7 +1421,7 @@ "alertingSpecificResourcesDescription": "Velg spesifikke ressurser for overvåking", "alertingSelectResources": "Velg ressurser…", "alertingResourcesSelected": "{count} ressurser valgt", - "alertingResourcesEmpty": "No resources with targets in the first 10 results.", + "alertingResourcesEmpty": "Ingen ressurser med mål i de første 10 resultatene.", "alertingSectionTrigger": "Utløser", "alertingTrigger": "Når skal det varsles", "alertingTriggerSiteOnline": "Nettsted er online", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "Endringer i helsekontrollstatus", "alertingTriggerResourceHealthy": "Ressurs sunn", "alertingTriggerResourceUnhealthy": "Ressurs usunn", + "alertingTriggerResourceDegraded": "Ressurs forringet", "alertingSearchHealthChecks": "Søk i helsekontroller…", "alertingHealthChecksEmpty": "Ingen tilgjengelige helsekontroller.", "alertingTriggerResourceToggle": "Endringer i ressursstatus", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Opprett den første serveradministratorkontoen. Det kan bare finnes én serveradministrator. Du kan alltid endre denne påloggingsinformasjonen senere.", "createAdminAccount": "Opprett administratorkonto", "setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.", - "certificateStatus": "Sertifikatstatus", + "certificateStatus": "Sertifikat", "loading": "Laster inn", "loadingAnalytics": "Laster inn analyser", "restart": "Start på nytt", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Konfigurer Helsekontroll", "configureHealthCheckDescription": "Sett opp helsekontroll for {target}", "enableHealthChecks": "Aktiver Helsekontroller", + "healthCheckDisabledStateDescription": "Når deaktivert, vil ikke nettstedet utføre helsekontroller, og tilstanden vil anses som ukjent.", "enableHealthChecksDescription": "Overvåk helsen til dette målet. Du kan overvåke et annet endepunkt enn målet hvis nødvendig.", "healthScheme": "Metode", "healthSelectScheme": "Velg metode", @@ -1947,6 +1966,8 @@ "httpMethod": "HTTP-metode", "selectHttpMethod": "Velg HTTP-metode", "domainPickerSubdomainLabel": "Underdomene", + "domainPickerWildcard": "Jokertegn", + "domainPickerWildcardPaidOnly": "Jokertegnsubdomener er en betalt funksjon. Vennligst oppgrader for å få tilgang til denne funksjonen.", "domainPickerBaseDomainLabel": "Grunndomene", "domainPickerSearchDomains": "Søk i domener...", "domainPickerNoDomainsFound": "Ingen domener funnet", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Denne adressen er en del av organisasjonens undernettverk. Den brukes til å løse aliasposter ved hjelp av intern DNS-oppløsning.", "resourcesTableClients": "Klienter", "resourcesTableAndOnlyAccessibleInternally": "og er kun tilgjengelig internt når de er koblet til med en klient.", - "resourcesTableNoTargets": "Ingen mål", "resourcesTableHealthy": "Frisk", "resourcesTableDegraded": "Nedgradert", - "resourcesTableOffline": "Frakoblet", + "resourcesTableUnhealthy": "Usunn", "resourcesTableUnknown": "Ukjent", "resourcesTableNotMonitored": "Ikke overvåket", + "resourcesTableNoTargets": "Ingen mål", "editInternalResourceDialogEditClientResource": "Rediger Private Ressurser", "editInternalResourceDialogUpdateResourceProperties": "Oppdater ressurskonfigurasjonen og få tilgangskontroller for {resourceName}", "editInternalResourceDialogResourceProperties": "Ressursegenskaper", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Bekreftet", "domainPickerUnverified": "Uverifisert", "domainPickerManual": "Manuell", - "domainPickerInvalidSubdomainStructure": "Dette underdomenet inneholder ugyldige tegn eller struktur. Det vil automatisk bli utsatt når du lagrer.", + "domainPickerInvalidSubdomainStructure": "Ugyldige tegn vil bli sanitert når de er lagret.", "domainPickerError": "Feil", "domainPickerErrorLoadDomains": "Kan ikke laste organisasjonens domener", "domainPickerErrorCheckAvailability": "Kunne ikke kontrollere domenetilgjengelighet", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Legg til klienter", "editInternalResourceDialogDestinationLabel": "Destinasjon", "editInternalResourceDialogDestinationDescription": "Spesifiser destinasjonsadressen for den interne ressursen. Dette kan være et vertsnavn, IP-adresse eller CIDR-sjikt avhengig av valgt modus. Valgfrie oppsett av intern DNS-alias for enklere identifikasjon.", + "internalResourceFormMultiSiteRoutingHelp": "Valg av flere nettsteder muliggjør motstandskraftig ruting og failover for høy tilgjengelighet.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Lær mer", "editInternalResourceDialogPortRestrictionsDescription": "Begrens tilgang til spesifikke TCP/UDP-porter eller tillate/blokkere alle porter.", "createInternalResourceDialogHttpConfiguration": "HTTP-konfigurasjon", "createInternalResourceDialogHttpConfigurationDescription": "Velg domenet klienter vil bruke for å nå denne ressursen via HTTP eller HTTPS.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Estimert ferdigstillelsestid (Valgfritt)", "privateMaintenanceScreenTitle": "Privat plassholder skjerm", "privateMaintenanceScreenMessage": "Dette domenet brukes på en privatressurs. Koble til ved å bruke Pangolin-klienten for å få tilgang til denne ressursen.", + "privateMaintenanceScreenSteps": "Når du er koblet til, hvis du fortsatt ser denne meldingen, peker kanskje DNS-cachen til nettleseren din fortsatt til den gamle adressen. For å rette på dette: lukk og åpne denne fanen eller nettleseren på nytt, og naviger deretter tilbake til denne siden.", "maintenanceTime": "f.eks. 2 timer, 1. november kl. 17:00", "maintenanceEstimatedTimeDescription": "Når du forventer at vedlikeholdet er ferdigstilt", "editDomain": "Rediger domene", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Slett", "publicIpEndpoint": "Endepunkt", "lastTriggeredAt": "Siste utløste", - "reject": "Avvis" + "reject": "Avvis", + "uptimeDaysAgo": "{count} days ago", + "uptimeToday": "I dag", + "uptimeNoDataAvailable": "Ingen data tilgjengelig", + "uptimeSuffix": "oppetid", + "uptimeDowntimeSuffix": "nedetid", + "uptimeTooltipUptimeLabel": "Oppetid", + "uptimeTooltipDowntimeLabel": "Nedetid", + "uptimeOngoing": "pågående", + "uptimeNoMonitoringData": "Ingen overvåkingsdata", + "uptimeNoData": "Ingen data", + "uptimeMiniBarDown": "Nede", + "uptimeSectionTitle": "Oppetid", + "uptimeSectionDescription": "Tilgjengelighet de siste {days} dagene", + "uptimeAddAlert": "Legg til varsling", + "uptimeViewAlerts": "Vis varsler", + "uptimeCreateEmailAlert": "Opprett e-postvarsel", + "uptimeAlertDescriptionSite": "Få beskjed på e-post når dette nettstedet går offline eller kommer tilbake online.", + "uptimeAlertDescriptionResource": "Få beskjed på e-post når denne ressursen går offline eller kommer tilbake online.", + "uptimeAlertNamePlaceholder": "Varslingsnavn", + "uptimeAdditionalEmails": "Flere e-poster", + "uptimeCreateAlert": "Opprett varsling", + "uptimeAlertNoRecipients": "Ingen mottakere", + "uptimeAlertNoRecipientsDescription": "Vennligst legg til minst én bruker, rolle, eller e-post for å varsle.", + "uptimeAlertCreated": "Varsel opprettet", + "uptimeAlertCreatedDescription": "Du vil bli varslet når dette endrer status.", + "uptimeAlertCreateFailed": "Kunne ikke opprette varsel", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Nøkkel", + "webhookHeaderValuePlaceholder": "Verdi", + "alertLabel": "Varsel", + "domainPickerWildcardSubdomainNotAllowed": "Jokertegnsubdomener er ikke tillatt.", + "domainPickerWildcardCertWarning": "Jokertegnressurser kan kreve ekstra konfigurasjon for å fungere skikkelig.", + "domainPickerWildcardCertWarningLink": "Lær mer", + "health": "Helse" } From 92822a20e8e49b72f92de421216f276675105230 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 27 Apr 2026 20:25:22 -0700 Subject: [PATCH 102/107] New translations en-us.json (Spanish) --- messages/es-ES.json | 68 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index e119adc1b..cf8c64f96 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -93,6 +93,8 @@ "siteConfirmCopy": "He copiado la configuración", "searchSitesProgress": "Buscar sitios...", "siteAdd": "Añadir sitio", + "sitesTableViewPublicResources": "Ver Recursos Públicos", + "sitesTableViewPrivateResources": "Ver Recursos Privados", "siteInstallNewt": "Instalar Newt", "siteInstallNewtDescription": "Recibe Newt corriendo en tu sistema", "WgConfiguration": "Configuración de Wirex Guard", @@ -110,6 +112,21 @@ "siteUpdatedDescription": "El sitio ha sido actualizado.", "siteGeneralDescription": "Configurar la configuración general de este sitio", "siteSettingDescription": "Configurar los ajustes en el sitio", + "siteResourcesTab": "Recursos", + "siteResourcesNoneOnSite": "Este sitio aún no tiene recursos públicos o privados.", + "siteResourcesSectionPublic": "Recursos Públicos", + "siteResourcesSectionPrivate": "Recursos Privados", + "siteResourcesSectionPublicDescription": "Recursos expuestos externamente a través de dominios o puertos.", + "siteResourcesSectionPrivateDescription": "Recursos disponibles en tu red privada a través del sitio.", + "siteResourcesViewAllPublic": "Ver todos los recursos", + "siteResourcesViewAllPrivate": "Ver todos los recursos", + "siteResourcesDialogDescription": "Descripción general de los recursos públicos y privados asociados con este sitio.", + "siteResourcesShowMore": "Mostrar más", + "siteResourcesPermissionDenied": "No tienes permiso para listar estos recursos.", + "siteResourcesEmptyPublic": "Aún no hay recursos públicos apuntando a este sitio.", + "siteResourcesEmptyPrivate": "Aún no hay recursos privados asociados con este sitio.", + "siteResourcesHowToAccess": "Cómo acceder", + "siteResourcesTargetsOnSite": "Objetivos en este sitio", "siteSetting": "Ajustes {siteName}", "siteNewtTunnel": "Sitio nuevo (recomendado)", "siteNewtTunnelDescription": "La forma más fácil de crear un punto de entrada en cualquier red. Sin configuración extra.", @@ -1415,6 +1432,7 @@ "alertingTriggerHcToggle": "El estado del chequeo de salud cambia", "alertingTriggerResourceHealthy": "Recurso saludable", "alertingTriggerResourceUnhealthy": "Recurso no saludable", + "alertingTriggerResourceDegraded": "Recurso degradado", "alertingSearchHealthChecks": "Buscar chequeos de salud…", "alertingHealthChecksEmpty": "No hay chequeos de salud disponibles.", "alertingTriggerResourceToggle": "El estado del recurso cambia", @@ -1578,7 +1596,7 @@ "initialSetupDescription": "Cree la cuenta de administrador del servidor inicial. Solo puede existir un administrador del servidor. Siempre puede cambiar estas credenciales más tarde.", "createAdminAccount": "Crear cuenta de administrador", "setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.", - "certificateStatus": "Estado del certificado", + "certificateStatus": "Certificado", "loading": "Cargando", "loadingAnalytics": "Cargando analíticas", "restart": "Reiniciar", @@ -1886,6 +1904,7 @@ "configureHealthCheck": "Configurar Chequeo de Salud", "configureHealthCheckDescription": "Configura la monitorización de salud para {target}", "enableHealthChecks": "Activar Chequeos de Salud", + "healthCheckDisabledStateDescription": "Cuando está deshabilitado, el sitio no realizará comprobaciones de salud y el estado se considerará desconocido.", "enableHealthChecksDescription": "Controlar la salud de este objetivo. Puedes supervisar un punto final diferente al objetivo si es necesario.", "healthScheme": "Método", "healthSelectScheme": "Seleccionar método", @@ -1947,6 +1966,8 @@ "httpMethod": "Método HTTP", "selectHttpMethod": "Seleccionar método HTTP", "domainPickerSubdomainLabel": "Subdominio", + "domainPickerWildcard": "Comodín", + "domainPickerWildcardPaidOnly": "Los subdominios comodín son una característica paga. Por favor, mejora tu plan para acceder a esta característica.", "domainPickerBaseDomainLabel": "Dominio base", "domainPickerSearchDomains": "Buscar dominios...", "domainPickerNoDomainsFound": "No se encontraron dominios", @@ -1972,12 +1993,12 @@ "resourcesTableAliasAddressInfo": "Esta dirección es parte de la subred de utilidad de la organización. Se utiliza para resolver registros de alias usando resolución DNS interna.", "resourcesTableClients": "Clientes", "resourcesTableAndOnlyAccessibleInternally": "y solo son accesibles internamente cuando se conectan con un cliente.", - "resourcesTableNoTargets": "Sin objetivos", "resourcesTableHealthy": "Saludable", "resourcesTableDegraded": "Degrado", - "resourcesTableOffline": "Desconectado", + "resourcesTableUnhealthy": "No saludable", "resourcesTableUnknown": "Desconocido", "resourcesTableNotMonitored": "No supervisado", + "resourcesTableNoTargets": "Sin objetivos", "editInternalResourceDialogEditClientResource": "Editar recurso privado", "editInternalResourceDialogUpdateResourceProperties": "Actualizar la configuración del recurso y los controles de acceso para {resourceName}", "editInternalResourceDialogResourceProperties": "Propiedades del recurso", @@ -2318,7 +2339,7 @@ "domainPickerVerified": "Verificado", "domainPickerUnverified": "Sin verificar", "domainPickerManual": "Manual", - "domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.", + "domainPickerInvalidSubdomainStructure": "Los caracteres inválidos serán saneados al guardar.", "domainPickerError": "Error", "domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización", "domainPickerErrorCheckAvailability": "No se pudo comprobar la disponibilidad del dominio", @@ -2863,6 +2884,8 @@ "editInternalResourceDialogAddClients": "Agregar clientes", "editInternalResourceDialogDestinationLabel": "Destino", "editInternalResourceDialogDestinationDescription": "Especifique la dirección de destino para el recurso interno. Puede ser un nombre de host, dirección IP o rango CIDR dependiendo del modo seleccionado. Opcionalmente establezca un alias DNS interno para una identificación más fácil.", + "internalResourceFormMultiSiteRoutingHelp": "Seleccionar múltiples sitios habilita el enrutamiento resistente y la conmutación por error para alta disponibilidad.", + "internalResourceFormMultiSiteRoutingHelpLearnMore": "Más información", "editInternalResourceDialogPortRestrictionsDescription": "Restringir el acceso a puertos TCP/UDP específicos o permitir/bloquear todos los puertos.", "createInternalResourceDialogHttpConfiguration": "Configuración HTTP", "createInternalResourceDialogHttpConfigurationDescription": "Elija el dominio que los clientes usarán para alcanzar este recurso a través de HTTP o HTTPS.", @@ -2908,6 +2931,7 @@ "maintenancePageTimeTitle": "Tiempo estimado de finalización (Opcional)", "privateMaintenanceScreenTitle": "Pantalla de marcador de posición privada", "privateMaintenanceScreenMessage": "Este dominio se está utilizando en un recurso privado. Conéctese usando el cliente Pangolin para acceder a este recurso.", + "privateMaintenanceScreenSteps": "Una vez conectado, si sigues viendo este mensaje, la caché de DNS de tu navegador puede seguir apuntando a la dirección antigua. Para solucionarlo: cierra por completo y vuelve a abrir esta pestaña o tu navegador, luego regresa a esta página.", "maintenanceTime": "Ej., 2 horas, 1 de noviembre a las 5:00 PM", "maintenanceEstimatedTimeDescription": "Cuando espera que el mantenimiento esté terminado", "editDomain": "Editar dominio", @@ -3142,5 +3166,39 @@ "idpDeleteAllOrgsMenu": "Eliminar", "publicIpEndpoint": "Punto final", "lastTriggeredAt": "Último disparo", - "reject": "Rechazar" + "reject": "Rechazar", + "uptimeDaysAgo": "Hace {count} días", + "uptimeToday": "Hoy", + "uptimeNoDataAvailable": "No hay datos disponibles", + "uptimeSuffix": "disponibilidad", + "uptimeDowntimeSuffix": "tiempo de inactividad", + "uptimeTooltipUptimeLabel": "Disponibilidad", + "uptimeTooltipDowntimeLabel": "Tiempo de inactividad", + "uptimeOngoing": "en curso", + "uptimeNoMonitoringData": "No hay datos de monitoreo", + "uptimeNoData": "Sin datos", + "uptimeMiniBarDown": "Caído", + "uptimeSectionTitle": "Disponibilidad", + "uptimeSectionDescription": "Disponibilidad durante los últimos {days} días", + "uptimeAddAlert": "Agregar alerta", + "uptimeViewAlerts": "Ver alertas", + "uptimeCreateEmailAlert": "Crear alerta de correo electrónico", + "uptimeAlertDescriptionSite": "Recibe notificaciones por correo electrónico cuando este sitio esté fuera de línea o vuelva en línea.", + "uptimeAlertDescriptionResource": "Recibe notificaciones por correo electrónico cuando este recurso esté fuera de línea o vuelva en línea.", + "uptimeAlertNamePlaceholder": "Nombre de la alerta", + "uptimeAdditionalEmails": "Emails adicionales", + "uptimeCreateAlert": "Crear alerta", + "uptimeAlertNoRecipients": "Sin destinatarios", + "uptimeAlertNoRecipientsDescription": "Por favor, agrega al menos un usuario, rol o correo electrónico para notificación.", + "uptimeAlertCreated": "Alerta creada", + "uptimeAlertCreatedDescription": "Serás notificado cuando cambie de estado.", + "uptimeAlertCreateFailed": "Error al crear la alerta", + "webhookUrlLabel": "URL", + "webhookHeaderKeyPlaceholder": "Clave", + "webhookHeaderValuePlaceholder": "Valor", + "alertLabel": "Alerta", + "domainPickerWildcardSubdomainNotAllowed": "No se permiten subdominios comodín.", + "domainPickerWildcardCertWarning": "Los recursos comodín pueden requerir configuración adicional para funcionar correctamente.", + "domainPickerWildcardCertWarningLink": "Más información", + "health": "Salud" } From 592ca64253acdc5c8867c2e882a85cd0ec2b436d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Apr 2026 09:57:19 -0700 Subject: [PATCH 103/107] Fix delete --- server/routers/siteResource/deleteSiteResource.ts | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 8d08d545d..df43d5c25 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -67,22 +67,9 @@ export async function deleteSiteResource( // Delete the site resource const [removedSiteResource] = await trx .delete(siteResources) - .where(and(eq(siteResources.siteResourceId, siteResourceId))) + .where(eq(siteResources.siteResourceId, siteResourceId)) .returning(); - // not sure why this is here... - // const [newt] = await trx - // .select() - // .from(newts) - // .where(eq(newts.siteId, removedSiteResource.siteId)) - // .limit(1); - - // if (!newt) { - // return next( - // createHttpError(HttpCode.NOT_FOUND, "Newt not found") - // ); - // } - await rebuildClientAssociationsFromSiteResource( removedSiteResource, trx From 8783c47a3cb87d8863bfcb75e05700e22519d03b Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Apr 2026 11:21:32 -0700 Subject: [PATCH 104/107] Dont allow clicking the wildcard resource link --- src/components/ResourceInfoBox.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index 4ceadb4f7..ca5ded4c5 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -40,10 +40,14 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { URL - + {resource.wildcard ? ( + {fullUrl} + ) : ( + + )} From 208289f498caa886e05d96f197f3f7d7b3c35004 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Apr 2026 12:02:21 -0700 Subject: [PATCH 105/107] Select all networks to prevent delete issues --- server/lib/rebuildClientAssociations.ts | 61 +++++++++++++------------ 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 40c5a5bf7..a5c9a9321 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -180,36 +180,41 @@ export async function rebuildClientAssociationsFromSiteResource( /////////// process the client-siteResource associations /////////// - // get all of the clients associated with other resources in the same network, - // joined through siteNetworks so we know which siteId each client belongs to - const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId - ? await trx - .select({ - clientId: clientSiteResourcesAssociationsCache.clientId, - siteId: siteNetworks.siteId - }) - .from(clientSiteResourcesAssociationsCache) - .innerJoin( - siteResources, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .innerJoin( - siteNetworks, - eq(siteNetworks.networkId, siteResources.networkId) - ) - .where( - and( - eq(siteResources.networkId, siteResource.networkId), - ne( - siteResources.siteResourceId, - siteResource.siteResourceId + // get all of the clients associated with other site resources that share + // any of the same sites as this site resource (via siteNetworks). We can't + // simply filter by networkId since each site resource has its own network; + // two site resources serving the same site typically belong to different + // networks that both happen to include the site through siteNetworks. + const sitesListSiteIds = sitesList.map((s) => s.siteId); + const allUpdatedClientsFromOtherResourcesOnThisSite = + sitesListSiteIds.length > 0 + ? await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + siteId: siteNetworks.siteId + }) + .from(clientSiteResourcesAssociationsCache) + .innerJoin( + siteResources, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId ) ) - ) - : []; + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + inArray(siteNetworks.siteId, sitesListSiteIds), + ne( + siteResources.siteResourceId, + siteResource.siteResourceId + ) + ) + ) + : []; // Build a per-site map so the loop below can check by siteId rather than // across the entire network. From b81ae3d9982ae81af392af94ae0c5cec76a2c8b9 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Apr 2026 15:14:43 -0700 Subject: [PATCH 106/107] Seed satatus data for resources, sites, and hc --- server/setup/scriptsPg/1.18.0.ts | 102 ++++++++++++++++++++++++--- server/setup/scriptsSqlite/1.18.0.ts | 88 ++++++++++++++++++++++- 2 files changed, 179 insertions(+), 11 deletions(-) diff --git a/server/setup/scriptsPg/1.18.0.ts b/server/setup/scriptsPg/1.18.0.ts index fe43112a2..debfaee48 100644 --- a/server/setup/scriptsPg/1.18.0.ts +++ b/server/setup/scriptsPg/1.18.0.ts @@ -1,5 +1,6 @@ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; +import { SiJfrog } from "react-icons/si"; const version = "1.18.0"; @@ -67,11 +68,12 @@ export default async function migration() { FROM "siteResources" sr WHERE sr."siteId" IS NOT NULL` ); - const existingSiteResourcesForNetwork = siteResourcesForNetworkQuery.rows as { - siteResourceId: number; - orgId: string; - siteId: number; - }[]; + const existingSiteResourcesForNetwork = + siteResourcesForNetworkQuery.rows as { + siteResourceId: number; + orgId: string; + siteId: number; + }[]; console.log( `Found ${existingSiteResourcesForNetwork.length} existing siteResource(s) to migrate to networks` @@ -446,10 +448,7 @@ export default async function migration() { `Migrated ${existingHealthChecks.length} targetHealthCheck row(s) with corrected IDs` ); } catch (e) { - console.error( - "Error while migrating targetHealthCheck rows:", - e - ); + console.error("Error while migrating targetHealthCheck rows:", e); throw e; } } @@ -493,5 +492,90 @@ export default async function migration() { } } + // Seed statusHistory for all existing sites + try { + const sitesQuery = await db.execute( + sql`SELECT "siteId", "orgId", "online" FROM "sites"` + ); + const allSites = sitesQuery.rows as { + siteId: number; + orgId: string; + online: boolean; + }[]; + + const now = Math.floor(Date.now() / 1000); + + for (const site of allSites) { + await db.execute(sql` + INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp") + VALUES ('site', ${site.siteId}, ${site.orgId}, ${site.online ? "online" : "offline"}, ${now}) + `); + } + + console.log(`Seeded statusHistory for ${allSites.length} site(s)`); + } catch (e) { + console.error("Error while seeding statusHistory for sites:", e); + throw e; + } + + // Seed statusHistory for all existing resources + try { + const resourcesQuery = await db.execute( + sql`SELECT "resourceId", "orgId", "health" FROM "resources"` + ); + const allResources = resourcesQuery.rows as { + resourceId: number; + orgId: string; + health: string | null; + }[]; + + const now = Math.floor(Date.now() / 1000); + + for (const resource of allResources) { + await db.execute(sql` + INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp") + VALUES ('resource', ${resource.resourceId}, ${resource.orgId}, ${resource.health ?? "unknown"}, ${now}) + `); + } + + console.log( + `Seeded statusHistory for ${allResources.length} resource(s)` + ); + } catch (e) { + console.error("Error while seeding statusHistory for resources:", e); + throw e; + } + + // Seed statusHistory for all existing health checks + try { + const healthChecksQuery = await db.execute( + sql`SELECT "targetHealthCheckId", "orgId", "hcHealth" FROM "targetHealthCheck"` + ); + const allHealthChecks = healthChecksQuery.rows as { + targetHealthCheckId: number; + orgId: string; + hcHealth: string | null; + }[]; + + const now = Math.floor(Date.now() / 1000); + + for (const hc of allHealthChecks) { + await db.execute(sql` + INSERT INTO "statusHistory" ("entityType", "entityId", "orgId", "status", "timestamp") + VALUES ('health_check', ${hc.targetHealthCheckId}, ${hc.orgId}, ${hc.hcHealth ?? "unknown"}, ${now}) + `); + } + + console.log( + `Seeded statusHistory for ${allHealthChecks.length} health check(s)` + ); + } catch (e) { + console.error( + "Error while seeding statusHistory for health checks:", + e + ); + throw e; + } + console.log(`${version} migration complete`); } diff --git a/server/setup/scriptsSqlite/1.18.0.ts b/server/setup/scriptsSqlite/1.18.0.ts index aba817df1..5f1718e7a 100644 --- a/server/setup/scriptsSqlite/1.18.0.ts +++ b/server/setup/scriptsSqlite/1.18.0.ts @@ -340,7 +340,6 @@ export default async function migration() { ALTER TABLE 'resources' ADD 'wildcard' integer DEFAULT false NOT NULL; ` ).run(); - })(); db.pragma("foreign_keys = ON"); @@ -364,7 +363,11 @@ export default async function migration() { const result = insertNetwork.run("resource", sr.orgId); const networkId = result.lastInsertRowid as number; insertSiteNetwork.run(sr.siteId, networkId); - updateSiteResource.run(networkId, networkId, sr.siteResourceId); + updateSiteResource.run( + networkId, + networkId, + sr.siteResourceId + ); } }); @@ -454,6 +457,87 @@ export default async function migration() { } console.log(`Migrated database`); + + // Seed statusHistory for all existing sites + const allSites = db + .prepare(`SELECT "siteId", "orgId", "online" FROM 'sites'`) + .all() as { siteId: number; orgId: string; online: number }[]; + + const insertSiteHistory = db.prepare( + `INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)` + ); + const now = Math.floor(Date.now() / 1000); + const seedSites = db.transaction(() => { + for (const site of allSites) { + insertSiteHistory.run( + "site", + site.siteId, + site.orgId, + site.online ? "online" : "offline", + now + ); + } + }); + seedSites(); + console.log(`Seeded statusHistory for ${allSites.length} site(s)`); + + // Seed statusHistory for all existing resources + const allResources = db + .prepare(`SELECT "resourceId", "orgId", "health" FROM 'resources'`) + .all() as { + resourceId: number; + orgId: string; + health: string | null; + }[]; + + const insertResourceHistory = db.prepare( + `INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)` + ); + const seedResources = db.transaction(() => { + for (const resource of allResources) { + insertResourceHistory.run( + "resource", + resource.resourceId, + resource.orgId, + resource.health ?? "unknown", + now + ); + } + }); + seedResources(); + console.log( + `Seeded statusHistory for ${allResources.length} resource(s)` + ); + + // Seed statusHistory for all existing health checks + const allHealthChecks = db + .prepare( + `SELECT "targetHealthCheckId", "orgId", "hcHealth" FROM 'targetHealthCheck'` + ) + .all() as { + targetHealthCheckId: number; + orgId: string; + hcHealth: string | null; + }[]; + + const insertHealthCheckHistory = db.prepare( + `INSERT INTO 'statusHistory' ("entityType", "entityId", "orgId", "status", "timestamp") VALUES (?, ?, ?, ?, ?)` + ); + const seedHealthChecks = db.transaction(() => { + for (const hc of allHealthChecks) { + insertHealthCheckHistory.run( + "health_check", + hc.targetHealthCheckId, + hc.orgId, + hc.hcHealth ?? "unknown", + now + ); + } + }); + seedHealthChecks(); + console.log( + `Seeded statusHistory for ${allHealthChecks.length} health check(s)` + ); } catch (e) { console.log("Failed to migrate db:", e); throw e; From 85415176ab9fc7c785c3fbc6677abeaacf32901a Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 28 Apr 2026 15:41:00 -0700 Subject: [PATCH 107/107] Clean imports --- server/setup/scriptsPg/1.18.0.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/setup/scriptsPg/1.18.0.ts b/server/setup/scriptsPg/1.18.0.ts index debfaee48..3262308f8 100644 --- a/server/setup/scriptsPg/1.18.0.ts +++ b/server/setup/scriptsPg/1.18.0.ts @@ -1,6 +1,5 @@ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; -import { SiJfrog } from "react-icons/si"; const version = "1.18.0";