Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions services/batcher/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions services/blockchain-anchor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
9 changes: 9 additions & 0 deletions services/endpoint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions services/endpoint/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
},
],
};
}
31 changes: 29 additions & 2 deletions services/endpoint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -321,6 +345,9 @@ export async function startEndpoint(): Promise<EndpointRuntime> {
},
{
timestampSkewSeconds: config.timestampSkewSeconds,
statusPort: config.port,
statusHost: config.statusHost,
statusTargets: config.statusTargets,
}
);

Expand Down
156 changes: 156 additions & 0 deletions services/endpoint/src/status-page.ts
Original file line number Diff line number Diff line change
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}

/**
* 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) => `
<tr data-service="${escapeHtml(service.name)}" data-port="${service.port}">
<td><span class="dot"></span>${escapeHtml(service.name)}</td>
<td>${service.port}</td>
<td class="state">Checking…</td>
<td class="latency">—</td>
</tr>`
)
.join('');

return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Connectivity Status</title>
<style>
:root { color-scheme: light dark; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
max-width: 640px;
margin: 3rem auto;
padding: 0 1rem;
color: #1a1a1a;
background: #fff;
}
h1 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.25rem; }
p.subtitle { color: #666; margin-top: 0; margin-bottom: 1.5rem; font-size: 0.9rem; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #eee; font-size: 0.9rem; }
th { color: #666; font-weight: 500; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.5rem; background: #999; }
.dot.up { background: #22c55e; }
.dot.down { background: #ef4444; }
.state.up { color: #22c55e; }
.state.down { color: #ef4444; }
@media (prefers-color-scheme: dark) {
body { color: #eee; background: #111; }
p.subtitle { color: #999; }
th { color: #999; }
th, td { border-bottom-color: #333; }
}
</style>
</head>
<body>
<h1>Connectivity Status</h1>
<p class="subtitle" id="subtitle">Checking services…</p>
<table>
<thead>
<tr><th>Service</th><th>Port</th><th>Status</th><th>Latency</th></tr>
</thead>
<tbody id="services">${rows}
</tbody>
</table>
<script>
(function () {
var HOST = ${JSON.stringify(options.host)};
var REFRESH_MS = ${refreshIntervalMs};

function setRowState(row, healthy, latencyMs) {
var dot = row.querySelector('.dot');
var state = row.querySelector('.state');
var latency = row.querySelector('.latency');
dot.className = 'dot ' + (healthy ? 'up' : 'down');
state.className = 'state ' + (healthy ? 'up' : 'down');
state.textContent = healthy ? 'Operational' : 'Unreachable';
latency.textContent = healthy && latencyMs !== null ? latencyMs + ' ms' : '—';
}

function checkRow(row) {
var port = row.getAttribute('data-port');
var startedAt = Date.now();
var controller = new AbortController();
var timeout = setTimeout(function () { controller.abort(); }, 1500);
return fetch('http://' + HOST + ':' + port + '/health', { signal: controller.signal })
.then(function (response) {
setRowState(row, response.ok, Date.now() - startedAt);
return response.ok;
})
.catch(function () {
setRowState(row, false, null);
return false;
})
.finally(function () { clearTimeout(timeout); });
}

function refresh() {
var rows = Array.prototype.slice.call(document.querySelectorAll('#services tr'));
Promise.all(rows.map(checkRow)).then(function (results) {
var subtitle = document.getElementById('subtitle');
var allHealthy = results.every(Boolean);
subtitle.textContent = (allHealthy ? 'All systems operational' : 'Some services are unreachable')
+ ' · updated ' + new Date().toLocaleTimeString();
});
}

refresh();
setInterval(refresh, REFRESH_MS);
})();
</script>
</body>
</html>
`;
}
Loading
Loading