Skip to content

Commit 2a40fd5

Browse files
committed
Add cloud-sync service and UI, local table picker and selection API
Expose local table management: add GET /api/local-tables and POST /api/select-local-table endpoints to list available local tables and switch the active local source (selection is blocked while a sync is running and it invalidates unsynced cache/state). Implement SyncEngine.list_local_tables to query the local DB information_schema for tables matching ^wfr[0-9]. Update the web UI to include a local table dropdown, loadLocalTables/selectLocalTable JS handlers, polling integration, and minor label tweak ("New" -> "New cloud"). Normalize the default local table name from wfr26test_base to wfr26base across config, .env.macbook and docker-compose defaults (also normalizing casing for TIMESCALE_TABLE), so the composed services and environment use the new base table name. Introduce a new cloud-sync microservice to copy rows from local TimescaleDB to a cloud TimescaleDB. Adds a FastAPI-based API and static web UI (static/index.html), a SyncEngine (sync.py) that uses psycopg2 to upsert rows and manage dynamic signal columns, a simple config module, requirements, and a Dockerfile exposing port 8092 with a healthcheck. Integrates the service into docker-compose.macbook-base.yml (service definition, env vars, depends_on), adds deploy/pg_hba.conf and mounts it into the TimescaleDB container with a command to load the custom hba, and updates deploy/.env.macbook with example CLOUD_POSTGRES_DSN and LOCAL_TABLE overrides. Cloud DSN is optional; sync is gated on CLOUD_POSTGRES_DSN being configured. Fix cloud-sync cursor; update deploy and status Handle named-server cursor behavior in cloud-sync: fetch the first batch before reading cursor.description so column names and signal columns are populated correctly, and iterate fetchmany() in a proper loop to avoid missing batches. Update .env.macbook to point DBC_HOST_PATH to the secret DBC location. Clarify docker-compose.macbook-base usage and environment file path in comments, and add status and Cloud Sync access URLs. Enhance status UI to show the DBC file name (shortened), color-code it (red for example.dbc), and retain the existing warning banner behavior for the example DBC. Use DBC_DISPLAY_NAME and don't clear frozenRef Preserve frozenRef when clearing traces and add a configurable DBC display name. Optional prompt-guide for CI
1 parent f155bf9 commit 2a40fd5

13 files changed

Lines changed: 1312 additions & 9 deletions

