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:
@@ -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