p-2000/server.js
edo 2c37d5934c Add P-2000 real-time monitor with WebSocket, map, and filter panel
- Koa server on port 10000 with WebSocket live feed from 112-nu.nl RSS
- PM2 watch mode for auto-restart on file changes
- Dark navy UI with per-type color accents (fire/ambulance/police/rescue)
- Slide-in filter panel with service type + 12 Dutch province filters
- Card click opens detail modal: parsed priority (A1/A2/MGS), vehicle
  number, rit/bon number, alarm type, meldkamer, and eenheden
- Server-side Nominatim geocoder (cached, rate-limited) powering an
  interactive Leaflet/OpenStreetMap map in the modal (CartoDB Voyager tiles)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 10:16:38 +00:00

96 lines
3.8 KiB
JavaScript

const http = require('http');
const path = require('path');
const Koa = require('koa');
const Router = require('@koa/router');
const serve = require('koa-static');
const { WebSocketServer, OPEN } = require('ws');
const P2000 = require('./src/p2000');
const { geocode, messageToQuery } = require('./src/geocoder');
const PORT = Number(process.env.PORT) || 10000;
const MAX_HISTORY = 500;
// ── Koa app ──────────────────────────────────────────────────────────────────
const app = new Koa();
const router = new Router();
app.use(serve(path.join(__dirname, 'public')));
router.get('/health', ctx => {
ctx.body = { status: 'ok', uptime: process.uptime(), messages: history.length };
});
// Paginated history — ?offset=0&limit=30&type=all
router.get('/api/messages', ctx => {
const offset = Math.max(0, parseInt(ctx.query.offset) || 0);
const limit = Math.min(100, Math.max(1, parseInt(ctx.query.limit) || 30));
const type = ctx.query.type || 'all';
const pool = type === 'all' ? history : history.filter(m => m.type === type);
ctx.body = {
messages: pool.slice(offset, offset + limit),
total: pool.length,
offset,
limit,
};
});
// Geocode a message by id — proxies Nominatim so the client never hits it directly
router.get('/api/geocode', async ctx => {
const msg = history.find(m => m.id === ctx.query.id);
if (!msg) { ctx.status = 404; ctx.body = { error: 'not found' }; return; }
const query = messageToQuery(msg);
if (!query) { ctx.body = { coords: null }; return; }
const coords = await geocode(query);
ctx.body = { coords, query };
});
app.use(router.routes()).use(router.allowedMethods());
// ── HTTP server ───────────────────────────────────────────────────────────────
const server = http.createServer(app.callback());
// ── WebSocket server ──────────────────────────────────────────────────────────
const wss = new WebSocketServer({ server, path: '/ws' });
const history = [];
function broadcast(payload) {
const data = JSON.stringify(payload);
for (const client of wss.clients) {
if (client.readyState === OPEN) client.send(data);
}
}
wss.on('connection', ws => {
// Send existing history so the UI isn't blank on first load
ws.send(JSON.stringify({ type: 'history', messages: history }));
ws.on('error', err => console.error('[ws] client error:', err.message));
});
// ── P-2000 poller ─────────────────────────────────────────────────────────────
const p2000 = new P2000();
p2000.on('batch', messages => {
history.push(...messages);
if (history.length > MAX_HISTORY) history.splice(0, history.length - MAX_HISTORY);
broadcast({ type: 'history', messages: history });
});
p2000.on('message', msg => {
history.unshift(msg);
if (history.length > MAX_HISTORY) history.pop();
broadcast({ type: 'message', message: msg });
});
p2000.start();
// ── Start ─────────────────────────────────────────────────────────────────────
server.listen(PORT, () => {
console.log(`[server] Listening on http://localhost:${PORT}`);
});
// Graceful shutdown
process.on('SIGINT', () => { p2000.stop(); server.close(() => process.exit(0)); });
process.on('SIGTERM', () => { p2000.stop(); server.close(() => process.exit(0)); });