Link port chips to the Docker host, not the proxy's domain (0.67.3)
CI / check (push) Successful in 8m20s
CI / build-and-push (push) Successful in 4m19s

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 <noreply@anthropic.com>
This commit is contained in:
menzeljandClaude Opus 5.5 committed 2026-10-24 23:19:09 +02:00
1 parent 06234c1067
commit ba788e8b03
13 files changed
+432 -22

No files matched your search

+13
View File
@@ -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) > (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
> complete. > complete.
## Upgrading to 0.67.3 — nothing to do
**Port links work behind a reverse proxy.** The port chips on a stack linked to
`<the address in the browser>:<port>`. 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 ## 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 StackPilot now runs `docker compose` the way it runs when typed in the stack's
+5
View File
@@ -80,6 +80,9 @@ class WebhookRead(SQLModel):
class SettingsRead(SQLModel): class SettingsRead(SQLModel):
update_check_interval_minutes: int 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 env_webhook_count: int
available_events: list[str] available_events: list[str]
webhook_types: list[str] webhook_types: list[str]
@@ -87,3 +90,5 @@ class SettingsRead(SQLModel):
class SettingsUpdate(SQLModel): class SettingsUpdate(SQLModel):
update_check_interval_minutes: Optional[int] = None update_check_interval_minutes: Optional[int] = None
# "" clears it back to the detected address.
port_link_host: Optional[str] = None
+41 -4
View File
@@ -1,6 +1,9 @@
"""Application settings: update interval + notification webhooks.""" """Application settings: update interval, port links + notification webhooks."""
from __future__ import annotations from __future__ import annotations
import ipaddress
import re
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select from sqlmodel import Session, select
@@ -18,7 +21,7 @@ from models.setting import (
WebhookUpdate, WebhookUpdate,
) )
from models.user import User 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"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -43,6 +46,27 @@ def _to_read(wh: Webhook) -> WebhookRead:
# General settings # 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
``:<port>`` 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) @router.get("", response_model=SettingsRead)
def get_settings( def get_settings(
@@ -51,6 +75,8 @@ def get_settings(
) -> SettingsRead: ) -> SettingsRead:
return SettingsRead( return SettingsRead(
update_check_interval_minutes=settings_service.get_update_interval(session), 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), env_webhook_count=len(env_settings.NOTIFY_WEBHOOKS),
available_events=ALL_EVENTS, available_events=ALL_EVENTS,
webhook_types=WEBHOOK_TYPES, webhook_types=WEBHOOK_TYPES,
@@ -64,9 +90,13 @@ def update_settings(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(require_admin), user: User = Depends(require_admin),
) -> SettingsRead: ) -> 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 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( settings_service.set_value(
session, session,
settings_service.KEY_UPDATE_INTERVAL, settings_service.KEY_UPDATE_INTERVAL,
@@ -77,6 +107,13 @@ def update_settings(
target="update_interval", detail=str(body.update_check_interval_minutes), target="update_interval", detail=str(body.update_check_interval_minutes),
ip=_ip(request), 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) return get_settings(session, user)
+99
View File
@@ -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/<name>`` — 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("<I", int(value, 16))))
def _default_route_networks(route_text: str) -> 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
+7
View File
@@ -11,6 +11,7 @@ from database import engine
from models.setting import Setting from models.setting import Setting
KEY_UPDATE_INTERVAL = "update_check_interval_minutes" KEY_UPDATE_INTERVAL = "update_check_interval_minutes"
KEY_PORT_LINK_HOST = "port_link_host"
def get(session: Session, key: str, default: Any = None) -> Any: 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: if isinstance(val, int) and val > 0:
return val return val
return env_settings.UPDATE_CHECK_INTERVAL_MINUTES 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 ""
+120
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version.""" """Single source of truth for the StackPilot release version."""
APP_VERSION = "0.67.2" APP_VERSION = "0.67.3"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.67.2", "version": "0.67.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+5 -1
View File
@@ -3,6 +3,10 @@ import type { User } from "@/types";
export interface AppSettings { export interface AppSettings {
update_check_interval_minutes: number; 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; env_webhook_count: number;
available_events: string[]; available_events: string[];
webhook_types: string[]; webhook_types: string[];
@@ -28,7 +32,7 @@ export interface WebhookInput {
export const settingsApi = { export const settingsApi = {
get: () => api.get<AppSettings>("/api/settings").then((r) => r.data), get: () => api.get<AppSettings>("/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<AppSettings>("/api/settings", body).then((r) => r.data), api.put<AppSettings>("/api/settings", body).then((r) => r.data),
listWebhooks: () => listWebhooks: () =>
@@ -1,4 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { settingsApi } from "@/api/settings";
import { defaultLinkHost, linkHost, urlHost } from "@/lib/portLinks";
export type ContainerPort = { export type ContainerPort = {
container: string; container: string;
@@ -6,14 +9,13 @@ export type ContainerPort = {
host_port?: string | null; host_port?: string | null;
}; };
const WILDCARD_IPS = new Set(["", "0.0.0.0", "::"]); function useDefaultLinkHost(): string {
const { data } = useQuery({
/** Pick the host to link to: an explicit override, the bound queryKey: ["settings"],
* host IP if it's a concrete address, otherwise the host we're viewing from. */ queryFn: settingsApi.get,
function linkHost(hostIp: string | undefined, override?: string): string { staleTime: 5 * 60 * 1000,
if (override) return override; });
if (hostIp && !WILDCARD_IPS.has(hostIp)) return hostIp; return defaultLinkHost(window.location.hostname, data);
return window.location.hostname;
} }
/** Best-effort scheme guess — most homelab services are http; common TLS /** Best-effort scheme guess — most homelab services are http; common TLS
@@ -33,6 +35,7 @@ export function ContainerPorts({
ports: ContainerPort[]; ports: ContainerPort[];
host?: string; host?: string;
}) { }) {
const fallback = useDefaultLinkHost();
const seen = new Set<string>(); const seen = new Set<string>();
const published = ports.filter((p) => { const published = ports.filter((p) => {
if (!p.host_port || seen.has(p.host_port)) return false; if (!p.host_port || seen.has(p.host_port)) return false;
@@ -44,9 +47,8 @@ export function ContainerPorts({
return ( return (
<span className="flex flex-wrap items-center gap-1"> <span className="flex flex-wrap items-center gap-1">
{published.map((p) => { {published.map((p) => {
const url = `${scheme(p.host_port!, p.container)}://${linkHost( const url = `${scheme(p.host_port!, p.container)}://${urlHost(
p.host_ip, linkHost(p.host_ip, fallback, host)
host
)}:${p.host_port}`; )}:${p.host_port}`;
return ( return (
<a <a
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { defaultLinkHost, linkHost, urlHost } from "./portLinks";
const detected = { port_link_host: "", detected_host_ip: "10.10.6.4" };
describe("defaultLinkHost", () => {
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");
});
});
+32
View File
@@ -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;
}
+41 -4
View File
@@ -973,14 +973,18 @@ function GeneralSection() {
const qc = useQueryClient(); const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ["settings"], queryFn: settingsApi.get }); const { data, isLoading } = useQuery({ queryKey: ["settings"], queryFn: settingsApi.get });
const [interval, setInterval] = useState(""); const [interval, setInterval] = useState("");
const [linkHost, setLinkHost] = useState("");
useEffect(() => { 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]); }, [data]);
const save = useMutation({ const save = useMutation({
mutationFn: () => mutationFn: (body: { update_check_interval_minutes?: number; port_link_host?: string }) =>
settingsApi.update({ update_check_interval_minutes: Number(interval) }), settingsApi.update(body),
onSuccess: () => { onSuccess: () => {
toast.success("Settings saved"); toast.success("Settings saved");
qc.invalidateQueries({ queryKey: ["settings"] }); qc.invalidateQueries({ queryKey: ["settings"] });
@@ -1006,7 +1010,10 @@ function GeneralSection() {
onChange={(e) => setInterval(e.target.value)} onChange={(e) => setInterval(e.target.value)}
className="max-w-[140px]" className="max-w-[140px]"
/> />
<Button onClick={() => save.mutate()} loading={save.isPending}> <Button
onClick={() => save.mutate({ update_check_interval_minutes: Number(interval) })}
loading={save.isPending && save.variables?.update_check_interval_minutes !== undefined}
>
Save Save
</Button> </Button>
</div> </div>
@@ -1014,6 +1021,36 @@ function GeneralSection() {
Minimum 5 minutes. Applies on the next check cycle. Minimum 5 minutes. Applies on the next check cycle.
</span> </span>
</label> </label>
<label className="block space-y-1">
<span className="text-sm font-medium">Host address for port links</span>
<div className="flex gap-2">
<Input
value={linkHost}
onChange={(e) => setLinkHost(e.target.value)}
placeholder={data?.detected_host_ip ?? "e.g. 192.168.1.10"}
className="max-w-[260px] font-mono"
/>
<Button
onClick={() => save.mutate({ port_link_host: linkHost })}
loading={save.isPending && save.variables?.port_link_host !== undefined}
>
Save
</Button>
</div>
<span className="text-xs text-slate-500">
IP or hostname the port chips on a stack open (<code>http://&lt;host&gt;:&lt;port&gt;</code>).
Leave empty to use{" "}
{data?.detected_host_ip ? (
<>
the detected host address <code>{data.detected_host_ip}</code> when StackPilot is
opened via a domain (e.g. behind a reverse proxy)
</>
) : (
<>the address StackPilot is opened at (the host address could not be detected)</>
)}
.
</span>
</label>
{data && data.env_webhook_count > 0 && ( {data && data.env_webhook_count > 0 && (
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
{data.env_webhook_count} generic webhook(s) configured via the{" "} {data.env_webhook_count} generic webhook(s) configured via the{" "}