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 { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||||
|
import { InstanceServiceUrls } from './InstanceServiceUrls';
|
||||||
import {
|
import {
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
SETTINGS_BRAND_TITLE_CLASS,
|
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.openChamberVersionLabel', { version: currentVersion })}</p>
|
||||||
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
|
<p>{t('aboutDialog.openCodeVersionLabel', { version: openCodeVersion || t('settings.openchamber.about.state.unknown') })}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<InstanceServiceUrls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
@@ -278,6 +280,11 @@ export const AboutSettings: React.FC<AboutSettingsProps> = ({ initialUpdateDialo
|
|||||||
</div>
|
</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">
|
<div className="flex items-center gap-4 px-4 py-4">
|
||||||
<a
|
<a
|
||||||
href={GITHUB_URL}
|
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.title': 'Über OpenChamber',
|
||||||
'settings.openchamber.about.field.version': 'Version',
|
'settings.openchamber.about.field.version': 'Version',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode-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.checking': 'Wird geprüft...',
|
||||||
'settings.openchamber.about.state.upToDate': 'Aktuell',
|
'settings.openchamber.about.state.upToDate': 'Aktuell',
|
||||||
'settings.openchamber.about.state.unknown': 'unbekannt',
|
'settings.openchamber.about.state.unknown': 'unbekannt',
|
||||||
|
|||||||
@@ -464,6 +464,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.about.title': 'About OpenChamber',
|
'settings.openchamber.about.title': 'About OpenChamber',
|
||||||
'settings.openchamber.about.field.version': 'Version',
|
'settings.openchamber.about.field.version': 'Version',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 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.checking': 'Checking...',
|
||||||
'settings.openchamber.about.state.upToDate': 'Up to date',
|
'settings.openchamber.about.state.upToDate': 'Up to date',
|
||||||
'settings.openchamber.about.state.unknown': 'unknown',
|
'settings.openchamber.about.state.unknown': 'unknown',
|
||||||
|
|||||||
@@ -431,6 +431,9 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.about.title": "Acerca de OpenChamber",
|
"settings.openchamber.about.title": "Acerca de OpenChamber",
|
||||||
"settings.openchamber.about.field.version": "Versión",
|
"settings.openchamber.about.field.version": "Versión",
|
||||||
"settings.openchamber.about.field.openCodeVersion": "Versión de OpenCode",
|
"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.checking": "Comprobando...",
|
||||||
"settings.openchamber.about.state.upToDate": "Actualizado",
|
"settings.openchamber.about.state.upToDate": "Actualizado",
|
||||||
"settings.openchamber.about.state.unknown": "desconocido",
|
"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.offerFailed': 'Échec de la création du lien d’association',
|
||||||
'settings.remoteInstances.relay.toast.linkCopied': 'Lien d’association copié',
|
'settings.remoteInstances.relay.toast.linkCopied': 'Lien d’association copié',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'Version d’OpenCode',
|
'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.openchamber.about.state.unknown': 'inconnue',
|
||||||
'settings.voice.page.field.ttsInputMode': 'Mode d’entrée TTS',
|
'settings.voice.page.field.ttsInputMode': 'Mode d’entrée TTS',
|
||||||
'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé',
|
'settings.voice.page.field.ttsInputModeSanitized': 'Nettoyé',
|
||||||
|
|||||||
@@ -464,6 +464,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.about.title': 'OpenChamber について',
|
'settings.openchamber.about.title': 'OpenChamber について',
|
||||||
'settings.openchamber.about.field.version': 'バージョン',
|
'settings.openchamber.about.field.version': 'バージョン',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode バージョン',
|
'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.checking': '確認中...',
|
||||||
'settings.openchamber.about.state.upToDate': '最新です',
|
'settings.openchamber.about.state.upToDate': '最新です',
|
||||||
'settings.openchamber.about.state.unknown': '不明',
|
'settings.openchamber.about.state.unknown': '不明',
|
||||||
|
|||||||
@@ -431,6 +431,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.about.title': 'OpenChamber 정보',
|
'settings.openchamber.about.title': 'OpenChamber 정보',
|
||||||
'settings.openchamber.about.field.version': '버전',
|
'settings.openchamber.about.field.version': '버전',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 버전',
|
'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.checking': '확인 중...',
|
||||||
'settings.openchamber.about.state.upToDate': '최신 상태',
|
'settings.openchamber.about.state.upToDate': '최신 상태',
|
||||||
'settings.openchamber.about.state.unknown': '알 수 없음',
|
'settings.openchamber.about.state.unknown': '알 수 없음',
|
||||||
|
|||||||
@@ -722,6 +722,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.about.actions.updateToVersion': 'Aktualizuj do wersji {version}',
|
'settings.openchamber.about.actions.updateToVersion': 'Aktualizuj do wersji {version}',
|
||||||
'settings.openchamber.about.field.version': 'Wersja',
|
'settings.openchamber.about.field.version': 'Wersja',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'Wersja OpenCode',
|
'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.checking': 'Sprawdzanie...',
|
||||||
'settings.openchamber.about.state.upToDate': 'Aktualna wersja',
|
'settings.openchamber.about.state.upToDate': 'Aktualna wersja',
|
||||||
'settings.openchamber.about.state.unknown': 'nieznane',
|
'settings.openchamber.about.state.unknown': 'nieznane',
|
||||||
|
|||||||
@@ -431,6 +431,9 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.about.title": "Sobre o OpenChamber",
|
"settings.openchamber.about.title": "Sobre o OpenChamber",
|
||||||
"settings.openchamber.about.field.version": "Versão",
|
"settings.openchamber.about.field.version": "Versão",
|
||||||
"settings.openchamber.about.field.openCodeVersion": "Versão do OpenCode",
|
"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.checking": "Verificando...",
|
||||||
"settings.openchamber.about.state.upToDate": "Atualizado",
|
"settings.openchamber.about.state.upToDate": "Atualizado",
|
||||||
"settings.openchamber.about.state.unknown": "desconhecido",
|
"settings.openchamber.about.state.unknown": "desconhecido",
|
||||||
|
|||||||
@@ -431,6 +431,9 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.about.title": "Про OpenChamber",
|
"settings.openchamber.about.title": "Про OpenChamber",
|
||||||
"settings.openchamber.about.field.version": "Версія",
|
"settings.openchamber.about.field.version": "Версія",
|
||||||
"settings.openchamber.about.field.openCodeVersion": "Версія OpenCode",
|
"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.checking": "Перевірка...",
|
||||||
"settings.openchamber.about.state.upToDate": "В актуальному стані",
|
"settings.openchamber.about.state.upToDate": "В актуальному стані",
|
||||||
"settings.openchamber.about.state.unknown": "невідомо",
|
"settings.openchamber.about.state.unknown": "невідомо",
|
||||||
|
|||||||
@@ -431,6 +431,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.about.title': '关于 OpenChamber',
|
'settings.openchamber.about.title': '关于 OpenChamber',
|
||||||
'settings.openchamber.about.field.version': '版本',
|
'settings.openchamber.about.field.version': '版本',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 版本',
|
'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.checking': '检查中...',
|
||||||
'settings.openchamber.about.state.upToDate': '已是最新',
|
'settings.openchamber.about.state.upToDate': '已是最新',
|
||||||
'settings.openchamber.about.state.unknown': '未知',
|
'settings.openchamber.about.state.unknown': '未知',
|
||||||
|
|||||||
@@ -428,6 +428,9 @@
|
|||||||
'settings.openchamber.about.title': '關於 OpenChamber',
|
'settings.openchamber.about.title': '關於 OpenChamber',
|
||||||
'settings.openchamber.about.field.version': '版本',
|
'settings.openchamber.about.field.version': '版本',
|
||||||
'settings.openchamber.about.field.openCodeVersion': 'OpenCode 版本',
|
'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.checking': '檢查中...',
|
||||||
'settings.openchamber.about.state.upToDate': '已是最新',
|
'settings.openchamber.about.state.upToDate': '已是最新',
|
||||||
'settings.openchamber.about.state.unknown': '未知',
|
'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.
|
// relay candidate lazily at request time, so a late-bound holder is enough.
|
||||||
let relayServiceInstance = null;
|
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, {
|
const bootstrapResult = bootstrapRuntime.setupBaseRoutes(app, {
|
||||||
process,
|
process,
|
||||||
openchamberVersion: OPENCHAMBER_VERSION,
|
openchamberVersion: OPENCHAMBER_VERSION,
|
||||||
@@ -1523,6 +1527,15 @@ async function main(options = {}) {
|
|||||||
apiOnly,
|
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,
|
verboseRequestLogs: OPENCHAMBER_VERBOSE_REQUEST_LOGS,
|
||||||
uiPassword,
|
uiPassword,
|
||||||
tunnelAuthController,
|
tunnelAuthController,
|
||||||
@@ -1598,6 +1611,7 @@ async function main(options = {}) {
|
|||||||
|
|
||||||
const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port);
|
const tunnelRuntimeContext = tunnelWiringRuntime.initialize(app, port);
|
||||||
const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext;
|
const { tunnelService, startTunnelWithNormalizedRequest } = tunnelRuntimeContext;
|
||||||
|
tunnelRuntimeContextHolder = tunnelRuntimeContext;
|
||||||
|
|
||||||
// Private relay host service: config + management routes + host client
|
// Private relay host service: config + management routes + host client
|
||||||
// lifecycle. Loopback port comes from the same source the tunnel uses so
|
// lifecycle. Loopback port comes from the same source the tunnel uses so
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export const createBootstrapRuntime = (dependencies) => {
|
|||||||
serverStartedAt,
|
serverStartedAt,
|
||||||
gracefulShutdown,
|
gracefulShutdown,
|
||||||
getHealthSnapshot,
|
getHealthSnapshot,
|
||||||
|
getServerPort,
|
||||||
|
getTunnelUrl,
|
||||||
verboseRequestLogs,
|
verboseRequestLogs,
|
||||||
uiPassword,
|
uiPassword,
|
||||||
tunnelAuthController,
|
tunnelAuthController,
|
||||||
@@ -81,6 +83,8 @@ export const createBootstrapRuntime = (dependencies) => {
|
|||||||
gracefulShutdown,
|
gracefulShutdown,
|
||||||
getHealthSnapshot,
|
getHealthSnapshot,
|
||||||
getServerId,
|
getServerId,
|
||||||
|
getServerPort,
|
||||||
|
getTunnelUrl,
|
||||||
tunnelAuthController,
|
tunnelAuthController,
|
||||||
uiAuthController,
|
uiAuthController,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
|||||||
serverStartedAt,
|
serverStartedAt,
|
||||||
gracefulShutdown,
|
gracefulShutdown,
|
||||||
getHealthSnapshot,
|
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).
|
// 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
|
// Exposed on /health and /api/version so a client can verify that a
|
||||||
// learned/probed address belongs to the expected server BEFORE sending its
|
// learned/probed address belongs to the expected server BEFORE sending its
|
||||||
@@ -356,6 +362,8 @@ export const registerServerStatusRoutes = (app, dependencies) => {
|
|||||||
runtime: runtimeName,
|
runtime: runtimeName,
|
||||||
pid: process.pid,
|
pid: process.pid,
|
||||||
startedAt: serverStartedAt,
|
startedAt: serverStartedAt,
|
||||||
|
port: getServerPort(),
|
||||||
|
tunnelUrl: getTunnelUrl(),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -791,4 +791,46 @@ describe('client auth routes', () => {
|
|||||||
socket: { remoteAddress: '203.0.113.10' },
|
socket: { remoteAddress: '203.0.113.10' },
|
||||||
})).toBe('unknown-public');
|
})).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