From ba788e8b037f409b90b9d0150467b0be3c2d7ef8 Mon Sep 17 00:00:00 2001 From: menzelj Date: Sat, 24 Oct 2026 23:19:09 +0200 Subject: [PATCH] Link port chips to the Docker host, not the proxy's domain (0.67.3) Ports published on 0.0.0.0 were linked at window.location.hostname. Opened through a reverse proxy such as Nginx Proxy Manager that is the proxy's domain, and domain:8080 ended up at the proxy instead of the container. The backend now reads the host's address from the host's routing table (${HOST_PROC_PATH}/1/net/route + fib_trie, the /proc mount the compose file already has): the IPv4 address on the interface with the best default route. There is no fallback to /proc/net, which in a bridged container is the container's own namespace with an unreachable 172.x address. The links use, in order: the new "Host address for port links" setting (Settings -> General), the page's hostname when it is an IP or localhost, the detected address, and the page's hostname as a last resort. Ports bound to one specific address still link there; IPv6 hosts are bracketed. The setting is validated before anything is written, so a bad value no longer leaves the interval half-saved in the same request. Co-Authored-By: Claude Opus 5.5 --- README.md | 13 ++ backend/models/setting.py | 5 + backend/routers/settings.py | 45 ++++++- backend/services/host_address_service.py | 99 +++++++++++++++ backend/services/settings_service.py | 7 + backend/tests/test_port_link_host.py | 120 ++++++++++++++++++ backend/version.py | 2 +- frontend/package.json | 2 +- frontend/src/api/settings.ts | 6 +- .../src/components/stacks/ContainerPorts.tsx | 24 ++-- frontend/src/lib/portLinks.test.ts | 54 ++++++++ frontend/src/lib/portLinks.ts | 32 +++++ frontend/src/pages/Settings.tsx | 45 ++++++- 13 files changed, 432 insertions(+), 22 deletions(-) create mode 100644 backend/services/host_address_service.py create mode 100644 backend/tests/test_port_link_host.py create mode 100644 frontend/src/lib/portLinks.test.ts create mode 100644 frontend/src/lib/portLinks.ts diff --git a/README.md b/README.md index e22c625..dad9ad9 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,19 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2) > complete. +## Upgrading to 0.67.3 — nothing to do + +**Port links work behind a reverse proxy.** The port chips on a stack linked to +`:`. Opened through a proxy such as Nginx +Proxy Manager that is the proxy's domain, and `stackpilot.example.com:8080` +ended up at the proxy instead of the container. They now link to the Docker +host's own address: the backend reads it from the host's routing table (the +`/proc:/host_proc:ro` mount the compose file already has — the address on the +interface with the default route). Opened via an IP, the links keep using that +IP. When the detected address is the wrong one (several NICs, a VPN as default +route), set **Settings → General → Host address for port links**; it wins +everywhere. Ports bound to one specific address still link to that address. + ## Upgrading to 0.67.2 — StackPilot and the terminal agree StackPilot now runs `docker compose` the way it runs when typed in the stack's diff --git a/backend/models/setting.py b/backend/models/setting.py index 5153d2e..3bbca5b 100644 --- a/backend/models/setting.py +++ b/backend/models/setting.py @@ -80,6 +80,9 @@ class WebhookRead(SQLModel): class SettingsRead(SQLModel): update_check_interval_minutes: int + # Where links to published ports point. "" = not set, use detected_host_ip. + port_link_host: str + detected_host_ip: Optional[str] env_webhook_count: int available_events: list[str] webhook_types: list[str] @@ -87,3 +90,5 @@ class SettingsRead(SQLModel): class SettingsUpdate(SQLModel): update_check_interval_minutes: Optional[int] = None + # "" clears it back to the detected address. + port_link_host: Optional[str] = None diff --git a/backend/routers/settings.py b/backend/routers/settings.py index 5ce43e9..7315d42 100644 --- a/backend/routers/settings.py +++ b/backend/routers/settings.py @@ -1,6 +1,9 @@ -"""Application settings: update interval + notification webhooks.""" +"""Application settings: update interval, port links + notification webhooks.""" from __future__ import annotations +import ipaddress +import re + from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session, select @@ -18,7 +21,7 @@ from models.setting import ( WebhookUpdate, ) from models.user import User -from services import audit_service, notify_service, settings_service +from services import audit_service, host_address_service, notify_service, settings_service router = APIRouter(prefix="/api/settings", tags=["settings"]) @@ -43,6 +46,27 @@ def _to_read(wh: Webhook) -> WebhookRead: # General settings # --------------------------------------------------------------------------- # +_HOSTNAME = re.compile(r"^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$") + + +def _clean_link_host(raw: str) -> str: + """A bare IP or hostname — no scheme, port or path, since the UI appends + ``:`` itself. IPv6 is stored without brackets.""" + value = raw.strip() + if not value: + return "" + unbracketed = value[1:-1] if value.startswith("[") and value.endswith("]") else value + try: + return str(ipaddress.ip_address(unbracketed)) + except ValueError: + pass + if _HOSTNAME.match(value): + return value.lower() + raise HTTPException( + status_code=400, + detail="Port link host must be a bare IP address or hostname (no http://, port or path)", + ) + @router.get("", response_model=SettingsRead) def get_settings( @@ -51,6 +75,8 @@ def get_settings( ) -> SettingsRead: return SettingsRead( update_check_interval_minutes=settings_service.get_update_interval(session), + port_link_host=settings_service.get_port_link_host(session), + detected_host_ip=host_address_service.detect_host_ip(), env_webhook_count=len(env_settings.NOTIFY_WEBHOOKS), available_events=ALL_EVENTS, webhook_types=WEBHOOK_TYPES, @@ -64,9 +90,13 @@ def update_settings( session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> SettingsRead: + # Validate everything before writing anything: a bad host must not leave + # the interval half-saved. + if body.update_check_interval_minutes is not None and body.update_check_interval_minutes < 5: + raise HTTPException(status_code=400, detail="Interval must be at least 5 minutes") + link_host = _clean_link_host(body.port_link_host) if body.port_link_host is not None else None + if body.update_check_interval_minutes is not None: - if body.update_check_interval_minutes < 5: - raise HTTPException(status_code=400, detail="Interval must be at least 5 minutes") settings_service.set_value( session, settings_service.KEY_UPDATE_INTERVAL, @@ -77,6 +107,13 @@ def update_settings( target="update_interval", detail=str(body.update_check_interval_minutes), ip=_ip(request), ) + if link_host is not None: + settings_service.set_value(session, settings_service.KEY_PORT_LINK_HOST, link_host) + audit_service.record( + session, user=user.username, action="settings.update", + target="port_link_host", detail=link_host or "(detect)", + ip=_ip(request), + ) return get_settings(session, user) diff --git a/backend/services/host_address_service.py b/backend/services/host_address_service.py new file mode 100644 index 0000000..849f1aa --- /dev/null +++ b/backend/services/host_address_service.py @@ -0,0 +1,99 @@ +"""The Docker host's own LAN address, for links to published ports. + +A port published on 0.0.0.0 is reachable at the host's address, but the +browser only knows the address it reached StackPilot at. Behind a reverse +proxy that is the proxy's domain, and ``domain:8080`` lands on the proxy, not +on the container. So the host's address is read here instead: the IPv4 +address on the interface that carries the host's default route. +""" +from __future__ import annotations + +import ipaddress +import os +import socket +import struct +from typing import Optional + +from config import settings + +_RTF_UP = 0x1 + + +def _host_net_file(name: str) -> Optional[str]: + """``${HOST_PROC_PATH}/1/net/`` — the host's namespace, see + ``port_service._proc_net_path``. There is deliberately no fallback to + ``/proc/net``: in a bridged container that is the container's own + namespace, whose 172.x address no browser can reach.""" + path = os.path.join(settings.HOST_PROC_PATH, "1", "net", name) + return path if os.path.isfile(path) else None + + +def _hex_ip(value: str) -> ipaddress.IPv4Address: + """/proc/net/route prints addresses as little-endian hex.""" + return ipaddress.IPv4Address(socket.inet_ntoa(struct.pack(" list[ipaddress.IPv4Network]: + """The on-link networks of the interface with the best default route.""" + defaults: list[tuple[int, str]] = [] + links: list[tuple[str, ipaddress.IPv4Network]] = [] + for line in route_text.splitlines()[1:]: + cols = line.split() + if len(cols) < 8: + continue + iface, dest, _gw, flags, _ref, _use, metric, mask = cols[:8] + try: + if not int(flags, 16) & _RTF_UP: + continue + dest_ip, mask_ip = _hex_ip(dest), _hex_ip(mask) + metric_n = int(metric) + except (ValueError, struct.error, OSError): + continue + if int(dest_ip) == 0 and int(mask_ip) == 0: + defaults.append((metric_n, iface)) + else: + try: + links.append((iface, ipaddress.IPv4Network(f"{dest_ip}/{mask_ip}"))) + except ValueError: + continue + if not defaults: + return [] + iface = min(defaults)[1] + return [net for name, net in links if name == iface] + + +def _local_addresses(fib_text: str) -> list[ipaddress.IPv4Address]: + """Addresses the kernel lists as ``/32 host LOCAL`` in fib_trie.""" + found: list[ipaddress.IPv4Address] = [] + last: Optional[str] = None + for line in fib_text.splitlines(): + stripped = line.strip() + if stripped.startswith("|--"): + last = stripped[3:].strip() + elif last and stripped.startswith("/32 host LOCAL"): + try: + addr = ipaddress.IPv4Address(last) + except ValueError: + continue + if not addr.is_loopback and addr not in found: + found.append(addr) + return found + + +def detect_host_ip() -> Optional[str]: + """The host's address on its default-route interface, or None when the + host's /proc is not mounted or the host has no IPv4 default route.""" + route_path, fib_path = _host_net_file("route"), _host_net_file("fib_trie") + if not route_path or not fib_path: + return None + try: + with open(route_path, "r", encoding="utf-8") as fh: + networks = _default_route_networks(fh.read()) + with open(fib_path, "r", encoding="utf-8") as fh: + addresses = _local_addresses(fh.read()) + except OSError: + return None + for addr in addresses: + if any(addr in net for net in networks): + return str(addr) + return None diff --git a/backend/services/settings_service.py b/backend/services/settings_service.py index eb0f57e..d83706a 100644 --- a/backend/services/settings_service.py +++ b/backend/services/settings_service.py @@ -11,6 +11,7 @@ from database import engine from models.setting import Setting KEY_UPDATE_INTERVAL = "update_check_interval_minutes" +KEY_PORT_LINK_HOST = "port_link_host" def get(session: Session, key: str, default: Any = None) -> Any: @@ -43,3 +44,9 @@ def get_update_interval(session: Optional[Session] = None) -> int: if isinstance(val, int) and val > 0: return val return env_settings.UPDATE_CHECK_INTERVAL_MINUTES + + +def get_port_link_host(session: Session) -> str: + """Host that port links point at, as configured; "" means detect it.""" + val = get(session, KEY_PORT_LINK_HOST) + return val if isinstance(val, str) else "" diff --git a/backend/tests/test_port_link_host.py b/backend/tests/test_port_link_host.py new file mode 100644 index 0000000..afb628d --- /dev/null +++ b/backend/tests/test_port_link_host.py @@ -0,0 +1,120 @@ +"""Port links behind a reverse proxy: the Docker host's address, not the domain.""" +from __future__ import annotations + +import pytest + +ROUTE = """Iface\tDestination\tGateway \tFlags\tRefCnt\tUse\tMetric\tMask\t\tMTU\tWindow\tIRTT +eth0\t00000000\t01060A0A\t0003\t0\t0\t0\t00000000\t0\t0\t0 +eth0\t00060A0A\t00000000\t0001\t0\t0\t0\tE0FFFFFF\t0\t0\t0 +docker0\t000011AC\t00000000\t0001\t0\t0\t0\t0000FFFF\t0\t0\t0 +""" + +# Trimmed from a real host: lo, eth0 10.10.6.4/27, docker0 172.17.0.1/16. +FIB_TRIE = """Main: + +-- 0.0.0.0/0 3 0 5 + |-- 0.0.0.0 + /0 universe UNICAST + +-- 10.10.6.0/27 2 1 2 + |-- 10.10.6.0 + /27 link UNICAST + |-- 10.10.6.4 + /32 host LOCAL + |-- 10.10.6.31 + /32 link BROADCAST + +-- 127.0.0.0/8 2 0 2 + |-- 127.0.0.1 + /32 host LOCAL + +-- 172.17.0.0/16 2 0 2 + |-- 172.17.0.1 + /32 host LOCAL +""" + + +@pytest.fixture +def host_proc(tmp_path, monkeypatch): + from config import settings + + net = tmp_path / "1" / "net" + net.mkdir(parents=True) + monkeypatch.setattr(settings, "HOST_PROC_PATH", str(tmp_path)) + return net + + +def test_the_default_route_interface_address_is_detected(host_proc): + from services import host_address_service + + (host_proc / "route").write_text(ROUTE) + (host_proc / "fib_trie").write_text(FIB_TRIE) + assert host_address_service.detect_host_ip() == "10.10.6.4" + + +def test_the_lowest_metric_default_route_wins(host_proc): + from services import host_address_service + + # A second default via docker0 with a worse metric must not take over. + route = ROUTE + "docker0\t00000000\t010011AC\t0003\t0\t0\t500\t00000000\t0\t0\t0\n" + (host_proc / "route").write_text(route) + (host_proc / "fib_trie").write_text(FIB_TRIE) + assert host_address_service.detect_host_ip() == "10.10.6.4" + + +def test_no_host_proc_means_no_guess(tmp_path, monkeypatch): + """Without the host's /proc, /proc/net is the container's own namespace; + its bridge address is unreachable, so nothing is better than that.""" + from config import settings + from services import host_address_service + + monkeypatch.setattr(settings, "HOST_PROC_PATH", str(tmp_path)) + assert host_address_service.detect_host_ip() is None + + +def test_no_default_route_means_no_guess(host_proc): + from services import host_address_service + + (host_proc / "route").write_text(ROUTE.splitlines()[0] + "\n" + ROUTE.splitlines()[2] + "\n") + (host_proc / "fib_trie").write_text(FIB_TRIE) + assert host_address_service.detect_host_ip() is None + + +def test_settings_report_the_detected_address(as_user, host_proc): + (host_proc / "route").write_text(ROUTE) + (host_proc / "fib_trie").write_text(FIB_TRIE) + r = as_user.get("/api/settings") + assert r.status_code == 200 + assert r.json()["detected_host_ip"] == "10.10.6.4" + + +@pytest.mark.parametrize( + "raw, stored", + [ + (" 10.10.6.4 ", "10.10.6.4"), + ("Docker-SRV001.lan", "docker-srv001.lan"), + ("[fd00::1]", "fd00::1"), + ("", ""), + ], +) +def test_port_link_host_is_stored_bare(as_admin, raw, stored): + r = as_admin.put("/api/settings", json={"port_link_host": raw}) + assert r.status_code == 200, r.text + assert r.json()["port_link_host"] == stored + assert as_admin.get("/api/settings").json()["port_link_host"] == stored + + +@pytest.mark.parametrize( + "raw", ["http://10.10.6.4", "10.10.6.4:8080", "host/path", "two words", "-bad.lan"] +) +def test_port_link_host_rejects_urls_and_junk(as_admin, raw): + before = as_admin.get("/api/settings").json() + r = as_admin.put( + "/api/settings", + json={"port_link_host": raw, "update_check_interval_minutes": before["update_check_interval_minutes"] + 7}, + ) + assert r.status_code == 400 + after = as_admin.get("/api/settings").json() + # Nothing of a rejected request is written, the interval included. + assert after["update_check_interval_minutes"] == before["update_check_interval_minutes"] + assert after["port_link_host"] == before["port_link_host"] + + +def test_only_admins_set_the_port_link_host(as_user): + assert as_user.put("/api/settings", json={"port_link_host": "10.0.0.1"}).status_code == 403 diff --git a/backend/version.py b/backend/version.py index a88c6cd..ea7bdaa 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.67.2" +APP_VERSION = "0.67.3" diff --git a/frontend/package.json b/frontend/package.json index 43b8713..d366d7c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.67.2", + "version": "0.67.3", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 912d17a..18d49c5 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -3,6 +3,10 @@ import type { User } from "@/types"; export interface AppSettings { update_check_interval_minutes: number; + /** Where links to published ports point; "" = use detected_host_ip. */ + port_link_host: string; + /** The Docker host's LAN address, null when it could not be read. */ + detected_host_ip: string | null; env_webhook_count: number; available_events: string[]; webhook_types: string[]; @@ -28,7 +32,7 @@ export interface WebhookInput { export const settingsApi = { get: () => api.get("/api/settings").then((r) => r.data), - update: (body: { update_check_interval_minutes?: number }) => + update: (body: { update_check_interval_minutes?: number; port_link_host?: string }) => api.put("/api/settings", body).then((r) => r.data), listWebhooks: () => diff --git a/frontend/src/components/stacks/ContainerPorts.tsx b/frontend/src/components/stacks/ContainerPorts.tsx index e09d8fc..03b5e8c 100644 --- a/frontend/src/components/stacks/ContainerPorts.tsx +++ b/frontend/src/components/stacks/ContainerPorts.tsx @@ -1,4 +1,7 @@ +import { useQuery } from "@tanstack/react-query"; import { ExternalLink } from "lucide-react"; +import { settingsApi } from "@/api/settings"; +import { defaultLinkHost, linkHost, urlHost } from "@/lib/portLinks"; export type ContainerPort = { container: string; @@ -6,14 +9,13 @@ export type ContainerPort = { host_port?: string | null; }; -const WILDCARD_IPS = new Set(["", "0.0.0.0", "::"]); - -/** Pick the host to link to: an explicit override, the bound - * host IP if it's a concrete address, otherwise the host we're viewing from. */ -function linkHost(hostIp: string | undefined, override?: string): string { - if (override) return override; - if (hostIp && !WILDCARD_IPS.has(hostIp)) return hostIp; - return window.location.hostname; +function useDefaultLinkHost(): string { + const { data } = useQuery({ + queryKey: ["settings"], + queryFn: settingsApi.get, + staleTime: 5 * 60 * 1000, + }); + return defaultLinkHost(window.location.hostname, data); } /** Best-effort scheme guess — most homelab services are http; common TLS @@ -33,6 +35,7 @@ export function ContainerPorts({ ports: ContainerPort[]; host?: string; }) { + const fallback = useDefaultLinkHost(); const seen = new Set(); const published = ports.filter((p) => { if (!p.host_port || seen.has(p.host_port)) return false; @@ -44,9 +47,8 @@ export function ContainerPorts({ return ( {published.map((p) => { - const url = `${scheme(p.host_port!, p.container)}://${linkHost( - p.host_ip, - host + const url = `${scheme(p.host_port!, p.container)}://${urlHost( + linkHost(p.host_ip, fallback, host) )}:${p.host_port}`; return ( { + it("uses the detected host address when opened via a proxy's domain", () => { + expect(defaultLinkHost("stackpilot.example.com", detected)).toBe("10.10.6.4"); + }); + + it("keeps the page's address when that already is an IP or localhost", () => { + expect(defaultLinkHost("192.168.1.5", detected)).toBe("192.168.1.5"); + expect(defaultLinkHost("[fd00::5]", detected)).toBe("[fd00::5]"); + expect(defaultLinkHost("localhost", detected)).toBe("localhost"); + }); + + it("prefers the configured host over everything", () => { + const configured = { port_link_host: "docker-srv001.lan", detected_host_ip: "10.10.6.4" }; + expect(defaultLinkHost("stackpilot.example.com", configured)).toBe("docker-srv001.lan"); + expect(defaultLinkHost("192.168.1.5", configured)).toBe("docker-srv001.lan"); + }); + + it("falls back to the page's hostname without settings or detection", () => { + expect(defaultLinkHost("stackpilot.example.com")).toBe("stackpilot.example.com"); + expect( + defaultLinkHost("stackpilot.example.com", { port_link_host: "", detected_host_ip: null }) + ).toBe("stackpilot.example.com"); + }); +}); + +describe("linkHost", () => { + it("links a port bound to one address at that address", () => { + expect(linkHost("10.0.0.9", "10.10.6.4")).toBe("10.0.0.9"); + }); + + it("uses the fallback for wildcard bindings", () => { + for (const ip of [undefined, "", "0.0.0.0", "::"]) { + expect(linkHost(ip, "10.10.6.4")).toBe("10.10.6.4"); + } + }); + + it("lets an explicit override win", () => { + expect(linkHost("10.0.0.9", "10.10.6.4", "nas.lan")).toBe("nas.lan"); + }); +}); + +describe("urlHost", () => { + it("brackets bare IPv6 addresses only", () => { + expect(urlHost("fd00::1")).toBe("[fd00::1]"); + expect(urlHost("[fd00::1]")).toBe("[fd00::1]"); + expect(urlHost("10.10.6.4")).toBe("10.10.6.4"); + expect(urlHost("example.com")).toBe("example.com"); + }); +}); diff --git a/frontend/src/lib/portLinks.ts b/frontend/src/lib/portLinks.ts new file mode 100644 index 0000000..c172dc8 --- /dev/null +++ b/frontend/src/lib/portLinks.ts @@ -0,0 +1,32 @@ +const WILDCARD_IPS = new Set(["", "0.0.0.0", "::"]); + +/** IPv6 literals need brackets in a URL; location.hostname already has them. */ +export function urlHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +function isIpLiteral(host: string): boolean { + return /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.startsWith("["); +} + +/** Where a port bound to all interfaces is reached. The page's own hostname + * only works when it *is* the Docker host — behind a reverse proxy it is the + * proxy's domain, and domain:8080 lands on the proxy. So: the configured + * host, else the page's hostname when that is an IP (or localhost), else the + * host address the backend detected, else the page's hostname after all. */ +export function defaultLinkHost( + page: string, + settings?: { port_link_host: string; detected_host_ip: string | null } +): string { + if (settings?.port_link_host) return settings.port_link_host; + if (isIpLiteral(page) || page === "localhost") return page; + return settings?.detected_host_ip || page; +} + +/** Pick the host to link to: an explicit override, the bound + * host IP if it's a concrete address, otherwise the default above. */ +export function linkHost(hostIp: string | undefined, fallback: string, override?: string): string { + if (override) return override; + if (hostIp && !WILDCARD_IPS.has(hostIp)) return hostIp; + return fallback; +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 2404b7d..e4f2b21 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -973,14 +973,18 @@ function GeneralSection() { const qc = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ["settings"], queryFn: settingsApi.get }); const [interval, setInterval] = useState(""); + const [linkHost, setLinkHost] = useState(""); useEffect(() => { - if (data) setInterval(String(data.update_check_interval_minutes)); + if (data) { + setInterval(String(data.update_check_interval_minutes)); + setLinkHost(data.port_link_host); + } }, [data]); const save = useMutation({ - mutationFn: () => - settingsApi.update({ update_check_interval_minutes: Number(interval) }), + mutationFn: (body: { update_check_interval_minutes?: number; port_link_host?: string }) => + settingsApi.update(body), onSuccess: () => { toast.success("Settings saved"); qc.invalidateQueries({ queryKey: ["settings"] }); @@ -1006,7 +1010,10 @@ function GeneralSection() { onChange={(e) => setInterval(e.target.value)} className="max-w-[140px]" /> - @@ -1014,6 +1021,36 @@ function GeneralSection() { Minimum 5 minutes. Applies on the next check cycle. + {data && data.env_webhook_count > 0 && (

{data.env_webhook_count} generic webhook(s) configured via the{" "}