From 46105b0ecf3190f945fd93dde67bd3c67fea8a75 Mon Sep 17 00:00:00 2001 From: Aitor Reviriego Amor Date: Tue, 2 Jun 2026 09:51:36 +0200 Subject: [PATCH 1/2] feat: backend en Cloud Run; deshacer workarounds de Render del frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mueve el despliegue del backend (+ mock) a Google Cloud Run (free, arranque ~1-2s con la petición esperando, sin el 502 de Render) y revierte los workarounds que metimos por culpa del cold-start de Render. Se conserva el dashboard (rediseño, KPIs, filtros). Frontend: - fetchDashboard vuelve a un fetch simple de /api/sales que lanza si falla (lo recoge error.tsx). Fuera SAMPLE, triggerRefresh y el import del JSON. - Borrados sample-sales.json y app/api/dashboard/route.ts (ya no hay self-heal). - Dashboard recibe 'sales' por props; quitado warming/poll/self-heal; mantiene filtros y derivaciones. page.tsx pasa las ventas. Despliegue/docs: - Borrado render.yaml; nuevo scripts/deploy-cloudrun.sh. - DEPLOY.md reescrito a Cloud Run; DEMO.md, README(.en).md, DEUDA-TECNICA.md y docker-compose.yml actualizados (Render -> Cloud Run; sin datos embebidos). Co-Authored-By: Claude Opus 4.8 --- DEMO.md | 111 ++++--------- DEPLOY.md | 122 +++++++------- DEUDA-TECNICA.md | 4 +- README.en.md | 34 ++-- README.md | 39 ++--- docker-compose.yml | 2 +- frontend/app/api/dashboard/route.ts | 12 -- frontend/app/components/Dashboard.test.tsx | 32 +--- frontend/app/components/Dashboard.tsx | 94 ++--------- frontend/app/lib/dashboard.ts | 41 +---- frontend/app/lib/sample-sales.json | 177 --------------------- frontend/app/page.tsx | 2 +- render.yaml | 60 ------- scripts/deploy-cloudrun.sh | 39 +++++ 14 files changed, 202 insertions(+), 567 deletions(-) delete mode 100644 frontend/app/api/dashboard/route.ts delete mode 100644 frontend/app/lib/sample-sales.json delete mode 100644 render.yaml create mode 100755 scripts/deploy-cloudrun.sh diff --git a/DEMO.md b/DEMO.md index ca3ee91..9f0f8fa 100644 --- a/DEMO.md +++ b/DEMO.md @@ -1,92 +1,49 @@ # Demo en vivo: cómo está montada -Referencia de cómo funciona la demo pública, de dónde salen los datos y cómo usar el backend -real si hace falta. **Resumen en una frase:** la demo vive **solo en Vercel** y es -**autosuficiente** (datos de ejemplo embebidos), así que no depende de ningún backend ni sufre -cold-starts. +Referencia de cómo funciona la demo pública: qué hay desplegado, de dónde salen los datos y cómo +usar el backend. **Resumen:** frontend en **Vercel** → backend en **Google Cloud Run** → mock en +**Cloud Run**. El frontend hace fetch server-side, así que no hay CORS de navegador. -## Decisión: datos embebidos en el frontend +## Por qué Cloud Run (y no Render) -El dashboard **calcula todos los agregados en cliente** (KPIs, series, by-product, by-customer) -a partir de las ventas crudas. Eso permitió desacoplar la demo del backend: en vez de depender de -un servicio .NET + mock que en el tier gratuito **se duermen** (Render free duerme tras ~15 min y -provocaba el clásico "No hay datos" / 502 al abrir en frío), el frontend trae un **dataset de -ejemplo embebido** y lo usa directamente. +Render free dormía el backend y el mock (~15 min) y devolvía 502 al despertar, dejando la demo en +blanco. Lo intentamos tapar con workarounds en el frontend (auto-heal + datos embebidos), pero la +solución de raíz fue mover el backend a **Cloud Run**: también escala a cero, pero el arranque en +frío es ~1-2 s y **la petición espera** al contenedor en vez de fallar. Así la demo carga sin el +problema de "no hay datos". Se eliminaron los workarounds del frontend. -Resultado: la demo **carga al instante, siempre**, sin Render, sin cold-start, sin servicios que -mantener despiertos. El backend real sigue existiendo (es el producto), pero es **opcional** para -la demo. +## Dónde está desplegado cada pieza -## Dónde está desplegado el frontend +| Pieza | Hosting | Notas | +|-------|---------|-------| +| Frontend (Next.js) | **Vercel** · `frontend/` | Siempre activo. Auto-deploy en cada push a `main`. `connect-analyzer.vercel.app`. | +| Backend (.NET 10) | **Cloud Run** · servicio `connect-analyzer-api` | Lee del mock, agrega y sirve la API REST. URL asignada al desplegar. | +| Mock (nginx) | **Cloud Run** · servicio `connect-analyzer-mock` | Sirve `sales.txt` (fixtures). URL asignada al desplegar. | -- **Hosting:** Vercel (siempre activo, sin cold-start). -- **URL:** -- **Proyecto:** Next.js (App Router) en `frontend/`. Vercel hace auto-deploy en cada push a `main` - (root directory = `frontend`). +## Cómo coge los datos -## Cómo coge los datos de prueba +1. **SSR**: `frontend/app/page.tsx` llama a `fetchDashboard()` (`frontend/app/lib/dashboard.ts`), que + hace `GET /api/sales` en el servidor. Si el backend no responde, lanza y se muestra + el error boundary (`app/error.tsx`). +2. **Cliente**: `Dashboard` recibe las ventas crudas y **deriva en cliente** KPIs, series, agregados + por producto/cliente y aplica los **filtros** (`frontend/app/lib/analytics.ts`). Sin más llamadas + al backend: los filtros recalculan sobre las ventas ya cargadas. +3. **`BACKEND_URL`** (env var de Vercel) apunta al servicio `connect-analyzer-api` de Cloud Run. En + local por defecto es `http://localhost:5080`; en Docker Compose, `http://backend:8080`. -1. **Dataset embebido:** `frontend/app/lib/sample-sales.json` — las 25 ventas ficticias del mock - (mismas fixtures que `backend/mocks/sap/data/sales.txt`, ya parseadas a JSON). -2. **`fetchDashboard()`** (`frontend/app/lib/dashboard.ts`) decide la fuente: - - Si `BACKEND_URL` apunta a un backend **alcanzable y con datos** → usa esos datos en vivo. - - En cualquier otro caso (sin `BACKEND_URL`, backend caído/dormido, respuesta vacía o timeout) - → **cae automáticamente al dataset embebido**. -3. El route handler `frontend/app/api/dashboard/route.ts` (`GET`) reexpone esto al cliente - mismo-origen; el componente `Dashboard` deriva KPIs/gráficos del set resultante y aplica los - filtros en cliente. - -**En la demo de Vercel, `BACKEND_URL` está vacío** → siempre sirve el dataset embebido → instantáneo. - -### Regenerar el dataset embebido - -Si cambian las fixtures del mock y quieres refrescar el JSON embebido, con el backend local -levantado en Mock: - -```bash -curl -s http://localhost:5080/api/sales | python3 -m json.tool > frontend/app/lib/sample-sales.json -``` - -## El backend (opcional): dónde está y cómo usarlo - -El backend .NET 10 (`backend/`, arquitectura hexagonal) **no es necesario para la demo**, pero es -el producto real y se puede usar/desplegar. - -### En local (con datos en vivo del mock, SAP o Shopify) +## El backend: cómo usarlo +### En local ```bash -docker compose up --build # levanta sap-mock + backend + frontend -# frontend en :3000 → BACKEND_URL=http://backend:8080 (lo pone docker-compose) +docker compose up --build # mock + backend + frontend (frontend en :3000) ``` - -La fuente se elige con la env var **`SalesSource`** (ver `backend/CLAUDE.md` y `.env.example`): - +Fuente de datos por **`SalesSource`** (ver `backend/CLAUDE.md` y `.env.example`): - `Mock` (por defecto) — lee el `.txt` del servicio `sap-mock`. -- `Sap` — OData real del SAP Business Accelerator Hub (requiere el secreto `Sap__ApiKey`). -- `Shopify` — Admin API real (requiere `Shopify__StoreUrl`, `Shopify__ClientId`, - `Shopify__ClientSecret`). - -Los secretos van en un `.env` en la raíz (gitignored); nunca en git. - -### Conectar la demo a un backend en vivo - -Si algún día quieres que la demo de Vercel use un backend real en vez del dataset embebido: - -1. Despliega el backend (+ mock si usas `SalesSource=Mock`) — ver [`DEPLOY.md`](./DEPLOY.md) - (Render Blueprint). Para SAP/Shopify, configura los secretos en el *Environment* del servicio. -2. En Vercel, define la env var **`BACKEND_URL`** apuntando a la URL pública del backend y - **Redeploy**. -3. El frontend usará el backend si responde con datos; si no, seguirá cayendo al dataset embebido. - -> Aviso: en hosting gratuito (Render) el backend se duerme y la primera petición tarda en -> despertar. Por eso, para una demo siempre-activa, lo recomendado es **dejar `BACKEND_URL` vacío** -> y servir el dataset embebido. +- `Sap` — OData real del SAP Business Accelerator Hub (secreto `Sap__ApiKey`). +- `Shopify` — Admin API real (`Shopify__StoreUrl`, `Shopify__ClientId`, `Shopify__ClientSecret`). -## Mapa rápido +Los secretos van en un `.env` en la raíz (gitignored), nunca en git. -| Pieza | Dónde | Necesaria para la demo | -|-------|-------|------------------------| -| Frontend (Next.js) | Vercel · `frontend/` | **Sí** | -| Dataset de ejemplo | `frontend/app/lib/sample-sales.json` | **Sí** (fuente por defecto) | -| Backend (.NET 10) | local (docker) u opcionalmente Render · `backend/` | No (opcional) | -| Mock (nginx) | local (docker) u opcionalmente Render · `backend/mocks/sap/` | No (opcional) | +### Desplegar / actualizar en Cloud Run +Ver [`DEPLOY.md`](./DEPLOY.md) (pasos `gcloud run deploy` o `scripts/deploy-cloudrun.sh`). Tras +desplegar, poner `BACKEND_URL` en Vercel con la URL del backend y redeploy. diff --git a/DEPLOY.md b/DEPLOY.md index 47c3b21..fb99da4 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,92 +1,96 @@ # Deploy -End-to-end deployment of the live demo: **backend + mock on [Render](https://render.com)** (Free tier, -no credit card required), **frontend on [Vercel](https://vercel.com)**. Since the frontend (`page.tsx`) -fetches the backend server-side, the demo flow is `Vercel (Next.js Server Component) → Render (backend) -→ Render (mock)` and **no browser CORS is involved**. +End-to-end deployment of the live demo: **backend + mock on [Google Cloud Run](https://cloud.google.com/run)** +(free tier, no cold-start of the "no data" kind), **frontend on [Vercel](https://vercel.com)**. The +frontend (`page.tsx`) fetches the backend server-side, so the flow is +`Vercel (Next.js Server Component) → Cloud Run (backend) → Cloud Run (mock)` and **no browser CORS is +involved**. + +> Why Cloud Run instead of Render: Render's free Web Services sleep after ~15 min and return 502 while +> waking, which left the demo blank. Cloud Run scales to zero too, but cold starts are ~1-2 s and the +> request **waits** for the container instead of failing — so the demo just works. ## Prerequisites -- A Render account (sign in with GitHub, no card needed for the Free plan). -- A Vercel account (same). -- (Optional, only for real SAP data) A free API key from the +- The `gcloud` CLI installed and authenticated (`gcloud auth login`). +- A Google Cloud project with billing enabled (the Cloud Run free tier covers this demo). +- A Vercel account. +- (Optional, only for real SAP data) a free API key from the [SAP Business Accelerator Hub](https://api.sap.com). -## 1. Deploy backend + mock with one Render Blueprint - -The repo ships a [`render.yaml`](./render.yaml) Blueprint that declares both Web Services. - -1. Go to → **New +** → **Blueprint**. -2. Connect your GitHub account and pick the `aitorevi/connect-analyzer` repository. -3. Render reads `render.yaml`, shows the two services it is about to create - (`connect-analyzer-mock` and `connect-analyzer-api`, Free plan, Frankfurt) → **Apply**. -4. The first build takes ~5 min per service. Watch the logs from each service page. +## 1. Deploy backend + mock to Cloud Run -Render assigns: -- Mock: `https://connect-analyzer-mock.onrender.com` (or a suffix if that name is taken). -- API: `https://connect-analyzer-api.onrender.com` +Both pieces ship a Dockerfile and listen on `8080`. `gcloud run deploy --source ` builds the +image with Cloud Build and deploys it. Run the steps below (or use +[`scripts/deploy-cloudrun.sh`](./scripts/deploy-cloudrun.sh), which does the two deploys and wires +`SapMock__BaseUrl` for you). -> If Render had to suffix the mock name, edit `SapMock__BaseUrl` in the **connect-analyzer-api** -> service's environment to the actual mock hostname, then **Manual Deploy** → **Clear build cache & deploy**. +```bash +# Once: select the project and enable the APIs +gcloud config set project +gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com + +# 1a. Mock (nginx serving the pipe-delimited Latin-1 .txt) +gcloud run deploy connect-analyzer-mock \ + --source backend/mocks/sap \ + --region europe-southwest1 \ + --port 8080 --allow-unauthenticated + +# Grab its URL +MOCK_URL=$(gcloud run services describe connect-analyzer-mock \ + --region europe-southwest1 --format='value(status.url)') + +# 1b. Backend (.NET 10), pointed at the mock, SQLite in /tmp (ephemeral, re-seeded on start) +gcloud run deploy connect-analyzer-api \ + --source backend \ + --region europe-southwest1 \ + --port 8080 --allow-unauthenticated \ + --set-env-vars "SalesSource=Mock,SapMock__BaseUrl=${MOCK_URL},Sqlite__Path=/tmp/sales.db" +``` -### Smoke-test +Smoke-test (URLs are printed by the deploys / `gcloud run services describe`): ```bash -curl -s https://connect-analyzer-mock.onrender.com/sales.txt | head -3 -curl -s https://connect-analyzer-api.onrender.com/api/sales | head -curl -X POST https://connect-analyzer-api.onrender.com/api/sales/refresh # forces re-ingestion +curl -s /sales.txt | head -3 +curl -s /api/sales | head +curl -s /api/sales/by-product ``` -The first call may take 30-50 s if the service was sleeping (Free Web Services nap after ~15 min -without traffic). - -### Optional: switch to real SAP data +### Optional: real SAP / Shopify instead of the mock -By default the backend uses the mock (`SalesSource=Mock`). To pull from the real SAP S/4HANA OData -sandbox instead, in the **connect-analyzer-api** service: +Set `SalesSource` and the matching secrets on the `connect-analyzer-api` service (then it redeploys): -1. **Environment** tab → set `Sap__ApiKey` (Secret) to your Business Accelerator Hub key. -2. Change `SalesSource` to `Sap`. -3. **Manual Deploy** → **Deploy latest commit**. +```bash +# SAP +gcloud run services update connect-analyzer-api --region europe-southwest1 \ + --set-env-vars SalesSource=Sap --set-env-vars Sap__ApiKey= +# Shopify +gcloud run services update connect-analyzer-api --region europe-southwest1 \ + --set-env-vars SalesSource=Shopify \ + --set-env-vars Shopify__StoreUrl=,Shopify__ClientId=,Shopify__ClientSecret= +``` -> The `Sap__ApiKey` variable is declared in `render.yaml` with `sync: false`, so Render does NOT -> populate it from the Blueprint — you must set it manually in the dashboard. Never commit the key. +(For real secrets, prefer Secret Manager + `--set-secrets` over `--set-env-vars`.) ## 2. Deploy the frontend (Vercel) In the Vercel dashboard: **Add New… → Project → Import the GitHub repo `aitorevi/connect-analyzer`**. -- **Root Directory**: `frontend` (the Next.js app lives in this subfolder). +- **Root Directory**: `frontend`. - **Framework Preset**: Next.js (auto-detected). -- **Environment Variables** — `BACKEND_URL` is **optional**: - - **Leave it unset** (recommended) → the dashboard serves its bundled sample data - (`frontend/app/lib/sample-sales.json`): instant, always-on, no backend needed. See [`DEMO.md`](./DEMO.md). - - Or set `BACKEND_URL` = your backend URL (e.g. `https://connect-analyzer-api.onrender.com`) to - use live data; the frontend falls back to the bundled data if it's unreachable. - -Click **Deploy**. Vercel builds with `next build` and serves the dashboard. +- **Environment Variables**: `BACKEND_URL` = the `connect-analyzer-api` Cloud Run URL (from step 1b). -> The backend (steps 1) is **not required** for the demo — the frontend is self-sufficient. Deploy -> it only if you want live data (incl. real SAP/Shopify) behind the dashboard. +Click **Deploy**. The frontend fetches the backend server-side and renders the dashboard; the +client derives KPIs/series and applies the filters. ## 3. Optional: lock CORS to your Vercel origin -`page.tsx` fetches the backend **server-side**, so the browser never calls the backend directly — CORS -is not exercised today. If you later add a client-side fetch, restrict the backend's CORS origin in the -**connect-analyzer-api** service environment: +`page.tsx` fetches the backend **server-side**, so the browser never calls the backend directly — +CORS is not exercised today. If you later add a client-side fetch, restrict the backend's CORS origin +on the `connect-analyzer-api` service: ``` Cors__AllowedOrigins__0 = https://.vercel.app ``` Never widen to `AllowAnyOrigin`. - -## Cold-start note - -The free Render Web Services sleep after ~15 min of inactivity. The first request after that -cold-starts in 30-50 s. Two ways to mitigate if it matters for the demo: - -- Visit the URL yourself a few seconds before showing it to someone (the dashboard `page.tsx` does - two fetches that warm both backend and mock in one go). -- Set up a tiny cron pinger (UptimeRobot, cron-job.org, etc.) hitting `/api/sales` every ~10 min. - Still on the free plan; just gentle keep-alive traffic. diff --git a/DEUDA-TECNICA.md b/DEUDA-TECNICA.md index 8075ce1..8ff0134 100644 --- a/DEUDA-TECNICA.md +++ b/DEUDA-TECNICA.md @@ -82,7 +82,7 @@ cachea el access token durante la vida del proceso y no reacciona a 401 del endp ### 7. SQLite del backend vive en `/tmp` _(prioridad media)_ -`docker-compose.yml` y `render.yaml` apuntan `Sqlite__Path` a `/tmp/sales.db` porque la +`docker-compose.yml` y el despliegue en Cloud Run apuntan `Sqlite__Path` a `/tmp/sales.db` porque la imagen `dotnet/aspnet:10.0` corre como `$APP_UID` (no-root) desde el commit `7d621bf` y `/app` (el `WORKDIR`) pertenece a root, así que el adaptador no puede crear el fichero ahí. `/tmp` es escribible por cualquier usuario pero **se borra al @@ -97,7 +97,7 @@ reiniciar el contenedor**, lo que tira la persistencia ingerida. - Volumen Docker dedicado (`volumes: - sales-db:/var/lib/connect-analyzer`) y `Sqlite__Path=/var/lib/connect-analyzer/sales.db`, ajustando ownership en el Dockerfile (`mkdir … && chown $APP_UID`). - - En Render, cambiar a un disco persistente del servicio en vez de `/tmp`. + - En Cloud Run, montar un volumen (p.ej. un bucket GCS o Cloud SQL) en vez de `/tmp`. - **Detectado:** durante la verificación end-to-end del MVP de Shopify (2026-05-31). ### 8. Manejo global de excepciones diff --git a/README.en.md b/README.en.md index 6d79cd2..1a171af 100644 --- a/README.en.md +++ b/README.en.md @@ -11,19 +11,20 @@ more analyses, filters, and only later real persistence and a real SAP source. ## Live demo -| Piece | URL | Hosting | -|--------------|---------------------------------------------------------|------------| -| Dashboard | | Vercel | -| Backend API | | Render | -| Mock SAP | | Render | +| Piece | Hosting | URL | +|--------------|---------------------|------------------------------------------------| +| Dashboard | Vercel | | +| Backend API | Google Cloud Run | `connect-analyzer-api` (URL assigned on deploy) | +| Mock SAP | Google Cloud Run | `connect-analyzer-mock` (URL assigned on deploy) | -> Render's Free tier sleeps services after ~15 min without traffic, so the **first request after that -> can take ~30-50 s** (cold start). Subsequent loads are instant. The backend retries the seed-on-startup -> with backoff so it self-heals even while the mock is also waking up. +The frontend (Vercel) fetches the backend **server-side**; the backend runs on **Cloud Run**, reads +the mock and computes the analytics, and the dashboard derives KPIs/series/filters on the client. +Cloud Run starts in ~1-2 s with the request waiting (no "no data" cold start like Render). The +frontend points at the backend via the `BACKEND_URL` env var. - **Case study**: [Post on aitorevi.dev](https://aitorevi.dev/en/blog/sap-analyzer) — why hexagonal, the `Result`/`Error` pattern, the real SAP adapter and the SQLite persistence layer. -- **Deploy from scratch**: see [`DEPLOY.md`](./DEPLOY.md) (Render Blueprint + Vercel, no credit card). +- **Deploy from scratch**: see [`DEPLOY.md`](./DEPLOY.md) (Google Cloud Run + Vercel). ## Architecture @@ -59,7 +60,7 @@ Three pieces, each in its own folder, orchestrated with **Docker Compose** local - **Frontend**: **Next.js 16** + **TypeScript** (App Router) + **Recharts**. Tests with **Vitest** + React Testing Library. - **Mock**: **nginx** serving static files. -- **Orchestration**: **Docker** + **Docker Compose** (local), **Render** + **Vercel** (live demo). +- **Orchestration**: **Docker** + **Docker Compose** (local), **Google Cloud Run** (backend + mock) + **Vercel** (live demo). ## Prerequisites @@ -87,7 +88,7 @@ To stop: `Ctrl+C`, or `docker compose down` to remove the containers. ## API -Local base: `http://localhost:5080` · Production: `https://connect-analyzer-api.onrender.com` +Local base: `http://localhost:5080` · Production: backend on Google Cloud Run (see [`DEPLOY.md`](./DEPLOY.md)). | Method | Endpoint | Response | |--------|---------------------------|---------------------------------------------------------------------------------| @@ -123,7 +124,7 @@ Configuration via environment variables / `appsettings` (see also [`.env.example Locally: `dotnet user-secrets set "Sap:ApiKey" ""`. - `Sap__BaseUrl` — base URL of the SAP OData service (defaults to the `API_SALES_ORDER_SRV` sandbox). - `SapMock__BaseUrl` — mock URL (in Docker: `http://sap-mock:8080`). -- `Sqlite__Path` — SQLite file path (defaults to `sales.db`; on Render we use `/tmp/sales.db`). +- `Sqlite__Path` — SQLite file path (defaults to `sales.db`; on Cloud Run we use `/tmp/sales.db`). - `Cors__AllowedOrigins__0` — origins allowed in the browser (defaults to `http://localhost:3000`). **Never** widen to `AllowAnyOrigin`. @@ -138,7 +139,8 @@ npm run lint # eslint ``` - `BACKEND_URL` — backend URL (in Docker: `http://backend:8080`; default `http://localhost:5080`; - on Vercel it points to `https://connect-analyzer-api.onrender.com`). + on Vercel, the backend's Cloud Run URL). The frontend reads it during SSR; if the backend is + unreachable the error boundary is shown. ### Mock (nginx) @@ -189,10 +191,10 @@ npm run test # watch mode ├── frontend/ # Next.js (App Router) + Recharts │ └── app/ # page.tsx (Server Component) + components/ ├── scripts/test-backend.sh # backend test runner (dockerised) +├── scripts/deploy-cloudrun.sh # deploy backend + mock to Cloud Run ├── docker-compose.yml # local orchestration of the three pieces -├── render.yaml # Render Blueprint (backend + mock) ├── .github/workflows/ci.yml # CI on GitHub Actions -├── DEPLOY.md # how to deploy the live demo (Render + Vercel) +├── DEPLOY.md # how to deploy the live demo (Cloud Run + Vercel) ├── .env.example # documented environment variables ├── CLAUDE.md # Claude Code guidance (+ a CLAUDE.md per piece) └── DEUDA-TECNICA.md # technical-debt log @@ -216,7 +218,7 @@ The mock mimics a real SAP export, so its files follow its quirks: ## Further reading -- [`DEPLOY.md`](./DEPLOY.md) — how to deploy the live demo (Render Blueprint + Vercel). +- [`DEPLOY.md`](./DEPLOY.md) — how to deploy the live demo (Google Cloud Run + Vercel). - [Blog post on aitorevi.dev](https://aitorevi.dev/en/blog/sap-analyzer) — case study: why hexagonal, the `Result`/`Error` pattern, the real SAP adapter and the SQLite persistence layer. - [`CLAUDE.md`](./CLAUDE.md) — working guide (global rules; each piece has its own `CLAUDE.md`). diff --git a/README.md b/README.md index d7322c5..48efc17 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,21 @@ real de SAP. ## Demo en vivo -**Dashboard:** (Vercel — siempre activo, carga instantánea). - -La demo es **autosuficiente**: el frontend trae un dataset de ejemplo embebido -(`frontend/app/lib/sample-sales.json`, las fixtures del mock) y **calcula todos los agregados en -cliente**, así que **no depende de ningún backend** ni sufre cold-starts. El backend .NET y el mock -son el producto real pero **opcionales** para la demo; si quieres datos en vivo (incl. SAP/Shopify), -se conecta vía la env var `BACKEND_URL` y, si no responde, el frontend cae al dataset embebido. - -- **Cómo está montada la demo** (front, datos, backend opcional): ver [`DEMO.md`](./DEMO.md). +| Pieza | Hosting | URL | +|--------------|------------------------|----------------------------------------------| +| Dashboard | Vercel | | +| Backend API | Google Cloud Run | `connect-analyzer-api` (URL asignada al desplegar) | +| Mock SAP | Google Cloud Run | `connect-analyzer-mock` (URL asignada al desplegar) | + +El frontend (Vercel) hace fetch **server-side** del backend en **Cloud Run**, que lee del mock y +calcula la analítica; el dashboard deriva KPIs/series/filtros en cliente. Cloud Run arranca en ~1-2 s +con la petición esperando (sin el cold-start de "no hay datos" que daba Render). El frontend apunta +al backend con la env var `BACKEND_URL`. + +- **Cómo está montada la demo** (front, datos, backend): ver [`DEMO.md`](./DEMO.md). - **Caso de estudio**: [Post en aitorevi.dev](https://aitorevi.dev/blog/sap-analyzer) — por qué hexagonal, patrón `Result`/`Error`, adaptador SAP real y persistencia con SQLite. -- **Cómo desplegar el backend de cero**: ver [`DEPLOY.md`](./DEPLOY.md) (Render Blueprint + Vercel, sin tarjeta). +- **Cómo desplegar el backend de cero**: ver [`DEPLOY.md`](./DEPLOY.md) (Google Cloud Run + Vercel). ## Arquitectura @@ -58,7 +61,7 @@ Tres piezas, cada una en su carpeta, orquestadas con **Docker Compose** en local - **Fuente real**: adaptador OData contra el sandbox del [SAP Business Accelerator Hub](https://api.sap.com). - **Frontend**: **Next.js 16** + **TypeScript** (App Router) + **Recharts**. Tests con **Vitest** + React Testing Library. - **Mock**: **nginx** sirviendo ficheros estáticos. -- **Orquestación**: **Docker** + **Docker Compose** (local), **Render** + **Vercel** (demo en vivo). +- **Orquestación**: **Docker** + **Docker Compose** (local), **Google Cloud Run** (backend + mock) + **Vercel** (demo en vivo). ## Requisitos previos @@ -86,7 +89,7 @@ Para parar: `Ctrl+C`, o `docker compose down` para eliminar los contenedores. ## API -Base local: `http://localhost:5080` · Producción: `https://connect-analyzer-api.onrender.com` +Base local: `http://localhost:5080` · Producción: backend en Google Cloud Run (ver [`DEPLOY.md`](./DEPLOY.md)). | Método | Endpoint | Respuesta | |--------|---------------------------|---------------------------------------------------------------------------------| @@ -129,7 +132,7 @@ Configuración por variables de entorno / `appsettings` (ver también [`.env.exa - `Shopify__ClientSecret` — **secreto**, solo si `SalesSource=Shopify`. Se intercambia por un access token vía Client Credentials Grant. Localmente: `dotnet user-secrets set "Shopify:ClientSecret" ""`. - `Shopify__ApiVersion` — versión de la Admin API (por defecto `2025-01`). -- `Sqlite__Path` — ruta del fichero SQLite (por defecto `sales.db`; en Render y en Docker Compose +- `Sqlite__Path` — ruta del fichero SQLite (por defecto `sales.db`; en Cloud Run y en Docker Compose usamos `/tmp/sales.db` porque el backend corre como usuario no-root). - `Cors__AllowedOrigins__0` — orígenes permitidos para el navegador (por defecto `http://localhost:3000`). **Nunca** ampliar a `AllowAnyOrigin`. @@ -145,8 +148,8 @@ npm run lint # eslint ``` - `BACKEND_URL` — URL del backend (en Docker: `http://backend:8080`; en local por defecto - `http://localhost:5080`). **Opcional**: si no se alcanza (o está vacío, como en la demo de - Vercel), el frontend usa el dataset embebido (`sample-sales.json`). Ver [`DEMO.md`](./DEMO.md). + `http://localhost:5080`; en Vercel, la URL del backend en Cloud Run). El frontend lee de aquí en + SSR; si el backend no responde, se muestra el error boundary. Ver [`DEMO.md`](./DEMO.md). ### Mock (nginx) @@ -198,10 +201,10 @@ npm run test # modo watch ├── frontend/ # Next.js (App Router) + Recharts │ └── app/ # page.tsx (Server Component) + components/ ├── scripts/test-backend.sh # runner de tests del backend (dockerizado) +├── scripts/deploy-cloudrun.sh # despliegue de backend + mock en Cloud Run ├── docker-compose.yml # orquestación local de las tres piezas -├── render.yaml # Blueprint de Render (backend + mock) ├── .github/workflows/ci.yml # CI en GitHub Actions -├── DEPLOY.md # cómo desplegar la demo (Render + Vercel) +├── DEPLOY.md # cómo desplegar la demo (Cloud Run + Vercel) ├── .env.example # variables de entorno documentadas ├── CLAUDE.md # guía para Claude Code (+ CLAUDE.md por pieza) └── DEUDA-TECNICA.md # registro de deuda técnica @@ -225,7 +228,7 @@ El mock imita un export real de SAP, así que sus ficheros siguen sus rarezas: ## Documentación adicional -- [`DEPLOY.md`](./DEPLOY.md) — cómo desplegar la demo en vivo (Render Blueprint + Vercel). +- [`DEPLOY.md`](./DEPLOY.md) — cómo desplegar la demo en vivo (Google Cloud Run + Vercel). - [Post en aitorevi.dev](https://aitorevi.dev/blog/sap-analyzer) — caso de estudio: por qué hexagonal, el patrón `Result`/`Error`, el adaptador SAP real y la persistencia con SQLite. - [`CLAUDE.md`](./CLAUDE.md) — guía de trabajo (reglas globales; cada pieza tiene su propio `CLAUDE.md`). diff --git a/docker-compose.yml b/docker-compose.yml index c9efa2f..481e0a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: - SapMock__BaseUrl=http://sap-mock:8080 # The image runs as the non-root $APP_UID user (see backend/Dockerfile); /app belongs to # root so the SQLite file cannot live there. /tmp is world-writable on the dotnet/aspnet - # image. Matches render.yaml's production path. + # image. Matches the Cloud Run production path. - Sqlite__Path=/tmp/sales.db depends_on: - sap-mock diff --git a/frontend/app/api/dashboard/route.ts b/frontend/app/api/dashboard/route.ts deleted file mode 100644 index 77c6dab..0000000 --- a/frontend/app/api/dashboard/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from "next/server"; -import { fetchDashboard, triggerRefresh } from "../../lib/dashboard"; - -export const dynamic = "force-dynamic"; - -export async function GET() { - return NextResponse.json(await fetchDashboard()); -} - -export async function POST() { - return NextResponse.json({ ok: await triggerRefresh() }); -} diff --git a/frontend/app/components/Dashboard.test.tsx b/frontend/app/components/Dashboard.test.tsx index 0a44ace..a3f6089 100644 --- a/frontend/app/components/Dashboard.test.tsx +++ b/frontend/app/components/Dashboard.test.tsx @@ -1,5 +1,5 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import Dashboard from "./Dashboard"; import type { Sale } from "../lib/dashboard"; @@ -28,34 +28,10 @@ const sale = (productName: string, customerId: string): Sale => ({ }); describe("Dashboard", () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("derives the charts from sales and never warms up when data is present", () => { - const fetchSpy = vi.spyOn(globalThis, "fetch"); - - render(); + it("derives the charts from the sales it receives", () => { + render(); expect(screen.getByTestId("by-product")).toHaveTextContent("2"); expect(screen.getByTestId("by-customer")).toHaveTextContent("2"); - expect(screen.queryByRole("status")).not.toBeInTheDocument(); - expect(fetchSpy).not.toHaveBeenCalled(); - }); - - it("shows a warming message and triggers a refresh when starting empty", async () => { - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - json: async () => ({ sales: [] }), - } as Response); - - render(); - - expect(await screen.findByRole("status")).toHaveTextContent( - /Calentando la demo/i, - ); - await waitFor(() => - expect(fetchSpy).toHaveBeenCalledWith("/api/dashboard", { method: "POST" }), - ); }); }); diff --git a/frontend/app/components/Dashboard.tsx b/frontend/app/components/Dashboard.tsx index b16ed66..f624a71 100644 --- a/frontend/app/components/Dashboard.tsx +++ b/frontend/app/components/Dashboard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import ProductRevenueUnitsChart from "./ProductRevenueUnitsChart"; import ByCustomerChart from "./ByCustomerChart"; import RevenueOverTimeChart from "./RevenueOverTimeChart"; @@ -22,27 +22,14 @@ import { uniqueProducts, type Filters, } from "../lib/analytics"; -import type { DashboardData, Sale } from "../lib/dashboard"; +import type { Sale } from "../lib/dashboard"; -type Props = { initialSales: Sale[] }; +type Props = { sales: Sale[] }; -const POLL_INTERVAL_MS = 5000; -const MAX_ATTEMPTS = 30; - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -export default function Dashboard({ initialSales }: Props) { - const initialEmpty = initialSales.length === 0; - - const [sales, setSales] = useState(initialSales); +export default function Dashboard({ sales }: Props) { const [filters, setFilters] = useState(EMPTY_FILTERS); - const [warming, setWarming] = useState(initialEmpty); - const [gaveUp, setGaveUp] = useState(false); - const running = useRef(false); - const hasData = sales.length > 0; const active = hasActiveFilters(filters); - const range = useMemo(() => dateRange(sales), [sales]); const productOptions = useMemo(() => uniqueProducts(sales), [sales]); const customerOptions = useMemo(() => uniqueCustomers(sales), [sales]); @@ -61,72 +48,19 @@ export default function Dashboard({ initialSales }: Props) { [byProduct, filtered], ); - const poll = useCallback(async () => { - if (running.current) return; - running.current = true; - - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - try { - await fetch("/api/dashboard", { method: "POST" }); - } catch { - } - try { - const res = await fetch("/api/dashboard", { cache: "no-store" }); - if (res.ok) { - const data: DashboardData = await res.json(); - if (data.sales.length > 0) { - setSales(data.sales); - setWarming(false); - running.current = false; - return; - } - } - } catch { - } - await delay(POLL_INTERVAL_MS); - } - - setWarming(false); - setGaveUp(true); - running.current = false; - }, []); - - useEffect(() => { - if (!initialEmpty) return; - const id = setTimeout(poll, 0); - return () => clearTimeout(id); - }, [initialEmpty, poll]); - return ( <> - {warming && !hasData && ( -

- Calentando la demo… el backend gratuito de Render tarda ~1 min en arrancar en frío. - Reintentando solo. -

- )} - {gaveUp && !hasData && ( -

- La demo sigue arrancando.{" "} - -

- )} - - {hasData && ( - setFilters(EMPTY_FILTERS)} - range={range} - productOptions={productOptions} - customerOptions={customerOptions} - active={active} - /> - )} + setFilters(EMPTY_FILTERS)} + range={range} + productOptions={productOptions} + customerOptions={customerOptions} + active={active} + /> - {hasData && filtered.length === 0 && ( + {filtered.length === 0 && (

No hay ventas para los filtros seleccionados.{" "}