File tree

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
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: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import logging
2+
import time
3+
from datetime import datetime, timezone
4+
from typing import Optional
5+
6+
from fastapi import BackgroundTasks, FastAPI, HTTPException
7+
from fastapi.middleware.cors import CORSMiddleware
8+
from fastapi.responses import RedirectResponse
9+
from fastapi.staticfiles import StaticFiles
10+
from pydantic import BaseModel
11+
12+
import config
13+
from sync import SyncEngine
14+
15+
logging.basicConfig(
16+
level=logging.INFO,
17+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
18+
)
19+
logger = logging.getLogger("cloud-sync")
20+
21+
app = FastAPI(title="WFR Cloud Sync")
22+
app.add_middleware(
23+
CORSMiddleware,
24+
allow_origins=["*"],
25+
allow_methods=["*"],
26+
allow_headers=["*"],
27+
)
28+
app.mount("/static", StaticFiles(directory="static"), name="static")
29+
30+
engine = SyncEngine()
31+
32+
# ── Sync state (module-level, single process) ─────────────────────────────────
33+
34+
_sync_state: dict = {
35+
"running": False,
36+
"rows_done": 0,
37+
"rows_total": 0,
38+
"last_sync_iso": None, # ISO timestamp of last completed sync
39+
"last_sync_rows": None, # row count of last completed sync
40+
"last_sync_elapsed": None, # seconds
41+
"last_error": None,
42+
# cached from last status call so /api/status is fast
43+
"_cloud_cursor": None,
44+
"_unsynced_count": None,
45+
"_unsynced_ts": 0.0, # monotonic time of last unsynced_count fetch
46+
}
47+
48+
_UNSYNCED_CACHE_TTL = 30.0 # seconds
49+
50+
51+
@app.get("/")
52+
def root():
53+
return RedirectResponse(url="/static/index.html")
54+
55+
56+
@app.get("/api/status")
57+
def status():
58+
# Local count — always fresh (fast local query)
59+
try:
60+
local_count = engine.get_local_count()
61+
except Exception as e:
62+
local_count = None
63+
logger.warning(f"get_local_count failed: {e}")
64+
65+
# Unsynced count — cached with TTL to avoid hammering cloud on every poll
66+
now = time.monotonic()
67+
if now - _sync_state["_unsynced_ts"] > _UNSYNCED_CACHE_TTL and not _sync_state["running"]:
68+
try:
69+
cursor = engine.get_cloud_cursor()
70+
_sync_state["_cloud_cursor"] = cursor.isoformat() if cursor else None
71+
_sync_state["_unsynced_count"] = engine.get_unsynced_count(cursor)
72+
_sync_state["_unsynced_ts"] = now
73+
except Exception as e:
74+
logger.warning(f"unsynced_count fetch failed: {e}")
75+
76+
cloud_configured = bool(config.CLOUD_POSTGRES_DSN)
77+
78+
return {
79+
"local_count": local_count,
80+
"local_table": config.LOCAL_TABLE,
81+
"cloud_table": engine.cloud_table,
82+
"cloud_configured": cloud_configured,
83+
"cloud_cursor": _sync_state["_cloud_cursor"],
84+
"unsynced_count": _sync_state["_unsynced_count"],
85+
"last_sync_iso": _sync_state["last_sync_iso"],
86+
"last_sync_rows": _sync_state["last_sync_rows"],
87+
"last_sync_elapsed": _sync_state["last_sync_elapsed"],
88+
"last_error": _sync_state["last_error"],
89+
"sync_running": _sync_state["running"],
90+
}
91+
92+
93+
@app.post("/api/check-cloud")
94+
def check_cloud():
95+
result = engine.check_cloud_connection()
96+
return result
97+
98+
99+
@app.post("/api/sync")
100+
def trigger_sync(background_tasks: BackgroundTasks):
101+
if _sync_state["running"]:
102+
raise HTTPException(status_code=409, detail="Sync already in progress")
103+
104+
if not config.CLOUD_POSTGRES_DSN:
105+
raise HTTPException(status_code=400, detail="CLOUD_POSTGRES_DSN not configured")
106+
107+
_sync_state["running"] = True
108+
_sync_state["rows_done"] = 0
109+
_sync_state["rows_total"] = 0
110+
_sync_state["last_error"] = None
111+
112+
background_tasks.add_task(_run_sync)
113+
return {"status": "started"}
114+
115+
116+
@app.get("/api/sync-status")
117+
def sync_status():
118+
return {
119+
"running": _sync_state["running"],
120+
"rows_done": _sync_state["rows_done"],
121+
"rows_total": _sync_state["rows_total"],
122+
"last_sync_iso": _sync_state["last_sync_iso"],
123+
"last_sync_rows": _sync_state["last_sync_rows"],
124+
"last_sync_elapsed": _sync_state["last_sync_elapsed"],
125+
"last_error": _sync_state["last_error"],
126+
}
127+
128+
129+
def _progress_cb(rows_done: int, rows_total: int) -> None:
130+
_sync_state["rows_done"] = rows_done
131+
_sync_state["rows_total"] = rows_total
132+
133+
134+
class SelectTablePayload(BaseModel):
135+
table: str
136+
137+
138+
class CreateTablePayload(BaseModel):
139+
table: str
140+
141+
142+
@app.get("/api/local-tables")
143+
def list_local_tables():
144+
"""List existing local tables (tables matching ^wfr[0-9] on the local DB)."""
145+
tables = engine.list_local_tables()
146+
return {"tables": tables, "current": engine.local_table}
147+
148+
149+
@app.post("/api/select-local-table")
150+
def select_local_table(payload: SelectTablePayload):
151+
"""Switch the active local source table for the next sync."""
152+
if _sync_state["running"]:
153+
raise HTTPException(status_code=409, detail="Cannot change table while sync is running")
154+
name = payload.table.lower().strip()
155+
if not name:
156+
raise HTTPException(status_code=400, detail="Table name is required")
157+
engine.local_table = name
158+
# Invalidate unsynced cache
159+
_sync_state["_unsynced_ts"] = 0.0
160+
_sync_state["_unsynced_count"] = None
161+
_sync_state["_cloud_cursor"] = None
162+
return {"selected": name}
163+
164+
165+
@app.get("/api/cloud-tables")
166+
def list_cloud_tables():
167+
"""List existing cloud tables (tables matching ^wfr[0-9] on the cloud DB)."""
168+
tables = engine.list_cloud_tables()
169+
return {"tables": tables, "current": engine.cloud_table}
170+
171+
172+
@app.post("/api/cloud-tables")
173+
def create_cloud_table(payload: CreateTablePayload):
174+
"""Create a new cloud hypertable."""
175+
name = payload.table.lower().strip()
176+
if not name:
177+
raise HTTPException(status_code=400, detail="Table name is required")
178+
try:
179+
engine.create_cloud_table(name)
180+
except Exception as e:
181+
raise HTTPException(status_code=500, detail=str(e))
182+
# Invalidate unsynced cache
183+
_sync_state["_unsynced_ts"] = 0.0
184+
return {"created": name}
185+
186+
187+
@app.post("/api/select-table")
188+
def select_table(payload: SelectTablePayload):
189+
"""Switch the active cloud table for the next sync."""
190+
if _sync_state["running"]:
191+
raise HTTPException(status_code=409, detail="Cannot change table while sync is running")
192+
name = payload.table.lower().strip()
193+
if not name:
194+
raise HTTPException(status_code=400, detail="Table name is required")
195+
engine.cloud_table = name
196+
# Invalidate unsynced cache so next /api/status recalculates
197+
_sync_state["_unsynced_ts"] = 0.0
198+
_sync_state["_unsynced_count"] = None
199+
_sync_state["_cloud_cursor"] = None
200+
return {"selected": name}
201+
202+
203+
def _run_sync() -> None:
204+
try:
205+
result = engine.sync(progress_cb=_progress_cb)
206+
_sync_state["last_sync_iso"] = datetime.now(timezone.utc).isoformat()
207+
_sync_state["last_sync_rows"] = result["rows_synced"]
208+
_sync_state["last_sync_elapsed"] = result["elapsed_s"]
209+
_sync_state["last_error"] = None
210+
# Invalidate unsynced cache
211+
_sync_state["_unsynced_ts"] = 0.0
212+
except Exception as e:
213+
logger.error(f"Sync failed: {e}")
214+
_sync_state["last_error"] = str(e)
215+
finally:
216+
_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)