Skip to content

Commit c2827a4

Browse files
authored
Merge pull request #64 from Western-Formula-Racing/timescale-sync
TimescaleDB local to cloud sync, wrong DBC warning
2 parents f155bf9 + 3d52d1f commit c2827a4

18 files changed

Lines changed: 1470 additions & 20 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Offline Docker Bundle
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
ref:
7+
description: 'Git ref to package (branch, tag, SHA)'
8+
required: true
9+
default: 'timescale-sync'
10+
11+
release:
12+
types: [published]
13+
14+
push:
15+
branches: [ timescale-sync, main, dev ]
16+
17+
env:
18+
REGISTRY: ghcr.io
19+
IMAGE_NAME: ${{ github.repository }}
20+
21+
jobs:
22+
package-offline:
23+
runs-on: ubuntu-latest
24+
permissions:
25+
contents: read
26+
packages: read
27+
28+
steps:
29+
- name: Checkout code
30+
uses: actions/checkout@v4
31+
with:
32+
ref: ${{ github.event.inputs.ref || github.ref }}
33+
34+
- name: Log in to Container Registry
35+
uses: docker/login-action@v3
36+
with:
37+
registry: ${{ env.REGISTRY }}
38+
username: ${{ github.actor }}
39+
password: ${{ secrets.GITHUB_TOKEN }}
40+
41+
- name: Set up Docker Buildx
42+
uses: docker/setup-buildx-action@v3
43+
44+
- name: Build cloud-sync image
45+
run: |
46+
docker compose -f universal-telemetry-software/deploy/docker-compose.macbook-base.yml build cloud-sync
47+
48+
- name: Pull all required images
49+
run: |
50+
docker pull ghcr.io/western-formula-racing/daq-radio/universal-telemetry:latest
51+
docker pull ghcr.io/western-formula-racing/daq-radio/pecan:latest
52+
docker pull timescale/timescaledb:latest-pg16
53+
docker pull redis:8.2
54+
docker pull bluenviron/mediamtx:latest
55+
docker pull grafana/grafana:latest
56+
57+
- name: Save images as tarball
58+
run: |
59+
cd universal-telemetry-software/deploy
60+
docker save \
61+
ghcr.io/western-formula-racing/daq-radio/universal-telemetry:latest \
62+
ghcr.io/western-formula-racing/daq-radio/pecan:latest \
63+
timescale/timescaledb:latest-pg16 \
64+
redis:8.2 \
65+
bluenviron/mediamtx:latest \
66+
deploy-cloud-sync:latest \
67+
grafana/grafana:latest \
68+
-o offline/wfr-docker-images.tar
69+
70+
- name: Upload as workflow artifact
71+
uses: actions/upload-artifact@v4
72+
with:
73+
name: wfr-docker-images-offline
74+
path: universal-telemetry-software/deploy/offline/wfr-docker-images.tar
75+
retention-days: 30
76+
77+
- name: Upload to Release
78+
if: github.event_name == 'release'
79+
uses: softprops/action-gh-release@v1
80+
with:
81+
files: universal-telemetry-software/deploy/offline/wfr-docker-images.tar
82+
env:
83+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

