feat(dashboard): auto-detect tailscale target port; clearer scan block

* Drop the Target port field from Settings — the dashboard already knows
  what port it's serving on, so use window.location.port (falling back
  to 80/443 by protocol). A user-overridable port adds no value and a
  stale stored value silently misroutes the funnel.
* Restructure the running-tunnel scan block to show three rows: the
  raw tailnet URL, the remote access code (rt= token, separate), and
  the QR image. Previously the auth URL was rendered as one long string
  that read as "the bare hostname" when truncated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-29 13:33:13 -07:00
parent 23e2c2514e
commit 79e4ce4617
2 changed files with 47 additions and 28 deletions

View File

@@ -3996,29 +3996,45 @@ export function SettingsModal({
{remoteStatus?.url && <code className="remote-status-url">{remoteStatus.url}</code>}
{remoteStatus?.lastError && <span className="field-error">{remoteStatus.lastError}</span>}
</div>
{tunnelState === "running" && (remoteStatus?.url || tunnelShareLink) && (
<div className="remote-share-block">
{remoteStatus?.url && (
<div className="remote-share-row">
<small>Tunnel URL:</small>
<code className="settings-url-output">{remoteStatus.url}</code>
</div>
)}
{tunnelShareLink?.url && (
<div className="remote-share-row">
<small>Scan to access:</small>
<code className="settings-url-output">{tunnelShareLink.url}</code>
{tunnelShareLink.qrSvg && (
{tunnelState === "running" && (remoteStatus?.url || tunnelShareLink) && (() => {
let accessCode: string | null = null;
let tailnetUrl: string | null = remoteStatus?.url ?? null;
if (tunnelShareLink?.url) {
try {
const parsed = new URL(tunnelShareLink.url);
accessCode = parsed.searchParams.get("rt");
if (!tailnetUrl) tailnetUrl = `${parsed.origin}/`;
} catch {
// fall through
}
}
return (
<div className="remote-share-block">
{tailnetUrl && (
<div className="remote-share-row">
<small>Tailnet URL:</small>
<code className="settings-url-output">{tailnetUrl}</code>
</div>
)}
{accessCode && (
<div className="remote-share-row">
<small>Remote access code:</small>
<code className="settings-url-output">{accessCode}</code>
</div>
)}
{tunnelShareLink?.qrSvg && (
<div className="remote-share-row">
<small>Scan to connect:</small>
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(tunnelShareLink.qrSvg)}`}
alt="Remote access QR code"
className="settings-qr-preview-image"
/>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
);
})()}
<div className="form-group">
<div className="remote-provider-selector" role="radiogroup" aria-label="Remote provider">
@@ -4038,9 +4054,7 @@ export function SettingsModal({
<div className="form-group remote-provider-settings">
{activeProvider === "tailscale" ? (
<>
<small>Tailscale Funnel exposes the configured port on your tailnet's public {`https://<machine>.<tailnet>.ts.net/`} URL — no hostname configuration is needed.</small>
<label htmlFor="remoteTailscaleTargetPort">Target port</label>
<input id="remoteTailscaleTargetPort" type="number" min={1} max={65535} value={Number(remoteForm.remoteTailscaleTargetPort ?? 4040)} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleTargetPort: Number(e.target.value || 4040) } as SettingsFormState))} />
<small>Tailscale Funnel will expose this dashboard on your tailnet's public {`https://<machine>.<tailnet>.ts.net/`} URL — no hostname or port configuration needed.</small>
<label htmlFor="remoteTailscaleAcceptRoutes" className="checkbox-label">
<input id="remoteTailscaleAcceptRoutes" type="checkbox" checked={Boolean(remoteForm.remoteTailscaleAcceptRoutes)} onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleAcceptRoutes: e.target.checked } as SettingsFormState))} />
Accept routes
@@ -4100,7 +4114,14 @@ export function SettingsModal({
remoteActiveProvider: activeProvider,
remoteTailscaleEnabled: activeProvider === "tailscale",
remoteTailscaleHostname: String(formState.remoteTailscaleHostname ?? ""),
remoteTailscaleTargetPort: Number(formState.remoteTailscaleTargetPort ?? 4040),
remoteTailscaleTargetPort: (() => {
const livePort = typeof window !== "undefined" ? Number(window.location.port) : NaN;
if (Number.isFinite(livePort) && livePort > 0) return livePort;
const proto = typeof window !== "undefined" ? window.location.protocol : "";
if (proto === "https:") return 443;
if (proto === "http:") return 80;
return Number(formState.remoteTailscaleTargetPort ?? 4040);
})(),
remoteTailscaleAcceptRoutes: Boolean(formState.remoteTailscaleAcceptRoutes),
remoteCloudflareEnabled: activeProvider === "cloudflare",
remoteCloudflareQuickTunnel: Boolean(formState.remoteCloudflareQuickTunnel ?? true),

View File

@@ -1615,14 +1615,12 @@ describe("SettingsModal", () => {
await userEvent.click(screen.getByLabelText("Tailscale"));
expect(screen.queryByLabelText("Hostname label")).not.toBeInTheDocument();
expect(screen.getByLabelText("Target port")).toBeInTheDocument();
expect(screen.queryByLabelText("Target port")).not.toBeInTheDocument();
expect(screen.getByLabelText("Accept routes")).toBeInTheDocument();
expect(screen.queryByLabelText("Tunnel name")).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Target port"), { target: { value: "4242" } });
await userEvent.click(screen.getByLabelText("Cloudflare"));
expect(screen.queryByLabelText("Target port")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Accept routes")).not.toBeInTheDocument();
if (!screen.queryByLabelText("Tunnel name")) {
const advancedDetails = screen.getByText(/Advanced \(Named Tunnel\)/i, { selector: "summary" }).closest("details") as HTMLDetailsElement;
@@ -1855,12 +1853,12 @@ describe("SettingsModal", () => {
await openRemoteSection();
await userEvent.click(screen.getByLabelText("Tailscale"));
expect(screen.getByLabelText("Target port")).toBeInTheDocument();
expect(screen.getByLabelText("Accept routes")).toBeInTheDocument();
expect(screen.queryByText(/Advanced \(Named Tunnel\)/i)).not.toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Cloudflare"));
expect(screen.getByText(/Advanced \(Named Tunnel\)/i)).toBeInTheDocument();
expect(screen.queryByLabelText("Target port")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Accept routes")).not.toBeInTheDocument();
});
it("sets quick tunnel false when opening Cloudflare advanced details", async () => {