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
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:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user