Dashboard de métricas ferroviarias en tiempo real con ingesta automática cada minuto (UI en español e inglés).
- Parte 1: Información General
- Qué es
- Objetivo
- Qué incluye
- Alcance del histórico
- Transparencia y publicación de datos
- Esquema de datos
- Parte 2: Guía Técnica
- Requisitos
- Instalación local rápida
- Instalación local paso a paso
- Variables de entorno
- Endpoints API
- Flujo de consumo API
- Ejemplo de rango histórico
- Recuperación de histórico
- Docker + Postgres
- Migración manual SQLite -> Postgres
- Conservación, actualización y verificación
- Operación del servicio y restauración
- Resultados de la mejora
retrasometro es una aplicación para monitorizar trenes en tiempo real y construir un histórico consultable de su comportamiento (retrasos, actividad, cobertura y tendencias).
El objetivo principal es almacenar y mantener un histórico trazable de datos públicos de circulación de trenes para facilitar análisis y transparencia operativa a lo largo del tiempo.
- Ingesta de
flotaLD.jsoncada 60 segundos (configurable). - Fallback robusto:
- reintento por lista de endpoints.
- caché local (
data/cache/flotaLD.latest.json) si falla la red.
- Almacenamiento histórico optimizado:
trains_current: estado actual de trenes activos.train_snapshots: snapshots compactados (solo cambios o heartbeat).train_observations: histórico completo por ciclo de ingesta.train_daily_stats: acumulados diarios por tren.
- Limpieza y compactación automática:
- elimina trenes no vistos tras un umbral (
STALE_TRAIN_SECONDS). - conserva snapshots, observaciones, lotes, ejecuciones y agregados sin límite por defecto.
- permite retenciones explícitas; las observaciones solo se retiran tras archivado verificado.
- la compactación física con
VACUUMse ejecuta mediante el script de mantenimiento.
- elimina trenes no vistos tras un umbral (
- Frontend + API:
- selector de idioma ES/EN.
- documentación API tipo Swagger con ejemplos reales.
- vista de datos en bruto en tiempo real (
/api/raw/live). - ventanas históricas (24h / 7d / 30d) y rango personalizado.
- Seguridad de API:
- clave temporal obligatoria para rutas de datos.
- máximo 1 petición concurrente por clave.
- intervalo mínimo por clave:
API_RATE_LIMIT_MS(200 ms por defecto; 1.000 ms en Docker Compose).
- La app puede reconstruir el pasado solo dentro de lo ya capturado en tu base.
- Si nunca se guardó una ventana temporal concreta, no se puede reconstruir al 100% desde el endpoint público (que entrega estado actual, no histórico completo).
Git conserva código, configuración de ejemplo, documentación y resultados de verificación. data/ y .env están excluidos: SQLite, archivos históricos y backups se mantienen en el almacenamiento del servicio.
Para distribuir los datos, usa una instantánea coherente de SQLite junto con los archivos del manifiesto. La API de backup de SQLite incorpora las transacciones confirmadas del WAL; una copia aislada de renfe.db mientras la ingesta está activa puede omitirlas. El paquete de mantenimiento contiene también .env, por lo que debe mantenerse privado.
trains_current: vista viva de trenes.train_snapshots: historial de cambios por tren.train_observations: historial temporal por ingesta (incluyesourceeis_estimated).train_daily_stats: acumulado por día y tren.train_hourly_train_stats: agregados horarios para consultas históricas.ingestion_batches: procedencia de cada lote de observaciones.observation_archive_chunks: rutas, rangos, recuentos y SHA-256 del histórico comprimido.ops_service_hourlyyops_materialization_state: métricas operativas y avance de materialización.ingestion_runs: auditoría de cada ciclo de ingesta.stations: catálogo de estaciones (estaciones.geojson).app_state: última marca del proveedor y estado de recuperación histórica.
- Bun 1.3+
- Zstd (
zstd) para leer o crear archivos históricos y ejecutar las comprobaciones de regresión. - Python 3.11+ y systemd solo para el mantenimiento del servicio local.
- Docker (opcional)
- Docker Compose (opcional)
bun install
bun run startAbre: http://localhost:3000
Modo desarrollo:
bun run dev- Abre terminal en la carpeta del proyecto.
- Instala dependencias:
bun install
- (Opcional) copia variables de entorno:
cp .env.example .env
- Arranca la app:
bun run start
- Abre en navegador:
http://localhost:3000
Copia .env.example a .env y ajusta si quieres:
cp .env.example .envVariables principales:
PORT: puerto HTTP (default3000).DB_PATH: ruta SQLite (default./data/renfe.db).POLL_INTERVAL_MS: frecuencia de ingesta (default60000).RENFE_ENDPOINTS: endpoints separados por coma.STATIONS_ENDPOINT: fuente principal GeoJSON de estaciones (larga distancia).STATIONS_SUPPLEMENTAL_ENDPOINT: fuente secundaria GeoJSON (p. ej. Cercanías) para completar códigos faltantes.FETCH_TIMEOUT_MS: timeout HTTP por intento.STALE_TRAIN_SECONDS: segundos para purgar trenes no vistos.SNAPSHOT_RETENTION_HOURS: retención de snapshots (0= conservar todo; valor por defecto).INGESTION_RUN_RETENTION_DAYS: retención de ejecuciones de ingesta (0= conservar todo).SNAPSHOT_HEARTBEAT_SECONDS: inserta snapshot aunque no cambie el tren cada N segundos.COMPACT_EVERY_RUNS: frecuencia de limpieza configurada,PRAGMA optimizey checkpoint WAL; no ejecutaVACUUM.DISPLAY_CACHE_DIR: carpeta de cache JSON de dashboard precomputado.DISPLAY_CACHE_24_SECONDS: refresco de caché 24h (60 segundos por defecto).DISPLAY_CACHE_168_SECONDS: refresco de caché 7d (60 segundos por defecto).DISPLAY_CACHE_720_SECONDS: refresco de caché 30d (60 segundos por defecto).API_KEY_TTL_SECONDS: duración de la clave API temporal.API_RATE_LIMIT_MS: intervalo mínimo entre peticiones por clave (default200).RAW_MAX_TRAINS: máximo de trenes en/api/raw/live.OBSERVATION_RETENTION_DAYS: ventana de detalle en SQLite (0= conservar todo). Solo se retiran filas con archivado verificado yARCHIVE_ENABLED=1.HOURLY_RETENTION_DAYS: retención detrain_hourly_train_stats(0= conservar todo).DAILY_RETENTION_DAYS: retención detrain_daily_stats(0= indefinido).BATCH_RETENTION_DAYS: retención deingestion_batches(0= conservar todo y sus relaciones).ARCHIVE_ENABLED: crea archivos de observaciones antiguas (desactivado por defecto). Los archivos ya existentes siguen siendo consultables.ARCHIVE_DIR: directorio destino paraJSONL.zstpor hora.ARCHIVE_ZSTD_LEVEL: nivel de compresión Zstd (1-19).- El archivado nuevo requiere
ARCHIVE_ENABLED=1yOBSERVATION_RETENTION_DAYS>0, además del binariozstd. Las demás tablas conservan sus propias retenciones. WAL_CHECKPOINT_TRUNCATE_BYTES: tamaño mínimo del WAL para checkpointTRUNCATE.HTTP_COMPRESSION_ENABLED: activa compresión HTTP (br/gzip).HTTP_COMPRESSION_MIN_BYTES: umbral mínimo de tamaño para comprimir respuestas.HTTP_GZIP_LEVEL: nivel degzip(1-9).HTTP_BROTLI_LEVEL: nivel debrotli(1-11).HISTORY_RETENTION_DAYS: compat legacy (alias deOBSERVATION_RETENTION_DAYS).RECOVERY_LOOKBACK_HOURS: ventana de recuperación desde snapshots al arrancar.POSTGRES_URL: conexión para migrar SQLite -> Postgres.SQLITE_PATH: ruta SQLite para script de migración.MIGRATION_BATCH_SIZE: lote de inserción en migración (entero entre 1 y 1.000; defecto 500).AUTO_MIGRATE_PG_ON_START: migración automática a Postgres al arrancar Docker (1/0, desactivada por defecto). El destino debe estar vacío; el script no sobrescribe datos existentes.BOOTSTRAP_SQLITE_PATH: ruta de bootstrap de SQLite al primer arranque Docker.BOOTSTRAP_CACHE_PATH: ruta de bootstrap de cache al primer arranque Docker.
Públicos:
GET /api/docsGET /api/healthGET /api/auth/request-keyGET /api/events: flujo SSE de avisos de ingesta y caché; mantiene la conexión abierta.
Protegidos (requieren x-api-key):
GET /api/dashboard?historyHours=24|168|720&historyFrom=ISO|epoch&historyTo=ISO|epochGET /api/ops?window=24|168|720GET /api/trains?q=&minDelay=&limit=&offset=GET /api/trains/:codComercial/history?hours=24GET /api/ingestion/runs?limit=50GET /api/raw/liveGET /api/history/coverage?hours=48POST /api/history/recover?hours=48
Idioma de API:
- sin parámetro
langen URL. - usa cabecera
Accept-Language(esoen).
Precisión numérica:
- los campos acumulados grandes (por ejemplo
accumulatedDelayMinutes) se exponen comostringpara evitar pérdida de precisión con valores muy altos. GET /api/dashboardañadehistoryPrecisionpara indicar si el rango salió de detalle crudo o agregado (raw_detail,aggregated_only,mixed).GET /api/history/coverageañadestoragecondbRows,archivedRows,archiveChunks.
- Solicita clave temporal:
GET /api/auth/request-key
- Usa la clave en cabecera:
x-api-key: <tu_clave>
- Respeta límites:
- 1 petición en curso por clave.
- intervalo indicado en
limits.minIntervalMsal solicitar la clave. No se reinicia la espera al terminar una petición.
KEY=$(curl -s 'http://localhost:3000/api/auth/request-key' | jq -r '.apiKey')
curl -s -H "x-api-key: $KEY" \\
-H "Accept-Language: es" \\
"http://localhost:3000/api/dashboard?historyFrom=2026-03-01T00:00:00Z&historyTo=2026-03-10T23:59:59Z" | jq .También acepta epoch segundos:
curl -s -H "x-api-key: $KEY" \\
"http://localhost:3000/api/dashboard?historyFrom=1772582400&historyTo=1773359999" | jq .- Si faltan observaciones, puedes recuperar parcialmente desde
train_snapshots:POST /api/history/recover?hours=48
- Verifica cobertura real y huecos:
GET /api/history/coverage?hours=48
Levantar app + Postgres:
docker compose up -d --buildServicios:
appenhttp://localhost:3011(puerto 3000 dentro del contenedor)postgresenlocalhost:5432(renfe/renfe, DBrenfe_analytics)
Persistencia entre sesiones:
appdatapara SQLite + cache (/app/data)pgdatapara Postgres (/var/lib/postgresql/data)
Integración automática al arrancar Docker:
- Si
appdataestá vacío y existe./data/renfe.db, se importa automáticamente. - Si existe
./data/cache/flotaLD.latest.json, también se importa. - La migración a PostgreSQL está desactivada por defecto.
AUTO_MIGRATE_PG_ON_START=1la solicita sobre un destino vacío.
El bootstrap copia SQLite y la caché; usa una instantánea coherente, nunca el archivo de una base con escrituras activas. Para trasladar el histórico comprimido, copia también sus archivos a appdata y conserva las rutas que figuran en observation_archive_chunks. No borres el volumen existente para volver a importar datos.
Ver volúmenes:
docker volume ls | grep -E "appdata|pgdata"
docker volume inspect "$(docker volume ls -q | grep appdata | head -n1)"
docker volume inspect "$(docker volume ls -q | grep pgdata | head -n1)"Con la base SQLite actual en ./data/renfe.db:
POSTGRES_URL=postgresql://renfe:renfe@localhost:5432/renfe_analytics \\
SQLITE_PATH=./data/renfe.db \\
bun run migrate:pgCon Docker Compose:
docker compose --profile tools run --rm migrate_to_postgresEl script exige tablas destino vacías y copia las 12 tablas de aplicación, incluidas las tablas Ops, dentro de una transacción PostgreSQL. Lee SQLite por lotes sobre una instantánea estable y comprueba recuentos. Si encuentra datos en el destino, aborta sin sobrescribirlos. Los archivos JSONL.zst se trasladan por separado; copiar el manifiesto no sustituye sus archivos.
Este proyecto se distribuye bajo:
Creative Commons Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
Este proyecto consume endpoints públicos desde frontend oficial de RENFE y está pensado para analítica/monitorización técnica.
Todas las retenciones están desactivadas por defecto. La ingesta se confirma en una única transacción SQLite con synchronous=FULL. Los archivos históricos se comprueban contra su SHA-256 al consultarlos y el archivado nuevo conserva todos los campos, incluido id.
El servidor entrega los datos actuales y los trenes en una petición. Los cálculos históricos se ejecutan en un Worker de Bun, con cachés de 60 segundos para 24h, 7d y 30d. GET /api/events avisa de cada ingesta confirmada y de las cachés actualizadas; las páginas se actualizan automáticamente. Si falta una caché, el dashboard muestra los datos actuales mientras se prepara el histórico. La fuente se consulta cada POLL_INTERVAL_MS (60 segundos por defecto).
Verificación reproducible:
bun run check
bun run test
node --check public/app.js
node --check public/ops-lab.js
git diff --checkLas regresiones usan una base temporal y prueban retención cero, recuperación idempotente, rollback de ingesta, fidelidad y corrupción de archivos, detalle archivado, bordes horarios, caché fría, autenticación, concurrencia HTTP, SSE y aislamiento del Worker. El mensaje [ingestor] error test failure corresponde al fallo provocado para comprobar rollback; el resultado final debe ser OK y el proceso terminar con código 0.
Mantenimiento local: python3 scripts/compact-storage.py detiene temporalmente retrasometro.service, respalda SQLite, retira el índice redundante y ejecuta VACUUM. Reanuda el servicio antes de comparar todos los campos entre las instantáneas. Conserva un backup.tar.zst con SQLite, archivos históricos y configuración, comprueba su restauración y guarda verification.json en data/backups/<fecha UTC>/. No borra las copias anteriores. El script apunta a data/renfe.db de este checkout y al servicio retrasometro.service; no toma DB_PATH del entorno ni opera sobre Docker. Requiere Python 3.11+, Zstd, acceso a systemd y espacio para varias instantáneas temporales. La compactación puede tardar varios minutos en una base grande.
La migración a PostgreSQL lee por lotes dentro de una instantánea estable y incluye las tablas Ops. Los archivos JSONL.zst deben acompañar al manifiesto: el script SQL no los traslada.
En el servidor aplicado, el checkout es /root/retrasometro, Bun escucha en 127.0.0.1:3011 y Nginx sirve https://retrasometro.com. El servicio usa bun run start; los cambios de TypeScript requieren reiniciarlo. La configuración local y el drop-in de systemd se mantienen fuera de Git.
cd /root/retrasometro
bun run check
bun run test
systemctl restart retrasometro.service
systemctl is-active retrasometro.service
curl --fail --silent http://127.0.0.1:3011/api/health
tail -n 30 /var/log/retrasometro.logEl drop-in /etc/systemd/system/retrasometro.service.d/runtime-hotfix.conf conserva estos valores. Reprodúcelos si trasladas este servicio; los valores de systemd prevalecen sobre .env:
[Service]
Environment=ARCHIVE_ENABLED=0
Environment=OBSERVATION_RETENTION_DAYS=0
Environment=COMPACT_EVERY_RUNS=1440
Environment=SNAPSHOT_RETENTION_HOURS=0
Environment=INGESTION_RUN_RETENTION_DAYS=0
Environment=HOURLY_RETENTION_DAYS=0
Environment=DAILY_RETENTION_DAYS=0
Environment=BATCH_RETENTION_DAYS=0
Environment=DISPLAY_CACHE_168_SECONDS=60
Environment=DISPLAY_CACHE_720_SECONDS=60Después de cambiar un drop-in, ejecuta systemctl daemon-reload y reinicia el servicio. Para SSE, el proxy debe permitir una conexión larga y transmitir los eventos sin acumularlos; la respuesta incluye X-Accel-Buffering: no y un comentario de mantenimiento cada 20 segundos. El servidor desactiva el timeout únicamente para esa ruta. HTML, JavaScript y CSS se sirven con no-cache, y la web debe recargarse una vez después del despliegue.
El backup verificado del 7 de octubre se creó a las 16:39:42, hora de Madrid. Su carpeta usa UTC: data/backups/20261007T143942Z/. Contiene backup.tar.zst y verification.json. La verificación conservada en Git permite comprobar una copia del paquete sin incluir los datos ni la configuración privada en el repositorio.
Para comprobar la restauración en un directorio aislado:
umask 077
backup=data/backups/20261007T143942Z/backup.tar.zst
restore_dir=$(mktemp -d /tmp/retrasometro-restore.XXXXXX)
zstd -q -t "$backup"
python3 - "$backup" docs/storage-verification-2026-10-07.json <<'PYHASH'
import hashlib, json, sys
from pathlib import Path
report = json.loads(Path(sys.argv[2]).read_text())
with Path(sys.argv[1]).open('rb') as stream:
assert hashlib.file_digest(stream, 'sha256').hexdigest() == report['backup_sha256']
print('OK: SHA-256 del paquete completo')
PYHASH
zstd -q -d -c "$backup" | tar -x -C "$restore_dir"
python3 - "$restore_dir/data/renfe.db" docs/storage-verification-2026-10-07.json <<'PYVERIFY'
import hashlib, json, sqlite3, sys
from pathlib import Path
path = Path(sys.argv[1]).resolve()
report = json.loads(Path(sys.argv[2]).read_text())
with path.open('rb') as stream:
assert hashlib.file_digest(stream, 'sha256').hexdigest() == report['sqlite_backup_sha256']
db = sqlite3.connect(path.as_uri() + '?mode=ro', uri=True)
try:
assert db.execute('PRAGMA quick_check').fetchall() == [('ok',)]
for name, expected in report['counts_before'].items():
assert db.execute(f'SELECT COUNT(*) FROM "{name}"').fetchone()[0] == expected
finally:
db.close()
print('OK: SHA-256, integridad y recuentos de SQLite restaurada')
PYVERIFYUsa el verification.json de la misma carpeta cuando compruebes otro backup. La extracción anterior deja .env y los archivos históricos dentro del directorio aislado y conserva la base activa. Para poner una restauración en servicio, detén primero la ingesta y guarda la base actual junto con su WAL, SHM, archivos históricos y configuración. Sustituirla por un backup anterior retrocede al instante de esa captura; las escrituras posteriores deben conservarse o recuperarse por separado. Al trasladar el proyecto, verifica que las rutas del manifiesto resuelvan a los archivos restaurados.
Los resultados, la pausa de mantenimiento, los límites de frescura y las comprobaciones de conservación están en STORAGE_REVIEW.md. La compactación ya se aplicó; no hace falta ejecutarla de nuevo al instalar este commit.