diff --git a/.env.example b/.env.example index 912bb72..0344fa8 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,11 @@ KAFKA_CLIENT_ID=sensors-connectivity REDIS_URL=redis://localhost:6379 SUBSTRATE_WS_URL=ws://localhost:9944 +REGISTRY_SYNC_HEALTH_PORT=3011 + +# Host used by the endpoint's status page to reach sibling services' +# health ports (defaults to localhost for local/dev setups). +STATUS_PAGE_HOST=localhost ENDPOINT_PORT=3000 ENDPOINT_SOURCE=endpoint diff --git a/services/batcher/src/index.ts b/services/batcher/src/index.ts index c28e978..5c2587d 100644 --- a/services/batcher/src/index.ts +++ b/services/batcher/src/index.ts @@ -696,6 +696,9 @@ function startHealthAndMetricsServer( if (request.url === '/health' || request.url === '/healthz') { response.statusCode = 200; response.setHeader('content-type', 'application/json; charset=utf-8'); + // Allow the endpoint service's status page to poll this endpoint + // directly from the browser. + response.setHeader('access-control-allow-origin', '*'); response.end(JSON.stringify({ status: 'ok' })); return; } diff --git a/services/blockchain-anchor/src/index.ts b/services/blockchain-anchor/src/index.ts index ac34dcf..8ba9eb4 100644 --- a/services/blockchain-anchor/src/index.ts +++ b/services/blockchain-anchor/src/index.ts @@ -658,6 +658,9 @@ function startHealthAndMetricsServer( logDebug('health check requested'); response.statusCode = 200; response.setHeader('content-type', 'application/json; charset=utf-8'); + // Allow the endpoint service's status page to poll this endpoint + // directly from the browser. + response.setHeader('access-control-allow-origin', '*'); response.end(JSON.stringify({ status: 'ok' })); return; } diff --git a/services/endpoint/README.md b/services/endpoint/README.md index 97966c0..e9385f3 100644 --- a/services/endpoint/README.md +++ b/services/endpoint/README.md @@ -115,6 +115,15 @@ Submit sensor telemetry data. - `409 Conflict` - Duplicate nonce (replay attack detected) - `503 Service Unavailable` - Kafka unavailable +### GET / + +Static, minimalistic status page. Server-rendered markup is identical on +every request (no server-side probing); an inline script in the page polls +this service's `/health` plus each sibling service's `/health` endpoint +(ports read from `.env`, reachable at `STATUS_PAGE_HOST`, default +`localhost`) directly from the browser and refreshes the table every 5 +seconds. + ### GET /health Health check endpoint. diff --git a/services/endpoint/src/config.ts b/services/endpoint/src/config.ts index 69e4246..ff97f38 100644 --- a/services/endpoint/src/config.ts +++ b/services/endpoint/src/config.ts @@ -13,11 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +export interface StatusTargetConfig { + name: string; + port: number; +} + export interface EndpointConfig { port: number; source: string; kafkaBrokers: string[]; timestampSkewSeconds: number; + /** Host used to reach sibling services' health ports for the status page. */ + statusHost: string; + /** Sibling services (with their default ports) shown on the status page. */ + statusTargets: StatusTargetConfig[]; } function parsePositiveInt(value: string | undefined, fallback: number): number { @@ -46,5 +55,28 @@ export function loadEndpointConfig( env.ENDPOINT_TIMESTAMP_SKEW_SECONDS, 300 ), + statusHost: env.STATUS_PAGE_HOST ?? 'localhost', + statusTargets: [ + { + name: 'registry-sync', + port: parsePositiveInt(env.REGISTRY_SYNC_HEALTH_PORT, 3011), + }, + { + name: 'pubsub-broadcaster', + port: parsePositiveInt(env.PUBSUB_BROADCASTER_HEALTH_PORT, 3020), + }, + { + name: 'heartbeat-tracker', + port: parsePositiveInt(env.HEARTBEAT_TRACKER_HEALTH_PORT, 3030), + }, + { + name: 'batcher', + port: parsePositiveInt(env.BATCHER_HEALTH_PORT, 3041), + }, + { + name: 'blockchain-anchor', + port: parsePositiveInt(env.BLOCKCHAIN_ANCHOR_HEALTH_PORT, 3050), + }, + ], }; } diff --git a/services/endpoint/src/index.ts b/services/endpoint/src/index.ts index e25b5dd..e3adf18 100644 --- a/services/endpoint/src/index.ts +++ b/services/endpoint/src/index.ts @@ -38,12 +38,13 @@ import { type EndpointEventProducer, } from './producer.js'; import { Producer } from '@platformatic/kafka'; -import { loadEndpointConfig } from './config.js'; +import { loadEndpointConfig, type StatusTargetConfig } from './config.js'; import { createSensorAuthProvider, loadSensorAuthConfig, } from './sensor-auth-factory.js'; import { logDebug, logError, logInfo, logWarn } from './logger.js'; +import { renderStatusPage } from './status-page.js'; export interface EndpointDeps { registryReader: RegistryReader; @@ -52,6 +53,12 @@ export interface EndpointDeps { export interface EndpointAppOptions { timestampSkewSeconds?: number; + /** Own listening port, used to probe `/health` for the status page. */ + statusPort?: number; + /** Host used to reach sibling services' health ports. */ + statusHost?: string; + /** Sibling services (with default ports) shown on the status page. */ + statusTargets?: StatusTargetConfig[]; } async function parseSignedEnvelope(request: FastifyRequest): Promise<{ @@ -102,9 +109,26 @@ export function createEndpointApp( timeWindow: '1 minute', }); - app.get('/health', async () => ({ status: 'ok' })); + app.get('/health', async (_request, reply) => { + reply.header('access-control-allow-origin', '*'); + return { status: 'ok' }; + }); app.get('/metrics', async () => metrics); + // Static, minimalistic status page: the server renders identical markup + // on every request (no server-side probing). A small inline script in + // the page itself fetches each service's `/health` endpoint (ports + // sourced from `.env`) directly from the browser and keeps the table + // updated on an interval. + const statusPageHtml = renderStatusPage({ + selfPort: options.statusPort ?? 3000, + host: options.statusHost ?? 'localhost', + targets: options.statusTargets ?? [], + }); + app.get('/', async (_request, reply) => + reply.type('text/html; charset=utf-8').send(statusPageHtml) + ); + // Rejection events are published fire-and-forget from request handlers so // that Kafka latency never delays the HTTP response. Track the in-flight // publishes here so `app.close()` can wait for them to settle before the @@ -321,6 +345,9 @@ export async function startEndpoint(): Promise { }, { timestampSkewSeconds: config.timestampSkewSeconds, + statusPort: config.port, + statusHost: config.statusHost, + statusTargets: config.statusTargets, } ); diff --git a/services/endpoint/src/status-page.ts b/services/endpoint/src/status-page.ts new file mode 100644 index 0000000..004a739 --- /dev/null +++ b/services/endpoint/src/status-page.ts @@ -0,0 +1,156 @@ +/** + * Copyright 2026 Robonomics Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { StatusTargetConfig } from './config.js'; + +export interface StatusPageOptions { + /** This service's own listening port. */ + selfPort: number; + /** Host used by the browser to reach every service's health port. */ + host: string; + /** Sibling services (name + default port) shown on the status page. */ + targets: StatusTargetConfig[]; + /** Poll interval, in milliseconds, used by the page's client-side script. */ + refreshIntervalMs?: number; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** + * Renders a fully static, dependency-free HTML status page. + * + * The server does no probing itself: the returned markup is identical on + * every request. A small inline script embedded in the page performs the + * `/health` checks directly from the browser and refreshes the table on an + * interval, so the page keeps itself up to date without any server work. + */ +export function renderStatusPage(options: StatusPageOptions): string { + const refreshIntervalMs = options.refreshIntervalMs ?? 5000; + const services = [ + { name: 'endpoint', port: options.selfPort }, + ...options.targets, + ]; + + const rows = services + .map( + (service) => ` + + ${escapeHtml(service.name)} + ${service.port} + Checking… + — + ` + ) + .join(''); + + return ` + + + + +Connectivity Status + + + +

