Phase 0: monorepo skeleton (hub, live-map, api, packages, infra, CI)

This commit is contained in:
Mavis
2026-06-02 15:47:28 +00:00
commit 7b19c54943
59 changed files with 2391 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
# Docker Compose for the kerbal-rt data tier.
#
# Phase 0 ships this as a deliverable but doesn't run anything here
# (no Docker available in the dev sandbox). Bring it up with:
#
# docker compose -f infra/docker-compose.yml up -d
#
# Then point the API at it via .env (see .env.example).
#
# Replaces the in-memory StateStore in apps/api/src/state.ts.
# Phase 1 wires Postgres + Timescale (telemetry), Redis (pubsub),
# and MinIO (media) into the API.
services:
postgres:
image: timescale/timescaledb:2.16.1-pg16
restart: unless-stopped
environment:
POSTGRES_USER: kerbal
POSTGRES_PASSWORD: kerbal
POSTGRES_DB: kerbal_rt
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
- ./init-sql:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kerbal -d kerbal_rt"]
interval: 5s
timeout: 5s
retries: 10
redis:
image: redis:7.4-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports:
- "6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
minio:
image: minio/minio:RELEASE.2024-09-13T20-26-02Z
restart: unless-stopped
command: ["server", "/data", "--console-address", ":9001"]
environment:
MINIO_ROOT_USER: kerbal
MINIO_ROOT_PASSWORD: kerbal-secret-change-me
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web console
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
# pgAdmin is convenient in dev; comment out for prod
pgadmin:
image: dpage/pgadmin4:8
restart: unless-stopped
profiles: ["dev"]
environment:
PGADMIN_DEFAULT_EMAIL: admin@kerbal.local
PGADMIN_DEFAULT_PASSWORD: admin
ports:
- "5050:80"
depends_on:
postgres:
condition: service_healthy
volumes:
postgres-data:
redis-data:
minio-data:
+17
View File
@@ -0,0 +1,17 @@
# Grafana provisioning: auto-load a TimescaleDB datasource on container start.
# Mount this file at /etc/grafana/provisioning/datasources/timescaledb.yml
apiVersion: 1
datasources:
- name: TimescaleDB
type: postgres
url: postgres:5432
database: kerbal_rt
user: kerbal
secureJsonData:
password: kerbal
jsonData:
sslmode: disable
postgresVersion: 1600
timescaledb: true
isDefault: true
+84
View File
@@ -0,0 +1,84 @@
-- Schema bootstrap for the kerbal-rt Postgres + Timescale install.
-- Runs automatically on first container start.
--
-- See kerbalrealtime-clone-plan.md §Component B for the full data model.
-- This is the Phase 0 minimum; Phase 1 fills in the rest.
CREATE EXTENSION IF NOT EXISTS timescaledb;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Bodies (celestial body catalog; small, mostly static)
CREATE TABLE IF NOT EXISTS bodies (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('star','planet','moon','asteroid','comet')),
parent_id TEXT REFERENCES bodies(id),
radius DOUBLE PRECISION NOT NULL,
sphere_of_influence DOUBLE PRECISION NOT NULL,
gravitational_parameter DOUBLE PRECISION NOT NULL,
rotation_period DOUBLE PRECISION NOT NULL,
axial_tilt DOUBLE PRECISION NOT NULL,
elements JSONB NOT NULL
);
-- Vessels (slowly-changing dimension)
CREATE TABLE IF NOT EXISTS vessels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT,
owner TEXT,
status TEXT NOT NULL CHECK (status IN ('ACTIVE','DECOMMISSIONED','LOST')) DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
retired_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS vessels_status_idx ON vessels(status);
-- Telemetry snapshots (high-volume time series)
CREATE TABLE IF NOT EXISTS telemetry_snapshots (
ts TIMESTAMPTZ NOT NULL,
ut DOUBLE PRECISION NOT NULL,
payload JSONB NOT NULL
);
SELECT create_hypertable('telemetry_snapshots', 'ts', if_not_exists => TRUE);
CREATE INDEX IF NOT EXISTS telemetry_snapshots_ut_idx ON telemetry_snapshots(ut DESC);
-- Mission events
CREATE TABLE IF NOT EXISTS mission_events (
id BIGSERIAL PRIMARY KEY,
mission TEXT NOT NULL,
vessel_id TEXT REFERENCES vessels(id),
title TEXT NOT NULL,
description TEXT,
scheduled_at TIMESTAMPTZ NOT NULL,
ut_at_event DOUBLE PRECISION,
duration_s INTEGER,
kind TEXT NOT NULL CHECK (kind IN ('LAUNCH','BURN','FLYBY','LANDING','DOCKING','EVA','ECLIPSE','OPERATION','OTHER')),
created_by TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS mission_events_scheduled_idx ON mission_events(scheduled_at);
-- Media
CREATE TABLE IF NOT EXISTS media (
id BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('image','youtube')),
url TEXT NOT NULL,
thumbnail_url TEXT,
caption TEXT,
tags TEXT[] NOT NULL DEFAULT '{}',
mission_id TEXT,
vessel_id TEXT REFERENCES vessels(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS media_tags_idx ON media USING GIN(tags);
CREATE INDEX IF NOT EXISTS media_created_idx ON media(created_at DESC);
-- Ground stations
CREATE TABLE IF NOT EXISTS ground_stations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
body_id TEXT NOT NULL REFERENCES bodies(id),
lat DOUBLE PRECISION NOT NULL,
lon DOUBLE PRECISION NOT NULL,
alt DOUBLE PRECISION NOT NULL
);
+37
View File
@@ -0,0 +1,37 @@
-- Seed data: the Kerbol system (KSP stock).
-- Numbers sourced from the KSP wiki and the in-game config files.
-- Gravitational parameter μ in m^3/s^2.
-- This is enough to render the system in the live map before
-- the real telemetry bridge is wired in.
INSERT INTO bodies (id, name, kind, parent_id, radius, sphere_of_influence, gravitational_parameter, rotation_period, axial_tilt, elements) VALUES
('kerbol', 'Kerbol', 'star', NULL, 261600000, 'Infinity'::float8::float8, 1.172332794e18, 432000, 0, '{"semiMajorAxis":0,"eccentricity":0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":0,"epoch":0}'),
('moho', 'Moho', 'planet', 'kerbol', 250000, 9646663, 1.686842e11, 1210000, 0.05, '{"semiMajorAxis":5263138304,"eccentricity":0.2,"inclination":0.075,"longitudeOfAscendingNode":1.5,"argumentOfPeriapsis":2.8,"meanAnomalyAtEpoch":1.0,"epoch":0}'),
('eve', 'Eve', 'planet', 'kerbol', 700000, 85109365, 8.1717302e12, 80500, 0.1, '{"semiMajorAxis":9832684544,"eccentricity":0.01,"inclination":0.1,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":3.14,"epoch":0}'),
('kerbin', 'Kerbin', 'planet', 'kerbol', 600000, 84159286, 3.5316e12, 21600, 0, '{"semiMajorAxis":13599840256,"eccentricity":0.05,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":0.7,"epoch":0}'),
('duna', 'Duna', 'planet', 'kerbol', 320000, 47921949, 3.0136321e11, 65518, 0.06, '{"semiMajorAxis":20726155264,"eccentricity":0.05,"inclination":0.02,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":1.5,"epoch":0}'),
('dres', 'Dres', 'planet', 'kerbol', 138000, 32832840, 2.1484489e10, 34800, 0.08, '{"semiMajorAxis":40839348203,"eccentricity":0.14,"inclination":0.08,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":2.1,"epoch":0}'),
('jool', 'Jool', 'planet', 'kerbol', 6000000, 2450055988, 2.82528e14, 36000, 0.05, '{"semiMajorAxis":68773560320,"eccentricity":0.05,"inclination":0.04,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":4.2,"epoch":0}'),
('eeloo', 'Eeloo', 'planet', 'kerbol', 210000, 119082940, 7.4410815e10, 19460, 0.1, '{"semiMajorAxis":90118820000,"eccentricity":0.26,"inclination":0.13,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":5.5,"epoch":0}'),
('gilly', 'Gilly', 'moon', 'eve', 13000, 126123, 8289449.8, 28260, 0.05, '{"semiMajorAxis":31500000,"eccentricity":0.18,"inclination":0.2,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":0.5,"epoch":0}'),
('mun', 'Mun', 'moon', 'kerbin', 200000, 2429559, 6.514e10, 138984, 0, '{"semiMajorAxis":12000000,"eccentricity":0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":1.0,"epoch":0}'),
('minmus', 'Minmus', 'moon', 'kerbin', 60000, 2247428, 1.765e9, 40400, 0.04, '{"semiMajorAxis":47000000,"eccentricity":0.0,"inclination":0.075,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":2.5,"epoch":0}'),
('ike', 'Ike', 'moon', 'duna', 130000, 1048598, 1.856e9, 65518, 0.05, '{"semiMajorAxis":3200000,"eccentricity":0.0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":1.5,"epoch":0}'),
('laythe', 'Laythe', 'moon', 'jool', 500000, 3723645, 1.840e11, 52980, 0, '{"semiMajorAxis":27184000,"eccentricity":0.0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":2.0,"epoch":0}'),
('vall', 'Vall', 'moon', 'jool', 240000, 2406401, 2.061e10, 106200, 0, '{"semiMajorAxis":43152000,"eccentricity":0.0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":2.5,"epoch":0}'),
('tylo', 'Tylo', 'moon', 'jool', 375000, 10856418, 2.122e11, 84600, 0, '{"semiMajorAxis":68500000,"eccentricity":0.0,"inclination":0,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":3.0,"epoch":0}'),
('bop', 'Bop', 'moon', 'jool', 65000, 1220600, 2.486e8, 360, 0.05, '{"semiMajorAxis":128500000,"eccentricity":0.23,"inclination":0.2,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":4.0,"epoch":0}'),
('pol', 'Pol', 'moon', 'jool', 44000, 1042138, 7.214e7, 340, 0.05, '{"semiMajorAxis":179890000,"eccentricity":0.17,"inclination":0.15,"longitudeOfAscendingNode":0,"argumentOfPeriapsis":0,"meanAnomalyAtEpoch":4.5,"epoch":0}')
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
radius = EXCLUDED.radius,
sphere_of_influence = EXCLUDED.sphere_of_influence,
gravitational_parameter = EXCLUDED.gravitational_parameter,
rotation_period = EXCLUDED.rotation_period,
axial_tilt = EXCLUDED.axial_tilt,
elements = EXCLUDED.elements;
-- Sample ground station: the project's "Montana DSN" (real site has this).
INSERT INTO ground_stations (id, name, body_id, lat, lon, alt) VALUES
('montana', 'Montana DSN', 'kerbin', 47.0, -110.0, 1200)
ON CONFLICT (id) DO NOTHING;
+64
View File
@@ -0,0 +1,64 @@
# nginx config for production. Phase 0 ships as deliverable; deploy in Phase 6.
#
# Routes:
# / -> hub (Next.js) on :3000
# /live-map/ -> live-map (Vite preview) on :3001
# /api/ -> API (Hono) on :4000
# /ws -> API WebSocket upgrade for /api/v1/live
upstream hub {
server 127.0.0.1:3000;
}
upstream map {
server 127.0.0.1:3001;
}
upstream api {
server 127.0.0.1:4000;
}
server {
listen 80;
server_name _;
client_max_body_size 25m;
# Hub
location / {
proxy_pass http://hub;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Live map (mounted under /live-map/ so assets resolve)
location /live-map/ {
proxy_pass http://map/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
# API + WebSocket
location /api/ {
proxy_pass http://api;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400; # keep WebSockets open
}
}
# Map for WebSocket connection upgrade
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}