feat(ui,server): surface active instance service URLs in About settings (#2669)

Show the running instance's local server URL and tunnel URL (when a
tunnel is active) as labeled, click-to-open buttons on the About page.
/api/system/info now reports the instance port and tunnel URL, resolved
lazily from the tunnel runtime so each Git-worktree instance identifies
itself in the UI without parsing terminal output.

Refs OPE-194
This commit is contained in:
Serhii Dziupin
2026-08-07 00:25:57 +03:00
committed by GitHub
parent 668a6f54fe
commit 834d2edb87
17 changed files with 214 additions and 0 deletions
@@ -9,6 +9,7 @@ import { Icon } from "@/components/icon/Icon";
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { InstanceServiceUrls } from './InstanceServiceUrls';
import {
SettingsSection,
SETTINGS_BRAND_TITLE_CLASS,
@@ -135,6 +136,7 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
<p>{t('aboutDialog.openChamberVersionLabel', { version: currentVersion })}</p>
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
</div>
<InstanceServiceUrls />
</div>
<div className="flex justify-center">
@@ -278,6 +280,11 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
</div>
)}
<div className="flex flex-col gap-2 border-b border-border/40 px-4 py-3 @xl:flex-row @xl:items-center @xl:justify-between">
<span className={SETTINGS_FIELD_LABEL_CLASS}>{t('settings.openchamber.about.field.instanceUrls')}</span>
<InstanceServiceUrls />
</div>
<div className="flex items-center gap-4 px-4 py-4">
<a
href={GITHUB_URL}
@@ -0,0 +1,106 @@
import React from 'react';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { openExternalUrl } from '@/lib/url';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
type InstanceServiceInfo = {
port: number | null;
tunnelUrl: string | null;
};
type InstanceService = {
key: string;
label: string;
url: string;
};
/**
* Shows the active instance's service URLs (local server port + tunnel URL,
* when a tunnel is active) as labeled buttons that open the URL in the
* browser. The data comes from `/api/system/info`, which the server derives
* from its own runtime state — this is what makes each Git-worktree instance
* distinguishable in the UI without reading terminal output.
*
* The section stays hidden when the endpoint is unavailable or reports no
* port/tunnel (e.g. VS Code runtime), so a failed fetch never renders stale
* or wrong URLs.
*/
export const InstanceServiceUrls: React.FC = () => {
const { t } = useI18n();
const [info, setInfo] = React.useState<InstanceServiceInfo | null>(null);
React.useEffect(() => {
let cancelled = false;
const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
const load = async () => {
try {
const response = await runtimeFetch('/api/system/info', {
signal: controller?.signal,
headers: { Accept: 'application/json' },
});
if (!response.ok) return;
const data = await response.json().catch(() => null) as { port?: unknown; tunnelUrl?: unknown } | null;
if (!data || cancelled) return;
const port = typeof data.port === 'number' && Number.isFinite(data.port) && data.port > 0 ? data.port : null;
const tunnelUrl = typeof data.tunnelUrl === 'string' && data.tunnelUrl.trim().length > 0
? data.tunnelUrl.trim()
: null;
setInfo({ port, tunnelUrl });
} catch {
// Best-effort: a failed fetch keeps the section hidden instead of
// showing data we cannot verify.
}
};
void load();
return () => {
cancelled = true;
controller?.abort();
};
}, []);
const services: InstanceService[] = [];
if (info?.port !== null && info?.port !== undefined) {
services.push({
key: 'application',
label: t('settings.openchamber.about.field.applicationUrl'),
url: `http://localhost:${info.port}/`,
});
}
if (info?.tunnelUrl) {
services.push({
key: 'tunnel',
label: t('settings.openchamber.about.field.tunnelUrl'),
url: info.tunnelUrl,
});
}
if (services.length === 0) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-2">
{services.map((service) => (
<Button
key={service.key}
type="button"
variant="outline"
size="sm"
title={service.label}
className="max-w-full gap-1.5 px-2.5"
onClick={() => {
void openExternalUrl(service.url);
}}
>
<Icon name="external-link" className="size-3.5 shrink-0" />
<span className="max-w-64 truncate font-mono typography-micro">{service.url}</span>
</Button>
))}
</div>
);
};
@@ -445,6 +445,9 @@ export const settingsDict = {
'settings.openchamber.about.title': 'Über OpenChamber',
'settings.openchamber.about.field.version': 'Version',
'settings.openchamber.about.field.openCodeVersion': 'OpenCode-Version',
'settings.openchamber.about.field.instanceUrls': 'Instanz-URLs',
'settings.openchamber.about.field.applicationUrl': 'Anwendung',
'settings.openchamber.about.field.tunnelUrl': 'Tunnel',
'settings.openchamber.about.state.checking': 'Wird geprüft...',
'settings.openchamber.about.state.upToDate': 'Aktuell',
'settings.openchamber.about.state.unknown': 'unbekannt',
@@ -464,6 +464,9 @@ export const settingsDict = {
'settings.openchamber.about.title': 'About OpenChamber',
'settings.openchamber.about.field.version': 'Version',
'settings.openchamber.about.field.openCodeVersion': 'OpenCode version',
'settings.openchamber.about.field.instanceUrls': 'Instance URLs',
'settings.openchamber.about.field.applicationUrl': 'Application',
'settings.openchamber.about.field.tunnelUrl': 'Tunnel',
'settings.openchamber.about.state.checking': 'Checking...',
'settings.openchamber.about.state.upToDate': 'Up to date',
'settings.openchamber.about.state.unknown': 'unknown',
@@ -431,6 +431,9 @@ export const settingsDict = {
"settings.openchamber.about.title": "Acerca de OpenChamber",
"settings.openchamber.about.field.version": "Versión",
"settings.openchamber.about.field.openCodeVersion": "Versión de OpenCode",
"settings.openchamber.about.field.instanceUrls": "URLs de la instancia",
"settings.openchamber.about.field.applicationUrl": "Aplicación",
"settings.openchamber.about.field.tunnelUrl": "Túnel",
"settings.openchamber.about.state.checking": "Comprobando...",
"settings.openchamber.about.state.upToDate": "Actualizado",
"settings.openchamber.about.state.unknown": "desconocido",
@@ -2059,6 +2059,9 @@ export const settingsDict = {
'settings.remoteInstances.relay.toast.offerFailed': 'Échec de la création du lien dassociation',
'settings.remoteInstances.relay.toast.linkCopied': 'Lien dassociation copié',
'settings.openchamber.about.field.openCodeVersion': 'Version dOpenCode',
'settings.openchamber.about.field.instanceUrls': 'URLs de linstance',
'settings.openchamber.about.field.applicationUrl': 'Application',
'settings.openchamber.about.field.tunnelUrl': 'Tunnel',
'settings.openchamber.about.state.unknown': 'inconnue',
'settings.voice.page.field.ttsInputMode': 'Mode dentrée TTS',
'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé',
@@ -464,6 +464,9 @@ export const settingsDict = {
'settings.openchamber.about.title': 'OpenChamber について',
'settings.openchamber.about.field.version': 'バージョン',
'settings.openchamber.about.field.openCodeVersion': 'OpenCode バージョン',
'settings.openchamber.about.field.instanceUrls': 'インスタンスのURL',
'settings.openchamber.about.field.applicationUrl': 'アプリケーション',
'settings.openchamber.about.field.tunnelUrl': 'トンネル',
'settings.openchamber.about.state.checking': '確認中...',
'settings.openchamber.about.state.upToDate': '最新です',
'settings.openchamber.about.state.unknown': '不明',
@@ -431,6 +431,9 @@ export const settingsDict = {
'settings.openchamber.about.title': 'OpenChamber 정보',
'settings.openchamber.about.field.version': '버전',
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 버전',
'settings.openchamber.about.field.instanceUrls': '인스턴스 URL',
'settings.openchamber.about.field.applicationUrl': '애플리케이션',
'settings.openchamber.about.field.tunnelUrl': '터널',
'settings.openchamber.about.state.checking': '확인 중...',
'settings.openchamber.about.state.upToDate': '최신 상태',
'settings.openchamber.about.state.unknown': '알 수 없음',
@@ -722,6 +722,9 @@ export const settingsDict = {
'settings.openchamber.about.actions.updateToVersion': 'Aktualizuj do wersji {version}',
'settings.openchamber.about.field.version': 'Wersja',
'settings.openchamber.about.field.openCodeVersion': 'Wersja OpenCode',
'settings.openchamber.about.field.instanceUrls': 'Adresy URL instancji',
'settings.openchamber.about.field.applicationUrl': 'Aplikacja',
'settings.openchamber.about.field.tunnelUrl': 'Tunel',
'settings.openchamber.about.state.checking': 'Sprawdzanie...',
'settings.openchamber.about.state.upToDate': 'Aktualna wersja',
'settings.openchamber.about.state.unknown': 'nieznane',
@@ -431,6 +431,9 @@ export const settingsDict = {
"settings.openchamber.about.title": "Sobre o OpenChamber",
"settings.openchamber.about.field.version": "Versão",
"settings.openchamber.about.field.openCodeVersion": "Versão do OpenCode",
"settings.openchamber.about.field.instanceUrls": "URLs da instância",
"settings.openchamber.about.field.applicationUrl": "Aplicativo",
"settings.openchamber.about.field.tunnelUrl": "Túnel",
"settings.openchamber.about.state.checking": "Verificando...",
"settings.openchamber.about.state.upToDate": "Atualizado",
"settings.openchamber.about.state.unknown": "desconhecido",
@@ -431,6 +431,9 @@ export const settingsDict = {
"settings.openchamber.about.title": "Про OpenChamber",
"settings.openchamber.about.field.version": "Версія",
"settings.openchamber.about.field.openCodeVersion": "Версія OpenCode",
"settings.openchamber.about.field.instanceUrls": "URL-адреси екземпляра",
"settings.openchamber.about.field.applicationUrl": "Застосунок",
"settings.openchamber.about.field.tunnelUrl": "Тунель",
"settings.openchamber.about.state.checking": "Перевірка...",
"settings.openchamber.about.state.upToDate": "В актуальному стані",
"settings.openchamber.about.state.unknown": "невідомо",
@@ -431,6 +431,9 @@ export const settingsDict = {
'settings.openchamber.about.title': '关于 OpenChamber',
'settings.openchamber.about.field.version': '版本',
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 版本',
'settings.openchamber.about.field.instanceUrls': '实例 URL',
'settings.openchamber.about.field.applicationUrl': '应用',
'settings.openchamber.about.field.tunnelUrl': '隧道',
'settings.openchamber.about.state.checking': '检查中...',
'settings.openchamber.about.state.upToDate': '已是最新',
'settings.openchamber.about.state.unknown': '未知',
@@ -428,6 +428,9 @@
'settings.openchamber.about.title': '關於 OpenChamber',
'settings.openchamber.about.field.version': '版本',
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 版本',
'settings.openchamber.about.field.instanceUrls': '執行個體 URL',
'settings.openchamber.about.field.applicationUrl': '應用程式',
'settings.openchamber.about.field.tunnelUrl': '隧道',
'settings.openchamber.about.state.checking': '檢查中...',
'settings.openchamber.about.state.upToDate': '已是最新',
'settings.openchamber.about.state.unknown': '未知',
+14
View File
@@ -1487,6 +1487,10 @@ async function main(options = {}) {
// relay candidate lazily at request time, so a late-bound holder is enough.
let relayServiceInstance = null;
// Same pattern for the tunnel runtime: created after the base routes so
// /api/system/info resolves port + tunnel URL lazily at request time.
let tunnelRuntimeContextHolder = null;
const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, {
process,
openchamberVersion: OPENCHAMBER_VERSION,
@@ -1523,6 +1527,15 @@ async function main(options = {}) {
apiOnly,
};
},
// Port this instance serves on and the active tunnel's public URL (if
// any), for /api/system/info. Resolved lazily because the tunnel runtime
// is created after these base routes are registered.
getServerPort: () => {
const activePort = tunnelRuntimeContextHolder?.getActivePort?.();
if (Number.isFinite(activePort) && activePort > 0) return activePort;
return Number.isFinite(port) && port > 0 ? port : null;
},
getTunnelUrl: () => tunnelRuntimeContextHolder?.tunnelService?.getPublicUrl?.() ?? null,
verboseRequestLogs: OPENCHAMBER_VERBOSE_REQUEST_LOGS,
uiPassword,
tunnelAuthController,
@@ -1598,6 +1611,7 @@ async function main(options = {}) {
const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port);
const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext;
tunnelRuntimeContextHolder = tunnelRuntimeContext;
// Private relay host service: config + management routes + host client
// lifecycle. Loopback port comes from the same source the tunnel uses so
+4
View File
@@ -19,6 +19,8 @@ export const createBootstrapRuntime = (dependencies) => {
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
getServerPort,
getTunnelUrl,
verboseRequestLogs,
uiPassword,
tunnelAuthController,
@@ -81,6 +83,8 @@ export const createBootstrapRuntime = (dependencies) => {
gracefulShutdown,
getHealthSnapshot,
getServerId,
getServerPort,
getTunnelUrl,
tunnelAuthController,
uiAuthController,
});
@@ -67,6 +67,12 @@ export const registerServerStatusRoutes = (app, dependencies) => {
serverStartedAt,
gracefulShutdown,
getHealthSnapshot,
// Port this OpenChamber instance serves on and the tunnel public URL (if
// a tunnel is active). Exposed on /api/system/info so the UI can surface
// the active instance's service URLs. Optional: older wiring omits them
// and the endpoint reports null.
getServerPort = () => null,
getTunnelUrl = () => null,
// Stable server identity (hash of the public signing key — not a secret).
// Exposed on /health and /api/version so a client can verify that a
// learned/probed address belongs to the expected server BEFORE sending its
@@ -356,6 +362,8 @@ export const registerServerStatusRoutes = (app, dependencies) => {
runtime: runtimeName,
pid: process.pid,
startedAt: serverStartedAt,
port: getServerPort(),
tunnelUrl: getTunnelUrl(),
});
});
@@ -791,4 +791,46 @@ describe('client auth routes', () => {
socket: { remoteAddress: '203.0.113.10' },
})).toBe('unknown-public');
});
it('reports null port and tunnel URL on /api/system/info when no getters are wired', async () => {
const app = express();
registerServerStatusRoutes(app, {
process,
serverStartedAt: '2026-01-01T00:00:00.000Z',
gracefulShutdown: vi.fn(async () => {}),
getHealthSnapshot: () => ({ status: 'ok' }),
openchamberVersion: '1.0.0',
runtimeName: 'test',
express,
});
const response = await request(app).get('/api/system/info');
expect(response.status).toBe(200);
expect(response.body.openchamberVersion).toBe('1.0.0');
expect(response.body.runtime).toBe('test');
expect(response.body.pid).toBeTypeOf('number');
expect(response.body.startedAt).toBeTypeOf('string');
expect(response.body.port).toBeNull();
expect(response.body.tunnelUrl).toBeNull();
});
it('reports the instance port and tunnel URL on /api/system/info from the wired getters', async () => {
const app = express();
registerServerStatusRoutes(app, {
process,
serverStartedAt: '2026-01-01T00:00:00.000Z',
gracefulShutdown: vi.fn(async () => {}),
getHealthSnapshot: () => ({ status: 'ok' }),
openchamberVersion: '1.0.0',
runtimeName: 'test',
express,
getServerPort: () => 9988,
getTunnelUrl: () => 'https://worktree-a.example.trycloudflare.com',
});
const response = await request(app).get('/api/system/info');
expect(response.status).toBe(200);
expect(response.body.port).toBe(9988);
expect(response.body.tunnelUrl).toBe('https://worktree-a.example.trycloudflare.com');
});
});