Connectivity Status

+

Checking services…

+ + + + + ${rows} + +
ServicePortStatusLatency
+ + + +`; +} diff --git a/services/endpoint/tests/status-page.test.ts b/services/endpoint/tests/status-page.test.ts new file mode 100644 index 0000000..3fd4c2f --- /dev/null +++ b/services/endpoint/tests/status-page.test.ts @@ -0,0 +1,113 @@ +/** + * Copyright 2026 Robonomics Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { describe, expect, it } from 'vitest'; +import { renderStatusPage } from '../src/status-page.js'; +import { InMemoryRegistryReader } from '@scp/registry-sync'; +import { createEndpointApp } from '../src/index.js'; + +describe('renderStatusPage', () => { + it('renders fully static markup listing each configured service and its port', () => { + const html = renderStatusPage({ + selfPort: 3000, + host: 'localhost', + targets: [ + { name: 'batcher', port: 3041 }, + { name: 'registry-sync', port: 3011 }, + ], + }); + + expect(html).toContain(''); + expect(html).toContain('data-service="endpoint"'); + expect(html).toContain('data-port="3000"'); + expect(html).toContain('data-service="batcher"'); + expect(html).toContain('data-port="3041"'); + expect(html).toContain('data-service="registry-sync"'); + expect(html).toContain('data-port="3011"'); + }); + + it('embeds a client-side script that polls /health and refreshes on an interval', () => { + const html = renderStatusPage({ + selfPort: 3000, + host: 'localhost', + targets: [], + }); + + expect(html).toContain('