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:
@@ -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 d’association',
|
||||
'settings.remoteInstances.relay.toast.linkCopied': 'Lien d’association copié',
|
||||
'settings.openchamber.about.field.openCodeVersion': 'Version d’OpenCode',
|
||||
'settings.openchamber.about.field.instanceUrls': 'URLs de l’instance',
|
||||
'settings.openchamber.about.field.applicationUrl': 'Application',
|
||||
'settings.openchamber.about.field.tunnelUrl': 'Tunnel',
|
||||
'settings.openchamber.about.state.unknown': 'inconnue',
|
||||
'settings.voice.page.field.ttsInputMode': 'Mode d’entré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': '未知',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user