pecan/src/pages/Trace.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -458,7 +458,6 @@ function Trace() {
458458

459459
const handleClear = useCallback(() => {
460460
clearTrace();
461-
frozenRef.current = [];
462461
}, [clearTrace]);
463462

464463
// Handle Easter Egg Trigger

server/installer/sandbox/Dockerfile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ RUN python -c "from langchain_community.embeddings import FastEmbedEmbeddings; F
1212
# Copy application code
1313
COPY code_generator.py .
1414
COPY stats_report.py .
15-
COPY prompt-guide.txt prompt-guide.txt.example ./
15+
COPY prompt-guide.txt.example ./
16+
# prompt-guide.txt is optional (local customization); fall back to example if absent
17+
RUN if [ ! -f prompt-guide.txt ]; then cp prompt-guide.txt.example prompt-guide.txt; fi
1618

1719
# Create directories for generated code, ChromaDB, and cache
1820
RUN mkdir -p /app/generated /app/chroma_db /app/cache

universal-telemetry-software/checklist/SETUP_CARD.tex

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,7 @@ \section*{9 -- TIMESCALE LOGGING (MacBook Only)}
545545
TimescaleDB runs on MacBook's \cmd{macbook-base} stack only. \\
546546
RPi base (\cmd{rpi-base}) does NOT write to DB -- it relays to MacBook via UDP/TCP. \\[1mm]
547547
Connect with \cmd{psql}: \cmd{psql postgresql://wfr:wfr\_password@localhost:5432/wfr} \\
548-
Check tables: \cmd{\textbackslash dt} \quad Check data: \cmd{SELECT COUNT(*) FROM wfr26test\_base;} \\
548+
Check tables: \cmd{\textbackslash dt} \quad Check data: \cmd{SELECT COUNT(*) FROM wfr26base;} \\
549549
Grafana: \cmd{http://localhost:8087} \quad user: \cmd{admin} \quad pass: \cmd{admin} (or as set in \cmd{.env}) \\
550550
\end{tabular}
551551

@@ -555,7 +555,7 @@ \section*{9 -- TIMESCALE LOGGING (MacBook Only)}
555555
\noindent
556556
\begin{tabular}{p{126mm}}
557557
\cmd{docker logs daq-telemetry $\vert$ grep "table="} \\[1mm]
558-
\textbf{PASS:} \cmd{table=WFR26test\_base} (or as set by \cmd{TIMESCALE\_TABLE}) \\
558+
\textbf{PASS:} \cmd{table=wfr26base} (or as set by \cmd{TIMESCALE\_TABLE}) \\
559559
\textbf{FAIL:} \cmd{ENABLE\_TIMESCALE\_LOGGING=true} not set, or TimescaleDB not reachable \\
560560
\textit{Schema:} wide format -- one row per CAN message, each decoded signal as a column. \\
561561
\textit{Table is created automatically on first write.} \\
@@ -635,7 +635,7 @@ \section*{10 -- TIMESYNCALEDB BRIDGE (Base Only)}
635635
It reads decoded CAN frames from Redis and writes them directly to the server stack's \\
636636
TimescaleDB over the network (writes to \cmd{POSTGRES\_DSN}). No local TimescaleDB. \\[1mm]
637637
Wide format: one row per CAN message, all signals as columns. \\
638-
Table: \cmd{WFR26test\_base} (MacBook) or as set by \cmd{TIMESCALE\_TABLE}. \\
638+
Table: \cmd{wfr26base} (MacBook) or as set by \cmd{TIMESCALE\_TABLE}. \\
639639
\end{tabular}
640640

641641
\vspace{1mm}
@@ -710,6 +710,7 @@ \section*{14 -- KEY PORTS}
710710
5006 & TCP & Packet retransmission \\
711711
5432 & TCP & TimescaleDB (MacBook only) \\
712712
6379 & TCP & Redis \\
713+
8092 & HTTP & Cloud Sync dashboard (MacBook only) \\
713714
8080 & HTTP & Status monitoring page \\
714715
8087 & HTTP & Grafana dashboards (MacBook) \\
715716
9080 & WebSocket & PECAN telemetry feed (plain WS) \\
@@ -741,7 +742,7 @@ \section*{15 -- ENV VAR QUICKREF}
741742
ENABLE\_VIDEO & false & Video streaming \\
742743
ENABLE\_AUDIO & false & Audio streaming \\
743744
ENABLE\_TIMESCALE\_LOGGING & false & Log telemetry to TimescaleDB (MacBook only) \\
744-
TIMESCALE\_TABLE & WFR26test\_base & Table name for telemetry writes \\
745+
TIMESCALE\_TABLE & wfr26base & Table name for telemetry writes \\
745746
POSTGRES\_DSN & (local) & TimescaleDB connection string (MacBook: local; RPi: points to MacBook) \\
746747
ENABLE\_WS\_RELAY & false & Start WS relay (ws\_relay.py) on port 9089 \\
747748
RELAY\_UPSTREAM\_WS & ws://127.0.0.1:9080 & Upstream WS URL for relay \\
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
FROM python:3.11-slim
2+
3+
ENV PYTHONUNBUFFERED=1 \
4+
PYTHONDONTWRITEBYTECODE=1
5+
6+
WORKDIR /app
7+
8+
COPY requirements.txt /tmp/requirements.txt
9+
RUN pip install --no-cache-dir -r /tmp/requirements.txt
10+
11+
COPY config.py sync.py app.py ./
12+
COPY static/ ./static/
13+
14+
EXPOSE 8092
15+
16+
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
17+
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8092/api/status')"
18+
19+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8092"]
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import logging
2+
import time
3+
from datetime import datetime, timezone
4+
5+
from fastapi import BackgroundTasks, FastAPI, HTTPException
6+
from fastapi.middleware.cors import CORSMiddleware
7+
from fastapi.responses import RedirectResponse
8+
from fastapi.staticfiles import StaticFiles
9+
from pydantic import BaseModel
10+
11+
import config
12+
from sync import SyncEngine
13+
14+
logging.basicConfig(
15+
level=logging.INFO,
16+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
17+
)
18+
logger = logging.getLogger("cloud-sync")
19+
20+
app = FastAPI(title="WFR Cloud Sync")
21+
app.add_middleware(
22+
CORSMiddleware,
23+
allow_origins=["*"],
24+
allow_methods=["*"],
25+
allow_headers=["*"],
26+
)
27+
app.mount("/static", StaticFiles(directory="static"), name="static")
28+
29+
engine = SyncEngine()
30+
31+
# ── Sync state (module-level, single process) ─────────────────────────────────
32+
33+
_sync_state: dict = {
34+
"running": False,
35+
"rows_done": 0,
36+
"rows_total": 0,
37+
"last_sync_iso": None, # ISO timestamp of last completed sync
38+
"last_sync_rows": None, # row count of last completed sync
39+
"last_sync_elapsed": None, # seconds
40+
"last_error": None,
41+
# cached from last status call so /api/status is fast
42+
"_cloud_cursor": None,
43+
"_unsynced_count": None,
44+
"_unsynced_ts": 0.0, # monotonic time of last unsynced_count fetch
45+
}
46+
47+
_UNSYNCED_CACHE_TTL = 30.0 # seconds
48+
49+
50+
@app.get("/")
51+
def root():
52+
return RedirectResponse(url="/static/index.html")
53+
54+
55+
@app.get("/api/status")
56+
def status():
57+
# Local count — always fresh (fast local query)
58+
try:
59+
local_count = engine.get_local_count()
60+
except Exception as e:
61+
local_count = None
62+
logger.warning(f"get_local_count failed: {e}")
63+
64+
# Unsynced count — cached with TTL to avoid hammering cloud on every poll
65+
now = time.monotonic()
66+
if now - _sync_state["_unsynced_ts"] > _UNSYNCED_CACHE_TTL and not _sync_state["running"]:
67+
try:
68+
cursor = engine.get_cloud_cursor()
69+
_sync_state["_cloud_cursor"] = cursor.isoformat() if cursor else None
70+
_sync_state["_unsynced_count"] = engine.get_unsynced_count(cursor)
71+
_sync_state["_unsynced_ts"] = now
72+
except Exception as e:
73+
logger.warning(f"unsynced_count fetch failed: {e}")
74+
75+
cloud_configured = bool(config.CLOUD_POSTGRES_DSN)
76+
77+
return {
78+
"local_count": local_count,
79+
"local_table": config.LOCAL_TABLE,
80+
"cloud_table": engine.cloud_table,
81+
"cloud_configured": cloud_configured,
82+
"cloud_cursor": _sync_state["_cloud_cursor"],
83+
"unsynced_count": _sync_state["_unsynced_count"],
84+
"last_sync_iso": _sync_state["last_sync_iso"],
85+
"last_sync_rows": _sync_state["last_sync_rows"],
86+
"last_sync_elapsed": _sync_state["last_sync_elapsed"],
87+
"last_error": _sync_state["last_error"],
88+
"sync_running": _sync_state["running"],
89+
}
90+
91+
92+
@app.post("/api/check-cloud")
93+
def check_cloud():
94+
result = engine.check_cloud_connection()
95+
return result
96+
97+
98+
@app.post("/api/sync")
99+
def trigger_sync(background_tasks: BackgroundTasks):
100+
if _sync_state["running"]:
101+
raise HTTPException(status_code=409, detail="Sync already in progress")
102+
103+
if not config.CLOUD_POSTGRES_DSN:
104+
raise HTTPException(status_code=400, detail="CLOUD_POSTGRES_DSN not configured")
105+
106+
_sync_state["running"] = True
107+
_sync_state["rows_done"] = 0
108+
_sync_state["rows_total"] = 0
109+
_sync_state["last_error"] = None
110+
111+
background_tasks.add_task(_run_sync)
112+
return {"status": "started"}
113+
114+
115+
@app.get("/api/sync-status")
116+
def sync_status():
117+
return {
118+
"running": _sync_state["running"],
119+
"rows_done": _sync_state["rows_done"],
120+
"rows_total": _sync_state["rows_total"],
121+
"last_sync_iso": _sync_state["last_sync_iso"],
122+
"last_sync_rows": _sync_state["last_sync_rows"],
123+
"last_sync_elapsed": _sync_state["last_sync_elapsed"],
124+
"last_error": _sync_state["last_error"],
125+
}
126+
127+
128+
def _progress_cb(rows_done: int, rows_total: int) -> None:
129+
_sync_state["rows_done"] = rows_done
130+
_sync_state["rows_total"] = rows_total
131+
132+
133+
class SelectTablePayload(BaseModel):
134+
table: str
135+
136+
137+
class CreateTablePayload(BaseModel):
138+
table: str
139+
140+
141+
@app.get("/api/local-tables")
142+
def list_local_tables():
143+
"""List existing local tables (tables matching ^wfr[0-9] on the local DB)."""
144+
tables = engine.list_local_tables()
145+
return {"tables": tables, "current": engine.local_table}
146+
147+
148+
@app.post("/api/select-local-table")
149+
def select_local_table(payload: SelectTablePayload):
150+
"""Switch the active local source table for the next sync."""
151+
if _sync_state["running"]:
152+
raise HTTPException(status_code=409, detail="Cannot change table while sync is running")
153+
name = payload.table.lower().strip()
154+
if not name:
155+
raise HTTPException(status_code=400, detail="Table name is required")
156+
engine.local_table = name
157+
# Invalidate unsynced cache
158+
_sync_state["_unsynced_ts"] = 0.0
159+
_sync_state["_unsynced_count"] = None
160+
_sync_state["_cloud_cursor"] = None
161+
return {"selected": name}
162+
163+
164+
@app.get("/api/cloud-tables")
165+
def list_cloud_tables():
166+
"""List existing cloud tables (tables matching ^wfr[0-9] on the cloud DB)."""
167+
tables = engine.list_cloud_tables()
168+
return {"tables": tables, "current": engine.cloud_table}
169+
170+
171+
@app.post("/api/cloud-tables")
172+
def create_cloud_table(payload: CreateTablePayload):
173+
"""Create a new cloud hypertable."""
174+
name = payload.table.lower().strip()
175+
if not name:
176+
raise HTTPException(status_code=400, detail="Table name is required")
177+
try:
178+
engine.create_cloud_table(name)
179+
except Exception as e:
180+
raise HTTPException(status_code=500, detail=str(e))
181+
# Invalidate unsynced cache
182+
_sync_state["_unsynced_ts"] = 0.0
183+
return {"created": name}
184+
185+
186+
@app.post("/api/select-table")
187+
def select_table(payload: SelectTablePayload):
188+
"""Switch the active cloud table for the next sync."""
189+
if _sync_state["running"]:
190+
raise HTTPException(status_code=409, detail="Cannot change table while sync is running")
191+
name = payload.table.lower().strip()
192+
if not name:
193+
raise HTTPException(status_code=400, detail="Table name is required")
194+
engine.cloud_table = name
195+
# Invalidate unsynced cache so next /api/status recalculates
196+
_sync_state["_unsynced_ts"] = 0.0
197+
_sync_state["_unsynced_count"] = None
198+
_sync_state["_cloud_cursor"] = None
199+
return {"selected": name}
200+
201+
202+
def _run_sync() -> None:
203+
try:
204+
result = engine.sync(progress_cb=_progress_cb)
205+
_sync_state["last_sync_iso"] = datetime.now(timezone.utc).isoformat()
206+
_sync_state["last_sync_rows"] = result["rows_synced"]
207+
_sync_state["last_sync_elapsed"] = result["elapsed_s"]
208+
_sync_state["last_error"] = None
209+
# Invalidate unsynced cache
210+
_sync_state["_unsynced_ts"] = 0.0
211+
except Exception as e:
212+
logger.error(f"Sync failed: {e}")
213+
_sync_state["last_error"] = str(e)
214+
finally:
215+
_sync_state["running"] = False
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import os
2+
3+
LOCAL_POSTGRES_DSN = os.getenv(
4+
"LOCAL_POSTGRES_DSN",
5+
"postgresql://wfr:wfr_password@timescaledb:5432/wfr",
6+
)
7+
LOCAL_TABLE = os.getenv("LOCAL_TABLE", "wfr26base").lower()
8+
9+
CLOUD_POSTGRES_DSN = os.getenv("CLOUD_POSTGRES_DSN", "")
10+
CLOUD_TABLE = os.getenv("CLOUD_TABLE", "wfr26").lower()
11+
12+
SYNC_BATCH_SIZE = int(os.getenv("SYNC_BATCH_SIZE", "5000"))
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
fastapi==0.115.4
2+
uvicorn[standard]==0.32.1
3+
psycopg2-binary>=2.9.9

0 commit comments

Comments
 (0)