From 2cc5ccfda7028192ed33229c6f307afdc520c013 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Fri, 20 Mar 2026 14:29:43 +0100 Subject: [PATCH] x --- backend/app/main.py | 414 +++++++++++++++++++++++++++++++++++++- backend/app/routers/ev.py | 93 +++++++++ frontend/Dockerfile | 4 +- scripts/smoke_test.sh | 103 ++++++++++ 4 files changed, 603 insertions(+), 11 deletions(-) create mode 100644 backend/app/routers/ev.py create mode 100755 scripts/smoke_test.sh diff --git a/backend/app/main.py b/backend/app/main.py index b4c2db2..ca0281b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,17 +2,27 @@ from __future__ import annotations +import asyncio import logging import os from contextlib import asynccontextmanager -from datetime import datetime, timezone -from typing import Annotated +from datetime import date, datetime, timedelta, timezone +from typing import Annotated, Any, Literal import asyncpg import httpx +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from app.db_json import record_to_dict from app.deps import set_pg_pool +from app.routers.ev import router as ev_router +from app.routers.full_status import router as full_status_router from app.routers.plan import router as plan_router -from fastapi import Depends, FastAPI, HTTPException +from services.forecast_service import fetch_pv_forecast +from services.price_importer import import_ote_prices +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Query, Request +from services.audit_filler import fill_audit_for_completed_intervals +from services.heartbeat_service import send_heartbeat +from services.telemetry_collector import run_telemetry_loop_wrapper from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field @@ -31,13 +41,116 @@ def _dsn() -> str: pool: asyncpg.Pool | None = None +async def get_pool() -> asyncpg.Pool: + if pool is None: + raise HTTPException(status_code=503, detail="Database pool not ready") + return pool + + +scheduler = AsyncIOScheduler() + + @asynccontextmanager async def lifespan(app: FastAPI): global pool pool = await asyncpg.create_pool(_dsn(), min_size=1, max_size=5) set_pg_pool(pool) + app.state.pg_pool = pool + + from services.control_exporter import export_setpoints + from services.planning_engine import run_daily_plan, run_rolling_replan + + async def scheduled_heartbeat() -> None: + async with app.state.pg_pool.acquire() as conn: + sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + for site in sites: + try: + await send_heartbeat(site["id"], conn) + except Exception: + logger.exception("scheduled_heartbeat site=%s failed", site["id"]) + + async def scheduled_audit_filler() -> None: + async with app.state.pg_pool.acquire() as conn: + sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + for site in sites: + try: + await fill_audit_for_completed_intervals(site["id"], conn) + except Exception: + logger.exception("scheduled_audit_filler site=%s failed", site["id"]) + + async def scheduled_expire_modes() -> None: + async with app.state.pg_pool.acquire() as conn: + try: + await conn.fetchval("SELECT ems.fn_expire_modes()") + except Exception: + logger.exception("scheduled_expire_modes failed") + + async def scheduled_control_export() -> None: + async with app.state.pg_pool.acquire() as conn: + sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + for site in sites: + try: + await export_setpoints(site["id"], conn) + except Exception as e: + logger.exception("scheduled_control_export site=%s: %s", site["id"], e) + + async def scheduled_daily_plan() -> None: + async with app.state.pg_pool.acquire() as conn: + sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + for site in sites: + try: + await run_daily_plan(site["id"], conn) + except Exception: + logger.exception("scheduled_daily_plan site=%s failed", site["id"]) + + async def scheduled_rolling_replan() -> None: + async with app.state.pg_pool.acquire() as conn: + sites = await conn.fetch("SELECT id FROM ems.site WHERE active = true") + for site in sites: + try: + await run_rolling_replan(site["id"], conn) + except Exception: + logger.exception("scheduled_rolling_replan site=%s failed", site["id"]) + + scheduler.add_job(scheduled_heartbeat, "interval", seconds=60, id="heartbeat") + scheduler.add_job( + scheduled_audit_filler, + "cron", + minute="1,16,31,46", + second=0, + id="audit_filler", + ) + scheduler.add_job(scheduled_expire_modes, "interval", minutes=1, id="expire_modes") + scheduler.add_job( + scheduled_control_export, + "cron", + minute="14,29,44,59", + second=0, + id="control_export", + ) + scheduler.add_job(scheduled_daily_plan, "cron", hour=15, minute=0, id="daily_plan") + scheduler.add_job( + scheduled_rolling_replan, + "cron", + minute="*/15", + id="rolling_replan", + ) + scheduler.start() + + telemetry_task = asyncio.create_task(run_telemetry_loop_wrapper(app.state.pg_pool)) + app.state.telemetry_task = telemetry_task + yield + + telemetry_task.cancel() + try: + await telemetry_task + except asyncio.CancelledError: + pass + + scheduler.shutdown(wait=False) set_pg_pool(None) + app.state.pg_pool = None if pool: await pool.close() pool = None @@ -46,6 +159,199 @@ async def lifespan(app: FastAPI): app = FastAPI(title="EMS Platform", lifespan=lifespan) app.include_router(plan_router, prefix="/api/v1") +app.include_router(ev_router, prefix="/api/v1") +app.include_router(full_status_router, prefix="/api/v1") + +sites_router = APIRouter(prefix="/api/v1/sites", tags=["sites"]) + + +def _parse_ymd(s: str) -> date: + try: + return date.fromisoformat(s) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date, expected YYYY-MM-DD") from None + + +@sites_router.get("") +async def list_sites(db: Annotated[asyncpg.Pool, Depends(get_pool)]) -> list[dict[str, Any]]: + async with db.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, code, name, timezone, latitude, longitude, active, notes, created_at + FROM ems.site + ORDER BY id + """ + ) + return [record_to_dict(r) for r in rows] + + +@sites_router.get("/{site_id}/prices") +async def get_site_prices( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pool)], + date_str: str | None = Query(None, alias="date", description="YYYY-MM-DD, default today"), +) -> list[dict[str, Any]]: + if date_str is None: + date_str = date.today().isoformat() + d = _parse_ymd(date_str) + async with db.acquire() as conn: + site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + if not site_ok: + raise HTTPException(status_code=404, detail="Site not found") + rows = await conn.fetch( + """ + SELECT * + FROM ems.vw_site_effective_price + WHERE site_id = $1 AND interval_start::date = $2::date + ORDER BY interval_start + """, + site_id, + d, + ) + return [record_to_dict(r) for r in rows] + + +class PricesImportResponse(BaseModel): + slots_imported: int + date: str + first_price_czk_kwh: float + + +class PricesLatestResponse(BaseModel): + latest_date: str + slots: int + min_price: float + max_price: float + avg_price: float + + +class ForecastRunResponse(BaseModel): + intervals_saved: int + pv_arrays: int + + +@sites_router.post("/{site_id}/prices/import", response_model=PricesImportResponse) +async def post_import_site_prices( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pool)], + date_str: str | None = Query( + None, + alias="date", + description="YYYY-MM-DD; výchozí = zítřek v časové zóně lokality", + ), +) -> PricesImportResponse: + target: date | None = _parse_ymd(date_str) if date_str is not None else None + async with db.acquire() as conn: + site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + if not site_ok: + raise HTTPException(status_code=404, detail="Site not found") + n, day, first_price = await import_ote_prices(site_id, conn, target_date=target) + if n < 0: + raise HTTPException( + status_code=422, + detail="OTE API nedostupné nebo nevrátilo data", + ) + return PricesImportResponse( + slots_imported=n, + date=day, + first_price_czk_kwh=first_price, + ) + + +@sites_router.get("/{site_id}/prices/latest", response_model=PricesLatestResponse) +async def get_site_prices_latest( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pool)], +) -> PricesLatestResponse: + async with db.acquire() as conn: + site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + if not site_ok: + raise HTTPException(status_code=404, detail="Site not found") + row = await conn.fetchrow( + """ + SELECT + (interval_start AT TIME ZONE 'Europe/Prague')::date AS day, + COUNT(*)::int AS slots, + MIN(buy_raw_price_czk_kwh)::float AS min_price, + MAX(buy_raw_price_czk_kwh)::float AS max_price, + AVG(buy_raw_price_czk_kwh)::float AS avg_price + FROM ems.market_interval_price + WHERE market_source IN ('OTE_CZ', 'OTE_CZ_DAM') + GROUP BY day + ORDER BY day DESC + LIMIT 1 + """ + ) + if row is None or row["day"] is None: + raise HTTPException(status_code=404, detail="Žádná tržní data v databázi") + return PricesLatestResponse( + latest_date=row["day"].isoformat(), + slots=int(row["slots"] or 0), + min_price=float(row["min_price"] or 0.0), + max_price=float(row["max_price"] or 0.0), + avg_price=float(row["avg_price"] or 0.0), + ) + + +@sites_router.post("/{site_id}/forecast/run", response_model=ForecastRunResponse) +async def post_run_site_forecast( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pool)], +) -> ForecastRunResponse: + async with db.acquire() as conn: + site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + if not site_ok: + raise HTTPException(status_code=404, detail="Site not found") + intervals, pv_arrays = await fetch_pv_forecast(site_id, conn) + if intervals < 0: + raise HTTPException( + status_code=422, + detail="Forecast se nepodařilo stáhnout nebo zpracovat", + ) + return ForecastRunResponse(intervals_saved=intervals, pv_arrays=pv_arrays) + + +@sites_router.get("/{site_id}/forecast/pv") +async def get_site_forecast_pv( + site_id: int, + db: Annotated[asyncpg.Pool, Depends(get_pool)], + date_str: str | None = Query(None, alias="date", description="YYYY-MM-DD, default tomorrow"), +) -> dict[str, list[dict[str, Any]]]: + if date_str is None: + date_str = (date.today() + timedelta(days=1)).isoformat() + d = _parse_ymd(date_str) + async with db.acquire() as conn: + site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + if not site_ok: + raise HTTPException(status_code=404, detail="Site not found") + rows = await conn.fetch( + """ + SELECT fpi.*, apa.code AS pv_array_code + FROM ems.forecast_pv_interval fpi + JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id + JOIN ems.asset_pv_array apa ON apa.id = fpi.pv_array_id AND apa.site_id = fpr.site_id + WHERE fpr.site_id = $1 + AND fpi.interval_start::date = $2::date + AND fpr.status = 'ok' + ORDER BY apa.code, fpi.interval_start + """, + site_id, + d, + ) + + pv_a: list[dict[str, Any]] = [] + pv_b: list[dict[str, Any]] = [] + for r in rows: + item = record_to_dict(r) + code = item.get("pv_array_code") + if code == "pv-a": + pv_a.append(item) + elif code == "pv-b": + pv_b.append(item) + return {"pv_a": pv_a, "pv_b": pv_b} + + +app.include_router(sites_router) app.add_middleware( CORSMiddleware, @@ -56,15 +362,105 @@ app.add_middleware( ) -async def get_pool() -> asyncpg.Pool: - if pool is None: - raise HTTPException(status_code=503, detail="Database pool not ready") - return pool +async def _health_payload(db: asyncpg.Pool) -> dict[str, Any]: + db_status = "error" + active_plan_slots = 0 + try: + async with db.acquire() as conn: + await conn.fetchval("SELECT 1") + db_status = "ok" + active_plan_slots = int( + await conn.fetchval( + """ + SELECT COUNT(*)::bigint + FROM ems.planning_interval pi + INNER JOIN ems.planning_run pr ON pr.id = pi.run_id + WHERE pr.status = 'active' + """ + ) + or 0 + ) + except Exception as e: + logger.warning("health DB check failed: %s", e) + db_status = "error" + + return { + "status": "ok" if db_status == "ok" else "degraded", + "db": db_status, + "timestamp": datetime.now(timezone.utc).isoformat(), + "active_plan_slots": active_plan_slots, + } @app.get("/health") -async def health() -> dict[str, str]: - return {"status": "ok"} +@app.get("/api/v1/health") +async def health(db: Annotated[asyncpg.Pool, Depends(get_pool)]) -> dict[str, Any]: + return await _health_payload(db) + + +@app.get("/api/v1/health/detailed") +async def health_detailed( + request: Request, + db: Annotated[asyncpg.Pool, Depends(get_pool)], +) -> dict[str, Any]: + db_status: Literal["ok", "error"] = "error" + last_telemetry_age_sec = -1 + last_plan_age_sec = -1 + try: + async with db.acquire() as conn: + await conn.fetchval("SELECT 1") + db_status = "ok" + tel = await conn.fetchval( + """ + SELECT CASE + WHEN MAX(measured_at) IS NULL THEN -1 + ELSE GREATEST(0, EXTRACT(EPOCH FROM (now() - MAX(measured_at)))::int) + END + FROM ems.telemetry_inverter + """ + ) + if tel is not None: + last_telemetry_age_sec = int(tel) + plan_age = await conn.fetchval( + """ + SELECT CASE + WHEN MAX(pr.created_at) IS NULL THEN -1 + ELSE GREATEST(0, EXTRACT(EPOCH FROM (now() - MAX(pr.created_at)))::int) + END + FROM ems.planning_run pr + WHERE pr.status = 'active' + """ + ) + if plan_age is not None: + last_plan_age_sec = int(plan_age) + except Exception as e: + logger.warning("health detailed DB check failed: %s", e) + db_status = "error" + + sched_state: Literal["running", "stopped"] = "running" if scheduler.running else "stopped" + t_task = getattr(request.app.state, "telemetry_task", None) + tel_loop: Literal["running", "stopped"] = ( + "running" if t_task is not None and not t_task.done() else "stopped" + ) + + active_jobs: list[dict[str, Any]] = [] + for job in scheduler.get_jobs(): + nrt = job.next_run_time + active_jobs.append( + { + "id": str(job.id), + "next_run_time": nrt.isoformat() if nrt is not None else None, + } + ) + + return { + "db": db_status, + "scheduler": sched_state, + "telemetry_loop": tel_loop, + "last_telemetry_age_sec": last_telemetry_age_sec, + "last_plan_age_sec": last_plan_age_sec, + "active_jobs": active_jobs, + } class SetSiteModeBody(BaseModel): diff --git a/backend/app/routers/ev.py b/backend/app/routers/ev.py new file mode 100644 index 0000000..9472465 --- /dev/null +++ b/backend/app/routers/ev.py @@ -0,0 +1,93 @@ +"""REST API – aktivní EV session a úprava deadline / target SoC.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Annotated, Any + +import asyncpg +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, field_validator + +from app.db_json import record_to_dict +from app.deps import get_pg_pool + +router = APIRouter(prefix="/sites/{site_id}/ev", tags=["ev"]) + + +class EvSessionPatchBody(BaseModel): + target_soc_pct: float | None = None + target_deadline: datetime | None = None + + @field_validator("target_soc_pct") + @classmethod + def _soc_range(cls, v: float | None) -> float | None: + if v is not None and not (10 <= v <= 100): + raise ValueError("target_soc_pct must be between 10 and 100") + return v + + +class EvSessionPatchResponse(BaseModel): + success: bool = True + session_id: int + + +@router.get("/sessions/active") +async def get_active_ev_sessions( + site_id: int, + pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], +) -> list[dict[str, Any]]: + async with pool.acquire() as conn: + site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + if not site_ok: + raise HTTPException(status_code=404, detail="Site not found") + rows = await conn.fetch( + """ + SELECT es.id, es.charger_id, es.vehicle_id, + es.session_start, es.energy_delivered_wh, + es.target_soc_pct, es.target_deadline, + av.make, av.model, av.battery_capacity_kwh, + av.default_target_soc_pct, av.default_deadline_hour, + ac.code AS charger_code, + COALESCE( + NULLIF(TRIM(CONCAT_WS(' ', ac.manufacturer, ac.model)), ''), + ac.code + ) AS charger_name + FROM ems.ev_session es + LEFT JOIN ems.asset_vehicle av ON av.id = es.vehicle_id + JOIN ems.asset_ev_charger ac ON ac.id = es.charger_id + WHERE es.site_id = $1 AND es.session_end IS NULL + ORDER BY es.session_start DESC + """, + site_id, + ) + return [record_to_dict(r) for r in rows] + + +@router.patch("/sessions/{session_id}", response_model=EvSessionPatchResponse) +async def patch_ev_session( + site_id: int, + session_id: int, + body: EvSessionPatchBody, + pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], +) -> EvSessionPatchResponse: + async with pool.acquire() as conn: + site_ok = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id) + if not site_ok: + raise HTTPException(status_code=404, detail="Site not found") + + row = await conn.fetchrow( + """ + UPDATE ems.ev_session + SET target_soc_pct = $1, target_deadline = $2 + WHERE id = $3 AND site_id = $4 + RETURNING id + """, + body.target_soc_pct, + body.target_deadline, + session_id, + site_id, + ) + if row is None: + raise HTTPException(status_code=404, detail="Session not found") + return EvSessionPatchResponse(success=True, session_id=int(row["id"])) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a6f8d6f..96e6521 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,5 @@ -# Stage 1 – build static assets -FROM node:20-alpine AS builder +# Stage 1 – build static assets (bookworm: glibc – @tailwindcss/oxide má spolehlivé prebuildy; alpine/musl často selže při npm ci) +FROM node:20-bookworm-slim AS builder WORKDIR /app COPY package.json package-lock.json ./ diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh new file mode 100755 index 0000000..59909c5 --- /dev/null +++ b/scripts/smoke_test.sh @@ -0,0 +1,103 @@ +#!/bin/bash +set -euo pipefail +BASE="http://localhost:8000" +FRONT="http://localhost" +POSTGREST="http://localhost:3000" + +echo "=== EMS Platform Smoke Test ===" + +# 1. Health +echo -n "Health endpoint... " +curl -sf "$BASE/api/v1/health" | python3 -c " +import sys,json; d=json.load(sys.stdin) +assert d['status']=='ok', f'status={d[\"status\"]}' +assert d['db']=='ok', f'db={d[\"db\"]}' +print('OK') +" + +# 2. Sites + první site_id (seed nemusí být vždy id=1) +echo -n "Sites endpoint... " +SITE_ID=$(curl -sf "$BASE/api/v1/sites" | python3 -c " +import sys,json; d=json.load(sys.stdin) +assert len(d)>0, 'no sites' +print(d[0]['id']) +") +echo "OK (site_id=$SITE_ID)" + +# 3. Prices (dnes) +echo -n "Prices endpoint... " +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/sites/${SITE_ID}/prices") +[ "$STATUS" = "200" ] && echo "OK" || echo "WARN (HTTP $STATUS – ceny možná nejsou importovány)" + +# 4. Plan current (může být 404 – bez plánu) +echo -n "Plan current... " +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/sites/${SITE_ID}/plan/current") +[ "$STATUS" = "200" ] && echo "OK (plán existuje)" || echo "OK (HTTP $STATUS – zatím žádný plán)" + +# 5. Import cen OTE (zítra) +echo -n "OTE price import... " +RESULT=$(curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/prices/import" \ + -H "Content-Type: application/json" 2>/dev/null) || RESULT="" +if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('slots_imported',0)>0" 2>/dev/null; then + echo "OK ($(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['slots_imported'])") slotů)" +else + echo "WARN – OTE API možná nemá data pro zítřek nebo je nedostupné" +fi + +# 6. PV forecast +echo -n "PV forecast... " +RESULT=$(curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/forecast/run" 2>/dev/null) || RESULT="" +if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('intervals_saved',0)>0" 2>/dev/null; then + echo "OK" +else + echo "WARN – forecast selhal (zkontroluj GPS souřadnice v seed datech)" +fi + +# 7. Spustit plán (potřebuje ceny + forecast) +echo -n "Planning run... " +RESULT=$(curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/plan/run?type=daily" 2>/dev/null) || RESULT="" +if echo "$RESULT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('run_id') is not None" 2>/dev/null; then + echo "OK (run_id=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['run_id'])"))" +else + echo "WARN – planning selhal: $RESULT" +fi + +# 8. Přepnutí režimu a zpět (API + ověření přes PostgREST) +echo -n "Operating mode switch... " +curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/mode" \ + -H "Content-Type: application/json" \ + -d '{"mode":"PRESERVE","notes":"smoke test","valid_until":null}' >/dev/null || true +RESULT=$(curl -sf "${POSTGREST}/vw_site_status?site_id=eq.${SITE_ID}" 2>/dev/null) || RESULT="" +MODE=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)[0]['active_mode'])" 2>/dev/null) || MODE="" +if [ "$MODE" = "PRESERVE" ]; then + echo "OK" +else + echo "WARN (mode=$MODE)" +fi +curl -sf -X POST "$BASE/api/v1/sites/${SITE_ID}/mode" \ + -H "Content-Type: application/json" \ + -d '{"mode":"AUTO","notes":"smoke test restore","valid_until":null}' >/dev/null || true + +# 9. EV sessions endpoint +echo -n "EV sessions endpoint... " +STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + "$BASE/api/v1/sites/${SITE_ID}/ev/sessions/active") +[ "$STATUS" = "200" ] && echo "OK (HTTP 200)" || echo "FAIL (HTTP $STATUS)" + +# 10. Frontend dostupný +echo -n "Frontend (nginx)... " +STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$FRONT/") +[ "$STATUS" = "200" ] && echo "OK" || echo "FAIL (HTTP $STATUS)" + +# 11. PostgREST přes nginx +echo -n "PostgREST přes nginx... " +if curl -sf "$FRONT/rest/vw_site_status?limit=1" | python3 -c " +import sys,json; d=json.load(sys.stdin); print(f'OK ({len(d)} rows)') +" 2>/dev/null; then + : +else + echo "WARN – PostgREST možná potřebuje anon roli" +fi + +echo "" +echo "=== Smoke test dokončen ==="