Phase 1: data pipeline (Postgres+Redis with in-memory fallback, mock telemetry publisher, WebSocket fan-out, hub /debug page)
CI / Lint, typecheck, test, build (pull_request) Failing after 10s

- packages/db: postgres.js + ioredis wrapper, StateStore interface with
  InMemory + Postgres implementations, schema migration runner
- apps/api: refactored to use @kerbal-rt/db; live WebSocket hub subscribes
  to store changes and broadcasts to all clients
- apps/tools/mock-telemetry: Node script that generates realistic KSP
  state and POSTs to /api/v1/ingest at 1Hz (uses same keplerian math
  as the live-map renderer)
- apps/hub: new /debug page that connects to /api/v1/live and shows
  the live state
- ksp/README.md: documents Phase 1c (real kRPC bridge) and the two
  implementation options
- Tests: 17 total (5 kepler math, 5 db store, 5 API health/state,
  2 API WebSocket fan-out)

End-to-end verified: mock publisher → API → hub /debug page,
11 snapshots/5s over WebSocket, vessels advancing in mean anomaly.
This commit is contained in:
Mavis
2026-06-02 18:54:46 +00:00
parent 938ba042b3
commit a457b9d96f
28 changed files with 2165 additions and 98 deletions
+195
View File
@@ -0,0 +1,195 @@
'use client';
import { useEffect, useState } from 'react';
import { Card, Pill, formatUtAsKspDate } from '@kerbal-rt/ui';
import type { LiveMessage, UniverseSnapshot } from '@kerbal-rt/shared-types';
const API_URL =
(typeof process !== 'undefined' && process.env.NEXT_PUBLIC_API_URL) || 'http://localhost:4000';
/**
* Debug page for Phase 1.
*
* Connects to /api/v1/live over WebSocket and shows the latest
* universe snapshot. Use this to verify the ingest → state → WS
* pipeline is working end-to-end.
*
* Run the mock-telemetry publisher in another terminal to drive
* the state, or POST to /api/v1/ingest manually.
*/
export default function DebugPage() {
const [snap, setSnap] = useState<UniverseSnapshot | null>(null);
const [status, setStatus] = useState<'connecting' | 'open' | 'closed'>('connecting');
const [lastUpdate, setLastUpdate] = useState<string | null>(null);
const [messageCount, setMessageCount] = useState(0);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const url = API_URL.replace(/^http/, 'ws') + '/api/v1/live';
let ws: WebSocket | null = null;
let reconnectTimer: number | null = null;
const connect = () => {
setStatus('connecting');
setError(null);
try {
ws = new WebSocket(url);
} catch (e) {
setError(String((e as Error).message ?? e));
scheduleReconnect();
return;
}
ws.onopen = () => {
setStatus('open');
};
ws.onmessage = (event) => {
setMessageCount((n) => n + 1);
setLastUpdate(new Date().toISOString());
try {
const msg = JSON.parse(event.data) as LiveMessage;
if (msg.type === 'snapshot') {
setSnap(msg.snapshot);
}
// ping/event_new/etc are ignored on the debug page
} catch (e) {
setError(`bad message: ${String((e as Error).message ?? e)}`);
}
};
ws.onerror = () => {
// The close event will fire too; we just record the moment
};
ws.onclose = () => {
setStatus('closed');
scheduleReconnect();
};
};
const scheduleReconnect = () => {
if (reconnectTimer) return;
reconnectTimer = window.setTimeout(connect, 2000);
};
connect();
return () => {
if (reconnectTimer) window.clearTimeout(reconnectTimer);
if (ws) ws.close();
};
}, []);
return (
<main style={{ maxWidth: 1100, margin: '0 auto', padding: '2rem 1rem' }}>
<header
style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}
>
<h1 style={{ fontSize: '1.5rem', margin: 0 }}>Debug Live State</h1>
<Pill tone={status === 'open' ? 'success' : status === 'connecting' ? 'warn' : 'danger'}>
WS {status}
</Pill>
<Pill tone="neutral">{messageCount} msgs</Pill>
</header>
{error && (
<Card title="Errors">
<p style={{ color: 'var(--pill-danger, #e44)' }}>{error}</p>
</Card>
)}
<div style={{ height: '1rem' }} />
<Card title="Connection">
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
<li>API: {API_URL}</li>
<li>WebSocket: {API_URL.replace(/^http/, 'ws')}/api/v1/live</li>
<li>Status: {status}</li>
<li>Last update: {lastUpdate ?? '—'}</li>
</ul>
</Card>
<div style={{ height: '1rem' }} />
{!snap ? (
<Card title="No snapshot yet">
<p>No snapshot received. Make sure:</p>
<ol>
<li>The API is running at {API_URL}</li>
<li>
Either the mock-telemetry publisher is running, or you&apos;ve POSTed to{' '}
<code>/api/v1/ingest</code>
</li>
</ol>
<p>
Try the mock publisher from the repo root:
<br />
<code>pnpm --filter @kerbal-rt/mock-telemetry start</code>
</p>
</Card>
) : (
<>
<Card title="Snapshot summary">
<ul style={{ margin: 0, paddingLeft: '1.2rem' }}>
<li>UT: {formatUtAsKspDate(snap.ut)}</li>
<li>Captured at: {snap.capturedAt}</li>
<li>Bodies: {snap.bodies.length}</li>
<li>Vessels: {snap.vessels.length}</li>
<li>Ground stations: {snap.groundStations.length}</li>
<li>Active vessel: {snap.activeVesselId ?? '—'}</li>
</ul>
</Card>
<div style={{ height: '1rem' }} />
<Card title={`Vessels (${snap.vessels.length})`}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--card-border)' }}>
<th>ID</th>
<th>Name</th>
<th>Owner</th>
<th>Situation</th>
<th>Body</th>
<th>sma</th>
<th>ecc</th>
</tr>
</thead>
<tbody>
{snap.vessels.map((v) => (
<tr key={v.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<td>
<code>{v.id}</code>
</td>
<td>{v.name}</td>
<td>{v.owner ?? '—'}</td>
<td>{v.situation}</td>
<td>{v.referenceBodyId}</td>
<td>{(v.orbit.semiMajorAxis / 1000).toFixed(0)} km</td>
<td>{v.orbit.eccentricity.toFixed(3)}</td>
</tr>
))}
</tbody>
</table>
</Card>
<div style={{ height: '1rem' }} />
<Card title={`Bodies (${snap.bodies.length})`}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{snap.bodies.map((b) => (
<span
key={b.id}
style={{
padding: '0.25rem 0.5rem',
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
fontSize: '0.8rem',
}}
>
{b.name} <span style={{ opacity: 0.5 }}>({b.kind})</span>
</span>
))}
</div>
</Card>
</>
)}
</main>
);
}
+12 -3
View File
@@ -22,7 +22,12 @@ export default function HomePage() {
<Card title="Quick links">
<ul>
<li>
<a href="/live-map">Live map</a> (scaffolded in the same Vite app)
<a href="/debug">Debug page</a> live state from the WebSocket (Phase 1)
</li>
<li>
<a href="http://localhost:3001" target="_blank" rel="noreferrer">
Live map (3D)
</a>
</li>
<li>
<a href="http://localhost:4000/health" target="_blank" rel="noreferrer">
@@ -30,8 +35,12 @@ export default function HomePage() {
</a>
</li>
<li>
<a href="https://github.com" target="_blank" rel="noreferrer">
Repo
<a
href="https://gitea.arnike.ru/Arnike/KSP-MissionControl"
target="_blank"
rel="noreferrer"
>
Repo on Gitea
</a>
</li>
</ul>