Compare commits
83 Commits
8f65efd5d1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 265e1c934b | |||
| ddf27d9b17 | |||
| 377b88c48d | |||
| b356e70148 | |||
| 708e550d06 | |||
| 0cce1c8170 | |||
| 34be6c58bc | |||
| 7bc9e67e96 | |||
| 93b3904755 | |||
| f44d57629f | |||
| 8222be2f0e | |||
| 177f2104c1 | |||
| f106a2bb02 | |||
| 27960c9a43 | |||
| fc7a1f9787 | |||
| ef3719187d | |||
| 7a93a28055 | |||
| f822509169 | |||
| d76c49fb9e | |||
| 9e7abfafc8 | |||
| 4d1883b459 | |||
| 5bcf65ff02 | |||
| 89e7d08d19 | |||
| 126a121056 | |||
| 31877fde9e | |||
| 0a38b2c374 | |||
| b8061ae31f | |||
| 145aa11c55 | |||
| 730eee6c8a | |||
| 4410e968db | |||
| 82a225de67 | |||
| adc0b0a0d0 | |||
| 9bf79a15ed | |||
| b46eb7a70f | |||
| ee86af6183 | |||
| 870e1bd4c2 | |||
| c269d8b296 | |||
| aac93678bb | |||
| c00d4fe157 | |||
| f5c8cf5fdc | |||
| b33fe35c4b | |||
| 73456c1424 | |||
| 9eb2b12fea | |||
| dfb5ceb926 | |||
| 91fb23bc53 | |||
| 58442da355 | |||
| 9bab9db4df | |||
| c82e234d6c | |||
| ecf61e7e1d | |||
| dafb3555d7 | |||
| 0d5fcf909c | |||
| 236635fbf4 | |||
| 0572097fb2 | |||
| 938aa2c6d3 | |||
| 8ebd7e4ee2 | |||
| 536b3659bb | |||
| 67f3237761 | |||
| 675b1e51dd | |||
| a83f9e63b9 | |||
| a75d0e1adc | |||
| e42eeb6324 | |||
| 9d45799900 | |||
| 946723197e | |||
| 3ea57f00b6 | |||
| 311f63e812 | |||
| 1d233fdc19 | |||
| c6eb1a9450 | |||
| 49a239006d | |||
| 6c5aa61f09 | |||
| 4c1ffe3d01 | |||
| 4e67c29267 | |||
| acb9be8345 | |||
| 189e9a218a | |||
| c4516c6f23 | |||
| 798b3e4f6b | |||
| a6d7e1d87f | |||
| 54d9a36686 | |||
| 24b565f5ea | |||
| bde25d8d84 | |||
| 8b6d6ba1d0 | |||
| e5da0301de | |||
| 318cb6037e | |||
| 3b456eb481 |
+2
-2
@@ -49,8 +49,8 @@ AUTHENTIK_CLIENT_ID=your-authentik-client-id
|
||||
AUTHENTIK_CLIENT_SECRET=your-authentik-client-secret
|
||||
AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
|
||||
|
||||
# N8N automation (future)
|
||||
# N8N_WEBHOOK_URL=http://10.10.10.166:5678/webhook
|
||||
# PMTiles rebuild webhook (pmtiles-webhook systemd service on satra)
|
||||
N8N_WEBHOOK_URL=http://10.10.10.166:9876
|
||||
|
||||
# External tool URLs (displayed in dashboard)
|
||||
NEXT_PUBLIC_GITEA_URL=http://10.10.10.166:3002
|
||||
|
||||
+6
-1
@@ -24,9 +24,13 @@ COPY . .
|
||||
ARG NEXT_PUBLIC_STORAGE_ADAPTER=database
|
||||
ARG NEXT_PUBLIC_APP_NAME=ArchiTools
|
||||
ARG NEXT_PUBLIC_APP_URL=https://tools.beletage.ro
|
||||
ARG NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
|
||||
ARG NEXT_PUBLIC_PMTILES_URL=
|
||||
ENV NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER}
|
||||
ENV NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}
|
||||
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
ENV NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL}
|
||||
ENV NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL}
|
||||
|
||||
# Increase memory for Next.js build if VM has limited RAM
|
||||
ENV NODE_OPTIONS="--max-old-space-size=2048"
|
||||
@@ -37,9 +41,10 @@ FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV TZ=Europe/Bucharest
|
||||
|
||||
# Install system deps + create user in a single layer
|
||||
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf \
|
||||
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf tzdata \
|
||||
&& addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
|
||||
+45
-6
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
architools:
|
||||
build:
|
||||
@@ -8,6 +6,8 @@ services:
|
||||
- NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER:-database}
|
||||
- NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME:-ArchiTools}
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://tools.beletage.ro}
|
||||
- NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
|
||||
- NEXT_PUBLIC_PMTILES_URL=/tiles/pmtiles/overview.pmtiles
|
||||
container_name: architools
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -58,6 +58,8 @@ services:
|
||||
- ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-}
|
||||
# Martin vector tile server (geoportal)
|
||||
- NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
|
||||
# PMTiles overview tiles — proxied through tile-cache nginx (HTTPS, no mixed-content)
|
||||
- NEXT_PUBLIC_PMTILES_URL=/tiles/pmtiles/overview.pmtiles
|
||||
# DWG-to-DXF sidecar
|
||||
- DWG2DXF_URL=http://dwg2dxf:5001
|
||||
# Email notifications (Brevo SMTP)
|
||||
@@ -68,6 +70,10 @@ services:
|
||||
- NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
|
||||
- NOTIFICATION_FROM_NAME=Alerte Termene
|
||||
- NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987
|
||||
# Weekend Deep Sync email reports (comma-separated for multiple recipients)
|
||||
- WEEKEND_SYNC_EMAIL=${WEEKEND_SYNC_EMAIL:-}
|
||||
# PMTiles rebuild webhook (pmtiles-webhook systemd service on host)
|
||||
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-http://10.10.10.166:9876}
|
||||
# Portal-only users (comma-separated, redirected to /portal)
|
||||
- PORTAL_ONLY_USERS=dtiurbe,d.tiurbe
|
||||
# Address Book API (inter-service auth for external tools)
|
||||
@@ -100,11 +106,44 @@ services:
|
||||
start_period: 10s
|
||||
|
||||
martin:
|
||||
image: ghcr.io/maplibre/martin:v0.15.0
|
||||
build:
|
||||
context: .
|
||||
dockerfile: martin.Dockerfile
|
||||
container_name: martin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3010:3000"
|
||||
command: ["--default-srid", "3844"]
|
||||
# No host port — only accessible via tile-cache nginx proxy
|
||||
command: ["--config", "/config/martin.yaml"]
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://architools_user:stictMyFon34!_gonY@10.10.10.166:5432/architools_db
|
||||
|
||||
tile-cache:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tile-cache.Dockerfile
|
||||
container_name: tile-cache
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3010:80"
|
||||
depends_on:
|
||||
- martin
|
||||
volumes:
|
||||
- tile-cache-data:/var/cache/nginx/tiles
|
||||
|
||||
tippecanoe:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: tippecanoe.Dockerfile
|
||||
container_name: tippecanoe
|
||||
profiles: ["tools"]
|
||||
environment:
|
||||
- DB_HOST=10.10.10.166
|
||||
- DB_PORT=5432
|
||||
- DB_NAME=architools_db
|
||||
- DB_USER=architools_user
|
||||
- DB_PASS=stictMyFon34!_gonY
|
||||
- MINIO_ENDPOINT=http://10.10.10.166:9002
|
||||
- MINIO_ACCESS_KEY=admin
|
||||
- MINIO_SECRET_KEY=MinioStrongPass123
|
||||
|
||||
volumes:
|
||||
tile-cache-data:
|
||||
|
||||
+5
-3
@@ -129,9 +129,11 @@ Quick reference: entry points, key files, API routes, and cross-module dependenc
|
||||
### Geoportal
|
||||
- **Route**: `/geoportal`
|
||||
- **Main component**: `components/geoportal-module.tsx`
|
||||
- **Key components**: `components/map-viewer.tsx` (MapLibre), `components/basemap-switcher.tsx`, `components/selection-toolbar.tsx`, `components/feature-info-panel.tsx`
|
||||
- **API routes**: `/api/geoportal/*` (search, boundary-check, uat-bounds, setup-views)
|
||||
- **Cross-deps**: **parcel-sync** (declared dependency — uses PostGIS data)
|
||||
- **Key components**: `components/map-viewer.tsx` (MapLibre, PMTiles protocol), `components/basemap-switcher.tsx`, `components/selection-toolbar.tsx`, `components/feature-info-panel.tsx`
|
||||
- **Tile infrastructure**: Martin v1.4.0 (live MVT) -> nginx tile-cache (7d TTL) -> Traefik; PMTiles (z0-z18, MinIO) for pre-generated overview tiles
|
||||
- **Monitor page**: `/monitor` — nginx/Martin/PMTiles status, rebuild + warm-cache actions
|
||||
- **API routes**: `/api/geoportal/*` (search, boundary-check, uat-bounds, setup-views, monitor)
|
||||
- **Cross-deps**: **parcel-sync** (declared dependency — uses PostGIS data), **MinIO** (PMTiles storage), **N8N** (rebuild webhook)
|
||||
|
||||
### Visual CoPilot
|
||||
- **Route**: `/visual-copilot`
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Geoportal Continuous Improvement — Mega Prompt
|
||||
|
||||
Use this prompt to start a new session focused on geoportal tile serving improvements.
|
||||
|
||||
---
|
||||
|
||||
## Context Prompt (copy-paste to Claude)
|
||||
|
||||
```
|
||||
Scopul acestei sesiuni este imbunatatirea continua a tile serving-ului pentru modulul Geoportal din ArchiTools.
|
||||
|
||||
Citeste aceste fisiere INAINTE de orice:
|
||||
- CLAUDE.md (project conventions)
|
||||
- geoportal/TILE-SERVER-EVALUATION.md (current architecture + roadmap)
|
||||
- src/modules/geoportal/components/map-viewer.tsx (MapLibre + PMTiles integration)
|
||||
- martin.yaml (Martin tile server config)
|
||||
- docker-compose.yml (infrastructure stack)
|
||||
- scripts/rebuild-overview-tiles.sh (PMTiles generation pipeline)
|
||||
- src/app/api/geoportal/monitor/route.ts (monitoring API)
|
||||
- src/app/(modules)/monitor/page.tsx (monitoring dashboard)
|
||||
|
||||
## Arhitectura curenta (2026-03-28):
|
||||
|
||||
Pipeline: Browser → PMTiles (MinIO, z0-z18, ~1-2 GB) | Martin (PostGIS) doar pentru gis_terenuri_status + gis_cladiri_status
|
||||
Cache: nginx tile-cache (7d TTL) in fata Martin | Browser cache 24h | PMTiles servit direct din MinIO
|
||||
|
||||
Stack:
|
||||
- PMTiles: overview.pmtiles pe MinIO (10.10.10.166:9002/tiles/overview.pmtiles)
|
||||
- nginx tile-cache: port 3010, proxy_cache 2GB, 7d TTL
|
||||
- Martin v1.4: port intern 3000, config baked in image, pool_size 8
|
||||
- tippecanoe Docker: one-shot rebuild, profiles: ["tools"]
|
||||
- N8N webhook: auto-rebuild dupa weekend deep sync
|
||||
|
||||
Rebuild PMTiles: ~45-60 min (565K+ features, z0-z18)
|
||||
Server: VM satra (10.10.10.166), 6 CPU, 16 GB RAM, Docker, Portainer CE
|
||||
|
||||
IMPORTANT:
|
||||
- NEXT_PUBLIC_* vars TREBUIE declarate ca ARG+ENV in Dockerfile (altfel webpack nu le vede)
|
||||
- Portainer CE nu monteaza fisiere din repo — bake configs in Docker images
|
||||
- Dupa schimbari la Dockerfile/NEXT_PUBLIC_: docker compose build --no-cache architools
|
||||
|
||||
Comenzi server (SSH bulibasa@10.10.10.166):
|
||||
cd /tmp/ArchiTools && git pull && docker compose --profile tools build tippecanoe && docker compose --profile tools run --rm tippecanoe
|
||||
docker compose build --no-cache architools && docker compose up -d architools
|
||||
bash /tmp/ArchiTools/scripts/warm-tile-cache.sh http://10.10.10.166:3010
|
||||
|
||||
Monitor dashboard: https://tools.beletage.ro/monitor
|
||||
N8N: http://n8n.beletage.ro (workflow "PMTiles Rebuild")
|
||||
|
||||
npx next build TREBUIE sa treaca dupa fiecare schimbare.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist periodic (lunar):
|
||||
|
||||
### 1. Check MLT Production Readiness
|
||||
```
|
||||
Verifica daca Martin suporta generare MLT din PostGIS (nu doar servire din MBTiles).
|
||||
Cauta:
|
||||
- Martin releases: https://github.com/maplibre/martin/releases
|
||||
- Martin MLT PR: https://github.com/maplibre/martin/pull/2512
|
||||
- PostGIS MLT: cauta "ST_AsMLT" in PostGIS development
|
||||
- MapLibre GL JS MLT: https://maplibre.org/maplibre-tile-spec/implementation-status/
|
||||
|
||||
Daca Martin poate genera MLT din PostGIS live:
|
||||
1. Testeaza pe un layer (gis_terenuri) cu encoding: "mlt" in map-viewer
|
||||
2. Compara tile sizes MVT vs MLT
|
||||
3. Daca merge, aplica pe toate layerele Martin
|
||||
|
||||
Status curent (2026-03-28): NU e viabil. Martin doar serveste MLT pre-generat, nu transcodeaza din PostGIS.
|
||||
```
|
||||
|
||||
### 2. mvt-rs Parallel Evaluation
|
||||
```
|
||||
Evalueaza mvt-rs ca alternativa Martin pentru deployment multi-tenant.
|
||||
|
||||
Prompt gata de folosit:
|
||||
|
||||
"Deployeaza mvt-rs v0.16+ in parallel cu Martin pe ArchiTools.
|
||||
|
||||
Context:
|
||||
- PostgreSQL: 10.10.10.166:5432, db architools_db, user architools_user
|
||||
- Martin actual: martin.yaml cu 9 surse PostGIS (EPSG:3844)
|
||||
- Docker stack: Portainer CE, Traefik v3
|
||||
- Scopul: per-layer access control pentru clienti externi
|
||||
|
||||
Steps:
|
||||
1. Adauga mvt-rs in docker-compose.yml pe port 3011
|
||||
2. Configureaza aceleasi layere ca martin.yaml
|
||||
3. Test: toate proprietatile apar in MVT? Performance vs Martin?
|
||||
4. Admin UI: creeaza user test, asigneaza permisiuni per layer
|
||||
5. Decision matrix: cand trecem de la Martin la mvt-rs
|
||||
|
||||
NU modifica setup-ul Martin existent. Evaluare paralela doar.
|
||||
mvt-rs repo: https://github.com/mvt-proj/mvt-rs
|
||||
Citeste CLAUDE.md si geoportal/TILE-SERVER-EVALUATION.md inainte."
|
||||
```
|
||||
|
||||
### 3. PMTiles Rebuild Optimization
|
||||
```
|
||||
Daca rebuild dureaza >60 min sau fisierul >3 GB:
|
||||
- Evalueaza tile-join pentru rebuild partial (doar layerul modificat)
|
||||
- Evalueaza --no-tile-size-limit vs --drop-densest-as-needed trade-off
|
||||
- Evalueaza split: un PMTiles per UAT sincronizat (rebuild doar orasul modificat)
|
||||
- Evalueaza cron nightly vs rebuild per sync event
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Limitations
|
||||
- tippecanoe `--drop-densest-as-needed` poate pierde features in zone dense la zoom mic
|
||||
- PMTiles data e statica — parcele noi nu apar pana la rebuild
|
||||
- MinIO CORS headers necesita Range + Content-Range exposed
|
||||
- Martin `pool_size: 8` — nu creste fara upgrade PostgreSQL
|
||||
- Portainer CE nu injecteaza env vars la build — toate in docker-compose.yml
|
||||
@@ -0,0 +1,334 @@
|
||||
# Tile Server Evaluation — ArchiTools Geoportal (March 2026)
|
||||
|
||||
## Context
|
||||
|
||||
ArchiTools Geoportal serves vector tiles (MVT) from PostgreSQL 16 + PostGIS 3 via Martin.
|
||||
Data: ~330K GIS features (parcels, buildings, admin boundaries) in EPSG:3844 (Stereo70), growing to 1M+.
|
||||
Frontend: MapLibre GL JS 5.21, Next.js 16, Docker self-hosted via Portainer CE.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
1. Martin v0.15.0 was running in **auto-discovery mode** — the existing `martin.yaml` config was never mounted
|
||||
2. Building labels (`cadastral_ref`) missing from MVT tiles despite the view exposing them
|
||||
3. Performance concerns at scale (330K -> 1M+ features)
|
||||
|
||||
---
|
||||
|
||||
## Solutions Evaluated (7 options + emerging tech)
|
||||
|
||||
### 1. Martin (Fix + Upgrade) — WINNER
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Root cause | `martin.yaml` not mounted in docker-compose — Martin ran in auto-discovery mode |
|
||||
| Fix | Bake config into custom image via Dockerfile + upgrade v0.15 -> v1.4.0 |
|
||||
| Performance | Fastest tile server benchmarked (2-3x faster than #2 Tegola) |
|
||||
| EPSG:3844 | Native support via `default_srid: 3844` |
|
||||
| New in v1.4 | ZSTD compression, MLT format, materialized views, better logging |
|
||||
|
||||
**Status: IMPLEMENTED AND VERIFIED IN PRODUCTION** (2026-03-27)
|
||||
|
||||
### 2. pg_tileserv (CrunchyData)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Architecture | Go binary, zero-config, delegates ST_AsMVT to PostGIS |
|
||||
| Property control | Auto from schema + URL `?properties=` parameter |
|
||||
| Performance | 2-3x slower than Martin (Rechsteiner benchmark) |
|
||||
| EPSG:3844 | Supported (auto-reprojects via ST_Transform) |
|
||||
| Killer feature | Function-based sources (full SQL tile functions) |
|
||||
| Dealbreaker | View extent estimation bug (#156) affects all our views, development stagnant (last release Feb 2025) |
|
||||
|
||||
**Verdict: NO** — slower, buggy with views, stagnant development.
|
||||
|
||||
### 3. Tegola (Go-based)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Architecture | Go, TOML config, explicit per-layer SQL |
|
||||
| Performance | 2nd in benchmarks, but 2-3x slower than Martin |
|
||||
| Built-in cache | File, S3/MinIO, Redis — with seed/purge CLI |
|
||||
| EPSG:3844 | **NOT SUPPORTED** (only 3857/4326) — requires ST_Transform in every query |
|
||||
| Killer feature | Built-in tile seeding and cache purging |
|
||||
|
||||
**Verdict: NO** — EPSG:3844 not supported, dealbreaker for our data.
|
||||
|
||||
### 4. t-rex (Rust-based)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Status | **Abandoned/unmaintained** — no releases since 2023 |
|
||||
|
||||
**Verdict: NO** — dead project.
|
||||
|
||||
### 5. GeoJSON Direct from Next.js API
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| 330K features | ~270 MB uncompressed, 800 MB-1.4 GB browser memory |
|
||||
| Browser impact | 10-30s main thread freeze, mobile crash |
|
||||
| Pan/zoom | Full re-fetch on every viewport change, flickering |
|
||||
| Viable range | Only at zoom 16+ with <500 features in viewport |
|
||||
|
||||
**Verdict: NO** — does not scale beyond ~20K features.
|
||||
|
||||
### 6. PMTiles (Pre-generated)
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| Architecture | Single-file tile archive, HTTP Range Requests, no server needed |
|
||||
| Performance | ~5ms per tile (vs 200-2000ms for Martin on low-zoom) |
|
||||
| Property control | tippecanoe gives explicit include/exclude per property |
|
||||
| Update strategy | Full rebuild required (~3-7 min for 330K features) |
|
||||
| EPSG:3844 | Requires reprojection to 4326 via ogr2ogr before tippecanoe |
|
||||
| MinIO serving | Yes — direct HTTP Range Requests with CORS |
|
||||
|
||||
**Verdict: YES as hybrid complement** — excellent for static UAT overview layers (z0-z12), Martin for live detail.
|
||||
|
||||
### 7. Emerging Solutions
|
||||
|
||||
| Solution | Status | Relevance |
|
||||
|---|---|---|
|
||||
| **mvt-rs** (Rust) | v0.16.2, active | Admin UI, auth per layer, cache — good for multi-tenant |
|
||||
| **MLT format** | Stable Jan 2026 | 6x compression, 4x faster decode — Martin v1.3+ supports it |
|
||||
| **BBOX** | Maturing | Similar to Tegola performance, unified raster+vector |
|
||||
| **DuckDB tiles** | Early | Not PostGIS replacement, interesting for GeoParquet |
|
||||
| **FlatGeobuf** | Stable | Good for <100K features, not a tile replacement |
|
||||
|
||||
---
|
||||
|
||||
## Benchmark Reference (Rechsteiner, April 2025)
|
||||
|
||||
| Rank | Server | Language | Relative Speed |
|
||||
|---|---|---|---|
|
||||
| 1 | **Martin** | Rust | 1x (fastest) |
|
||||
| 2 | Tegola | Go | 2-3x slower |
|
||||
| 3 | BBOX | Rust | ~same as Tegola |
|
||||
| 4 | pg_tileserv | Go | ~4x slower |
|
||||
| 5 | TiPg | Python | Slower |
|
||||
| 6 | ldproxy | Java | 4-70x slower |
|
||||
|
||||
Source: [github.com/FabianRechsteiner/vector-tiles-benchmark](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap
|
||||
|
||||
### Phase 1: Martin Fix — DONE (2026-03-27)
|
||||
|
||||
Changes applied:
|
||||
- `martin.Dockerfile`: custom image that COPY-s `martin.yaml` into `/config/`
|
||||
- `docker-compose.yml`: Martin v0.15 -> v1.4.0, build from Dockerfile, `--config` flag
|
||||
- `martin.yaml`: comment updated to reflect v1.4
|
||||
- `map-viewer.tsx`: building labels layer activated (`cladiriLabel` at minzoom 16)
|
||||
|
||||
#### Deployment Lessons Learned
|
||||
|
||||
1. **Docker image tag format changed at v1.0**: old tags use `v` prefix (`v0.15.0`), new tags do not (`1.4.0`). The tag `ghcr.io/maplibre/martin:v1.4.0` does NOT exist — correct is `ghcr.io/maplibre/martin:1.4.0`.
|
||||
|
||||
2. **Portainer CE volume mount pitfall**: volume `./martin.yaml:/config/martin.yaml:ro` fails because Portainer deploys only the docker-compose.yml content, not the full git repo. Docker silently creates an empty directory instead of failing. Solution: bake config into a custom image with a 2-line Dockerfile:
|
||||
```dockerfile
|
||||
FROM ghcr.io/maplibre/martin:1.4.0
|
||||
COPY martin.yaml /config/martin.yaml
|
||||
```
|
||||
|
||||
3. **Martin config format is stable**: YAML format unchanged from v0.15 to v1.4 — `postgres.tables`, `connection_string`, `auto_publish`, `properties` map all work identically. No migration needed.
|
||||
|
||||
4. **PostGIS view geometry type**: Martin logs `UNKNOWN GEOMETRY TYPE` for all views — this is normal for nested views (`SELECT * FROM parent_view`). Views don't register specific geometry types in `geometry_columns`. Does not affect tile generation or property inclusion.
|
||||
|
||||
### Phase 2A: nginx Tile Cache — DONE (2026-03-27)
|
||||
|
||||
**Impact**: 10-100x faster on repeat requests, zero PostGIS load for cached tiles.
|
||||
|
||||
Changes applied:
|
||||
- `nginx/tile-cache.conf`: proxy_cache config with 2GB cache zone, 7-day TTL, stale serving
|
||||
- `tile-cache.Dockerfile`: bakes nginx config into custom image (Portainer CE pattern)
|
||||
- `docker-compose.yml`: `tile-cache` container, Martin no longer exposed on host
|
||||
- Gzip passthrough (Martin already compresses), browser caching via Cache-Control headers
|
||||
- CORS headers for cross-origin tile requests
|
||||
|
||||
### Phase 2B: PMTiles — DONE (2026-03-27)
|
||||
|
||||
**Impact**: Sub-10ms overview tiles, zero PostGIS load for z0-z18.
|
||||
|
||||
Changes applied:
|
||||
- `scripts/rebuild-overview-tiles.sh`: ogr2ogr export (3844->4326) + tippecanoe generation
|
||||
- PMTiles archive: z0-z18, ~1-2 GB, includes all terenuri, cladiri, UATs, and administrativ layers
|
||||
- `map-viewer.tsx`: pmtiles:// protocol registered on MapLibre, hybrid source switching
|
||||
- MinIO bucket `tiles` with public read + CORS for Range Requests
|
||||
- N8N webhook trigger for rebuild (via monitor page)
|
||||
- Monitor page (`/monitor`): rebuild + warm-cache actions with live status polling
|
||||
|
||||
### Phase 2C: MLT Format — DEFERRED
|
||||
|
||||
Martin v1.4 advertises MLT support, but it cannot generate MLT from PostGIS live queries.
|
||||
MLT generation requires pre-built tile archives (tippecanoe does not output MLT either).
|
||||
No actionable path until Martin or tippecanoe adds MLT output from PostGIS sources.
|
||||
|
||||
### Phase 2D: mvt-rs Evaluation — FUTURE (Multi-Tenant)
|
||||
|
||||
**Impact**: Built-in auth, admin UI, per-layer access control.
|
||||
**Effort**: 1-2 days for evaluation + migration.
|
||||
|
||||
Reserved for when external client access to the geoportal is needed.
|
||||
mvt-rs (v0.16.2+, Rust, Salvo framework) provides per-layer auth and admin UI.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Current Architecture (as of 2026-03-27)
|
||||
|
||||
Full tile-serving pipeline in production:
|
||||
|
||||
```
|
||||
PostGIS (EPSG:3844)
|
||||
|
|
||||
+--> Martin v1.4.0 (live MVT from 9 PostGIS views)
|
||||
| |
|
||||
| +--> tile-cache (nginx reverse proxy, 2GB disk, 7d TTL)
|
||||
| |
|
||||
| +--> Traefik (tools.beletage.ro/tiles)
|
||||
|
|
||||
+--> ogr2ogr (3844->4326) + tippecanoe (z0-z18)
|
||||
|
|
||||
+--> PMTiles archive (~1-2 GB)
|
||||
|
|
||||
+--> MinIO bucket "tiles" (HTTP Range Requests)
|
||||
|
|
||||
+--> MapLibre (pmtiles:// protocol)
|
||||
```
|
||||
|
||||
**Hybrid strategy**:
|
||||
- PMTiles serves pre-generated overview tiles (all zoom levels, all layers)
|
||||
- Martin serves live detail tiles (real-time PostGIS data)
|
||||
- nginx tile-cache sits in front of Martin to absorb repeat requests
|
||||
- Rebuild triggered via N8N webhook from the `/monitor` page
|
||||
|
||||
---
|
||||
|
||||
## Operational Commands
|
||||
|
||||
### Rebuild PMTiles
|
||||
|
||||
Trigger from the Monitor page (`/monitor` -> "Rebuild PMTiles" button), which sends a webhook to N8N.
|
||||
N8N runs `scripts/rebuild-overview-tiles.sh` on the server.
|
||||
|
||||
Manual rebuild (SSH to 10.10.10.166):
|
||||
```bash
|
||||
cd /path/to/architools
|
||||
bash scripts/rebuild-overview-tiles.sh
|
||||
```
|
||||
|
||||
### Warm nginx Cache
|
||||
|
||||
Trigger from the Monitor page (`/monitor` -> "Warm Cache" button).
|
||||
Pre-loads frequently accessed tiles into the nginx disk cache.
|
||||
|
||||
### Purge nginx Tile Cache
|
||||
|
||||
```bash
|
||||
docker exec tile-cache rm -rf /var/cache/nginx/tiles/*
|
||||
docker exec tile-cache nginx -s reload
|
||||
```
|
||||
|
||||
### Restart Martin (after PostGIS view changes)
|
||||
|
||||
```bash
|
||||
docker restart martin
|
||||
```
|
||||
|
||||
Martin caches source schema at startup — must restart after DDL changes to pick up new columns.
|
||||
|
||||
### Check PMTiles Status
|
||||
|
||||
```bash
|
||||
# Check file size and last modified in MinIO
|
||||
docker exec minio mc stat local/tiles/overview.pmtiles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
### Martin v1.4.0 Deployment Architecture
|
||||
|
||||
```
|
||||
Gitea repo (martin.yaml + martin.Dockerfile)
|
||||
-> Portainer CE builds custom image: FROM martin:1.4.0, COPY martin.yaml
|
||||
-> Container starts with --config /config/martin.yaml
|
||||
-> Reads DATABASE_URL from environment
|
||||
-> Serves 9 PostGIS view sources on port 3000
|
||||
-> Host maps 3010:3000
|
||||
-> Traefik proxies tools.beletage.ro/tiles -> host:3010
|
||||
```
|
||||
|
||||
**Critical**: Do NOT use volume mounts for config files in Portainer CE stacks.
|
||||
Always bake configs into custom images via Dockerfile COPY.
|
||||
|
||||
### Martin Config (validated compatible v0.15 through v1.4)
|
||||
|
||||
The `martin.yaml` at project root defines 9 sources with explicit properties.
|
||||
Config format unchanged from v0.15 to v1.4 — no migration needed.
|
||||
|
||||
Key config features used:
|
||||
- `auto_publish: false` — only explicitly listed sources are served
|
||||
- `default_srid: 3844` — all sources use Stereo70
|
||||
- `properties:` map per source — explicit column name + PostgreSQL type
|
||||
- `minzoom/maxzoom` per source — controls tile generation range
|
||||
- `bounds: [20.2, 43.5, 30.0, 48.3]` — approximate Romania extent
|
||||
|
||||
### Docker Image Tag Convention
|
||||
|
||||
Martin changed tag format at v1.0:
|
||||
- Pre-1.0: `ghcr.io/maplibre/martin:v0.15.0` (with `v` prefix)
|
||||
- Post-1.0: `ghcr.io/maplibre/martin:1.4.0` (no `v` prefix)
|
||||
- Also available: `latest`, `nightly`
|
||||
|
||||
### PostGIS View Chain
|
||||
|
||||
```
|
||||
GisFeature table (Prisma) -> gis_features view -> gis_terenuri / gis_cladiri / gis_administrativ
|
||||
-> gis_terenuri_status / gis_cladiri_status (with JOINs)
|
||||
GisUat table -> gis_uats_z0/z5/z8/z12 (with ST_SimplifyPreserveTopology)
|
||||
```
|
||||
|
||||
### MapLibre Layer Architecture
|
||||
|
||||
```
|
||||
Sources (Martin): gis_uats_z0, z5, z8, z12, administrativ, terenuri, cladiri
|
||||
Layers per source: fill + line + label (where applicable)
|
||||
Selection: Separate highlight layers on terenuri source
|
||||
Drawing: GeoJSON source for freehand/rect polygon
|
||||
Building labels: cladiriLabel layer, cadastral_ref at minzoom 16
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployment Pitfalls (Discovered During Implementation)
|
||||
|
||||
1. **Portainer CE does not expose repo files to containers at runtime.** Volume mounts like `./file.conf:/etc/file.conf:ro` fail silently — Docker creates an empty directory. Always bake config files into custom images via Dockerfile COPY.
|
||||
|
||||
2. **Martin Docker tag format change at v1.0.** `v1.4.0` does not exist, `1.4.0` does. Always check [ghcr.io/maplibre/martin](https://github.com/maplibre/martin/pkgs/container/martin) for actual tags.
|
||||
|
||||
3. **Martin logs `UNKNOWN GEOMETRY TYPE` for PostGIS views.** This is normal — nested views don't register geometry types in `geometry_columns`. Does not affect functionality.
|
||||
|
||||
4. **Martin auto-discovery mode is unreliable for property inclusion.** Always use explicit config with `auto_publish: false` and per-source `properties:` definitions.
|
||||
|
||||
5. **Martin caches source schema at startup.** After PostGIS view DDL changes (e.g., adding columns to gis_features), Martin must be restarted to pick up new columns.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Martin Documentation](https://maplibre.org/martin/)
|
||||
- [Martin Releases](https://github.com/maplibre/martin/releases)
|
||||
- [Martin Container Registry](https://github.com/maplibre/martin/pkgs/container/martin)
|
||||
- [Vector Tiles Benchmark (Rechsteiner 2025)](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
|
||||
- [PMTiles Specification](https://github.com/protomaps/PMTiles)
|
||||
- [tippecanoe (Felt)](https://github.com/felt/tippecanoe)
|
||||
- [MLT Format Announcement](https://maplibre.org/news/2026-01-23-mlt-release/)
|
||||
- [mvt-rs](https://github.com/mvt-proj/mvt-rs)
|
||||
- [pg_tileserv](https://github.com/CrunchyData/pg_tileserv)
|
||||
- [Tegola](https://github.com/go-spatial/tegola)
|
||||
- [Serving Vector Tiles Fast (Spatialists)](https://spatialists.ch/posts/2025/04/05-serving-vector-tiles-fast/)
|
||||
@@ -0,0 +1,292 @@
|
||||
# Skill: MapLibre GL JS Performance for Large GIS Datasets
|
||||
|
||||
## When to Use
|
||||
|
||||
When building web maps with MapLibre GL JS that display large spatial datasets (>10K features). Covers source type selection, layer optimization, label rendering, and client-side performance tuning.
|
||||
|
||||
---
|
||||
|
||||
## Source Type Decision Matrix
|
||||
|
||||
| Dataset Size | Recommended Source | Reason |
|
||||
|---|---|---|
|
||||
| <2K features | GeoJSON | Simple, full property access, smooth |
|
||||
| 2K-20K features | GeoJSON (careful) | Works but `setData()` updates lag 200-400ms |
|
||||
| 20K-100K features | Vector tiles (MVT) | GeoJSON causes multi-second freezes |
|
||||
| 100K+ features | Vector tiles (MVT) | GeoJSON crashes mobile, 1GB+ memory on desktop |
|
||||
| Static/archival | PMTiles | Pre-generated, ~5ms per tile, zero server load |
|
||||
|
||||
### GeoJSON Memory Profile
|
||||
|
||||
| Features (polygons, ~20 coords each) | JSON Size | Browser Memory | Load Time |
|
||||
|---|---|---|---|
|
||||
| 1K | 0.8 MB | ~50 MB | <1s |
|
||||
| 10K | 8 MB | ~200 MB | 1-3s |
|
||||
| 50K | 41 MB | ~600 MB | 5-15s freeze |
|
||||
| 100K | 82 MB | ~1.2 GB | 15-30s freeze |
|
||||
| 330K | 270 MB | ~1.5 GB+ | Crash |
|
||||
|
||||
The bottleneck is `JSON.stringify` on the main thread when data is transferred to the Web Worker for `geojson-vt` tiling.
|
||||
|
||||
---
|
||||
|
||||
## Vector Tile Source Configuration
|
||||
|
||||
### Zoom-Dependent Source Loading
|
||||
|
||||
Don't load data you don't need. Set `minzoom`/`maxzoom` on sources and layers:
|
||||
|
||||
```typescript
|
||||
// Source: only request tiles in useful zoom range
|
||||
map.addSource('parcels', {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.example.com/parcels/{z}/{x}/{y}'],
|
||||
minzoom: 10, // don't request below z10
|
||||
maxzoom: 18, // server maxzoom (tiles overzoom beyond this)
|
||||
});
|
||||
|
||||
// Layer: only render when meaningful
|
||||
map.addLayer({
|
||||
id: 'parcels-fill',
|
||||
type: 'fill',
|
||||
source: 'parcels',
|
||||
'source-layer': 'parcels',
|
||||
minzoom: 13, // visible from z13 (even if source loads from z10)
|
||||
maxzoom: 20, // render up to z20 (overzooming tiles from z18)
|
||||
paint: { ... },
|
||||
});
|
||||
```
|
||||
|
||||
### Multiple Sources at Different Detail Levels
|
||||
|
||||
For large datasets, serve simplified versions at low zoom:
|
||||
|
||||
```typescript
|
||||
// Simplified overview (server: ST_Simplify, fewer properties)
|
||||
map.addSource('parcels-overview', {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.example.com/parcels_simplified/{z}/{x}/{y}'],
|
||||
minzoom: 6, maxzoom: 14,
|
||||
});
|
||||
|
||||
// Full detail
|
||||
map.addSource('parcels-detail', {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.example.com/parcels/{z}/{x}/{y}'],
|
||||
minzoom: 14, maxzoom: 18,
|
||||
});
|
||||
|
||||
// Layers with zoom handoff
|
||||
map.addLayer({
|
||||
id: 'parcels-overview-fill', source: 'parcels-overview',
|
||||
minzoom: 10, maxzoom: 14, // disappears at z14
|
||||
...
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'parcels-detail-fill', source: 'parcels-detail',
|
||||
minzoom: 14, // appears at z14
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Label Rendering Best Practices
|
||||
|
||||
### Text Labels on Polygons
|
||||
|
||||
```typescript
|
||||
map.addLayer({
|
||||
id: 'parcel-labels',
|
||||
type: 'symbol',
|
||||
source: 'parcels',
|
||||
'source-layer': 'parcels',
|
||||
minzoom: 16, // only show labels at high zoom
|
||||
layout: {
|
||||
'text-field': ['coalesce', ['get', 'cadastral_ref'], ''],
|
||||
'text-font': ['Noto Sans Regular'],
|
||||
'text-size': 10,
|
||||
'text-anchor': 'center',
|
||||
'text-allow-overlap': false, // prevent label collisions
|
||||
'text-max-width': 8, // wrap long labels (in ems)
|
||||
'text-optional': true, // label is optional — feature renders without it
|
||||
'symbol-placement': 'point', // placed at polygon centroid
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#1e3a5f',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 1, // readability on any background
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Performance Tips for Labels
|
||||
|
||||
- **`text-allow-overlap: false`** — essential for dense datasets, MapLibre auto-removes colliding labels
|
||||
- **`text-optional: true`** — allow symbol layer to show icon without text if text collides
|
||||
- **High `minzoom`** (16+) — labels are expensive to render, only show when meaningful
|
||||
- **`text-font`** — use fonts available in the basemap style. Custom fonts require glyph server.
|
||||
- **`symbol-sort-key`** — prioritize which labels show first (e.g., larger parcels)
|
||||
|
||||
```typescript
|
||||
layout: {
|
||||
'symbol-sort-key': ['*', -1, ['get', 'area_value']], // larger areas get priority
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Selection and Interaction Patterns
|
||||
|
||||
### Click Selection (single feature)
|
||||
|
||||
```typescript
|
||||
map.on('click', 'parcels-fill', (e) => {
|
||||
const feature = e.features?.[0];
|
||||
if (!feature) return;
|
||||
const props = feature.properties;
|
||||
|
||||
// Highlight via filter
|
||||
map.setFilter('selection-highlight', ['==', 'object_id', props.object_id]);
|
||||
});
|
||||
```
|
||||
|
||||
### queryRenderedFeatures for Box/Polygon Selection
|
||||
|
||||
```typescript
|
||||
// Rectangle selection
|
||||
const features = map.queryRenderedFeatures(
|
||||
[[x1, y1], [x2, y2]], // pixel bbox
|
||||
{ layers: ['parcels-fill'] }
|
||||
);
|
||||
|
||||
// Features are from rendered tiles — properties may be limited
|
||||
// For full properties, fetch from API by ID
|
||||
```
|
||||
|
||||
**Important:** `queryRenderedFeatures` only returns features currently rendered in the viewport tiles. Properties in MVT tiles may be a subset of the full database record. For detailed properties, use a separate API endpoint.
|
||||
|
||||
### Highlight Layer Pattern
|
||||
|
||||
Dedicated layer with dynamic filter for selection highlighting:
|
||||
|
||||
```typescript
|
||||
// Add once during map setup
|
||||
map.addLayer({
|
||||
id: 'selection-fill',
|
||||
type: 'fill',
|
||||
source: 'parcels',
|
||||
'source-layer': 'parcels',
|
||||
filter: ['==', 'object_id', '__NONE__'], // show nothing initially
|
||||
paint: { 'fill-color': '#f59e0b', 'fill-opacity': 0.5 },
|
||||
});
|
||||
|
||||
// Update filter on selection
|
||||
const ids = Array.from(selectedIds);
|
||||
map.setFilter('selection-fill',
|
||||
ids.length > 0
|
||||
? ['in', ['to-string', ['get', 'object_id']], ['literal', ids]]
|
||||
: ['==', 'object_id', '__NONE__']
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basemap Management
|
||||
|
||||
### Multiple Basemap Support
|
||||
|
||||
Switching basemaps requires recreating the map (MapLibre limitation). Preserve view state:
|
||||
|
||||
```typescript
|
||||
const viewStateRef = useRef({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM });
|
||||
|
||||
// Save on every move
|
||||
map.on('moveend', () => {
|
||||
viewStateRef.current = {
|
||||
center: map.getCenter().toArray(),
|
||||
zoom: map.getZoom(),
|
||||
};
|
||||
});
|
||||
|
||||
// On basemap switch: destroy map, recreate with saved view state
|
||||
// All sources + layers must be re-added after style load
|
||||
```
|
||||
|
||||
### Raster Basemaps
|
||||
|
||||
```typescript
|
||||
const style: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: {
|
||||
basemap: {
|
||||
type: 'raster',
|
||||
tiles: ['https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}'],
|
||||
tileSize: 256,
|
||||
attribution: '© Google',
|
||||
},
|
||||
},
|
||||
layers: [{
|
||||
id: 'basemap', type: 'raster', source: 'basemap',
|
||||
minzoom: 0, maxzoom: 20,
|
||||
}],
|
||||
};
|
||||
```
|
||||
|
||||
### Vector Basemaps (OpenFreeMap, MapTiler)
|
||||
|
||||
```typescript
|
||||
// Style URL — includes all sources + layers
|
||||
const map = new maplibregl.Map({
|
||||
style: 'https://tiles.openfreemap.org/styles/liberty',
|
||||
});
|
||||
|
||||
// Hide unwanted built-in layers (e.g., admin boundaries you'll replace)
|
||||
for (const layer of map.getStyle().layers) {
|
||||
if (/boundar|admin/i.test(layer.id)) {
|
||||
map.setLayoutProperty(layer.id, 'visibility', 'none');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Checklist
|
||||
|
||||
### Server Side
|
||||
- [ ] Spatial index (GiST) on geometry column
|
||||
- [ ] Zoom-dependent simplified views for overview levels
|
||||
- [ ] `minzoom`/`maxzoom` per tile source to prevent pathological tiles
|
||||
- [ ] HTTP cache (nginx proxy_cache / Varnish) in front of tile server
|
||||
- [ ] PMTiles for static layers (no DB hit)
|
||||
- [ ] Exclude large geometry columns from list queries
|
||||
|
||||
### Client Side
|
||||
- [ ] Set `minzoom` on layers to avoid rendering at useless zoom levels
|
||||
- [ ] `text-allow-overlap: false` on all symbol layers
|
||||
- [ ] Use `text-optional: true` for labels
|
||||
- [ ] Don't add GeoJSON sources for >20K features
|
||||
- [ ] Use `queryRenderedFeatures` (not `querySourceFeatures`) for interaction
|
||||
- [ ] Preserve view state across basemap switches (ref, not state)
|
||||
- [ ] Debounce viewport-dependent API calls (search, feature loading)
|
||||
|
||||
### Memory Management
|
||||
- [ ] Remove unused sources/layers when switching views
|
||||
- [ ] Clear GeoJSON sources with `setData(emptyFeatureCollection)` before removing
|
||||
- [ ] Use `map.remove()` in cleanup (useEffect return)
|
||||
- [ ] Don't store large GeoJSON in React state (use refs)
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **GeoJSON `setData()` freezes main thread** — `JSON.stringify` runs synchronously for every update
|
||||
2. **`queryRenderedFeatures` returns simplified geometry** — don't use for area/distance calculations
|
||||
3. **Vector tile properties may be truncated** — tile servers can drop properties to fit tile size limits
|
||||
4. **Basemap switch requires full map recreation** — save/restore view state and re-add all overlay layers
|
||||
5. **`text-font` must match basemap fonts** — if using vector basemap, use its font stack; if raster, you need a glyph server
|
||||
6. **Popup/tooltip on dense data causes flicker** — debounce mousemove handlers
|
||||
7. **Large fill layers without `minzoom` tank performance** — 100K polygons at z0 is pathological
|
||||
8. **`map.setFilter` with huge ID lists is slow** — for >1000 selected features, consider a separate GeoJSON source
|
||||
9. **MapLibre CSS must be loaded manually in SSR frameworks** — inject `<link>` in `useEffect` or import statically
|
||||
10. **React strict mode double-mounts effects** — guard map initialization with ref check
|
||||
@@ -0,0 +1,272 @@
|
||||
# Skill: PMTiles Generation Pipeline from PostGIS
|
||||
|
||||
## When to Use
|
||||
|
||||
When you need to pre-generate vector tiles from PostGIS data for fast static serving. Ideal for overview/boundary layers that change infrequently, serving from S3/MinIO/CDN without a tile server, or eliminating database load for tile serving.
|
||||
|
||||
---
|
||||
|
||||
## Complete Pipeline
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Tool | Purpose | Install |
|
||||
|---|---|---|
|
||||
| ogr2ogr (GDAL) | PostGIS export + reprojection | `apt install gdal-bin` or Docker |
|
||||
| tippecanoe | MVT tile generation → PMTiles | `ghcr.io/felt/tippecanoe` Docker image |
|
||||
| mc (MinIO client) | Upload to MinIO/S3 | `brew install minio/stable/mc` |
|
||||
|
||||
### Step 1: Export from PostGIS
|
||||
|
||||
```bash
|
||||
# Single layer — FlatGeobuf is fastest for tippecanoe input
|
||||
ogr2ogr -f FlatGeobuf \
|
||||
-s_srs EPSG:3844 \ # source SRID (your data)
|
||||
-t_srs EPSG:4326 \ # tippecanoe REQUIRES WGS84
|
||||
parcels.fgb \
|
||||
"PG:host=10.10.10.166 dbname=mydb user=myuser password=mypass" \
|
||||
-sql "SELECT id, name, area, geom FROM my_view WHERE geom IS NOT NULL"
|
||||
|
||||
# Multiple layers in parallel
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
parcels.fgb "PG:..." -sql "SELECT ... FROM gis_terenuri" &
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
buildings.fgb "PG:..." -sql "SELECT ... FROM gis_cladiri" &
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
uats.fgb "PG:..." -sql "SELECT ... FROM gis_uats_z12" &
|
||||
wait
|
||||
```
|
||||
|
||||
**Why FlatGeobuf over GeoJSON:**
|
||||
- Binary columnar format — tippecanoe reads it 3-5x faster
|
||||
- No JSON parsing overhead
|
||||
- Streaming read (no need to load entire file in memory)
|
||||
- tippecanoe native support since v2.17+
|
||||
|
||||
### Step 2: Generate PMTiles with tippecanoe
|
||||
|
||||
```bash
|
||||
# Single layer
|
||||
tippecanoe \
|
||||
-o parcels.pmtiles \
|
||||
--name="Parcels" \
|
||||
--layer="parcels" \
|
||||
--minimum-zoom=6 \
|
||||
--maximum-zoom=15 \
|
||||
--base-zoom=15 \
|
||||
--drop-densest-as-needed \
|
||||
--extend-zooms-if-still-dropping \
|
||||
--detect-shared-borders \
|
||||
--simplification=10 \
|
||||
--hilbert \
|
||||
--force \
|
||||
parcels.fgb
|
||||
|
||||
# Multi-layer (combined file)
|
||||
tippecanoe \
|
||||
-o combined.pmtiles \
|
||||
--named-layer=parcels:parcels.fgb \
|
||||
--named-layer=buildings:buildings.fgb \
|
||||
--named-layer=uats:uats.fgb \
|
||||
--minimum-zoom=0 \
|
||||
--maximum-zoom=15 \
|
||||
--drop-densest-as-needed \
|
||||
--detect-shared-borders \
|
||||
--hilbert \
|
||||
--force
|
||||
```
|
||||
|
||||
#### Key tippecanoe Flags
|
||||
|
||||
| Flag | Purpose | When to Use |
|
||||
|---|---|---|
|
||||
| `--minimum-zoom=N` | Lowest zoom level | Always set |
|
||||
| `--maximum-zoom=N` | Highest zoom level (full detail) | Always set |
|
||||
| `--base-zoom=N` | Zoom where ALL features kept (no dropping) | Set to max-zoom |
|
||||
| `--drop-densest-as-needed` | Drop features in dense areas at low zoom | Large polygon datasets |
|
||||
| `--extend-zooms-if-still-dropping` | Auto-increase max zoom if needed | Safety net |
|
||||
| `--detect-shared-borders` | Prevent gaps between adjacent polygons | **Critical for parcels/admin boundaries** |
|
||||
| `--coalesce-densest-as-needed` | Merge small features at low zoom | Building footprints |
|
||||
| `--simplification=N` | Pixel tolerance for geometry simplification | Reduce tile size at low zoom |
|
||||
| `--hilbert` | Hilbert curve ordering | Better compression, always use |
|
||||
| `-y col1 -y col2` | Include ONLY these properties | Reduce tile size |
|
||||
| `-x col1 -x col2` | Exclude these properties | Remove large/unnecessary fields |
|
||||
| `--force` | Overwrite existing output | Scripts |
|
||||
| `--no-feature-limit` | No limit per tile | When density matters |
|
||||
| `--no-tile-size-limit` | No tile byte limit | When completeness matters |
|
||||
|
||||
#### Property Control
|
||||
|
||||
```bash
|
||||
# Include only specific properties (whitelist)
|
||||
tippecanoe -o out.pmtiles -y name -y area -y type parcels.fgb
|
||||
|
||||
# Exclude specific properties (blacklist)
|
||||
tippecanoe -o out.pmtiles -x raw_json -x internal_id parcels.fgb
|
||||
|
||||
# Zoom-dependent properties (different attributes per zoom)
|
||||
# Use tippecanoe-json format with per-feature "tippecanoe" key
|
||||
```
|
||||
|
||||
### Step 3: Upload to MinIO (Atomic Swap)
|
||||
|
||||
```bash
|
||||
# Upload to temp name first
|
||||
mc cp combined.pmtiles myminio/tiles/combined_new.pmtiles
|
||||
|
||||
# Atomic rename (zero-downtime swap)
|
||||
mc mv myminio/tiles/combined_new.pmtiles myminio/tiles/combined.pmtiles
|
||||
```
|
||||
|
||||
### Step 4: MinIO CORS Configuration
|
||||
|
||||
```bash
|
||||
# Required for browser-direct Range Requests
|
||||
mc admin config set myminio api cors_allow_origin="https://tools.beletage.ro"
|
||||
|
||||
# Or bucket policy for public read
|
||||
mc anonymous set download myminio/tiles
|
||||
```
|
||||
|
||||
MinIO CORS must expose Range/Content-Range headers:
|
||||
```json
|
||||
{
|
||||
"CORSRules": [{
|
||||
"AllowedOrigins": ["https://your-domain.com"],
|
||||
"AllowedMethods": ["GET", "HEAD"],
|
||||
"AllowedHeaders": ["Range", "If-None-Match"],
|
||||
"ExposeHeaders": ["Content-Range", "Content-Length", "ETag"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MapLibre GL JS Integration
|
||||
|
||||
```bash
|
||||
npm install pmtiles
|
||||
```
|
||||
|
||||
```typescript
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { Protocol } from 'pmtiles';
|
||||
|
||||
// Register ONCE at app initialization
|
||||
const protocol = new Protocol();
|
||||
maplibregl.addProtocol('pmtiles', protocol.tile);
|
||||
|
||||
// Add source to map
|
||||
map.addSource('my-tiles', {
|
||||
type: 'vector',
|
||||
url: 'pmtiles://https://minio.example.com/tiles/combined.pmtiles',
|
||||
});
|
||||
|
||||
// Add layers
|
||||
map.addLayer({
|
||||
id: 'parcels-fill',
|
||||
type: 'fill',
|
||||
source: 'my-tiles',
|
||||
'source-layer': 'parcels', // layer name from tippecanoe --layer or --named-layer
|
||||
minzoom: 10,
|
||||
maxzoom: 16,
|
||||
paint: { 'fill-color': '#22c55e', 'fill-opacity': 0.15 },
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
maplibregl.removeProtocol('pmtiles');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hybrid Architecture (PMTiles + Live Tile Server)
|
||||
|
||||
```
|
||||
Zoom 0-14: PMTiles from MinIO (pre-generated, ~5ms, zero DB load)
|
||||
Zoom 14+: Martin from PostGIS (live, always-current, ~50-200ms)
|
||||
```
|
||||
|
||||
```typescript
|
||||
// PMTiles for overview
|
||||
map.addSource('overview', {
|
||||
type: 'vector',
|
||||
url: 'pmtiles://https://minio/tiles/overview.pmtiles',
|
||||
});
|
||||
|
||||
// Martin for detail
|
||||
map.addSource('detail', {
|
||||
type: 'vector',
|
||||
tiles: ['https://tiles.example.com/{source}/{z}/{x}/{y}'],
|
||||
minzoom: 14,
|
||||
maxzoom: 18,
|
||||
});
|
||||
|
||||
// Layers with zoom handoff
|
||||
map.addLayer({
|
||||
id: 'parcels-overview', source: 'overview', 'source-layer': 'parcels',
|
||||
minzoom: 6, maxzoom: 14, // PMTiles handles low zoom
|
||||
...
|
||||
});
|
||||
map.addLayer({
|
||||
id: 'parcels-detail', source: 'detail', 'source-layer': 'gis_terenuri',
|
||||
minzoom: 14, // Martin handles high zoom
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rebuild Strategies
|
||||
|
||||
### Nightly Cron
|
||||
|
||||
```bash
|
||||
# crontab -e
|
||||
0 2 * * * /opt/scripts/rebuild-tiles.sh >> /var/log/tile-rebuild.log 2>&1
|
||||
```
|
||||
|
||||
### After Data Sync (webhook/API trigger)
|
||||
|
||||
```bash
|
||||
# Call from sync completion handler
|
||||
curl -X POST http://n8n:5678/webhook/rebuild-tiles
|
||||
```
|
||||
|
||||
### Partial Rebuild (single layer update)
|
||||
|
||||
```bash
|
||||
# Rebuild just parcels, then merge with existing layers
|
||||
tippecanoe -o parcels_new.pmtiles ... parcels.fgb
|
||||
tile-join -o combined_new.pmtiles --force \
|
||||
parcels_new.pmtiles \
|
||||
buildings_existing.pmtiles \
|
||||
uats_existing.pmtiles
|
||||
mc cp combined_new.pmtiles myminio/tiles/combined.pmtiles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Time Estimates
|
||||
|
||||
| Features | Type | Zoom Range | Time | Output Size |
|
||||
|---|---|---|---|---|
|
||||
| 500 | Polygons (UAT) | z0-z12 | <5s | 10-30 MB |
|
||||
| 100K | Polygons (buildings) | z12-z15 | 30-90s | 100-200 MB |
|
||||
| 330K | Polygons (parcels) | z6-z15 | 2-5 min | 200-400 MB |
|
||||
| 1M | Polygons (mixed) | z0-z15 | 8-15 min | 500 MB-1 GB |
|
||||
|
||||
tippecanoe is highly optimized and uses parallel processing.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **tippecanoe only accepts WGS84 (EPSG:4326)** — always reproject with ogr2ogr first
|
||||
2. **`--detect-shared-borders` is critical for parcels** — without it, gaps appear between adjacent polygons
|
||||
3. **GeoJSON input is slow** — use FlatGeobuf for 3-5x faster reads
|
||||
4. **No incremental updates** — must rebuild entire file (use `tile-join` for layer-level replacement)
|
||||
5. **MinIO needs CORS for browser-direct access** — Range + Content-Range headers must be exposed
|
||||
6. **Large properties bloat tile size** — use `-y`/`-x` flags to control what goes into tiles
|
||||
7. **`--no-tile-size-limit` can produce huge tiles** — use with `--drop-densest-as-needed` safety valve
|
||||
8. **Atomic upload prevents serving partial files** — always upload as temp name then rename
|
||||
@@ -0,0 +1,213 @@
|
||||
# Skill: Vector Tile Serving from PostGIS
|
||||
|
||||
## When to Use
|
||||
|
||||
When building a web map that serves vector tiles from PostgreSQL/PostGIS data. Applies to any project using MapLibre GL JS, Mapbox GL JS, or OpenLayers with MVT tiles from a spatial database.
|
||||
|
||||
---
|
||||
|
||||
## Core Architecture Decision
|
||||
|
||||
**Always use a dedicated tile server over GeoJSON for datasets >20K features.**
|
||||
|
||||
GeoJSON limits:
|
||||
- 20K polygons: visible jank on `setData()`, 200-400ms freezes
|
||||
- 50K polygons: multi-second freezes, 500MB+ browser memory
|
||||
- 100K+ polygons: crashes mobile browsers, 1-2GB memory on desktop
|
||||
- `JSON.stringify` runs on main thread — blocks UI proportional to data size
|
||||
|
||||
Vector tiles (MVT) solve this:
|
||||
- Only visible tiles loaded (~50-200KB per viewport)
|
||||
- Incremental pan/zoom (no re-fetch)
|
||||
- ~100-200MB client memory regardless of total dataset size
|
||||
- Works on mobile
|
||||
|
||||
---
|
||||
|
||||
## Tile Server Rankings (Rechsteiner Benchmark, April 2025)
|
||||
|
||||
| Rank | Server | Language | Speed | Notes |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **Martin** | Rust | 1x | Clear winner, 95-122ms range |
|
||||
| 2 | Tegola | Go | 2-3x slower | Only supports SRID 3857/4326 |
|
||||
| 3 | BBOX | Rust | ~same as Tegola | Unified raster+vector |
|
||||
| 4 | pg_tileserv | Go | ~4x slower | Zero-config but limited control |
|
||||
| 5 | TiPg | Python | Slower | Not for production scale |
|
||||
| 6 | ldproxy | Java | 4-70x slower | Enterprise/OGC compliance |
|
||||
|
||||
Source: [github.com/FabianRechsteiner/vector-tiles-benchmark](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
|
||||
|
||||
---
|
||||
|
||||
## Martin: Best Practices
|
||||
|
||||
### Always use explicit config (not auto-discovery)
|
||||
|
||||
Auto-discovery can drop properties, misdetect SRIDs, and behave unpredictably with nested views.
|
||||
|
||||
```yaml
|
||||
postgres:
|
||||
connection_string: ${DATABASE_URL}
|
||||
default_srid: 3844 # your source SRID
|
||||
auto_publish: false # explicit sources only
|
||||
tables:
|
||||
my_layer:
|
||||
schema: public
|
||||
table: my_view_name
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3] # approximate extent
|
||||
minzoom: 10
|
||||
maxzoom: 18
|
||||
properties:
|
||||
object_id: text # explicit column name: pg_type
|
||||
name: text
|
||||
area: float8
|
||||
```
|
||||
|
||||
### Docker image tags
|
||||
|
||||
Martin changed tag format at v1.0:
|
||||
- Pre-1.0: `ghcr.io/maplibre/martin:v0.15.0` (with `v` prefix)
|
||||
- Post-1.0: `ghcr.io/maplibre/martin:1.4.0` (no `v` prefix)
|
||||
|
||||
### Docker deployment
|
||||
|
||||
**If your orchestrator has access to the full repo** (docker-compose CLI, Docker Swarm with repo checkout):
|
||||
```yaml
|
||||
martin:
|
||||
image: ghcr.io/maplibre/martin:1.4.0
|
||||
command: ["--config", "/config/martin.yaml"]
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
volumes:
|
||||
- ./martin.yaml:/config/martin.yaml:ro
|
||||
ports:
|
||||
- "3010:3000"
|
||||
```
|
||||
|
||||
**If using Portainer CE or any system that only sees docker-compose.yml** (not full repo):
|
||||
Volume mounts for repo files fail silently — Docker creates an empty directory instead.
|
||||
Bake config into a custom image:
|
||||
|
||||
```dockerfile
|
||||
# martin.Dockerfile
|
||||
FROM ghcr.io/maplibre/martin:1.4.0
|
||||
COPY martin.yaml /config/martin.yaml
|
||||
```
|
||||
|
||||
```yaml
|
||||
martin:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: martin.Dockerfile
|
||||
command: ["--config", "/config/martin.yaml"]
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
ports:
|
||||
- "3010:3000"
|
||||
```
|
||||
|
||||
### Custom SRID handling
|
||||
|
||||
Martin handles non-4326/3857 SRIDs natively. Set `default_srid` globally or `srid` per source. Martin reprojects to Web Mercator (3857) internally for tile envelope calculations. Your PostGIS spatial indexes on the source SRID are used correctly.
|
||||
|
||||
### Zoom-dependent simplification
|
||||
|
||||
Create separate views per zoom range with `ST_SimplifyPreserveTopology`:
|
||||
|
||||
```sql
|
||||
-- z0-5: heavy simplification (2000m tolerance)
|
||||
CREATE VIEW my_layer_z0 AS
|
||||
SELECT id, name, ST_SimplifyPreserveTopology(geom, 2000) AS geom
|
||||
FROM my_table;
|
||||
|
||||
-- z8-12: moderate (50m)
|
||||
CREATE VIEW my_layer_z8 AS
|
||||
SELECT id, name, ST_SimplifyPreserveTopology(geom, 50) AS geom
|
||||
FROM my_table;
|
||||
|
||||
-- z12+: full precision
|
||||
CREATE VIEW my_layer_z12 AS
|
||||
SELECT * FROM my_table;
|
||||
```
|
||||
|
||||
### Performance at 1M+ features
|
||||
|
||||
- Set `minzoom` per source to avoid pathological low-zoom tiles
|
||||
- Buildings: minzoom 14 (skip at overview levels)
|
||||
- Use zoom-dependent simplified views for boundaries
|
||||
- Add HTTP cache (nginx proxy_cache) in front of Martin
|
||||
- Consider PMTiles for static overview layers
|
||||
|
||||
---
|
||||
|
||||
## PMTiles: Pre-generated Tile Archives
|
||||
|
||||
Best for: static/rarely-changing layers, overview zoom levels, eliminating DB load.
|
||||
|
||||
### Pipeline
|
||||
|
||||
```bash
|
||||
# 1. Export from PostGIS, reproject to WGS84
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
layer.fgb "PG:dbname=mydb" \
|
||||
-sql "SELECT id, name, geom FROM my_table"
|
||||
|
||||
# 2. Generate PMTiles
|
||||
tippecanoe -o output.pmtiles \
|
||||
--layer="my_layer" layer.fgb \
|
||||
--minimum-zoom=0 --maximum-zoom=14 \
|
||||
--drop-densest-as-needed \
|
||||
--detect-shared-borders \
|
||||
--hilbert --force
|
||||
|
||||
# 3. Serve from any HTTP server with Range request support (MinIO, nginx, CDN)
|
||||
```
|
||||
|
||||
### MapLibre integration
|
||||
|
||||
```typescript
|
||||
import { Protocol } from 'pmtiles';
|
||||
maplibregl.addProtocol('pmtiles', new Protocol().tile);
|
||||
|
||||
// Add source
|
||||
map.addSource('overview', {
|
||||
type: 'vector',
|
||||
url: 'pmtiles://https://my-server/tiles/overview.pmtiles',
|
||||
});
|
||||
```
|
||||
|
||||
### Hybrid approach (recommended for large datasets)
|
||||
|
||||
- PMTiles for overview (z0-z14): pre-generated, ~5ms serving, zero DB load
|
||||
- Martin for detail (z14+): live from PostGIS, always-current data
|
||||
- Rebuild PMTiles on schedule (nightly) or after data sync
|
||||
|
||||
---
|
||||
|
||||
## MLT (MapLibre Tiles) — Next-Gen Format (2026)
|
||||
|
||||
- 6x better compression than MVT (column-oriented layout)
|
||||
- 3.7-4.4x faster client decode (SIMD-friendly)
|
||||
- Martin v1.3+ supports serving MLT
|
||||
- MapLibre GL JS 5.x supports decoding MLT
|
||||
- Spec: [github.com/maplibre/maplibre-tile-spec](https://github.com/maplibre/maplibre-tile-spec)
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Martin auto-discovery drops properties** — always use explicit config with `auto_publish: false`
|
||||
2. **Martin Docker tag format changed at v1.0** — `v0.15.0` (with v) but `1.4.0` (without v). Check actual tags at ghcr.io.
|
||||
3. **Portainer CE volume mounts fail silently** — Docker creates empty directory instead of file. Bake configs into images via Dockerfile COPY.
|
||||
4. **Martin logs `UNKNOWN GEOMETRY TYPE` for views** — normal for nested views, does not affect tile generation
|
||||
5. **Nested views lose SRID metadata** — cast geometry: `geom::geometry(Geometry, 3844)`
|
||||
6. **GisUat.geometry is huge** — always `select` to exclude in list queries
|
||||
7. **Low-zoom tiles scan entire dataset** — use zoom-dependent simplified views
|
||||
8. **No tile cache by default** — add nginx/Varnish in front of any tile server
|
||||
9. **tippecanoe requires WGS84** — reproject from custom SRID before generating PMTiles
|
||||
10. **PMTiles not incrementally updatable** — full rebuild required on data change
|
||||
11. **Tegola doesn't support custom SRIDs** — only 3857/4326, requires ST_Transform everywhere
|
||||
12. **pg_tileserv `ST_Estimated_Extent` fails on views** — use materialized views or function layers
|
||||
13. **Martin caches source schema at startup** — restart after view DDL changes
|
||||
@@ -0,0 +1,2 @@
|
||||
FROM ghcr.io/maplibre/martin:1.4.0
|
||||
COPY martin.yaml /config/martin.yaml
|
||||
+4
-3
@@ -1,9 +1,10 @@
|
||||
# Martin v0.15 configuration — optimized tile sources for ArchiTools Geoportal
|
||||
# Martin v1.4 configuration — optimized tile sources for ArchiTools Geoportal
|
||||
# All geometries are EPSG:3844 (Stereo70). Bounds are approximate Romania extent.
|
||||
# Original table data is NEVER modified — views compute simplification on-the-fly.
|
||||
|
||||
postgres:
|
||||
connection_string: ${DATABASE_URL}
|
||||
pool_size: 8
|
||||
default_srid: 3844
|
||||
auto_publish: false
|
||||
tables:
|
||||
@@ -83,7 +84,7 @@ postgres:
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 10
|
||||
minzoom: 17
|
||||
maxzoom: 18
|
||||
properties:
|
||||
object_id: text
|
||||
@@ -138,7 +139,7 @@ postgres:
|
||||
geometry_column: geom
|
||||
srid: 3844
|
||||
bounds: [20.2, 43.5, 30.0, 48.3]
|
||||
minzoom: 12
|
||||
minzoom: 17
|
||||
maxzoom: 18
|
||||
properties:
|
||||
object_id: text
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# nginx tile cache for Martin vector tile server
|
||||
# Proxy-cache layer: 10-100x faster on repeat requests, zero PostGIS load for cached tiles
|
||||
|
||||
proxy_cache_path /var/cache/nginx/tiles
|
||||
levels=1:2
|
||||
keys_zone=tiles:64m
|
||||
max_size=2g
|
||||
inactive=7d
|
||||
use_temp_path=off;
|
||||
|
||||
# Log format with cache status for monitoring (docker logs tile-cache | grep HIT/MISS)
|
||||
log_format tiles '$remote_addr [$time_local] "$request" $status '
|
||||
'cache=$upstream_cache_status size=$body_bytes_sent '
|
||||
'time=$request_time';
|
||||
|
||||
server {
|
||||
access_log /var/log/nginx/access.log tiles;
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Health check
|
||||
location = /health {
|
||||
access_log off;
|
||||
return 200 "ok\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# nginx status (active connections, request counts) — for monitoring
|
||||
location = /status {
|
||||
access_log off;
|
||||
stub_status on;
|
||||
}
|
||||
|
||||
# Martin catalog endpoint (no cache)
|
||||
location = /catalog {
|
||||
proxy_pass http://martin:3000/catalog;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# PMTiles from MinIO — HTTPS proxy for browser access (avoids mixed-content block)
|
||||
# Browser fetches: /pmtiles/overview.pmtiles → MinIO http://10.10.10.166:9002/tiles/overview.pmtiles
|
||||
location /pmtiles/ {
|
||||
proxy_pass http://10.10.10.166:9002/tiles/;
|
||||
proxy_set_header Host 10.10.10.166:9002;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Range requests — essential for PMTiles (byte-range tile lookups)
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_pass_request_headers on;
|
||||
|
||||
# Browser cache — file changes only on rebuild (~weekly)
|
||||
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
|
||||
|
||||
# CORS — PMTiles loaded from tools.beletage.ro page
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Range, If-None-Match, If-Range, Accept-Encoding" always;
|
||||
add_header Access-Control-Expose-Headers "Content-Range, Content-Length, ETag, Accept-Ranges" always;
|
||||
|
||||
# Preflight
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Range, If-Range";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# Tile requests — cache aggressively
|
||||
location / {
|
||||
proxy_pass http://martin:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Cache config — tiles change only on sync (weekly), long TTL is safe
|
||||
proxy_cache tiles;
|
||||
proxy_cache_key "$request_uri";
|
||||
proxy_cache_valid 200 7d;
|
||||
proxy_cache_valid 204 1h;
|
||||
proxy_cache_valid 404 1m;
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
proxy_cache_lock on;
|
||||
proxy_cache_lock_timeout 5s;
|
||||
|
||||
# Browser caching — tiles are immutable between syncs
|
||||
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always;
|
||||
|
||||
# Pass cache status header (useful for debugging)
|
||||
add_header X-Cache-Status $upstream_cache_status always;
|
||||
|
||||
# CORS headers for tile requests
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Range, If-None-Match, Accept-Encoding" always;
|
||||
add_header Access-Control-Expose-Headers "Content-Range, Content-Length, ETag, X-Cache-Status" always;
|
||||
|
||||
# Handle preflight
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
add_header Content-Length 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Let Martin gzip natively — pass compressed response through to client and cache
|
||||
gzip off;
|
||||
|
||||
# Timeouts (Martin can be slow on low-zoom tiles)
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 30s;
|
||||
}
|
||||
}
|
||||
Generated
+10
@@ -25,6 +25,7 @@
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pmtiles": "^4.4.0",
|
||||
"proj4": "^2.20.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -10806,6 +10807,15 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pmtiles": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.0.tgz",
|
||||
"integrity": "sha512-tCLI1C5134MR54i8izUWhse0QUtO/EC33n9yWp1N5dYLLvyc197U0fkF5gAJhq1TdWO9Tvl+9hgvFvM0fR27Zg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"next-auth": "^4.24.13",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.13",
|
||||
"pmtiles": "^4.4.0",
|
||||
"proj4": "^2.20.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.4.3",
|
||||
|
||||
@@ -59,6 +59,10 @@ WHERE geometry IS NOT NULL AND geom IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS gis_feature_geom_idx
|
||||
ON "GisFeature" USING GIST (geom);
|
||||
|
||||
-- B-tree index on layerId for view filtering (LIKE 'TERENURI%', 'CLADIRI%')
|
||||
CREATE INDEX IF NOT EXISTS gis_feature_layer_id_idx
|
||||
ON "GisFeature" ("layerId");
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. QGIS-friendly views
|
||||
-- - Clean snake_case column names
|
||||
|
||||
@@ -19,6 +19,36 @@ model KeyValueStore {
|
||||
@@index([namespace])
|
||||
}
|
||||
|
||||
// ─── GIS: Sync Scheduling ──────────────────────────────────────────
|
||||
|
||||
model GisSyncRule {
|
||||
id String @id @default(uuid())
|
||||
siruta String? /// Set = UAT-specific rule
|
||||
county String? /// Set = county-wide default rule
|
||||
frequency String /// "3x-daily"|"daily"|"weekly"|"monthly"|"manual"
|
||||
syncTerenuri Boolean @default(true)
|
||||
syncCladiri Boolean @default(true)
|
||||
syncNoGeom Boolean @default(false)
|
||||
syncEnrich Boolean @default(false)
|
||||
priority Int @default(5) /// 1=highest, 10=lowest
|
||||
enabled Boolean @default(true)
|
||||
allowedHoursStart Int? /// null = no restriction, e.g. 1 for 01:00
|
||||
allowedHoursEnd Int? /// e.g. 5 for 05:00
|
||||
allowedDays String? /// e.g. "1,2,3,4,5" for weekdays, null = all days
|
||||
lastSyncAt DateTime?
|
||||
lastSyncStatus String? /// "done"|"error"
|
||||
lastSyncError String?
|
||||
nextDueAt DateTime?
|
||||
label String? /// Human-readable note
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([siruta, county])
|
||||
@@index([enabled, nextDueAt])
|
||||
@@index([county])
|
||||
@@index([frequency])
|
||||
}
|
||||
|
||||
// ─── GIS: eTerra ParcelSync ────────────────────────────────────────
|
||||
|
||||
model GisFeature {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
# rebuild-overview-tiles.sh — Export all overview layers from PostGIS, generate PMTiles, upload to MinIO
|
||||
# Includes: UAT boundaries, administrativ, simplified terenuri (z10-z14), simplified cladiri (z12-z14)
|
||||
# Usage: ./scripts/rebuild-overview-tiles.sh
|
||||
# Dependencies: ogr2ogr (GDAL), tippecanoe, mc (MinIO client)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Configuration ──
|
||||
DB_HOST="${DB_HOST:-10.10.10.166}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_NAME="${DB_NAME:-architools_db}"
|
||||
DB_USER="${DB_USER:-architools_user}"
|
||||
DB_PASS="${DB_PASS:-stictMyFon34!_gonY}"
|
||||
|
||||
MINIO_ALIAS="${MINIO_ALIAS:-myminio}"
|
||||
MINIO_BUCKET="${MINIO_BUCKET:-tiles}"
|
||||
MINIO_ENDPOINT="${MINIO_ENDPOINT:-http://10.10.10.166:9002}"
|
||||
MINIO_ACCESS_KEY="${MINIO_ACCESS_KEY:-admin}"
|
||||
MINIO_SECRET_KEY="${MINIO_SECRET_KEY:-MinioStrongPass123}"
|
||||
|
||||
TMPDIR="${TMPDIR:-/tmp/tile-rebuild}"
|
||||
OUTPUT_FILE="overview.pmtiles"
|
||||
|
||||
PG_CONN="PG:host=${DB_HOST} port=${DB_PORT} dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}"
|
||||
|
||||
echo "[$(date -Iseconds)] Starting overview tile rebuild..."
|
||||
|
||||
# ── Setup ──
|
||||
mkdir -p "$TMPDIR"
|
||||
cd "$TMPDIR"
|
||||
|
||||
# ── Step 1: Export views from PostGIS (parallel) ──
|
||||
echo "[$(date -Iseconds)] Exporting PostGIS views to FlatGeobuf..."
|
||||
|
||||
# UAT boundaries (4 zoom levels)
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
uats_z0.fgb "$PG_CONN" \
|
||||
-sql "SELECT name, siruta, geom FROM gis_uats_z0 WHERE geom IS NOT NULL" &
|
||||
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
uats_z5.fgb "$PG_CONN" \
|
||||
-sql "SELECT name, siruta, geom FROM gis_uats_z5 WHERE geom IS NOT NULL" &
|
||||
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
uats_z8.fgb "$PG_CONN" \
|
||||
-sql "SELECT name, siruta, county, geom FROM gis_uats_z8 WHERE geom IS NOT NULL" &
|
||||
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
uats_z12.fgb "$PG_CONN" \
|
||||
-sql "SELECT name, siruta, county, geom FROM gis_uats_z12 WHERE geom IS NOT NULL" &
|
||||
|
||||
# Administrativ (intravilan, arii speciale)
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
administrativ.fgb "$PG_CONN" \
|
||||
-sql "SELECT object_id, siruta, layer_id, cadastral_ref, geom FROM gis_administrativ WHERE geom IS NOT NULL" &
|
||||
|
||||
# Terenuri for overview — let tippecanoe handle simplification
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
terenuri_overview.fgb "$PG_CONN" \
|
||||
-sql "SELECT object_id, siruta, cadastral_ref, area_value, layer_id, geom
|
||||
FROM gis_terenuri WHERE geom IS NOT NULL" &
|
||||
|
||||
# Cladiri for overview — let tippecanoe handle simplification
|
||||
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
|
||||
cladiri_overview.fgb "$PG_CONN" \
|
||||
-sql "SELECT object_id, siruta, cadastral_ref, area_value, layer_id, geom
|
||||
FROM gis_cladiri WHERE geom IS NOT NULL" &
|
||||
|
||||
wait
|
||||
echo "[$(date -Iseconds)] Export complete."
|
||||
|
||||
# ── Step 2: Generate PMTiles with tippecanoe ──
|
||||
echo "[$(date -Iseconds)] Generating PMTiles..."
|
||||
|
||||
# Per-layer zoom ranges — avoids processing features at zoom levels where they won't appear
|
||||
# UAT boundaries: only at their respective zoom bands (saves processing z13-z18 for simple polygons)
|
||||
# Terenuri/Cladiri: only z13+/z14+ (the expensive layers skip z0-z12 entirely)
|
||||
tippecanoe \
|
||||
-o "$OUTPUT_FILE" \
|
||||
-L'{"layer":"gis_uats_z0","file":"uats_z0.fgb","minzoom":0,"maxzoom":5}' \
|
||||
-L'{"layer":"gis_uats_z5","file":"uats_z5.fgb","minzoom":5,"maxzoom":8}' \
|
||||
-L'{"layer":"gis_uats_z8","file":"uats_z8.fgb","minzoom":8,"maxzoom":12}' \
|
||||
-L'{"layer":"gis_uats_z12","file":"uats_z12.fgb","minzoom":12,"maxzoom":14}' \
|
||||
-L'{"layer":"gis_administrativ","file":"administrativ.fgb","minzoom":10,"maxzoom":16}' \
|
||||
-L'{"layer":"gis_terenuri","file":"terenuri_overview.fgb","minzoom":13,"maxzoom":18}' \
|
||||
-L'{"layer":"gis_cladiri","file":"cladiri_overview.fgb","minzoom":14,"maxzoom":18}' \
|
||||
--base-zoom=18 \
|
||||
--drop-densest-as-needed \
|
||||
--detect-shared-borders \
|
||||
--no-tile-stats \
|
||||
--hilbert \
|
||||
--force
|
||||
|
||||
echo "[$(date -Iseconds)] PMTiles generated: $(du -h "$OUTPUT_FILE" | cut -f1)"
|
||||
|
||||
# ── Step 3: Upload to MinIO (atomic swap) ──
|
||||
echo "[$(date -Iseconds)] Uploading to MinIO..."
|
||||
|
||||
# Configure MinIO client alias (idempotent)
|
||||
mc alias set "$MINIO_ALIAS" "$MINIO_ENDPOINT" "$MINIO_ACCESS_KEY" "$MINIO_SECRET_KEY" --api S3v4 2>/dev/null || true
|
||||
|
||||
# Ensure bucket exists
|
||||
mc mb --ignore-existing "${MINIO_ALIAS}/${MINIO_BUCKET}" 2>/dev/null || true
|
||||
|
||||
# Upload as temp file first
|
||||
mc cp "$OUTPUT_FILE" "${MINIO_ALIAS}/${MINIO_BUCKET}/overview_new.pmtiles"
|
||||
|
||||
# Atomic rename (zero-downtime swap)
|
||||
mc mv "${MINIO_ALIAS}/${MINIO_BUCKET}/overview_new.pmtiles" "${MINIO_ALIAS}/${MINIO_BUCKET}/overview.pmtiles"
|
||||
|
||||
echo "[$(date -Iseconds)] Upload complete."
|
||||
|
||||
# ── Step 4: Cleanup ──
|
||||
rm -f uats_z0.fgb uats_z5.fgb uats_z8.fgb uats_z12.fgb administrativ.fgb \
|
||||
terenuri_overview.fgb cladiri_overview.fgb "$OUTPUT_FILE"
|
||||
echo "[$(date -Iseconds)] Rebuild finished successfully."
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# tile-cache-stats.sh — Show tile cache hit/miss statistics
|
||||
# Usage: ./scripts/tile-cache-stats.sh [MINUTES]
|
||||
# Reads recent nginx logs from tile-cache container.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MINUTES="${1:-60}"
|
||||
|
||||
echo "=== Tile Cache Stats (last ${MINUTES}min) ==="
|
||||
echo ""
|
||||
|
||||
# Get nginx status (active connections)
|
||||
echo "--- Connections ---"
|
||||
curl -s "http://10.10.10.166:3010/status" 2>/dev/null || echo "(status endpoint unavailable)"
|
||||
echo ""
|
||||
|
||||
# Parse recent logs for cache hit/miss ratio
|
||||
echo "--- Cache Performance ---"
|
||||
docker logs tile-cache --since "${MINUTES}m" 2>/dev/null | \
|
||||
grep -oP 'cache=\K\w+' | sort | uniq -c | sort -rn || echo "(no logs in timeframe)"
|
||||
|
||||
echo ""
|
||||
echo "--- Cache Size ---"
|
||||
docker exec tile-cache du -sh /var/cache/nginx/tiles/ 2>/dev/null || echo "(cannot read cache dir)"
|
||||
|
||||
echo ""
|
||||
echo "--- Slowest Tiles (>1s) ---"
|
||||
docker logs tile-cache --since "${MINUTES}m" 2>/dev/null | \
|
||||
grep -oP 'time=\K[0-9.]+' | awk '$1 > 1.0 {print $1"s"}' | sort -rn | head -5 || echo "(none)"
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
# warm-tile-cache.sh — Pre-populate nginx tile cache with common tiles
|
||||
# Usage: ./scripts/warm-tile-cache.sh [BASE_URL]
|
||||
# Run after deploy or cache purge to ensure fast first-load for users.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE="${1:-http://10.10.10.166:3010}"
|
||||
PARALLEL="${PARALLEL:-8}"
|
||||
TOTAL=0
|
||||
HITS=0
|
||||
|
||||
echo "[$(date -Iseconds)] Warming tile cache at $BASE ..."
|
||||
|
||||
# ── Helper: fetch a range of tiles ──
|
||||
fetch_tiles() {
|
||||
local source="$1" z="$2" x_min="$3" x_max="$4" y_min="$5" y_max="$6"
|
||||
for x in $(seq "$x_min" "$x_max"); do
|
||||
for y in $(seq "$y_min" "$y_max"); do
|
||||
echo "${BASE}/${source}/${z}/${x}/${y}"
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# ── Romania bounding box at various zoom levels ──
|
||||
# Lon: 20.2-30.0, Lat: 43.5-48.3
|
||||
# Tile coords computed from slippy map formula
|
||||
|
||||
# z5: UATs coarse (2 tiles)
|
||||
fetch_tiles gis_uats_z5 5 17 18 11 11
|
||||
|
||||
# z7: UATs moderate (12 tiles)
|
||||
fetch_tiles gis_uats_z8 7 69 73 44 46
|
||||
|
||||
# z8: UATs + labels (40 tiles)
|
||||
fetch_tiles gis_uats_z8 8 139 147 88 92
|
||||
|
||||
# z9: UATs labels (100 tiles — major cities area)
|
||||
fetch_tiles gis_uats_z8 9 279 288 177 185
|
||||
|
||||
# z10: Administrativ + terenuri sources start loading
|
||||
# Focus on major metro areas: Bucharest, Cluj, Timisoara, Iasi, Brasov
|
||||
# Bucharest area (z12)
|
||||
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
|
||||
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
|
||||
# Cluj area (z12)
|
||||
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
|
||||
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
|
||||
|
||||
echo "[$(date -Iseconds)] Fetching tiles ($PARALLEL concurrent)..."
|
||||
|
||||
# Pipe all URLs through xargs+curl for parallel fetching
|
||||
fetch_tiles gis_uats_z5 5 17 18 11 11
|
||||
fetch_tiles gis_uats_z8 7 69 73 44 46
|
||||
fetch_tiles gis_uats_z8 8 139 147 88 92
|
||||
fetch_tiles gis_uats_z8 9 279 288 177 185
|
||||
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
|
||||
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
|
||||
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
|
||||
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
|
||||
|
||||
# Actually execute all fetches
|
||||
{
|
||||
fetch_tiles gis_uats_z5 5 17 18 11 11
|
||||
fetch_tiles gis_uats_z8 7 69 73 44 46
|
||||
fetch_tiles gis_uats_z8 8 139 147 88 92
|
||||
fetch_tiles gis_uats_z8 9 279 288 177 185
|
||||
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
|
||||
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
|
||||
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
|
||||
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
|
||||
} | xargs -P "$PARALLEL" -I {} curl -sf -o /dev/null {} 2>/dev/null
|
||||
|
||||
echo "[$(date -Iseconds)] Cache warming complete."
|
||||
@@ -0,0 +1,736 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
|
||||
type MonitorData = {
|
||||
timestamp: string;
|
||||
nginx?: { activeConnections?: number; requests?: number; reading?: number; writing?: number; waiting?: number; error?: string };
|
||||
martin?: { status?: string; sources?: string[]; sourceCount?: number; error?: string };
|
||||
pmtiles?: { url?: string; status?: string; size?: string; lastModified?: string; error?: string };
|
||||
cacheTests?: { tile: string; status: string; cache: string }[];
|
||||
config?: { martinUrl?: string; pmtilesUrl?: string; n8nWebhook?: string };
|
||||
};
|
||||
|
||||
type EterraSessionStatus = {
|
||||
connected: boolean;
|
||||
username?: string;
|
||||
connectedAt?: string;
|
||||
activeJobCount: number;
|
||||
eterraAvailable?: boolean;
|
||||
eterraMaintenance?: boolean;
|
||||
eterraHealthMessage?: string;
|
||||
};
|
||||
|
||||
type GisStats = {
|
||||
totalUats: number;
|
||||
totalFeatures: number;
|
||||
totalTerenuri: number;
|
||||
totalCladiri: number;
|
||||
totalEnriched: number;
|
||||
totalNoGeom: number;
|
||||
countiesWithData: number;
|
||||
lastSyncAt: string | null;
|
||||
dbSizeMb: number | null;
|
||||
};
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [data, setData] = useState<MonitorData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState("");
|
||||
const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]);
|
||||
const [counties, setCounties] = useState<string[]>([]);
|
||||
const [selectedCounty, setSelectedCounty] = useState("");
|
||||
const [eterraSession, setEterraSession] = useState<EterraSessionStatus>({ connected: false, activeJobCount: 0 });
|
||||
const [eterraConnecting, setEterraConnecting] = useState(false);
|
||||
const [showLoginForm, setShowLoginForm] = useState(false);
|
||||
const [eterraUser, setEterraUser] = useState("");
|
||||
const [eterraPwd, setEterraPwd] = useState("");
|
||||
const [gisStats, setGisStats] = useState<GisStats | null>(null);
|
||||
const rebuildPrevRef = useRef<string | null>(null);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/geoportal/monitor");
|
||||
if (res.ok) setData(await res.json());
|
||||
} catch { /* noop */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
// Auto-refresh every 30s
|
||||
useEffect(() => {
|
||||
const interval = setInterval(refresh, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh]);
|
||||
|
||||
const addLog = useCallback((type: "info" | "ok" | "error" | "wait", msg: string) => {
|
||||
setLogs((prev) => [{ time: new Date().toLocaleTimeString("ro-RO"), type, msg }, ...prev.slice(0, 49)]);
|
||||
}, []);
|
||||
|
||||
// Fetch counties for sync selector
|
||||
useEffect(() => {
|
||||
fetch("/api/eterra/counties")
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((d: { counties: string[] }) => setCounties(d.counties ?? []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// eTerra session status — poll every 30s
|
||||
const fetchEterraSession = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/session");
|
||||
if (res.ok) setEterraSession(await res.json() as EterraSessionStatus);
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchEterraSession();
|
||||
const interval = setInterval(() => void fetchEterraSession(), 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchEterraSession]);
|
||||
|
||||
// GIS stats — poll every 30s
|
||||
const fetchGisStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/stats");
|
||||
if (res.ok) setGisStats(await res.json() as GisStats);
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchGisStats();
|
||||
const interval = setInterval(() => void fetchGisStats(), 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchGisStats]);
|
||||
|
||||
const handleEterraConnect = async () => {
|
||||
setEterraConnecting(true);
|
||||
try {
|
||||
const payload: Record<string, string> = { action: "connect" };
|
||||
if (eterraUser.trim()) payload.username = eterraUser.trim();
|
||||
if (eterraPwd.trim()) payload.password = eterraPwd.trim();
|
||||
const res = await fetch("/api/eterra/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const d = await res.json() as { success?: boolean; error?: string };
|
||||
if (d.success) {
|
||||
await fetchEterraSession();
|
||||
addLog("ok", "eTerra conectat");
|
||||
setShowLoginForm(false);
|
||||
setEterraPwd("");
|
||||
} else {
|
||||
addLog("error", `eTerra: ${d.error ?? "Eroare conectare"}`);
|
||||
}
|
||||
} catch {
|
||||
addLog("error", "eTerra: eroare retea");
|
||||
}
|
||||
setEterraConnecting(false);
|
||||
};
|
||||
|
||||
const handleEterraDisconnect = async () => {
|
||||
const res = await fetch("/api/eterra/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "disconnect" }),
|
||||
});
|
||||
const d = await res.json() as { success?: boolean; error?: string };
|
||||
if (d.success) {
|
||||
setEterraSession({ connected: false, activeJobCount: 0 });
|
||||
addLog("info", "eTerra deconectat");
|
||||
} else {
|
||||
addLog("error", `Deconectare: ${d.error ?? "Eroare"}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup poll on unmount
|
||||
useEffect(() => {
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, []);
|
||||
|
||||
const triggerRebuild = async () => {
|
||||
setActionLoading("rebuild");
|
||||
addLog("info", "Se trimite webhook la N8N...");
|
||||
try {
|
||||
const res = await fetch("/api/geoportal/monitor", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "rebuild" }),
|
||||
});
|
||||
const result = await res.json() as { ok?: boolean; error?: string; alreadyRunning?: boolean; previousPmtiles?: { lastModified: string } };
|
||||
if (!result.ok) {
|
||||
addLog("error", result.error ?? "Eroare necunoscuta");
|
||||
setActionLoading("");
|
||||
return;
|
||||
}
|
||||
addLog("ok", result.alreadyRunning
|
||||
? "Rebuild deja in curs. Se monitorizeaza..."
|
||||
: "Webhook trimis. Rebuild pornit...");
|
||||
rebuildPrevRef.current = result.previousPmtiles?.lastModified ?? null;
|
||||
// Poll every 15s to check if PMTiles was updated
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const checkRes = await fetch("/api/geoportal/monitor", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "check-rebuild", previousLastModified: rebuildPrevRef.current }),
|
||||
});
|
||||
const check = await checkRes.json() as { changed?: boolean; current?: { size: string; lastModified: string } };
|
||||
if (check.changed) {
|
||||
addLog("ok", `Rebuild finalizat! PMTiles: ${check.current?.size}, actualizat: ${check.current?.lastModified}`);
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
setActionLoading("");
|
||||
refresh();
|
||||
}
|
||||
} catch { /* continue polling */ }
|
||||
}, 15_000);
|
||||
// Timeout after 90 min (z18 builds can take 45-60 min)
|
||||
setTimeout(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
addLog("error", "Timeout: rebuild nu s-a finalizat in 90 minute");
|
||||
setActionLoading("");
|
||||
}
|
||||
}, 90 * 60_000);
|
||||
} catch {
|
||||
addLog("error", "Nu s-a putut trimite webhook-ul");
|
||||
setActionLoading("");
|
||||
}
|
||||
};
|
||||
|
||||
const triggerWarmCache = async () => {
|
||||
setActionLoading("warm-cache");
|
||||
addLog("info", "Se incarca tile-uri in cache...");
|
||||
try {
|
||||
const res = await fetch("/api/geoportal/monitor", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "warm-cache" }),
|
||||
});
|
||||
const result = await res.json() as { ok?: boolean; error?: string; total?: number; hits?: number; misses?: number; errors?: number; message?: string };
|
||||
if (result.ok) {
|
||||
addLog("ok", result.message ?? "Cache warming finalizat");
|
||||
} else {
|
||||
addLog("error", result.error ?? "Eroare");
|
||||
}
|
||||
} catch {
|
||||
addLog("error", "Eroare la warm cache");
|
||||
}
|
||||
setActionLoading("");
|
||||
setTimeout(refresh, 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Tile Infrastructure Monitor</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{data?.timestamp ? `Ultima actualizare: ${new Date(data.timestamp).toLocaleTimeString("ro-RO")}` : "Se incarca..."}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "..." : "Reincarca"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Nginx Card */}
|
||||
<Card title="nginx Tile Cache">
|
||||
{data?.nginx?.error ? (
|
||||
<StatusBadge status="error" label={data.nginx.error} />
|
||||
) : data?.nginx ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<StatusBadge status="ok" label="Online" />
|
||||
<div className="grid grid-cols-2 gap-1 mt-2">
|
||||
<Stat label="Conexiuni active" value={data.nginx.activeConnections} />
|
||||
<Stat label="Total requests" value={data.nginx.requests?.toLocaleString()} />
|
||||
<Stat label="Reading" value={data.nginx.reading} />
|
||||
<Stat label="Writing" value={data.nginx.writing} />
|
||||
<Stat label="Waiting" value={data.nginx.waiting} />
|
||||
</div>
|
||||
</div>
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
|
||||
{/* Martin Card */}
|
||||
<Card title="Martin Tile Server">
|
||||
{data?.martin?.error ? (
|
||||
<StatusBadge status="error" label={data.martin.error} />
|
||||
) : data?.martin ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<StatusBadge status="ok" label={`${data.martin.sourceCount} surse active`} />
|
||||
<div className="mt-2 space-y-1">
|
||||
{data.martin.sources?.map((s) => (
|
||||
<span key={s} className="inline-block mr-1 mb-1 px-2 py-0.5 rounded bg-muted text-xs">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
|
||||
{/* PMTiles Card */}
|
||||
<Card title="PMTiles Overview">
|
||||
{data?.pmtiles?.error ? (
|
||||
<StatusBadge status="error" label={data.pmtiles.error} />
|
||||
) : data?.pmtiles?.status === "not configured" ? (
|
||||
<StatusBadge status="warn" label="Nu e configurat" />
|
||||
) : data?.pmtiles ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<StatusBadge status="ok" label={data.pmtiles.size ?? "OK"} />
|
||||
<Stat label="Ultima modificare" value={data.pmtiles.lastModified} />
|
||||
</div>
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cache Test Results */}
|
||||
<Card title="Cache Test">
|
||||
{data?.cacheTests ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="py-2 pr-4">Tile</th>
|
||||
<th className="py-2 pr-4">HTTP</th>
|
||||
<th className="py-2">Cache</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.cacheTests.map((t, i) => (
|
||||
<tr key={i} className="border-b border-border/50">
|
||||
<td className="py-2 pr-4 font-mono text-xs">{t.tile}</td>
|
||||
<td className="py-2 pr-4">{t.status}</td>
|
||||
<td className="py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
t.cache === "HIT" ? "bg-green-500/20 text-green-400" :
|
||||
t.cache === "MISS" ? "bg-yellow-500/20 text-yellow-400" :
|
||||
"bg-red-500/20 text-red-400"
|
||||
}`}>
|
||||
{t.cache}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
|
||||
{/* eTerra Connection + Live Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* Connection card */}
|
||||
<Card title="Conexiune eTerra">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||
eterraSession.eterraMaintenance ? "bg-yellow-400 animate-pulse" :
|
||||
eterraSession.connected ? "bg-green-400" : "bg-red-400"
|
||||
}`} />
|
||||
<span className="text-sm font-medium">
|
||||
{eterraSession.eterraMaintenance ? "Mentenanta" :
|
||||
eterraSession.connected ? (eterraSession.username || "Conectat") : "Deconectat"}
|
||||
</span>
|
||||
</div>
|
||||
{eterraSession.connected && eterraSession.connectedAt && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Conectat de la {new Date(eterraSession.connectedAt).toLocaleTimeString("ro-RO")}
|
||||
</div>
|
||||
)}
|
||||
{eterraSession.connected && eterraSession.activeJobCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
<span>{eterraSession.activeJobCount} {eterraSession.activeJobCount === 1 ? "job activ" : "joburi active"}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-1">
|
||||
{eterraSession.connected ? (
|
||||
<button
|
||||
onClick={handleEterraDisconnect}
|
||||
className="w-full text-xs px-3 py-1.5 rounded-md border border-border hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30 transition-colors"
|
||||
>
|
||||
Deconecteaza
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowLoginForm((v) => !v)}
|
||||
className="w-full text-xs px-3 py-1.5 rounded-md border border-border hover:border-primary/50 hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
{showLoginForm ? "Anuleaza" : "Conecteaza"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showLoginForm && !eterraSession.connected && (
|
||||
<div className="space-y-2 pt-1">
|
||||
<input
|
||||
type="text"
|
||||
value={eterraUser}
|
||||
onChange={(e) => setEterraUser(e.target.value)}
|
||||
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
placeholder="Utilizator eTerra"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={eterraPwd}
|
||||
onChange={(e) => setEterraPwd(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleEterraConnect(); }}
|
||||
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
placeholder="Parola"
|
||||
/>
|
||||
<button
|
||||
onClick={handleEterraConnect}
|
||||
disabled={eterraConnecting || !eterraUser.trim() || !eterraPwd.trim()}
|
||||
className="w-full h-8 rounded-md bg-primary text-primary-foreground text-xs hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{eterraConnecting ? "Se conecteaza..." : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Live stats cards */}
|
||||
<StatCard
|
||||
label="UAT-uri"
|
||||
value={gisStats?.totalUats}
|
||||
sub={gisStats?.countiesWithData ? `din ${gisStats.countiesWithData} judete` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Parcele"
|
||||
value={gisStats?.totalTerenuri}
|
||||
sub={gisStats?.totalEnriched ? `${gisStats.totalEnriched.toLocaleString("ro-RO")} enriched` : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cladiri"
|
||||
value={gisStats?.totalCladiri}
|
||||
sub={gisStats?.dbSizeMb ? `DB: ${gisStats.dbSizeMb >= 1024 ? `${(gisStats.dbSizeMb / 1024).toFixed(1)} GB` : `${gisStats.dbSizeMb} MB`}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{gisStats?.lastSyncAt && (
|
||||
<div className="text-xs text-muted-foreground text-right -mt-2">
|
||||
Ultimul sync: {new Date(gisStats.lastSyncAt).toLocaleString("ro-RO")} — auto-refresh 30s
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Card title="Actiuni">
|
||||
{/* Tile infrastructure actions */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Tile-uri</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ActionButton
|
||||
label="Rebuild PMTiles"
|
||||
description="Regenereaza tile-urile overview din PostGIS (~45-60 min)"
|
||||
loading={actionLoading === "rebuild"}
|
||||
onClick={triggerRebuild}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Warm Cache"
|
||||
description="Pre-incarca tile-uri frecvente in nginx cache"
|
||||
loading={actionLoading === "warm-cache"}
|
||||
onClick={triggerWarmCache}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync actions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sincronizare eTerra</h3>
|
||||
<a
|
||||
href="/sync-management"
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
Gestioneaza reguli sync
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<SyncTestButton
|
||||
label="Sync All Romania"
|
||||
description={`Toate judetele (${gisStats?.countiesWithData ?? "?"} jud, ${gisStats?.totalUats ?? "?"} UAT) — poate dura ore`}
|
||||
siruta=""
|
||||
mode="base"
|
||||
includeNoGeometry={false}
|
||||
actionKey="sync-all-counties"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
customEndpoint="/api/eterra/sync-all-counties"
|
||||
/>
|
||||
<SyncTestButton
|
||||
label="Refresh ALL UATs"
|
||||
description={`Delta sync pe toate ${gisStats?.totalUats ?? "?"} UAT-urile din DB`}
|
||||
siruta=""
|
||||
mode="base"
|
||||
includeNoGeometry={false}
|
||||
actionKey="refresh-all"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
customEndpoint="/api/eterra/refresh-all"
|
||||
/>
|
||||
<SyncTestButton
|
||||
label="Test Delta — Cluj-Napoca"
|
||||
description="Parcele + cladiri existente, fara magic (54975)"
|
||||
siruta="54975"
|
||||
mode="base"
|
||||
includeNoGeometry={false}
|
||||
actionKey="delta-cluj-base"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
/>
|
||||
<SyncTestButton
|
||||
label="Test Delta — Feleacu"
|
||||
description="Magic + no-geom, cu enrichment (57582)"
|
||||
siruta="57582"
|
||||
mode="magic"
|
||||
includeNoGeometry={true}
|
||||
actionKey="delta-feleacu-magic"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* County sync */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Sync pe judet</h3>
|
||||
<div className="flex items-end gap-3">
|
||||
<select
|
||||
value={selectedCounty}
|
||||
onChange={(e) => setSelectedCounty(e.target.value)}
|
||||
className="h-9 w-52 rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Alege judet...</option>
|
||||
{counties.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<SyncTestButton
|
||||
label={selectedCounty ? `Sync ${selectedCounty}` : "Sync Judet"}
|
||||
description="TERENURI + CLADIRI + INTRAVILAN pentru tot judetul"
|
||||
siruta=""
|
||||
mode="base"
|
||||
includeNoGeometry={false}
|
||||
actionKey="sync-county"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
customEndpoint="/api/eterra/sync-county"
|
||||
customBody={{ county: selectedCounty }}
|
||||
disabled={!selectedCounty}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Activity Log */}
|
||||
{logs.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b border-border">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Log activitate</span>
|
||||
<button onClick={() => setLogs([])} className="text-xs text-muted-foreground hover:text-foreground transition-colors">Sterge</button>
|
||||
</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-4 py-1.5 text-xs border-b border-border/30 last:border-0">
|
||||
<span className="text-muted-foreground shrink-0 font-mono">{log.time}</span>
|
||||
<span className={`shrink-0 ${
|
||||
log.type === "ok" ? "text-green-400" :
|
||||
log.type === "error" ? "text-red-400" :
|
||||
log.type === "wait" ? "text-yellow-400" :
|
||||
"text-blue-400"
|
||||
}`}>
|
||||
{log.type === "ok" ? "[OK]" : log.type === "error" ? "[ERR]" : log.type === "wait" ? "[...]" : "[i]"}
|
||||
</span>
|
||||
<span>{log.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config */}
|
||||
<Card title="Configuratie">
|
||||
{data?.config ? (
|
||||
<div className="space-y-1 text-sm font-mono">
|
||||
<div><span className="text-muted-foreground">MARTIN_URL:</span> {data.config.martinUrl}</div>
|
||||
<div><span className="text-muted-foreground">PMTILES_URL:</span> {data.config.pmtilesUrl}</div>
|
||||
<div><span className="text-muted-foreground">N8N_WEBHOOK:</span> {data.config.n8nWebhook}</div>
|
||||
</div>
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Sub-components ---- */
|
||||
|
||||
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground mb-3">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, label }: { status: "ok" | "error" | "warn"; label: string }) {
|
||||
const colors = {
|
||||
ok: "bg-green-500/20 text-green-400",
|
||||
error: "bg-red-500/20 text-red-400",
|
||||
warn: "bg-yellow-500/20 text-yellow-400",
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium ${colors[status]}`}>
|
||||
<span className={`w-2 h-2 rounded-full ${status === "ok" ? "bg-green-400" : status === "error" ? "bg-red-400" : "bg-yellow-400"}`} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value?: string | number | null }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs">{label}</div>
|
||||
<div className="font-medium">{value ?? "-"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Skeleton() {
|
||||
return <div className="h-16 rounded bg-muted/50 animate-pulse" />;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub }: { label: string; value?: number | null; sub?: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="text-xs text-muted-foreground mb-1">{label}</div>
|
||||
<div className="text-2xl font-bold tabular-nums">
|
||||
{value != null ? value.toLocaleString("ro-RO") : <span className="text-muted-foreground">--</span>}
|
||||
</div>
|
||||
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({ label, description, loading, onClick }: {
|
||||
label: string; description: string; loading: boolean; onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
|
||||
>
|
||||
<span className="font-medium text-sm">{loading ? "Se ruleaza..." : label}</span>
|
||||
<span className="text-xs text-muted-foreground">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint, customBody, disabled }: {
|
||||
label: string; description: string; siruta: string; mode: "base" | "magic";
|
||||
includeNoGeometry: boolean; actionKey: string; actionLoading: string;
|
||||
setActionLoading: (v: string) => void;
|
||||
addLog: (type: "info" | "ok" | "error" | "wait", msg: string) => void;
|
||||
pollRef: React.MutableRefObject<ReturnType<typeof setInterval> | null>;
|
||||
customEndpoint?: string;
|
||||
customBody?: Record<string, unknown>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const formatElapsed = () => {
|
||||
if (!startTimeRef.current) return "";
|
||||
const s = Math.round((Date.now() - startTimeRef.current) / 1000);
|
||||
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setActionLoading(actionKey);
|
||||
startTimeRef.current = Date.now();
|
||||
addLog("info", `[${label}] Pornire...`);
|
||||
try {
|
||||
const endpoint = customEndpoint ?? "/api/eterra/sync-background";
|
||||
const body = customEndpoint ? (customBody ?? {}) : { siruta, mode, includeNoGeometry };
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = await res.json() as { jobId?: string; error?: string };
|
||||
if (!res.ok) {
|
||||
addLog("error", `[${label}] ${d.error ?? "Eroare start"}`);
|
||||
setActionLoading(""); return;
|
||||
}
|
||||
addLog("ok", `[${label}] Job: ${d.jobId?.slice(0, 8)}`);
|
||||
const jid = d.jobId;
|
||||
let lastPhase = "";
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pr = await fetch(`/api/eterra/progress?jobId=${jid}`);
|
||||
const pg = await pr.json() as { status?: string; phase?: string; downloaded?: number; total?: number; note?: string; message?: string };
|
||||
const pct = pg.total ? Math.round(((pg.downloaded ?? 0) / pg.total) * 100) : 0;
|
||||
const elapsed = formatElapsed();
|
||||
const phaseChanged = pg.phase !== lastPhase;
|
||||
if (phaseChanged) lastPhase = pg.phase ?? "";
|
||||
// Only log phase changes and completion to keep log clean
|
||||
if (phaseChanged || pg.status === "done" || pg.status === "error") {
|
||||
const noteStr = pg.note ? ` — ${pg.note}` : "";
|
||||
addLog(
|
||||
pg.status === "done" ? "ok" : pg.status === "error" ? "error" : "wait",
|
||||
`[${label}] ${elapsed} | ${pg.phase ?? "..."} (${pct}%)${noteStr}`,
|
||||
);
|
||||
}
|
||||
if (pg.status === "done" || pg.status === "error") {
|
||||
const totalTime = formatElapsed();
|
||||
addLog(pg.status === "done" ? "ok" : "error",
|
||||
`[${label}] TOTAL: ${totalTime}${pg.message ? " — " + pg.message : ""}`,
|
||||
);
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
setActionLoading("");
|
||||
}
|
||||
} catch { /* continue */ }
|
||||
}, 3000);
|
||||
setTimeout(() => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current); pollRef.current = null;
|
||||
addLog("error", `[${label}] Timeout 3h (${formatElapsed()})`);
|
||||
setActionLoading("");
|
||||
}
|
||||
}, 3 * 60 * 60_000);
|
||||
} catch { addLog("error", `[${label}] Eroare retea`); setActionLoading(""); }
|
||||
}}
|
||||
disabled={!!actionLoading || !!disabled}
|
||||
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
|
||||
>
|
||||
<span className="font-medium text-sm">{actionLoading === actionKey ? "Se ruleaza..." : label}</span>
|
||||
<span className="text-xs text-muted-foreground">{description}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs";
|
||||
|
||||
/* ─── Types ──────────────────────────────────────────────── */
|
||||
|
||||
type SyncRule = {
|
||||
id: string;
|
||||
siruta: string | null;
|
||||
county: string | null;
|
||||
frequency: string;
|
||||
syncTerenuri: boolean;
|
||||
syncCladiri: boolean;
|
||||
syncNoGeom: boolean;
|
||||
syncEnrich: boolean;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
allowedHoursStart: number | null;
|
||||
allowedHoursEnd: number | null;
|
||||
allowedDays: string | null;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncStatus: string | null;
|
||||
lastSyncError: string | null;
|
||||
nextDueAt: string | null;
|
||||
label: string | null;
|
||||
createdAt: string;
|
||||
// enriched
|
||||
uatName: string | null;
|
||||
uatCount: number;
|
||||
};
|
||||
|
||||
type SchedulerStats = {
|
||||
totalRules: number;
|
||||
activeRules: number;
|
||||
dueNow: number;
|
||||
withErrors: number;
|
||||
frequencyDistribution: Record<string, number>;
|
||||
totalCounties: number;
|
||||
countiesWithRules: number;
|
||||
};
|
||||
|
||||
type CountyOverview = {
|
||||
county: string;
|
||||
totalUats: number;
|
||||
withRules: number;
|
||||
defaultFreq: string | null;
|
||||
};
|
||||
|
||||
/* ─── Constants ──────────────────────────────────────────── */
|
||||
|
||||
const FREQ_LABELS: Record<string, string> = {
|
||||
"3x-daily": "3x/zi",
|
||||
daily: "Zilnic",
|
||||
weekly: "Saptamanal",
|
||||
monthly: "Lunar",
|
||||
manual: "Manual",
|
||||
};
|
||||
|
||||
const FREQ_COLORS: Record<string, string> = {
|
||||
"3x-daily": "bg-red-500/20 text-red-400",
|
||||
daily: "bg-orange-500/20 text-orange-400",
|
||||
weekly: "bg-blue-500/20 text-blue-400",
|
||||
monthly: "bg-gray-500/20 text-gray-400",
|
||||
manual: "bg-purple-500/20 text-purple-400",
|
||||
};
|
||||
|
||||
/* ─── Page ───────────────────────────────────────────────── */
|
||||
|
||||
export default function SyncManagementPage() {
|
||||
const [rules, setRules] = useState<SyncRule[]>([]);
|
||||
const [globalDefault, setGlobalDefault] = useState("monthly");
|
||||
const [stats, setStats] = useState<SchedulerStats | null>(null);
|
||||
const [counties, setCounties] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [filterCounty, setFilterCounty] = useState("");
|
||||
const [filterFreq, setFilterFreq] = useState("");
|
||||
|
||||
const fetchRules = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/sync-rules");
|
||||
if (res.ok) {
|
||||
const d = (await res.json()) as { rules: SyncRule[]; globalDefault: string };
|
||||
setRules(d.rules);
|
||||
setGlobalDefault(d.globalDefault);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/sync-rules/scheduler");
|
||||
if (res.ok) {
|
||||
const d = (await res.json()) as { stats: SchedulerStats };
|
||||
setStats(d.stats);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
const fetchCounties = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/counties");
|
||||
if (res.ok) {
|
||||
const d = (await res.json()) as { counties: string[] };
|
||||
setCounties(d.counties ?? []);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void Promise.all([fetchRules(), fetchStats(), fetchCounties()]).then(() =>
|
||||
setLoading(false),
|
||||
);
|
||||
}, [fetchRules, fetchStats, fetchCounties]);
|
||||
|
||||
// Auto-refresh every 30s
|
||||
useEffect(() => {
|
||||
const iv = setInterval(() => {
|
||||
void fetchRules();
|
||||
void fetchStats();
|
||||
}, 30_000);
|
||||
return () => clearInterval(iv);
|
||||
}, [fetchRules, fetchStats]);
|
||||
|
||||
const toggleEnabled = async (rule: SyncRule) => {
|
||||
await fetch(`/api/eterra/sync-rules/${rule.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: !rule.enabled }),
|
||||
});
|
||||
void fetchRules();
|
||||
};
|
||||
|
||||
const deleteRule = async (rule: SyncRule) => {
|
||||
await fetch(`/api/eterra/sync-rules/${rule.id}`, { method: "DELETE" });
|
||||
void fetchRules();
|
||||
void fetchStats();
|
||||
};
|
||||
|
||||
const updateGlobalDefault = async (freq: string) => {
|
||||
await fetch("/api/eterra/sync-rules/global-default", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ frequency: freq }),
|
||||
});
|
||||
setGlobalDefault(freq);
|
||||
};
|
||||
|
||||
const filteredRules = rules.filter((r) => {
|
||||
if (filterCounty && r.county !== filterCounty && r.siruta) {
|
||||
// For UAT rules, need to check if UAT is in filtered county — skip for now, show all UAT rules when county filter is set
|
||||
return false;
|
||||
}
|
||||
if (filterCounty && r.county && r.county !== filterCounty) return false;
|
||||
if (filterFreq && r.frequency !== filterFreq) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Build county overview from stats
|
||||
const countyOverview: CountyOverview[] = counties.map((c) => {
|
||||
const countyRule = rules.find((r) => r.county === c && !r.siruta);
|
||||
const uatRules = rules.filter((r) => r.county === null && r.siruta !== null);
|
||||
return {
|
||||
county: c,
|
||||
totalUats: 0, // filled by separate query if needed
|
||||
withRules: (countyRule ? 1 : 0) + uatRules.length,
|
||||
defaultFreq: countyRule?.frequency ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Sync Management</h1>
|
||||
<div className="h-64 rounded-lg bg-muted/50 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Sync Management</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reguli de sincronizare eTerra — {rules.length} reguli configurate
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/monitor"
|
||||
className="px-4 py-2 rounded border border-border text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
Monitor
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Global Default */}
|
||||
<div className="rounded-lg border border-border bg-card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-medium">Frecventa implicita (UAT-uri fara regula)</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Se aplica la UAT-urile care nu au regula specifica si nici regula de judet
|
||||
</p>
|
||||
</div>
|
||||
<select
|
||||
value={globalDefault}
|
||||
onChange={(e) => void updateGlobalDefault(e.target.value)}
|
||||
className="h-9 rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
{Object.entries(FREQ_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="rules">
|
||||
<TabsList>
|
||||
<TabsTrigger value="rules">Reguli ({rules.length})</TabsTrigger>
|
||||
<TabsTrigger value="status">Status</TabsTrigger>
|
||||
<TabsTrigger value="counties">Judete ({counties.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ═══ RULES TAB ═══ */}
|
||||
<TabsContent value="rules" className="space-y-4 mt-4">
|
||||
{/* Filters + Add button */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<select
|
||||
value={filterCounty}
|
||||
onChange={(e) => setFilterCounty(e.target.value)}
|
||||
className="h-9 w-48 rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Toate judetele</option>
|
||||
{counties.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterFreq}
|
||||
onChange={(e) => setFilterFreq(e.target.value)}
|
||||
className="h-9 w-40 rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Toate frecventele</option>
|
||||
{Object.entries(FREQ_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="ml-auto">
|
||||
<button
|
||||
onClick={() => setShowAddDialog(true)}
|
||||
className="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90"
|
||||
>
|
||||
Adauga regula
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rules table */}
|
||||
{filteredRules.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-card p-8 text-center text-muted-foreground">
|
||||
Nicio regula {filterCounty || filterFreq ? "pentru filtrul selectat" : "configurata"}. Apasa "Adauga regula" pentru a incepe.
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left py-2.5 px-3 font-medium">Scope</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Frecventa</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Pasi</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Prioritate</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Ultimul sync</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Urmatorul</th>
|
||||
<th className="text-center py-2.5 px-3 font-medium">Activ</th>
|
||||
<th className="text-right py-2.5 px-3 font-medium">Actiuni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRules.map((r) => (
|
||||
<RuleRow
|
||||
key={r.id}
|
||||
rule={r}
|
||||
onToggle={() => void toggleEnabled(r)}
|
||||
onDelete={() => void deleteRule(r)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══ STATUS TAB ═══ */}
|
||||
<TabsContent value="status" className="space-y-4 mt-4">
|
||||
{stats && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard label="Total reguli" value={stats.totalRules} />
|
||||
<StatCard label="Active" value={stats.activeRules} />
|
||||
<StatCard
|
||||
label="Scadente acum"
|
||||
value={stats.dueNow}
|
||||
highlight={stats.dueNow > 0}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cu erori"
|
||||
value={stats.withErrors}
|
||||
highlight={stats.withErrors > 0}
|
||||
error
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Distributie frecvente
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(FREQ_LABELS).map(([key, label]) => {
|
||||
const count = stats.frequencyDistribution[key] ?? 0;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md border border-border"
|
||||
>
|
||||
<FreqBadge freq={key} />
|
||||
<span className="text-sm font-medium">{count}</span>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
|
||||
Acoperire
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Judete cu reguli:</span>{" "}
|
||||
<span className="font-medium">{stats.countiesWithRules} / {stats.totalCounties}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Default global:</span>{" "}
|
||||
<FreqBadge freq={globalDefault} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overdue rules */}
|
||||
{stats.dueNow > 0 && (
|
||||
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4">
|
||||
<h3 className="text-sm font-semibold text-yellow-400 mb-2">
|
||||
Reguli scadente ({stats.dueNow})
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scheduler-ul va procesa aceste reguli la urmatorul tick.
|
||||
(Scheduler-ul unificat va fi activat in Phase 2)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══ COUNTIES TAB ═══ */}
|
||||
<TabsContent value="counties" className="space-y-4 mt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Seteaza frecventa de sync la nivel de judet. UAT-urile cu regula proprie o vor suprascrie.
|
||||
</p>
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
<th className="text-left py-2.5 px-3 font-medium">Judet</th>
|
||||
<th className="text-left py-2.5 px-3 font-medium">Frecventa curenta</th>
|
||||
<th className="text-right py-2.5 px-3 font-medium">Seteaza frecventa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{counties.map((c) => (
|
||||
<CountyRow
|
||||
key={c}
|
||||
county={c}
|
||||
currentFreq={countyOverview.find((o) => o.county === c)?.defaultFreq ?? null}
|
||||
globalDefault={globalDefault}
|
||||
onSetFreq={async (freq) => {
|
||||
await fetch("/api/eterra/sync-rules/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "set-county-frequency",
|
||||
county: c,
|
||||
frequency: freq,
|
||||
}),
|
||||
});
|
||||
void fetchRules();
|
||||
void fetchStats();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Add Rule Dialog */}
|
||||
{showAddDialog && (
|
||||
<AddRuleDialog
|
||||
counties={counties}
|
||||
onClose={() => setShowAddDialog(false)}
|
||||
onCreated={() => {
|
||||
setShowAddDialog(false);
|
||||
void fetchRules();
|
||||
void fetchStats();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Sub-components ─────────────────────────────────────── */
|
||||
|
||||
function FreqBadge({ freq }: { freq: string }) {
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${FREQ_COLORS[freq] ?? "bg-muted text-muted-foreground"}`}>
|
||||
{FREQ_LABELS[freq] ?? freq}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, highlight, error }: {
|
||||
label: string; value: number; highlight?: boolean; error?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 ${
|
||||
highlight
|
||||
? error ? "border-red-500/30 bg-red-500/5" : "border-yellow-500/30 bg-yellow-500/5"
|
||||
: "border-border bg-card"
|
||||
}`}>
|
||||
<div className="text-xs text-muted-foreground mb-1">{label}</div>
|
||||
<div className={`text-2xl font-bold tabular-nums ${
|
||||
highlight ? (error ? "text-red-400" : "text-yellow-400") : ""
|
||||
}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuleRow({ rule, onToggle, onDelete }: {
|
||||
rule: SyncRule; onToggle: () => void; onDelete: () => void;
|
||||
}) {
|
||||
const scope = rule.siruta
|
||||
? (rule.uatName ?? rule.siruta)
|
||||
: rule.county
|
||||
? `Judet: ${rule.county}`
|
||||
: "Global";
|
||||
|
||||
const scopeSub = rule.siruta
|
||||
? `SIRUTA ${rule.siruta}`
|
||||
: rule.uatCount > 0
|
||||
? `${rule.uatCount} UAT-uri`
|
||||
: null;
|
||||
|
||||
const isOverdue = rule.nextDueAt && new Date(rule.nextDueAt) < new Date();
|
||||
|
||||
return (
|
||||
<tr className={`border-b border-border/50 ${!rule.enabled ? "opacity-50" : ""}`}>
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="font-medium">{scope}</div>
|
||||
{scopeSub && <div className="text-xs text-muted-foreground">{scopeSub}</div>}
|
||||
{rule.label && <div className="text-xs text-blue-400 mt-0.5">{rule.label}</div>}
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<FreqBadge freq={rule.frequency} />
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
<div className="flex gap-1">
|
||||
{rule.syncTerenuri && <StepIcon label="T" title="Terenuri" />}
|
||||
{rule.syncCladiri && <StepIcon label="C" title="Cladiri" />}
|
||||
{rule.syncNoGeom && <StepIcon label="N" title="No-geom" />}
|
||||
{rule.syncEnrich && <StepIcon label="E" title="Enrichment" />}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 tabular-nums">{rule.priority}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{rule.lastSyncAt ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${
|
||||
rule.lastSyncStatus === "done" ? "bg-green-400" :
|
||||
rule.lastSyncStatus === "error" ? "bg-red-400" : "bg-gray-400"
|
||||
}`} />
|
||||
<span className="text-xs">{relativeTime(rule.lastSyncAt)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Niciodata</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{rule.nextDueAt ? (
|
||||
<span className={`text-xs ${isOverdue ? "text-yellow-400 font-medium" : "text-muted-foreground"}`}>
|
||||
{isOverdue ? "Scadent" : relativeTime(rule.nextDueAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`w-8 h-5 rounded-full transition-colors relative ${
|
||||
rule.enabled ? "bg-green-500" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
rule.enabled ? "left-3.5" : "left-0.5"
|
||||
}`} />
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="text-xs text-muted-foreground hover:text-red-400 transition-colors"
|
||||
>
|
||||
Sterge
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function StepIcon({ label, title }: { label: string; title: string }) {
|
||||
return (
|
||||
<span
|
||||
title={title}
|
||||
className="w-5 h-5 rounded text-[10px] font-bold flex items-center justify-center bg-muted text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CountyRow({ county, currentFreq, globalDefault, onSetFreq }: {
|
||||
county: string;
|
||||
currentFreq: string | null;
|
||||
globalDefault: string;
|
||||
onSetFreq: (freq: string) => Promise<void>;
|
||||
}) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2.5 px-3 font-medium">{county}</td>
|
||||
<td className="py-2.5 px-3">
|
||||
{currentFreq ? (
|
||||
<FreqBadge freq={currentFreq} />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Implicit ({FREQ_LABELS[globalDefault] ?? globalDefault})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right">
|
||||
<select
|
||||
value={currentFreq ?? ""}
|
||||
disabled={saving}
|
||||
onChange={async (e) => {
|
||||
if (!e.target.value) return;
|
||||
setSaving(true);
|
||||
await onSetFreq(e.target.value);
|
||||
setSaving(false);
|
||||
}}
|
||||
className="h-8 rounded-md border border-border bg-background px-2 text-xs"
|
||||
>
|
||||
<option value="">Alege...</option>
|
||||
{Object.entries(FREQ_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRuleDialog({ counties, onClose, onCreated }: {
|
||||
counties: string[];
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [ruleType, setRuleType] = useState<"uat" | "county">("county");
|
||||
const [siruta, setSiruta] = useState("");
|
||||
const [county, setCounty] = useState("");
|
||||
const [frequency, setFrequency] = useState("daily");
|
||||
const [syncEnrich, setSyncEnrich] = useState(false);
|
||||
const [syncNoGeom, setSyncNoGeom] = useState(false);
|
||||
const [priority, setPriority] = useState(5);
|
||||
const [label, setLabel] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// UAT search — load all once, filter client-side
|
||||
const [uatSearch, setUatSearch] = useState("");
|
||||
const [allUats, setAllUats] = useState<Array<{ siruta: string; name: string }>>([]);
|
||||
const [uatName, setUatName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/uats");
|
||||
if (res.ok) {
|
||||
const d = (await res.json()) as { uats?: Array<{ siruta: string; name: string }> };
|
||||
setAllUats(d.uats ?? []);
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const uatResults = uatSearch.length >= 2
|
||||
? allUats
|
||||
.filter((u) => {
|
||||
const q = uatSearch.toLowerCase();
|
||||
return u.name.toLowerCase().includes(q) || u.siruta.includes(q);
|
||||
})
|
||||
.slice(0, 10)
|
||||
: [];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError("");
|
||||
setSaving(true);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
frequency,
|
||||
syncEnrich,
|
||||
syncNoGeom,
|
||||
priority,
|
||||
label: label.trim() || null,
|
||||
};
|
||||
|
||||
if (ruleType === "uat") {
|
||||
if (!siruta) { setError("Selecteaza un UAT"); setSaving(false); return; }
|
||||
body.siruta = siruta;
|
||||
} else {
|
||||
if (!county) { setError("Selecteaza un judet"); setSaving(false); return; }
|
||||
body.county = county;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/eterra/sync-rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const d = (await res.json()) as { rule?: SyncRule; error?: string };
|
||||
if (!res.ok) {
|
||||
setError(d.error ?? "Eroare");
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
onCreated();
|
||||
} catch {
|
||||
setError("Eroare retea");
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-card border border-border rounded-lg p-6 w-full max-w-md space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">Adauga regula de sync</h2>
|
||||
|
||||
{/* Rule type toggle */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setRuleType("county")}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
ruleType === "county" ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
Judet
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRuleType("uat")}
|
||||
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
ruleType === "uat" ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
UAT specific
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scope selection */}
|
||||
{ruleType === "county" ? (
|
||||
<select
|
||||
value={county}
|
||||
onChange={(e) => setCounty(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Alege judet...</option>
|
||||
{counties.map((c) => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={uatSearch}
|
||||
onChange={(e) => { setUatSearch(e.target.value); setSiruta(""); setUatName(""); }}
|
||||
placeholder="Cauta UAT (nume sau SIRUTA)..."
|
||||
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
/>
|
||||
{uatName && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
Selectat: {uatName} ({siruta})
|
||||
</div>
|
||||
)}
|
||||
{uatResults.length > 0 && !siruta && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border rounded-md shadow-lg max-h-40 overflow-y-auto z-10">
|
||||
{uatResults.map((u) => (
|
||||
<button
|
||||
key={u.siruta}
|
||||
onClick={() => {
|
||||
setSiruta(u.siruta);
|
||||
setUatName(u.name);
|
||||
setUatSearch("");
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{u.name} <span className="text-muted-foreground">({u.siruta})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Frequency */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">Frecventa</label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
>
|
||||
{Object.entries(FREQ_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Sync steps */}
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncEnrich}
|
||||
onChange={(e) => setSyncEnrich(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Enrichment
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncNoGeom}
|
||||
onChange={(e) => setSyncNoGeom(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
No-geom parcels
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||
Prioritate (1=cea mai mare, 10=cea mai mica)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
className="h-9 w-20 rounded-md border border-border bg-background px-3 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Nota (optional)"
|
||||
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-muted/50"
|
||||
>
|
||||
Anuleaza
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Se salveaza..." : "Creeaza"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── Helpers ────────────────────────────────────────────── */
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const abs = Math.abs(diff);
|
||||
const future = diff < 0;
|
||||
const s = Math.floor(abs / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
const d = Math.floor(h / 24);
|
||||
|
||||
let str: string;
|
||||
if (d > 0) str = `${d}z`;
|
||||
else if (h > 0) str = `${h}h`;
|
||||
else if (m > 0) str = `${m}m`;
|
||||
else str = `${s}s`;
|
||||
|
||||
return future ? `in ${str}` : `acum ${str}`;
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useDeferredValue, useRef } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
Moon,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
MapPin,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
WifiOff,
|
||||
Activity,
|
||||
Play,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
|
||||
type StepStatus = "pending" | "done" | "error";
|
||||
|
||||
type CityState = {
|
||||
siruta: string;
|
||||
name: string;
|
||||
county: string;
|
||||
priority: number;
|
||||
steps: Record<StepName, StepStatus>;
|
||||
lastActivity?: string;
|
||||
errorMessage?: string;
|
||||
dbStats?: {
|
||||
terenuri: number;
|
||||
cladiri: number;
|
||||
total: number;
|
||||
enriched: number;
|
||||
};
|
||||
};
|
||||
|
||||
type QueueState = {
|
||||
cities: CityState[];
|
||||
lastSessionDate?: string;
|
||||
totalSessions: number;
|
||||
completedCycles: number;
|
||||
};
|
||||
|
||||
type SyncStatus = "running" | "error" | "waiting" | "idle";
|
||||
|
||||
type CurrentActivity = {
|
||||
city: string;
|
||||
step: string;
|
||||
startedAt: string;
|
||||
} | null;
|
||||
|
||||
const STEPS: StepName[] = [
|
||||
"sync_terenuri",
|
||||
"sync_cladiri",
|
||||
"import_nogeom",
|
||||
"enrich",
|
||||
];
|
||||
|
||||
const STEP_LABELS: Record<StepName, string> = {
|
||||
sync_terenuri: "Terenuri",
|
||||
sync_cladiri: "Cladiri",
|
||||
import_nogeom: "No-geom",
|
||||
enrich: "Enrichment",
|
||||
};
|
||||
|
||||
/** Auto-poll intervals */
|
||||
const POLL_ACTIVE_MS = 15_000; // 15s when running
|
||||
const POLL_IDLE_MS = 60_000; // 60s when idle
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Page */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function WeekendDeepSyncPage() {
|
||||
const [state, setState] = useState<QueueState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// Live status
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
|
||||
const [currentActivity, setCurrentActivity] = useState<CurrentActivity>(null);
|
||||
const [inWeekendWindow, setInWeekendWindow] = useState(false);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
|
||||
// UAT autocomplete for adding cities
|
||||
type UatEntry = { siruta: string; name: string; county?: string };
|
||||
const [uatData, setUatData] = useState<UatEntry[]>([]);
|
||||
const [uatQuery, setUatQuery] = useState("");
|
||||
const [uatResults, setUatResults] = useState<UatEntry[]>([]);
|
||||
const [showUatResults, setShowUatResults] = useState(false);
|
||||
const uatRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const fetchState = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/eterra/weekend-sync");
|
||||
if (!res.ok) {
|
||||
setFetchError(`Server: ${res.status} ${res.statusText}`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
state: QueueState | null;
|
||||
syncStatus?: SyncStatus;
|
||||
currentActivity?: CurrentActivity;
|
||||
inWeekendWindow?: boolean;
|
||||
};
|
||||
setState(data.state);
|
||||
setSyncStatus(data.syncStatus ?? "idle");
|
||||
setCurrentActivity(data.currentActivity ?? null);
|
||||
setInWeekendWindow(data.inWeekendWindow ?? false);
|
||||
setFetchError(null);
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Conexiune esuata";
|
||||
setFetchError(msg);
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Initial load + UAT list
|
||||
useEffect(() => {
|
||||
void fetchState();
|
||||
fetch("/api/eterra/uats")
|
||||
.then((r) => r.json())
|
||||
.then((data: { uats?: UatEntry[] }) => {
|
||||
if (data.uats) setUatData(data.uats);
|
||||
})
|
||||
.catch(() => {
|
||||
fetch("/uat.json")
|
||||
.then((r) => r.json())
|
||||
.then((fallback: UatEntry[]) => setUatData(fallback))
|
||||
.catch(() => {});
|
||||
});
|
||||
}, [fetchState]);
|
||||
|
||||
// Auto-poll: 15s when running, 60s otherwise
|
||||
useEffect(() => {
|
||||
const interval = syncStatus === "running" ? POLL_ACTIVE_MS : POLL_IDLE_MS;
|
||||
const timer = setInterval(() => void fetchState(), interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchState, syncStatus]);
|
||||
|
||||
// UAT autocomplete filter
|
||||
const normalizeText = (text: string) =>
|
||||
text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
|
||||
|
||||
const deferredUatQuery = useDeferredValue(uatQuery);
|
||||
useEffect(() => {
|
||||
const raw = deferredUatQuery.trim();
|
||||
if (raw.length < 2) { setUatResults([]); return; }
|
||||
const isDigit = /^\d+$/.test(raw);
|
||||
const query = normalizeText(raw);
|
||||
const nameMatches: UatEntry[] = [];
|
||||
const countyOnly: UatEntry[] = [];
|
||||
for (const item of uatData) {
|
||||
// Skip cities already in queue
|
||||
if (state?.cities.some((c) => c.siruta === item.siruta)) continue;
|
||||
if (isDigit) {
|
||||
if (item.siruta.startsWith(raw)) nameMatches.push(item);
|
||||
} else {
|
||||
if (normalizeText(item.name).includes(query)) nameMatches.push(item);
|
||||
else if (item.county && normalizeText(item.county).includes(query))
|
||||
countyOnly.push(item);
|
||||
}
|
||||
}
|
||||
setUatResults([...nameMatches, ...countyOnly].slice(0, 10));
|
||||
}, [deferredUatQuery, uatData, state?.cities]);
|
||||
|
||||
const doAction = async (body: Record<string, unknown>) => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/eterra/weekend-sync", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
setFetchError(data.error ?? `Eroare: ${res.status}`);
|
||||
} else {
|
||||
setFetchError(null);
|
||||
}
|
||||
await fetchState();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Actiune esuata";
|
||||
setFetchError(msg);
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
const handleAddUat = async (uat: UatEntry) => {
|
||||
await doAction({
|
||||
action: "add",
|
||||
siruta: uat.siruta,
|
||||
name: uat.name,
|
||||
county: uat.county ?? "",
|
||||
priority: 3,
|
||||
});
|
||||
setUatQuery("");
|
||||
setUatResults([]);
|
||||
setShowUatResults(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl py-12 text-center text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
|
||||
<p>Se incarca...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cities = state?.cities ?? [];
|
||||
const totalSteps = cities.length * STEPS.length;
|
||||
const doneSteps = cities.reduce(
|
||||
(sum, c) => sum + STEPS.filter((s) => c.steps[s] === "done").length,
|
||||
0,
|
||||
);
|
||||
const progressPct = totalSteps > 0 ? Math.round((doneSteps / totalSteps) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Moon className="h-6 w-6 text-indigo-500" />
|
||||
Weekend Deep Sync
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sincronizare Magic completa pentru municipii mari — Vin/Sam/Dum
|
||||
23:00-04:00
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{lastRefresh && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{lastRefresh.toLocaleTimeString("ro-RO", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
|
||||
</span>
|
||||
)}
|
||||
{syncStatus !== "running" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
if (window.confirm("Descarca terenuri + cladiri pentru orasele pending?"))
|
||||
void doAction({ action: "trigger", onlySteps: ["sync_terenuri", "sync_cladiri"] });
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
Descarca parcele
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-indigo-600 border-indigo-300 hover:bg-indigo-50 dark:text-indigo-400 dark:border-indigo-700 dark:hover:bg-indigo-950/30"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
if (window.confirm("Pornesti sincronizarea completa? Va procesa toti pasii pending."))
|
||||
void doAction({ action: "trigger" });
|
||||
}}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Sync complet
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void fetchState()}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||
Reincarca
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection error banner */}
|
||||
{fetchError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-400">
|
||||
<WifiOff className="h-4 w-4 shrink-0" />
|
||||
<span>{fetchError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live status banner */}
|
||||
{syncStatus === "running" && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-indigo-200 bg-indigo-50 px-4 py-2.5 text-sm text-indigo-700 dark:border-indigo-800 dark:bg-indigo-950/30 dark:text-indigo-400">
|
||||
<Activity className="h-4 w-4 shrink-0 animate-pulse" />
|
||||
<span className="font-medium">Sincronizarea ruleaza</span>
|
||||
{currentActivity && (
|
||||
<span>
|
||||
— {currentActivity.city} / {STEP_LABELS[currentActivity.step as StepName] ?? currentActivity.step}
|
||||
</span>
|
||||
)}
|
||||
<Loader2 className="h-3.5 w-3.5 ml-auto animate-spin opacity-50" />
|
||||
</div>
|
||||
)}
|
||||
{syncStatus === "error" && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-400">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span className="font-medium">Erori in ultimul ciclu</span>
|
||||
<span>
|
||||
— {cities.filter((c) => STEPS.some((s) => c.steps[s] === "error")).map((c) => c.name).join(", ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{syncStatus === "waiting" && !fetchError && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-400">
|
||||
<Clock className="h-4 w-4 shrink-0" />
|
||||
<span>Fereastra weekend activa — se asteapta urmatorul slot de procesare</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats bar */}
|
||||
<Card>
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-4 flex-wrap text-sm">
|
||||
<span>
|
||||
<span className="font-semibold">{cities.length}</span> orase in
|
||||
coada
|
||||
</span>
|
||||
<span>
|
||||
Progres ciclu:{" "}
|
||||
<span className="font-semibold">{doneSteps}/{totalSteps}</span>{" "}
|
||||
pasi ({progressPct}%)
|
||||
</span>
|
||||
{state?.totalSessions != null && state.totalSessions > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
{state.totalSessions} sesiuni | {state.completedCycles ?? 0}{" "}
|
||||
cicluri complete
|
||||
</span>
|
||||
)}
|
||||
{state?.lastSessionDate && (
|
||||
<span className="text-muted-foreground">
|
||||
Ultima sesiune: {state.lastSessionDate}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{totalSteps > 0 && (
|
||||
<div className="h-2 w-full rounded-full bg-muted mt-2">
|
||||
<div
|
||||
className="h-2 rounded-full bg-indigo-500 transition-all duration-300"
|
||||
style={{ width: `${Math.max(1, progressPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* City cards */}
|
||||
<div className="space-y-3">
|
||||
{cities
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((city) => {
|
||||
const doneCount = STEPS.filter(
|
||||
(s) => city.steps[s] === "done",
|
||||
).length;
|
||||
const hasError = STEPS.some((s) => city.steps[s] === "error");
|
||||
const allDone = doneCount === STEPS.length;
|
||||
const isActive = currentActivity?.city === city.name;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={city.siruta}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
isActive && "border-indigo-300 ring-1 ring-indigo-200 dark:border-indigo-700 dark:ring-indigo-800",
|
||||
allDone && !isActive && "border-emerald-200 dark:border-emerald-800",
|
||||
hasError && !isActive && "border-rose-200 dark:border-rose-800",
|
||||
)}
|
||||
>
|
||||
<CardContent className="py-3 px-4 space-y-2">
|
||||
{/* City header */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-semibold">{city.name}</span>
|
||||
{city.county && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
jud. {city.county}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{city.siruta}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px]"
|
||||
>
|
||||
P{city.priority}
|
||||
</Badge>
|
||||
|
||||
{/* Status icon */}
|
||||
{allDone ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500 ml-auto" />
|
||||
) : hasError ? (
|
||||
<XCircle className="h-4 w-4 text-rose-500 ml-auto" />
|
||||
) : doneCount > 0 ? (
|
||||
<Clock className="h-4 w-4 text-amber-500 ml-auto" />
|
||||
) : null}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-1 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
disabled={actionLoading}
|
||||
onClick={() =>
|
||||
void doAction({
|
||||
action: "reset",
|
||||
siruta: city.siruta,
|
||||
})
|
||||
}
|
||||
title="Reseteaza progresul"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[10px] text-destructive"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
`Stergi ${city.name} din coada?`,
|
||||
)
|
||||
)
|
||||
void doAction({
|
||||
action: "remove",
|
||||
siruta: city.siruta,
|
||||
});
|
||||
}}
|
||||
title="Sterge din coada"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps progress */}
|
||||
<div className="flex gap-1.5">
|
||||
{STEPS.map((step) => {
|
||||
const status = city.steps[step];
|
||||
const isRunning = isActive && currentActivity?.step === step;
|
||||
return (
|
||||
<div
|
||||
key={step}
|
||||
className={cn(
|
||||
"flex-1 rounded-md border px-2 py-1.5 text-center text-[11px] transition-colors",
|
||||
isRunning &&
|
||||
"bg-indigo-50 border-indigo-300 text-indigo-700 dark:bg-indigo-950/30 dark:border-indigo-700 dark:text-indigo-400 animate-pulse",
|
||||
!isRunning && status === "done" &&
|
||||
"bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/30 dark:border-emerald-800 dark:text-emerald-400",
|
||||
!isRunning && status === "error" &&
|
||||
"bg-rose-50 border-rose-200 text-rose-700 dark:bg-rose-950/30 dark:border-rose-800 dark:text-rose-400",
|
||||
!isRunning && status === "pending" &&
|
||||
"bg-muted/30 border-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isRunning && <Loader2 className="h-3 w-3 inline mr-1 animate-spin" />}
|
||||
{STEP_LABELS[step]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* DB stats + error */}
|
||||
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||
{city.dbStats && city.dbStats.total > 0 && (
|
||||
<>
|
||||
<span>
|
||||
DB: {city.dbStats.terenuri.toLocaleString("ro")} ter.
|
||||
+ {city.dbStats.cladiri.toLocaleString("ro")} clad.
|
||||
</span>
|
||||
{city.dbStats.enriched > 0 && (
|
||||
<span className="text-teal-600 dark:text-teal-400">
|
||||
{city.dbStats.enriched.toLocaleString("ro")}{" "}
|
||||
enriched
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{city.lastActivity && (
|
||||
<span>
|
||||
Ultima activitate:{" "}
|
||||
{new Date(city.lastActivity).toLocaleString("ro-RO", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{city.errorMessage && (
|
||||
<span className="text-rose-500 truncate max-w-[300px]">
|
||||
{city.errorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add city — UAT autocomplete */}
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
Adauga oras in coada
|
||||
</h3>
|
||||
<div className="relative" ref={uatRef}>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Cauta UAT — scrie nume sau cod SIRUTA..."
|
||||
value={uatQuery}
|
||||
onChange={(e) => {
|
||||
setUatQuery(e.target.value);
|
||||
setShowUatResults(true);
|
||||
}}
|
||||
onFocus={() => setShowUatResults(true)}
|
||||
onBlur={() => setTimeout(() => setShowUatResults(false), 150)}
|
||||
className="pl-9 h-9"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
{showUatResults && uatResults.length > 0 && (
|
||||
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-60 overflow-auto">
|
||||
{uatResults.map((item) => (
|
||||
<button
|
||||
key={item.siruta}
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
void handleAddUat(item);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{item.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({item.siruta})
|
||||
</span>
|
||||
{item.county && (
|
||||
<span className="text-muted-foreground">
|
||||
— jud. {item.county}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Reset all button */}
|
||||
{cities.length > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
disabled={actionLoading}
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Resetezi progresul pentru TOATE orasele? Se va reporni ciclul de la zero.",
|
||||
)
|
||||
)
|
||||
void doAction({ action: "reset_all" });
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3 mr-1" />
|
||||
Reseteaza tot
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info footer */}
|
||||
<div className="text-xs text-muted-foreground space-y-1 pb-4">
|
||||
<p>
|
||||
Sincronizarea ruleaza automat Vineri, Sambata si Duminica noaptea
|
||||
(23:00-04:00). Procesarea e intercalata intre orase si se reia de
|
||||
unde a ramas. Pagina se actualizeaza automat la fiecare {syncStatus === "running" ? "15" : "60"} secunde.
|
||||
</p>
|
||||
<p>
|
||||
Prioritate: P1 = primele procesate, P2 = urmatoarele, P3 = adaugate
|
||||
manual. In cadrul aceleiasi prioritati, ordinea e aleatorie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||
import {
|
||||
getLayerFreshness,
|
||||
isFresh,
|
||||
} from "@/modules/parcel-sync/services/enrich-service";
|
||||
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 300; // 5 min max — N8N handles overall timeout
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
type UatRefreshResult = {
|
||||
siruta: string;
|
||||
uatName: string;
|
||||
action: "synced" | "fresh" | "error";
|
||||
reason?: string;
|
||||
terenuri?: { new: number; removed: number };
|
||||
cladiri?: { new: number; removed: number };
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/eterra/auto-refresh
|
||||
*
|
||||
* Server-to-server endpoint called by N8N cron to keep DB data fresh.
|
||||
* Auth: Authorization: Bearer <NOTIFICATION_CRON_SECRET>
|
||||
*
|
||||
* Query params:
|
||||
* ?maxUats=5 — max UATs to process per run (default 5, max 10)
|
||||
* ?maxAgeHours=168 — freshness threshold in hours (default 168 = 7 days)
|
||||
* ?forceFullSync=true — force full re-download (for weekly deep sync)
|
||||
* ?includeEnrichment=true — re-enrich UATs with partial enrichment
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
// ── Auth ──
|
||||
const secret = process.env.NOTIFICATION_CRON_SECRET;
|
||||
if (!secret) {
|
||||
return NextResponse.json(
|
||||
{ error: "NOTIFICATION_CRON_SECRET not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
const authHeader = request.headers.get("Authorization");
|
||||
const token = authHeader?.replace("Bearer ", "");
|
||||
if (token !== secret) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// ── Parse params ──
|
||||
const url = new URL(request.url);
|
||||
const maxUats = Math.min(
|
||||
Number(url.searchParams.get("maxUats") ?? "5") || 5,
|
||||
10,
|
||||
);
|
||||
const maxAgeHours =
|
||||
Number(url.searchParams.get("maxAgeHours") ?? "168") || 168;
|
||||
const forceFullSync = url.searchParams.get("forceFullSync") === "true";
|
||||
const includeEnrichment =
|
||||
url.searchParams.get("includeEnrichment") === "true";
|
||||
|
||||
// ── Credentials ──
|
||||
const username = process.env.ETERRA_USERNAME;
|
||||
const password = process.env.ETERRA_PASSWORD;
|
||||
if (!username || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "ETERRA_USERNAME / ETERRA_PASSWORD not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Health check ──
|
||||
const health = await checkEterraHealthNow();
|
||||
if (!health.available) {
|
||||
return NextResponse.json({
|
||||
processed: 0,
|
||||
skipped: 0,
|
||||
errors: 0,
|
||||
duration: "0s",
|
||||
message: `eTerra indisponibil: ${health.message ?? "maintenance"}`,
|
||||
details: [],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Find UATs with data in DB ──
|
||||
const uatGroups = await prisma.gisFeature.groupBy({
|
||||
by: ["siruta"],
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
// Resolve UAT names
|
||||
const sirutas = uatGroups.map((g) => g.siruta);
|
||||
const uatRecords = await prisma.gisUat.findMany({
|
||||
where: { siruta: { in: sirutas } },
|
||||
select: { siruta: true, name: true },
|
||||
});
|
||||
const nameMap = new Map(uatRecords.map((u) => [u.siruta, u.name]));
|
||||
|
||||
// ── Check freshness per UAT ──
|
||||
type UatCandidate = {
|
||||
siruta: string;
|
||||
uatName: string;
|
||||
featureCount: number;
|
||||
terenuriStale: boolean;
|
||||
cladiriStale: boolean;
|
||||
enrichedCount: number;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
const stale: UatCandidate[] = [];
|
||||
const fresh: string[] = [];
|
||||
|
||||
for (const group of uatGroups) {
|
||||
const sir = group.siruta;
|
||||
const [tStatus, cStatus] = await Promise.all([
|
||||
getLayerFreshness(sir, "TERENURI_ACTIVE"),
|
||||
getLayerFreshness(sir, "CLADIRI_ACTIVE"),
|
||||
]);
|
||||
const tFresh = isFresh(tStatus.lastSynced, maxAgeHours);
|
||||
const cFresh = isFresh(cStatus.lastSynced, maxAgeHours);
|
||||
|
||||
if (forceFullSync || !tFresh || !cFresh) {
|
||||
stale.push({
|
||||
siruta: sir,
|
||||
uatName: nameMap.get(sir) ?? sir,
|
||||
featureCount: group._count.id,
|
||||
terenuriStale: !tFresh || forceFullSync,
|
||||
cladiriStale: !cFresh || forceFullSync,
|
||||
enrichedCount: tStatus.enrichedCount,
|
||||
totalCount: tStatus.featureCount + cStatus.featureCount,
|
||||
});
|
||||
} else {
|
||||
fresh.push(sir);
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle stale UATs so we don't always process the same ones first
|
||||
for (let i = stale.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[stale[i]!, stale[j]!] = [stale[j]!, stale[i]!];
|
||||
}
|
||||
|
||||
const toProcess = stale.slice(0, maxUats);
|
||||
const startTime = Date.now();
|
||||
const details: UatRefreshResult[] = [];
|
||||
let errorCount = 0;
|
||||
|
||||
// ── Process stale UATs ──
|
||||
for (let idx = 0; idx < toProcess.length; idx++) {
|
||||
const uat = toProcess[idx]!;
|
||||
|
||||
// Random delay between UATs (30-120s) to spread load
|
||||
if (idx > 0) {
|
||||
const delay = 30_000 + Math.random() * 90_000;
|
||||
await sleep(delay);
|
||||
}
|
||||
|
||||
const uatStart = Date.now();
|
||||
console.log(
|
||||
`[auto-refresh] Processing UAT ${uat.siruta} (${uat.uatName})...`,
|
||||
);
|
||||
|
||||
try {
|
||||
let terenuriResult = { newFeatures: 0, removedFeatures: 0 };
|
||||
let cladiriResult = { newFeatures: 0, removedFeatures: 0 };
|
||||
|
||||
if (uat.terenuriStale) {
|
||||
const res = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
|
||||
uatName: uat.uatName,
|
||||
forceFullSync,
|
||||
});
|
||||
terenuriResult = { newFeatures: res.newFeatures, removedFeatures: res.removedFeatures };
|
||||
}
|
||||
|
||||
if (uat.cladiriStale) {
|
||||
const res = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
|
||||
uatName: uat.uatName,
|
||||
forceFullSync,
|
||||
});
|
||||
cladiriResult = { newFeatures: res.newFeatures, removedFeatures: res.removedFeatures };
|
||||
}
|
||||
|
||||
// Optional: re-enrich if partial enrichment
|
||||
if (includeEnrichment && uat.enrichedCount < uat.totalCount) {
|
||||
try {
|
||||
const { EterraClient } = await import(
|
||||
"@/modules/parcel-sync/services/eterra-client"
|
||||
);
|
||||
const { enrichFeatures } = await import(
|
||||
"@/modules/parcel-sync/services/enrich-service"
|
||||
);
|
||||
const enrichClient = await EterraClient.create(username, password);
|
||||
await enrichFeatures(enrichClient, uat.siruta);
|
||||
} catch (enrichErr) {
|
||||
console.warn(
|
||||
`[auto-refresh] Enrichment failed for ${uat.siruta}:`,
|
||||
enrichErr instanceof Error ? enrichErr.message : enrichErr,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - uatStart;
|
||||
console.log(
|
||||
`[auto-refresh] UAT ${uat.siruta}: terenuri +${terenuriResult.newFeatures}/-${terenuriResult.removedFeatures}, cladiri +${cladiriResult.newFeatures}/-${cladiriResult.removedFeatures} (${(durationMs / 1000).toFixed(1)}s)`,
|
||||
);
|
||||
|
||||
details.push({
|
||||
siruta: uat.siruta,
|
||||
uatName: uat.uatName,
|
||||
action: "synced",
|
||||
terenuri: { new: terenuriResult.newFeatures, removed: terenuriResult.removedFeatures },
|
||||
cladiri: { new: cladiriResult.newFeatures, removed: cladiriResult.removedFeatures },
|
||||
durationMs,
|
||||
});
|
||||
} catch (error) {
|
||||
errorCount++;
|
||||
const msg = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(`[auto-refresh] Error on UAT ${uat.siruta}: ${msg}`);
|
||||
details.push({
|
||||
siruta: uat.siruta,
|
||||
uatName: uat.uatName,
|
||||
action: "error",
|
||||
reason: msg,
|
||||
durationMs: Date.now() - uatStart,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuration = Date.now() - startTime;
|
||||
const durationStr =
|
||||
totalDuration > 60_000
|
||||
? `${Math.floor(totalDuration / 60_000)}m ${Math.round((totalDuration % 60_000) / 1000)}s`
|
||||
: `${Math.round(totalDuration / 1000)}s`;
|
||||
|
||||
console.log(
|
||||
`[auto-refresh] Completed ${toProcess.length}/${stale.length} UATs, ${errorCount} errors (${durationStr})`,
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
processed: toProcess.length,
|
||||
skipped: fresh.length,
|
||||
staleTotal: stale.length,
|
||||
errors: errorCount,
|
||||
duration: durationStr,
|
||||
details,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* GET /api/eterra/counties
|
||||
*
|
||||
* Returns distinct county names from GisUat, sorted alphabetically.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const rows = await prisma.gisUat.findMany({
|
||||
where: { county: { not: null } },
|
||||
select: { county: true },
|
||||
distinct: ["county"],
|
||||
orderBy: { county: "asc" },
|
||||
});
|
||||
const counties = rows.map((r) => r.county).filter(Boolean) as string[];
|
||||
return NextResponse.json({ counties });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare la interogare judete";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* GET /api/eterra/debug-fields?siruta=161829&cadRef=77102
|
||||
*
|
||||
* Diagnostic endpoint — shows all available fields from eTerra + local DB
|
||||
* for a specific parcel and its buildings.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const siruta = url.searchParams.get("siruta") ?? "161829";
|
||||
const cadRef = url.searchParams.get("cadRef") ?? "77102";
|
||||
|
||||
const username = process.env.ETERRA_USERNAME;
|
||||
const password = process.env.ETERRA_PASSWORD;
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: "ETERRA creds missing" }, { status: 500 });
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
query: { siruta, cadRef },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
try {
|
||||
const client = await EterraClient.create(username, password);
|
||||
|
||||
// 1. GIS layer: TERENURI_ACTIVE — raw attributes
|
||||
const terenuri = await client.listLayerByWhere(
|
||||
{ id: "TERENURI_ACTIVE", name: "TERENURI_ACTIVE", endpoint: "aut" },
|
||||
`ADMIN_UNIT_ID=${siruta} AND IS_ACTIVE=1 AND NATIONAL_CADASTRAL_REFERENCE='${cadRef}'`,
|
||||
{ limit: 1, outFields: "*" },
|
||||
);
|
||||
const parcelAttrs = terenuri[0]?.attributes ?? null;
|
||||
result.gis_parcela = {
|
||||
found: !!parcelAttrs,
|
||||
fields: parcelAttrs
|
||||
? Object.entries(parcelAttrs)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => ({ field: k, value: v, type: typeof v }))
|
||||
: [],
|
||||
};
|
||||
|
||||
// 2. GIS layer: CLADIRI_ACTIVE — buildings on this parcel
|
||||
const cladiri = await client.listLayerByWhere(
|
||||
{ id: "CLADIRI_ACTIVE", name: "CLADIRI_ACTIVE", endpoint: "aut" },
|
||||
`ADMIN_UNIT_ID=${siruta} AND IS_ACTIVE=1 AND NATIONAL_CADASTRAL_REFERENCE LIKE '${cadRef}-%'`,
|
||||
{ limit: 20, outFields: "*" },
|
||||
);
|
||||
result.gis_cladiri = {
|
||||
count: cladiri.length,
|
||||
buildings: cladiri.map((c) => {
|
||||
const a = c.attributes;
|
||||
return {
|
||||
cadastralRef: a.NATIONAL_CADASTRAL_REFERENCE,
|
||||
fields: Object.entries(a)
|
||||
.sort(([x], [y]) => x.localeCompare(y))
|
||||
.filter(([, v]) => v != null && v !== "" && v !== 0)
|
||||
.map(([k, v]) => ({ field: k, value: v, type: typeof v })),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// 3. Immovable details (enrichment source)
|
||||
const immId = parcelAttrs?.IMMOVABLE_ID;
|
||||
const wsId = parcelAttrs?.WORKSPACE_ID;
|
||||
if (immId && wsId) {
|
||||
try {
|
||||
const details = await client.fetchImmovableParcelDetails(
|
||||
wsId as string | number,
|
||||
immId as string | number,
|
||||
);
|
||||
result.immovable_parcel_details = {
|
||||
count: details.length,
|
||||
items: details,
|
||||
};
|
||||
} catch (e) {
|
||||
result.immovable_parcel_details = {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Immovable list entry (address source)
|
||||
try {
|
||||
const listResponse = await client.fetchImmovableListByAdminUnit(
|
||||
wsId as number,
|
||||
siruta,
|
||||
0,
|
||||
5,
|
||||
true,
|
||||
);
|
||||
const items = (listResponse?.content ?? []) as Record<string, unknown>[];
|
||||
// Find our specific immovable
|
||||
const match = items.find(
|
||||
(item) => String(item.immovablePk) === String(immId) ||
|
||||
String(item.identifierDetails ?? "").includes(cadRef),
|
||||
);
|
||||
result.immovable_list_entry = {
|
||||
totalInUat: listResponse?.totalElements ?? "?",
|
||||
matchFound: !!match,
|
||||
entry: match ?? null,
|
||||
note: "Acest obiect contine campul immovableAddresses cu adresa completa",
|
||||
};
|
||||
} catch (e) {
|
||||
result.immovable_list_entry = {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Documentation data (owner source)
|
||||
try {
|
||||
const docResponse = await client.fetchDocumentationData(
|
||||
wsId as number,
|
||||
[String(immId)],
|
||||
);
|
||||
const immovables = docResponse?.immovables ?? [];
|
||||
const regs = docResponse?.partTwoRegs ?? [];
|
||||
result.documentation_data = {
|
||||
immovablesCount: immovables.length,
|
||||
immovables: immovables.slice(0, 3),
|
||||
registrationsCount: regs.length,
|
||||
registrations: regs.slice(0, 10),
|
||||
note: "partTwoRegs contine proprietarii (nodeType=P, nodeStatus=-1=radiat)",
|
||||
};
|
||||
} catch (e) {
|
||||
result.documentation_data = {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Local DB data (what we have stored)
|
||||
const dbParcel = await prisma.gisFeature.findFirst({
|
||||
where: { layerId: "TERENURI_ACTIVE", siruta, cadastralRef: cadRef },
|
||||
select: {
|
||||
objectId: true,
|
||||
cadastralRef: true,
|
||||
areaValue: true,
|
||||
isActive: true,
|
||||
enrichment: true,
|
||||
enrichedAt: true,
|
||||
geometrySource: true,
|
||||
},
|
||||
});
|
||||
const dbBuildings = await prisma.gisFeature.findMany({
|
||||
where: {
|
||||
layerId: "CLADIRI_ACTIVE",
|
||||
siruta,
|
||||
cadastralRef: { startsWith: `${cadRef}-` },
|
||||
},
|
||||
select: {
|
||||
objectId: true,
|
||||
cadastralRef: true,
|
||||
areaValue: true,
|
||||
attributes: true,
|
||||
},
|
||||
});
|
||||
result.local_db = {
|
||||
parcel: dbParcel
|
||||
? {
|
||||
objectId: dbParcel.objectId,
|
||||
cadastralRef: dbParcel.cadastralRef,
|
||||
areaValue: dbParcel.areaValue,
|
||||
enrichedAt: dbParcel.enrichedAt,
|
||||
geometrySource: dbParcel.geometrySource,
|
||||
enrichment: dbParcel.enrichment,
|
||||
}
|
||||
: null,
|
||||
buildings: dbBuildings.map((b) => ({
|
||||
objectId: b.objectId,
|
||||
cadastralRef: b.cadastralRef,
|
||||
areaValue: b.areaValue,
|
||||
is_legal: (b.attributes as Record<string, unknown>)?.IS_LEGAL,
|
||||
})),
|
||||
};
|
||||
} catch (e) {
|
||||
result.error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
return NextResponse.json(result, {
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
@@ -250,6 +250,20 @@ export async function POST(req: Request) {
|
||||
pushProgress();
|
||||
updatePhaseProgress(2, 2);
|
||||
}
|
||||
// Sync admin layers (lightweight, non-fatal)
|
||||
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
|
||||
try {
|
||||
await syncLayer(
|
||||
validated.username,
|
||||
validated.password,
|
||||
validated.siruta,
|
||||
adminLayer,
|
||||
{ jobId, isSubStep: true },
|
||||
);
|
||||
} catch {
|
||||
// admin layers are best-effort
|
||||
}
|
||||
}
|
||||
finishPhase();
|
||||
|
||||
/* ══════════════════════════════════════════════════════════ */
|
||||
@@ -548,6 +562,19 @@ export async function POST(req: Request) {
|
||||
zip.file("terenuri.gpkg", terenuriGpkg);
|
||||
zip.file("cladiri.gpkg", cladiriGpkg);
|
||||
|
||||
// DXF versions (non-fatal)
|
||||
try {
|
||||
const { gpkgToDxf } = await import(
|
||||
"@/modules/parcel-sync/services/gpkg-export"
|
||||
);
|
||||
const tDxf = await gpkgToDxf(terenuriGpkg, "TERENURI_ACTIVE");
|
||||
if (tDxf) zip.file("terenuri.dxf", tDxf);
|
||||
const cDxf = await gpkgToDxf(cladiriGpkg, "CLADIRI_ACTIVE");
|
||||
if (cDxf) zip.file("cladiri.dxf", cDxf);
|
||||
} catch {
|
||||
// DXF conversion not available — skip silently
|
||||
}
|
||||
|
||||
// ── Comprehensive quality analysis ──
|
||||
const withGeomRecords = dbTerenuri.filter(
|
||||
(r) =>
|
||||
@@ -671,6 +698,15 @@ export async function POST(req: Request) {
|
||||
|
||||
if (validated.mode === "magic" && magicGpkg && csvContent) {
|
||||
zip.file("terenuri_magic.gpkg", magicGpkg);
|
||||
try {
|
||||
const { gpkgToDxf } = await import(
|
||||
"@/modules/parcel-sync/services/gpkg-export"
|
||||
);
|
||||
const mDxf = await gpkgToDxf(magicGpkg, "TERENURI_MAGIC");
|
||||
if (mDxf) zip.file("terenuri_magic.dxf", mDxf);
|
||||
} catch {
|
||||
// DXF conversion not available
|
||||
}
|
||||
zip.file("terenuri_complet.csv", csvContent);
|
||||
report.magic = {
|
||||
csvRows: csvContent.split("\n").length - 1,
|
||||
|
||||
@@ -182,6 +182,15 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
|
||||
zip.file("terenuri.gpkg", terenuriGpkg);
|
||||
zip.file("cladiri.gpkg", cladiriGpkg);
|
||||
|
||||
// DXF versions (non-fatal — ogr2ogr may not be available)
|
||||
const { gpkgToDxf } = await import(
|
||||
"@/modules/parcel-sync/services/gpkg-export"
|
||||
);
|
||||
const terenuriDxf = await gpkgToDxf(terenuriGpkg, "TERENURI_ACTIVE");
|
||||
if (terenuriDxf) zip.file("terenuri.dxf", terenuriDxf);
|
||||
const cladiriDxf = await gpkgToDxf(cladiriGpkg, "CLADIRI_ACTIVE");
|
||||
if (cladiriDxf) zip.file("cladiri.dxf", cladiriDxf);
|
||||
|
||||
if (mode === "magic") {
|
||||
// ── Magic: enrichment-merged GPKG + CSV + quality report ──
|
||||
const headers = [
|
||||
@@ -295,6 +304,8 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
|
||||
});
|
||||
|
||||
zip.file("terenuri_magic.gpkg", magicGpkg);
|
||||
const magicDxf = await gpkgToDxf(magicGpkg, "TERENURI_MAGIC");
|
||||
if (magicDxf) zip.file("terenuri_magic.dxf", magicDxf);
|
||||
zip.file("terenuri_complet.csv", csvRows.join("\n"));
|
||||
|
||||
// ── Quality analysis ──
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* POST /api/eterra/refresh-all
|
||||
*
|
||||
* Runs delta sync on ALL UATs in DB sequentially.
|
||||
* UATs with >30% enrichment → magic mode (sync + enrichment).
|
||||
* UATs with ≤30% enrichment → base mode (sync only).
|
||||
*
|
||||
* Returns immediately with jobId — progress via /api/eterra/progress.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
setProgress,
|
||||
clearProgress,
|
||||
type SyncProgress,
|
||||
} from "@/modules/parcel-sync/services/progress-store";
|
||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function POST() {
|
||||
const username = process.env.ETERRA_USERNAME ?? "";
|
||||
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||
if (!username || !password) {
|
||||
return Response.json(
|
||||
{ error: "ETERRA_USERNAME / ETERRA_PASSWORD not configured" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const jobId = crypto.randomUUID();
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
phase: "Pregătire refresh complet",
|
||||
});
|
||||
|
||||
void runRefreshAll(jobId, username, password);
|
||||
|
||||
return Response.json({ jobId, message: "Refresh complet pornit" }, { status: 202 });
|
||||
}
|
||||
|
||||
async function runRefreshAll(jobId: string, username: string, password: string) {
|
||||
const push = (p: Partial<SyncProgress>) =>
|
||||
setProgress({ jobId, downloaded: 0, total: 100, status: "running", ...p } as SyncProgress);
|
||||
|
||||
try {
|
||||
// Health check
|
||||
const health = await checkEterraHealthNow();
|
||||
if (!health.available) {
|
||||
setProgress({ jobId, downloaded: 0, total: 100, status: "error", phase: "eTerra indisponibil", message: health.message ?? "maintenance" });
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all UATs with features + enrichment ratio
|
||||
const uats = await prisma.$queryRawUnsafe<
|
||||
Array<{ siruta: string; name: string | null; total: number; enriched: number }>
|
||||
>(
|
||||
`SELECT f.siruta, u.name, COUNT(*)::int as total,
|
||||
COUNT(*) FILTER (WHERE f."enrichedAt" IS NOT NULL)::int as enriched
|
||||
FROM "GisFeature" f LEFT JOIN "GisUat" u ON f.siruta = u.siruta
|
||||
WHERE f."layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND f."objectId" > 0
|
||||
GROUP BY f.siruta, u.name ORDER BY total DESC`,
|
||||
);
|
||||
|
||||
if (uats.length === 0) {
|
||||
setProgress({ jobId, downloaded: 100, total: 100, status: "done", phase: "Niciun UAT in DB" });
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: Array<{ siruta: string; name: string; mode: string; duration: number; note: string }> = [];
|
||||
let errors = 0;
|
||||
|
||||
for (let i = 0; i < uats.length; i++) {
|
||||
const uat = uats[i]!;
|
||||
const uatName = uat.name ?? uat.siruta;
|
||||
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||
const isMagic = ratio > 0.3;
|
||||
const mode = isMagic ? "magic" : "base";
|
||||
const pct = Math.round(((i) / uats.length) * 100);
|
||||
|
||||
push({
|
||||
downloaded: pct,
|
||||
total: 100,
|
||||
phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
||||
note: results.length > 0 ? `Ultimul: ${results[results.length - 1]!.name} — ${results[results.length - 1]!.note}` : undefined,
|
||||
});
|
||||
|
||||
const uatStart = Date.now();
|
||||
try {
|
||||
// Sync TERENURI + CLADIRI (quick-count + VALID_FROM delta)
|
||||
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName });
|
||||
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName });
|
||||
|
||||
let enrichNote = "";
|
||||
if (isMagic) {
|
||||
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
|
||||
const eRes = await enrichFeatures(client, uat.siruta);
|
||||
enrichNote = eRes.status === "done"
|
||||
? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
|
||||
: ` | enrich err: ${eRes.error}`;
|
||||
}
|
||||
|
||||
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||
const parts = [
|
||||
tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
|
||||
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
|
||||
: "T:ok",
|
||||
cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
|
||||
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
|
||||
: "C:ok",
|
||||
];
|
||||
const note = `${parts.join(", ")}${enrichNote} (${dur}s)`;
|
||||
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note });
|
||||
console.log(`[refresh-all] ${i + 1}/${uats.length} ${uatName}: ${note}`);
|
||||
} catch (err) {
|
||||
errors++;
|
||||
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note: `ERR: ${msg}` });
|
||||
console.error(`[refresh-all] ${uatName}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalDur = results.reduce((s, r) => s + r.duration, 0);
|
||||
const summary = `${uats.length} UATs, ${errors} erori, ${totalDur}s total`;
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: "done",
|
||||
phase: "Refresh complet finalizat",
|
||||
message: summary,
|
||||
note: results.map((r) => `${r.name}: ${r.note}`).join("\n"),
|
||||
});
|
||||
console.log(`[refresh-all] Done: ${summary}`);
|
||||
setTimeout(() => clearProgress(jobId), 6 * 3_600_000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
setProgress({ jobId, downloaded: 0, total: 100, status: "error", phase: "Eroare", message: msg });
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* GET /api/eterra/stats
|
||||
*
|
||||
* Lightweight endpoint for the monitor page — returns aggregate counts
|
||||
* suitable for polling every 30s without heavy DB load.
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* totalUats: number,
|
||||
* totalFeatures: number,
|
||||
* totalTerenuri: number,
|
||||
* totalCladiri: number,
|
||||
* totalEnriched: number,
|
||||
* totalNoGeom: number,
|
||||
* countiesWithData: number,
|
||||
* lastSyncAt: string | null,
|
||||
* dbSizeMb: number | null,
|
||||
* }
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [
|
||||
totalUats,
|
||||
totalFeatures,
|
||||
totalTerenuri,
|
||||
totalCladiri,
|
||||
totalEnriched,
|
||||
totalNoGeom,
|
||||
countyAgg,
|
||||
lastSync,
|
||||
dbSize,
|
||||
] = await Promise.all([
|
||||
prisma.gisUat.count(),
|
||||
prisma.gisFeature.count({ where: { objectId: { gt: 0 } } }),
|
||||
prisma.gisFeature.count({ where: { layerId: "TERENURI_ACTIVE", objectId: { gt: 0 } } }),
|
||||
prisma.gisFeature.count({ where: { layerId: "CLADIRI_ACTIVE", objectId: { gt: 0 } } }),
|
||||
prisma.gisFeature.count({ where: { enrichedAt: { not: null } } }),
|
||||
prisma.gisFeature.count({ where: { geometrySource: "NO_GEOMETRY" } }),
|
||||
prisma.gisUat.groupBy({
|
||||
by: ["county"],
|
||||
where: { county: { not: null } },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.gisSyncRun.findFirst({
|
||||
where: { status: "done" },
|
||||
orderBy: { completedAt: "desc" },
|
||||
select: { completedAt: true },
|
||||
}),
|
||||
prisma.$queryRaw<Array<{ size: string }>>`
|
||||
SELECT pg_size_pretty(pg_database_size(current_database())) as size
|
||||
`,
|
||||
]);
|
||||
|
||||
// Parse DB size to MB
|
||||
const sizeStr = dbSize[0]?.size ?? "";
|
||||
let dbSizeMb: number | null = null;
|
||||
const mbMatch = sizeStr.match(/([\d.]+)\s*(MB|GB|TB)/i);
|
||||
if (mbMatch) {
|
||||
const val = parseFloat(mbMatch[1]!);
|
||||
const unit = mbMatch[2]!.toUpperCase();
|
||||
dbSizeMb = unit === "GB" ? val * 1024 : unit === "TB" ? val * 1024 * 1024 : val;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
totalUats,
|
||||
totalFeatures,
|
||||
totalTerenuri,
|
||||
totalCladiri,
|
||||
totalEnriched,
|
||||
totalNoGeom,
|
||||
countiesWithData: countyAgg.length,
|
||||
lastSyncAt: lastSync?.completedAt?.toISOString() ?? null,
|
||||
dbSizeMb: dbSizeMb ? Math.round(dbSizeMb) : null,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* POST /api/eterra/sync-all-counties
|
||||
*
|
||||
* Starts a background sync for ALL counties in the database (entire Romania).
|
||||
* Iterates counties sequentially, running county-sync logic for each.
|
||||
* Returns immediately with jobId — progress via /api/eterra/progress.
|
||||
*
|
||||
* Body: {} (no params needed)
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import {
|
||||
setProgress,
|
||||
clearProgress,
|
||||
type SyncProgress,
|
||||
} from "@/modules/parcel-sync/services/progress-store";
|
||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||
import { createAppNotification } from "@/core/notifications/app-notifications";
|
||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Concurrency guard — blocks both this and single county sync */
|
||||
const g = globalThis as {
|
||||
__countySyncRunning?: string;
|
||||
__allCountiesSyncRunning?: boolean;
|
||||
};
|
||||
|
||||
export async function POST() {
|
||||
const session = getSessionCredentials();
|
||||
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
|
||||
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
|
||||
if (!username || !password) {
|
||||
return Response.json(
|
||||
{ error: "Credentiale lipsa — conecteaza-te la eTerra mai intai." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
if (g.__allCountiesSyncRunning) {
|
||||
return Response.json(
|
||||
{ error: "Sync All Romania deja in curs" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (g.__countySyncRunning) {
|
||||
return Response.json(
|
||||
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const jobId = crypto.randomUUID();
|
||||
g.__allCountiesSyncRunning = true;
|
||||
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
phase: "Pregatire sync Romania...",
|
||||
});
|
||||
|
||||
void runAllCountiesSync(jobId, username, password);
|
||||
|
||||
return Response.json(
|
||||
{ jobId, message: "Sync All Romania pornit" },
|
||||
{ status: 202 },
|
||||
);
|
||||
}
|
||||
|
||||
async function runAllCountiesSync(
|
||||
jobId: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
const push = (p: Partial<SyncProgress>) =>
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
...p,
|
||||
} as SyncProgress);
|
||||
|
||||
try {
|
||||
// Health check
|
||||
const health = await checkEterraHealthNow();
|
||||
if (!health.available) {
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "error",
|
||||
phase: "eTerra indisponibil",
|
||||
message: health.message ?? "maintenance",
|
||||
});
|
||||
g.__allCountiesSyncRunning = false;
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all distinct counties, ordered alphabetically
|
||||
const countyRows = await prisma.gisUat.groupBy({
|
||||
by: ["county"],
|
||||
where: { county: { not: null } },
|
||||
_count: true,
|
||||
orderBy: { county: "asc" },
|
||||
});
|
||||
|
||||
const counties = countyRows
|
||||
.map((r) => r.county)
|
||||
.filter((c): c is string => c != null);
|
||||
|
||||
if (counties.length === 0) {
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: "done",
|
||||
phase: "Niciun judet gasit in DB",
|
||||
});
|
||||
g.__allCountiesSyncRunning = false;
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
push({ phase: `0/${counties.length} judete — pornire...` });
|
||||
|
||||
const countyResults: Array<{
|
||||
county: string;
|
||||
uatCount: number;
|
||||
errors: number;
|
||||
duration: number;
|
||||
}> = [];
|
||||
let totalErrors = 0;
|
||||
let totalUats = 0;
|
||||
|
||||
for (let ci = 0; ci < counties.length; ci++) {
|
||||
const county = counties[ci]!;
|
||||
g.__countySyncRunning = county;
|
||||
|
||||
// Get UATs for this county
|
||||
const uats = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
siruta: string;
|
||||
name: string | null;
|
||||
total: number;
|
||||
enriched: number;
|
||||
}>
|
||||
>(
|
||||
`SELECT u.siruta, u.name,
|
||||
COALESCE(f.total, 0)::int as total,
|
||||
COALESCE(f.enriched, 0)::int as enriched
|
||||
FROM "GisUat" u
|
||||
LEFT JOIN (
|
||||
SELECT siruta, COUNT(*)::int as total,
|
||||
COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched
|
||||
FROM "GisFeature"
|
||||
WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0
|
||||
GROUP BY siruta
|
||||
) f ON u.siruta = f.siruta
|
||||
WHERE u.county = $1
|
||||
ORDER BY COALESCE(f.total, 0) DESC`,
|
||||
county,
|
||||
);
|
||||
|
||||
if (uats.length === 0) {
|
||||
countyResults.push({ county, uatCount: 0, errors: 0, duration: 0 });
|
||||
continue;
|
||||
}
|
||||
|
||||
const countyStart = Date.now();
|
||||
let countyErrors = 0;
|
||||
|
||||
for (let i = 0; i < uats.length; i++) {
|
||||
const uat = uats[i]!;
|
||||
const uatName = uat.name ?? uat.siruta;
|
||||
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||
const isMagic = ratio > 0.3;
|
||||
const mode = isMagic ? "magic" : "base";
|
||||
|
||||
// Progress: county level + UAT level — update before starting UAT
|
||||
const countyPct = ci / counties.length;
|
||||
const uatPct = i / uats.length;
|
||||
const overallPct = Math.round((countyPct + uatPct / counties.length) * 100);
|
||||
|
||||
push({
|
||||
downloaded: overallPct,
|
||||
total: 100,
|
||||
phase: `[${ci + 1}/${counties.length}] ${county} — [${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
||||
note: countyResults.length > 0
|
||||
? `Ultimul judet: ${countyResults[countyResults.length - 1]!.county} (${countyResults[countyResults.length - 1]!.uatCount} UAT, ${countyResults[countyResults.length - 1]!.errors} err)`
|
||||
: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName, jobId, isSubStep: true });
|
||||
await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName, jobId, isSubStep: true });
|
||||
|
||||
// LIMITE_INTRAV_DYNAMIC — best effort
|
||||
try {
|
||||
await syncLayer(username, password, uat.siruta, "LIMITE_INTRAV_DYNAMIC", { uatName, jobId, isSubStep: true });
|
||||
} catch { /* skip */ }
|
||||
|
||||
// Enrichment for magic mode
|
||||
if (isMagic) {
|
||||
try {
|
||||
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
|
||||
await enrichFeatures(client, uat.siruta);
|
||||
} catch {
|
||||
// Enrichment failure is non-fatal
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
countyErrors++;
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
console.error(`[sync-all] ${county}/${uatName}: ${msg}`);
|
||||
}
|
||||
|
||||
// Update progress AFTER UAT completion
|
||||
const completedUatPct = (i + 1) / uats.length;
|
||||
const completedOverallPct = Math.round((countyPct + completedUatPct / counties.length) * 100);
|
||||
push({
|
||||
downloaded: completedOverallPct,
|
||||
total: 100,
|
||||
phase: `[${ci + 1}/${counties.length}] ${county} — [${i + 1}/${uats.length}] ${uatName} finalizat`,
|
||||
});
|
||||
}
|
||||
|
||||
const dur = Math.round((Date.now() - countyStart) / 1000);
|
||||
countyResults.push({ county, uatCount: uats.length, errors: countyErrors, duration: dur });
|
||||
totalErrors += countyErrors;
|
||||
totalUats += uats.length;
|
||||
|
||||
console.log(
|
||||
`[sync-all] ${ci + 1}/${counties.length} ${county}: ${uats.length} UAT, ${countyErrors} err, ${dur}s`,
|
||||
);
|
||||
}
|
||||
|
||||
const totalDur = countyResults.reduce((s, r) => s + r.duration, 0);
|
||||
const summary = `${counties.length} judete, ${totalUats} UAT-uri, ${totalErrors} erori, ${formatDuration(totalDur)}`;
|
||||
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: totalErrors > 0 && totalErrors === totalUats ? "error" : "done",
|
||||
phase: "Sync Romania finalizat",
|
||||
message: summary,
|
||||
});
|
||||
|
||||
await createAppNotification({
|
||||
type: totalErrors > 0 ? "sync-error" : "sync-complete",
|
||||
title: totalErrors > 0
|
||||
? `Sync Romania: ${totalErrors} erori din ${totalUats} UAT-uri`
|
||||
: `Sync Romania: ${totalUats} UAT-uri in ${counties.length} judete`,
|
||||
message: summary,
|
||||
metadata: { jobId, counties: counties.length, totalUats, totalErrors, totalDuration: totalDur },
|
||||
});
|
||||
|
||||
console.log(`[sync-all] Done: ${summary}`);
|
||||
|
||||
// Trigger PMTiles rebuild after full Romania sync
|
||||
await firePmtilesRebuild("all-counties-sync-complete", {
|
||||
counties: counties.length,
|
||||
totalUats,
|
||||
totalErrors,
|
||||
});
|
||||
|
||||
setTimeout(() => clearProgress(jobId), 12 * 3_600_000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "error",
|
||||
phase: "Eroare",
|
||||
message: msg,
|
||||
});
|
||||
await createAppNotification({
|
||||
type: "sync-error",
|
||||
title: "Sync Romania: eroare generala",
|
||||
message: msg,
|
||||
metadata: { jobId },
|
||||
});
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
} finally {
|
||||
g.__allCountiesSyncRunning = false;
|
||||
g.__countySyncRunning = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h${String(m).padStart(2, "0")}m`;
|
||||
}
|
||||
@@ -187,80 +187,106 @@ async function runBackground(params: {
|
||||
getLayerFreshness(siruta, "CLADIRI_ACTIVE"),
|
||||
]);
|
||||
|
||||
const terenuriNeedsSync =
|
||||
forceSync ||
|
||||
!isFresh(terenuriStatus.lastSynced) ||
|
||||
terenuriStatus.featureCount === 0;
|
||||
const cladiriNeedsSync =
|
||||
forceSync ||
|
||||
!isFresh(cladiriStatus.lastSynced) ||
|
||||
cladiriStatus.featureCount === 0;
|
||||
const terenuriNeedsFullSync =
|
||||
forceSync || terenuriStatus.featureCount === 0;
|
||||
const cladiriNeedsFullSync =
|
||||
forceSync || cladiriStatus.featureCount === 0;
|
||||
|
||||
if (terenuriNeedsSync) {
|
||||
phase = "Sincronizare terenuri";
|
||||
push({});
|
||||
const r = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
|
||||
forceFullSync: forceSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
if (r.status === "error")
|
||||
throw new Error(r.error ?? "Sync terenuri failed");
|
||||
}
|
||||
// Always call syncLayer — it handles quick-count + VALID_FROM delta internally.
|
||||
// Only force full download when no local data or explicit forceSync.
|
||||
phase = "Sincronizare terenuri";
|
||||
push({});
|
||||
const terenuriResult = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
|
||||
forceFullSync: terenuriNeedsFullSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
if (terenuriResult.status === "error")
|
||||
throw new Error(terenuriResult.error ?? "Sync terenuri failed");
|
||||
updateOverall(0.5);
|
||||
|
||||
if (cladiriNeedsSync) {
|
||||
phase = "Sincronizare clădiri";
|
||||
push({});
|
||||
const r = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
|
||||
forceFullSync: forceSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
if (r.status === "error")
|
||||
throw new Error(r.error ?? "Sync clădiri failed");
|
||||
}
|
||||
|
||||
// Sync intravilan limits (always, lightweight layer)
|
||||
phase = "Sincronizare limite intravilan";
|
||||
phase = "Sincronizare clădiri";
|
||||
push({});
|
||||
try {
|
||||
await syncLayer(username, password, siruta, "LIMITE_INTRAV_DYNAMIC", {
|
||||
forceFullSync: forceSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
} catch {
|
||||
// Non-critical — don't fail the whole job
|
||||
note = "Avertisment: limite intravilan nu s-au sincronizat";
|
||||
const cladiriResult = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
|
||||
forceFullSync: cladiriNeedsFullSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
if (cladiriResult.status === "error")
|
||||
throw new Error(cladiriResult.error ?? "Sync clădiri failed");
|
||||
|
||||
// Sync admin layers — skip if synced within 24h
|
||||
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
|
||||
const adminStatus = await getLayerFreshness(siruta, adminLayer);
|
||||
if (!forceSync && isFresh(adminStatus.lastSynced, 24)) continue;
|
||||
phase = `Sincronizare ${adminLayer === "LIMITE_UAT" ? "limite UAT" : "limite intravilan"}`;
|
||||
push({});
|
||||
try {
|
||||
await syncLayer(username, password, siruta, adminLayer, {
|
||||
forceFullSync: forceSync,
|
||||
jobId,
|
||||
isSubStep: true,
|
||||
});
|
||||
} catch {
|
||||
note = `Avertisment: ${adminLayer} nu s-a sincronizat`;
|
||||
push({});
|
||||
}
|
||||
}
|
||||
|
||||
if (!terenuriNeedsSync && !cladiriNeedsSync) {
|
||||
note = "Date proaspete — sync skip";
|
||||
}
|
||||
const syncSummary = [
|
||||
terenuriResult.newFeatures > 0 ? `${terenuriResult.newFeatures} terenuri noi` : null,
|
||||
terenuriResult.validFromUpdated ? `${terenuriResult.validFromUpdated} terenuri actualizate` : null,
|
||||
cladiriResult.newFeatures > 0 ? `${cladiriResult.newFeatures} cladiri noi` : null,
|
||||
cladiriResult.validFromUpdated ? `${cladiriResult.validFromUpdated} cladiri actualizate` : null,
|
||||
].filter(Boolean);
|
||||
note = syncSummary.length > 0 ? syncSummary.join(", ") : "Fără schimbări";
|
||||
finishPhase();
|
||||
|
||||
/* ── Phase 2: No-geometry import (optional) ──────── */
|
||||
if (hasNoGeom && weights.noGeom > 0) {
|
||||
setPhase("Import parcele fără geometrie", weights.noGeom);
|
||||
const noGeomClient = await EterraClient.create(username, password, {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
const res = await syncNoGeometryParcels(noGeomClient, siruta, {
|
||||
onProgress: (done, tot, ph) => {
|
||||
phase = ph;
|
||||
push({});
|
||||
},
|
||||
});
|
||||
if (res.status === "error") {
|
||||
note = `Avertisment no-geom: ${res.error}`;
|
||||
setPhase("Verificare parcele fără geometrie", weights.noGeom);
|
||||
// Skip no-geom import if recently done (within 48h) and not forced
|
||||
const { PrismaClient } = await import("@prisma/client");
|
||||
const _prisma = new PrismaClient();
|
||||
let skipNoGeom = false;
|
||||
try {
|
||||
const recentNoGeom = await _prisma.gisFeature.findFirst({
|
||||
where: {
|
||||
layerId: "TERENURI_ACTIVE",
|
||||
siruta,
|
||||
geometrySource: "NO_GEOMETRY",
|
||||
updatedAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
skipNoGeom = !forceSync && recentNoGeom != null;
|
||||
} catch { /* proceed with import */ }
|
||||
await _prisma.$disconnect();
|
||||
|
||||
if (skipNoGeom) {
|
||||
note = "Parcele fără geometrie — actualizate recent, skip";
|
||||
push({});
|
||||
} else {
|
||||
const cleanNote =
|
||||
res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : "";
|
||||
note = `${res.imported} parcele noi importate${cleanNote}`;
|
||||
phase = "Import parcele fără geometrie";
|
||||
push({});
|
||||
const noGeomClient = await EterraClient.create(username, password, {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
const res = await syncNoGeometryParcels(noGeomClient, siruta, {
|
||||
onProgress: (done, tot, ph) => {
|
||||
phase = ph;
|
||||
push({});
|
||||
},
|
||||
});
|
||||
if (res.status === "error") {
|
||||
note = `Avertisment no-geom: ${res.error}`;
|
||||
push({});
|
||||
} else {
|
||||
const cleanNote =
|
||||
res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : "";
|
||||
note = `${res.imported} parcele noi importate${cleanNote}`;
|
||||
push({});
|
||||
}
|
||||
}
|
||||
finishPhase();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* POST /api/eterra/sync-county
|
||||
*
|
||||
* Starts a background sync for all UATs in a given county.
|
||||
* Syncs TERENURI_ACTIVE, CLADIRI_ACTIVE, and LIMITE_INTRAV_DYNAMIC.
|
||||
* UATs with >30% enrichment → magic mode (sync + enrichment).
|
||||
*
|
||||
* Body: { county: string }
|
||||
* Returns immediately with jobId — progress via /api/eterra/progress.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import {
|
||||
setProgress,
|
||||
clearProgress,
|
||||
type SyncProgress,
|
||||
} from "@/modules/parcel-sync/services/progress-store";
|
||||
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
||||
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
||||
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
||||
import { createAppNotification } from "@/core/notifications/app-notifications";
|
||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
||||
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Concurrency guard */
|
||||
const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean };
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { county?: string };
|
||||
try {
|
||||
body = (await req.json()) as { county?: string };
|
||||
} catch {
|
||||
return Response.json({ error: "Body invalid" }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = getSessionCredentials();
|
||||
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
|
||||
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
|
||||
if (!username || !password) {
|
||||
return Response.json(
|
||||
{ error: "Credentiale lipsa — conecteaza-te la eTerra mai intai." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const county = body.county?.trim();
|
||||
if (!county) {
|
||||
return Response.json({ error: "Judetul lipseste" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (g.__allCountiesSyncRunning) {
|
||||
return Response.json(
|
||||
{ error: "Sync All Romania in curs — asteapta sa se termine" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
if (g.__countySyncRunning) {
|
||||
return Response.json(
|
||||
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const jobId = crypto.randomUUID();
|
||||
g.__countySyncRunning = county;
|
||||
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
phase: `Pregatire sync ${county}`,
|
||||
});
|
||||
|
||||
void runCountySync(jobId, county, username, password);
|
||||
|
||||
return Response.json(
|
||||
{ jobId, message: `Sync judet ${county} pornit` },
|
||||
{ status: 202 },
|
||||
);
|
||||
}
|
||||
|
||||
async function runCountySync(
|
||||
jobId: string,
|
||||
county: string,
|
||||
username: string,
|
||||
password: string,
|
||||
) {
|
||||
const push = (p: Partial<SyncProgress>) =>
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
...p,
|
||||
} as SyncProgress);
|
||||
|
||||
try {
|
||||
// Health check
|
||||
const health = await checkEterraHealthNow();
|
||||
if (!health.available) {
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "error",
|
||||
phase: "eTerra indisponibil",
|
||||
message: health.message ?? "maintenance",
|
||||
});
|
||||
await createAppNotification({
|
||||
type: "sync-error",
|
||||
title: `Sync ${county}: eTerra indisponibil`,
|
||||
message: health.message ?? "Serviciul eTerra este in mentenanta",
|
||||
metadata: { county, jobId },
|
||||
});
|
||||
g.__countySyncRunning = undefined;
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all UATs in this county with feature stats
|
||||
const uats = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
siruta: string;
|
||||
name: string | null;
|
||||
total: number;
|
||||
enriched: number;
|
||||
}>
|
||||
>(
|
||||
`SELECT u.siruta, u.name,
|
||||
COALESCE(f.total, 0)::int as total,
|
||||
COALESCE(f.enriched, 0)::int as enriched
|
||||
FROM "GisUat" u
|
||||
LEFT JOIN (
|
||||
SELECT siruta, COUNT(*)::int as total,
|
||||
COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched
|
||||
FROM "GisFeature"
|
||||
WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0
|
||||
GROUP BY siruta
|
||||
) f ON u.siruta = f.siruta
|
||||
WHERE u.county = $1
|
||||
ORDER BY COALESCE(f.total, 0) DESC`,
|
||||
county,
|
||||
);
|
||||
|
||||
if (uats.length === 0) {
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: "done",
|
||||
phase: `Niciun UAT gasit in ${county}`,
|
||||
});
|
||||
g.__countySyncRunning = undefined;
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
siruta: string;
|
||||
name: string;
|
||||
mode: string;
|
||||
duration: number;
|
||||
note: string;
|
||||
}> = [];
|
||||
let errors = 0;
|
||||
|
||||
let totalNewFeatures = 0;
|
||||
|
||||
for (let i = 0; i < uats.length; i++) {
|
||||
const uat = uats[i]!;
|
||||
const uatName = uat.name ?? uat.siruta;
|
||||
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||
const isMagic = ratio > 0.3;
|
||||
const mode = isMagic ? "magic" : "base";
|
||||
const pct = Math.round((i / uats.length) * 100);
|
||||
|
||||
push({
|
||||
downloaded: pct,
|
||||
total: 100,
|
||||
phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
||||
note:
|
||||
results.length > 0
|
||||
? `Ultimul: ${results[results.length - 1]!.name} — ${results[results.length - 1]!.note}`
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const uatStart = Date.now();
|
||||
try {
|
||||
// Sync TERENURI + CLADIRI — pass jobId for sub-progress
|
||||
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
|
||||
uatName, jobId, isSubStep: true,
|
||||
});
|
||||
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
|
||||
uatName, jobId, isSubStep: true,
|
||||
});
|
||||
|
||||
// Sync ADMINISTRATIV (intravilan) — wrapped in try/catch since it needs UAT geometry
|
||||
let adminNote = "";
|
||||
try {
|
||||
const aRes = await syncLayer(
|
||||
username,
|
||||
password,
|
||||
uat.siruta,
|
||||
"LIMITE_INTRAV_DYNAMIC",
|
||||
{ uatName, jobId, isSubStep: true },
|
||||
);
|
||||
if (aRes.newFeatures > 0) {
|
||||
adminNote = ` | A:+${aRes.newFeatures}`;
|
||||
}
|
||||
} catch {
|
||||
adminNote = " | A:skip";
|
||||
}
|
||||
|
||||
// Enrichment for magic mode
|
||||
let enrichNote = "";
|
||||
if (isMagic) {
|
||||
const client = await EterraClient.create(username, password, {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
const eRes = await enrichFeatures(client, uat.siruta);
|
||||
enrichNote =
|
||||
eRes.status === "done"
|
||||
? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
|
||||
: ` | enrich err: ${eRes.error}`;
|
||||
}
|
||||
|
||||
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||
const parts = [
|
||||
tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
|
||||
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
|
||||
: "T:ok",
|
||||
cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
|
||||
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
|
||||
: "C:ok",
|
||||
];
|
||||
totalNewFeatures += tRes.newFeatures + cRes.newFeatures;
|
||||
const note = `${parts.join(", ")}${adminNote}${enrichNote} (${dur}s)`;
|
||||
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note });
|
||||
|
||||
// Update progress AFTER UAT completion (so % reflects completed work)
|
||||
const completedPct = Math.round(((i + 1) / uats.length) * 100);
|
||||
push({
|
||||
downloaded: completedPct,
|
||||
total: 100,
|
||||
phase: `[${i + 1}/${uats.length}] ${uatName} finalizat`,
|
||||
note: `${note}`,
|
||||
});
|
||||
|
||||
console.log(`[sync-county:${county}] ${i + 1}/${uats.length} ${uatName}: ${note}`);
|
||||
} catch (err) {
|
||||
errors++;
|
||||
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
results.push({
|
||||
siruta: uat.siruta,
|
||||
name: uatName,
|
||||
mode,
|
||||
duration: dur,
|
||||
note: `ERR: ${msg}`,
|
||||
});
|
||||
// Still update progress after error
|
||||
const completedPct = Math.round(((i + 1) / uats.length) * 100);
|
||||
push({
|
||||
downloaded: completedPct,
|
||||
total: 100,
|
||||
phase: `[${i + 1}/${uats.length}] ${uatName} — eroare`,
|
||||
});
|
||||
console.error(`[sync-county:${county}] ${uatName}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalDur = results.reduce((s, r) => s + r.duration, 0);
|
||||
const summary = `${uats.length} UAT-uri, ${errors} erori, ${totalDur}s total`;
|
||||
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: errors > 0 && errors === uats.length ? "error" : "done",
|
||||
phase: `Sync ${county} finalizat`,
|
||||
message: summary,
|
||||
note: results.map((r) => `${r.name}: ${r.note}`).join("\n"),
|
||||
});
|
||||
|
||||
await createAppNotification({
|
||||
type: errors > 0 ? "sync-error" : "sync-complete",
|
||||
title:
|
||||
errors > 0
|
||||
? `Sync ${county}: ${errors} erori din ${uats.length} UAT-uri`
|
||||
: `Sync ${county}: ${uats.length} UAT-uri sincronizate`,
|
||||
message: summary,
|
||||
metadata: { county, jobId, uatCount: uats.length, errors, totalDuration: totalDur },
|
||||
});
|
||||
|
||||
console.log(`[sync-county:${county}] Done: ${summary}`);
|
||||
|
||||
// Trigger PMTiles rebuild if new features were synced
|
||||
if (totalNewFeatures > 0) {
|
||||
await firePmtilesRebuild("county-sync-complete", {
|
||||
county,
|
||||
uatCount: uats.length,
|
||||
newFeatures: totalNewFeatures,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => clearProgress(jobId), 6 * 3_600_000);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "error",
|
||||
phase: "Eroare",
|
||||
message: msg,
|
||||
});
|
||||
await createAppNotification({
|
||||
type: "sync-error",
|
||||
title: `Sync ${county}: eroare generala`,
|
||||
message: msg,
|
||||
metadata: { county, jobId },
|
||||
});
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
} finally {
|
||||
g.__countySyncRunning = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* PATCH /api/eterra/sync-rules/[id] — Update a sync rule
|
||||
* DELETE /api/eterra/sync-rules/[id] — Delete a sync rule
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
|
||||
|
||||
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
|
||||
if (frequency === "manual") return null;
|
||||
const base = lastSyncAt ?? new Date();
|
||||
const ms: Record<string, number> = {
|
||||
"3x-daily": 8 * 3600_000,
|
||||
daily: 24 * 3600_000,
|
||||
weekly: 7 * 24 * 3600_000,
|
||||
monthly: 30 * 24 * 3600_000,
|
||||
};
|
||||
return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null;
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
const existing = await prisma.gisSyncRule.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: "Regula nu exista" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = (await req.json()) as Record<string, unknown>;
|
||||
|
||||
// Validate frequency if provided
|
||||
if (body.frequency && !VALID_FREQUENCIES.includes(body.frequency as string)) {
|
||||
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Build update data — only include provided fields
|
||||
const data: Record<string, unknown> = {};
|
||||
const fields = [
|
||||
"frequency", "syncTerenuri", "syncCladiri", "syncNoGeom", "syncEnrich",
|
||||
"priority", "enabled", "allowedHoursStart", "allowedHoursEnd",
|
||||
"allowedDays", "label",
|
||||
];
|
||||
for (const f of fields) {
|
||||
if (f in body) data[f] = body[f];
|
||||
}
|
||||
|
||||
// Recompute nextDueAt if frequency changed
|
||||
if (body.frequency) {
|
||||
data.nextDueAt = computeNextDue(
|
||||
body.frequency as string,
|
||||
existing.lastSyncAt,
|
||||
);
|
||||
}
|
||||
|
||||
// If enabled changed to true and no nextDueAt, compute it
|
||||
if (body.enabled === true && !existing.nextDueAt && !data.nextDueAt) {
|
||||
const freq = (body.frequency as string) ?? existing.frequency;
|
||||
data.nextDueAt = computeNextDue(freq, existing.lastSyncAt);
|
||||
}
|
||||
|
||||
const updated = await prisma.gisSyncRule.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
return NextResponse.json({ rule: updated });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
try {
|
||||
await prisma.gisSyncRule.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* POST /api/eterra/sync-rules/bulk — Bulk operations on sync rules
|
||||
*
|
||||
* Actions:
|
||||
* - set-county-frequency: Create or update a county-level rule
|
||||
* - enable/disable: Toggle multiple rules by IDs
|
||||
* - delete: Delete multiple rules by IDs
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
|
||||
|
||||
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
|
||||
if (frequency === "manual") return null;
|
||||
const base = lastSyncAt ?? new Date();
|
||||
const ms: Record<string, number> = {
|
||||
"3x-daily": 8 * 3600_000,
|
||||
daily: 24 * 3600_000,
|
||||
weekly: 7 * 24 * 3600_000,
|
||||
monthly: 30 * 24 * 3600_000,
|
||||
};
|
||||
return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null;
|
||||
}
|
||||
|
||||
type BulkBody = {
|
||||
action: string;
|
||||
county?: string;
|
||||
frequency?: string;
|
||||
syncEnrich?: boolean;
|
||||
syncNoGeom?: boolean;
|
||||
ruleIds?: string[];
|
||||
};
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as BulkBody;
|
||||
|
||||
switch (body.action) {
|
||||
case "set-county-frequency": {
|
||||
if (!body.county || !body.frequency) {
|
||||
return NextResponse.json({ error: "county si frequency obligatorii" }, { status: 400 });
|
||||
}
|
||||
if (!VALID_FREQUENCIES.includes(body.frequency)) {
|
||||
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Upsert county-level rule
|
||||
const existing = await prisma.gisSyncRule.findFirst({
|
||||
where: { county: body.county, siruta: null },
|
||||
});
|
||||
|
||||
const rule = existing
|
||||
? await prisma.gisSyncRule.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
frequency: body.frequency,
|
||||
syncEnrich: body.syncEnrich ?? existing.syncEnrich,
|
||||
syncNoGeom: body.syncNoGeom ?? existing.syncNoGeom,
|
||||
nextDueAt: computeNextDue(body.frequency, existing.lastSyncAt),
|
||||
},
|
||||
})
|
||||
: await prisma.gisSyncRule.create({
|
||||
data: {
|
||||
county: body.county,
|
||||
frequency: body.frequency,
|
||||
syncEnrich: body.syncEnrich ?? false,
|
||||
syncNoGeom: body.syncNoGeom ?? false,
|
||||
nextDueAt: computeNextDue(body.frequency, null),
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ rule, action: "set-county-frequency" });
|
||||
}
|
||||
|
||||
case "enable":
|
||||
case "disable": {
|
||||
if (!body.ruleIds?.length) {
|
||||
return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 });
|
||||
}
|
||||
const result = await prisma.gisSyncRule.updateMany({
|
||||
where: { id: { in: body.ruleIds } },
|
||||
data: { enabled: body.action === "enable" },
|
||||
});
|
||||
return NextResponse.json({ updated: result.count, action: body.action });
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
if (!body.ruleIds?.length) {
|
||||
return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 });
|
||||
}
|
||||
const result = await prisma.gisSyncRule.deleteMany({
|
||||
where: { id: { in: body.ruleIds } },
|
||||
});
|
||||
return NextResponse.json({ deleted: result.count, action: "delete" });
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: `Actiune necunoscuta: ${body.action}` }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* GET /api/eterra/sync-rules/global-default — Get global default frequency
|
||||
* PATCH /api/eterra/sync-rules/global-default — Set global default frequency
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const NAMESPACE = "sync-management";
|
||||
const KEY = "global-default";
|
||||
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const row = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: KEY } },
|
||||
});
|
||||
const val = row?.value as { frequency?: string } | null;
|
||||
return NextResponse.json({ frequency: val?.frequency ?? "monthly" });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as { frequency?: string };
|
||||
if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency)) {
|
||||
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: KEY } },
|
||||
update: { value: { frequency: body.frequency } },
|
||||
create: { namespace: NAMESPACE, key: KEY, value: { frequency: body.frequency } },
|
||||
});
|
||||
|
||||
return NextResponse.json({ frequency: body.frequency });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* GET /api/eterra/sync-rules — List all sync rules, enriched with UAT/county names
|
||||
* POST /api/eterra/sync-rules — Create a new sync rule
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"] as const;
|
||||
|
||||
/** Compute nextDueAt from lastSyncAt + frequency interval */
|
||||
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
|
||||
if (frequency === "manual") return null;
|
||||
const base = lastSyncAt ?? new Date();
|
||||
const ms = {
|
||||
"3x-daily": 8 * 3600_000,
|
||||
daily: 24 * 3600_000,
|
||||
weekly: 7 * 24 * 3600_000,
|
||||
monthly: 30 * 24 * 3600_000,
|
||||
}[frequency];
|
||||
if (!ms) return null;
|
||||
return new Date(base.getTime() + ms);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const rules = await prisma.gisSyncRule.findMany({
|
||||
orderBy: [{ priority: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
// Enrich with UAT names for UAT-specific rules
|
||||
const sirutas = rules
|
||||
.map((r) => r.siruta)
|
||||
.filter((s): s is string => s != null);
|
||||
|
||||
const uatMap = new Map<string, string>();
|
||||
if (sirutas.length > 0) {
|
||||
const uats = await prisma.gisUat.findMany({
|
||||
where: { siruta: { in: sirutas } },
|
||||
select: { siruta: true, name: true },
|
||||
});
|
||||
for (const u of uats) uatMap.set(u.siruta, u.name);
|
||||
}
|
||||
|
||||
// For county rules, get UAT count per county
|
||||
const counties = rules
|
||||
.map((r) => r.county)
|
||||
.filter((c): c is string => c != null);
|
||||
|
||||
const countyCountMap = new Map<string, number>();
|
||||
if (counties.length > 0) {
|
||||
const counts = await prisma.gisUat.groupBy({
|
||||
by: ["county"],
|
||||
where: { county: { in: counties } },
|
||||
_count: true,
|
||||
});
|
||||
for (const c of counts) {
|
||||
if (c.county) countyCountMap.set(c.county, c._count);
|
||||
}
|
||||
}
|
||||
|
||||
const enriched = rules.map((r) => ({
|
||||
...r,
|
||||
uatName: r.siruta ? (uatMap.get(r.siruta) ?? null) : null,
|
||||
uatCount: r.county ? (countyCountMap.get(r.county) ?? 0) : r.siruta ? 1 : 0,
|
||||
}));
|
||||
|
||||
// Get global default
|
||||
const globalDefault = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: "sync-management", key: "global-default" } },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
rules: enriched,
|
||||
globalDefault: (globalDefault?.value as { frequency?: string })?.frequency ?? "monthly",
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as {
|
||||
siruta?: string;
|
||||
county?: string;
|
||||
frequency?: string;
|
||||
syncTerenuri?: boolean;
|
||||
syncCladiri?: boolean;
|
||||
syncNoGeom?: boolean;
|
||||
syncEnrich?: boolean;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
allowedHoursStart?: number | null;
|
||||
allowedHoursEnd?: number | null;
|
||||
allowedDays?: string | null;
|
||||
label?: string | null;
|
||||
};
|
||||
|
||||
if (!body.siruta && !body.county) {
|
||||
return NextResponse.json({ error: "Trebuie specificat siruta sau judetul" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency as typeof VALID_FREQUENCIES[number])) {
|
||||
return NextResponse.json(
|
||||
{ error: `Frecventa invalida. Valori permise: ${VALID_FREQUENCIES.join(", ")}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate siruta exists
|
||||
if (body.siruta) {
|
||||
const uat = await prisma.gisUat.findUnique({ where: { siruta: body.siruta } });
|
||||
if (!uat) {
|
||||
return NextResponse.json({ error: `UAT ${body.siruta} nu exista` }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate county has UATs
|
||||
if (body.county && !body.siruta) {
|
||||
const count = await prisma.gisUat.count({ where: { county: body.county } });
|
||||
if (count === 0) {
|
||||
return NextResponse.json({ error: `Niciun UAT in judetul ${body.county}` }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing rule with same scope
|
||||
const existing = await prisma.gisSyncRule.findFirst({
|
||||
where: {
|
||||
siruta: body.siruta ?? null,
|
||||
county: body.siruta ? null : (body.county ?? null),
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
return NextResponse.json(
|
||||
{ error: "Exista deja o regula pentru acest scope", existingId: existing.id },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const nextDueAt = computeNextDue(body.frequency, null);
|
||||
|
||||
const rule = await prisma.gisSyncRule.create({
|
||||
data: {
|
||||
siruta: body.siruta ?? null,
|
||||
county: body.siruta ? null : (body.county ?? null),
|
||||
frequency: body.frequency,
|
||||
syncTerenuri: body.syncTerenuri ?? true,
|
||||
syncCladiri: body.syncCladiri ?? true,
|
||||
syncNoGeom: body.syncNoGeom ?? false,
|
||||
syncEnrich: body.syncEnrich ?? false,
|
||||
priority: body.priority ?? 5,
|
||||
enabled: body.enabled ?? true,
|
||||
allowedHoursStart: body.allowedHoursStart ?? null,
|
||||
allowedHoursEnd: body.allowedHoursEnd ?? null,
|
||||
allowedDays: body.allowedDays ?? null,
|
||||
label: body.label ?? null,
|
||||
nextDueAt,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ rule }, { status: 201 });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* GET /api/eterra/sync-rules/scheduler — Scheduler status
|
||||
*
|
||||
* Returns current scheduler state from KeyValueStore + computed stats.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Get scheduler state from KV (will be populated by the scheduler in Phase 2)
|
||||
const kvState = await prisma.keyValueStore.findUnique({
|
||||
where: {
|
||||
namespace_key: { namespace: "sync-management", key: "scheduler-state" },
|
||||
},
|
||||
});
|
||||
|
||||
// Compute rule stats
|
||||
const [totalRules, activeRules, dueNow, withErrors] = await Promise.all([
|
||||
prisma.gisSyncRule.count(),
|
||||
prisma.gisSyncRule.count({ where: { enabled: true } }),
|
||||
prisma.gisSyncRule.count({
|
||||
where: { enabled: true, nextDueAt: { lte: new Date() } },
|
||||
}),
|
||||
prisma.gisSyncRule.count({
|
||||
where: { lastSyncStatus: "error" },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Frequency distribution
|
||||
const freqDist = await prisma.gisSyncRule.groupBy({
|
||||
by: ["frequency"],
|
||||
where: { enabled: true },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
// County coverage
|
||||
const totalCounties = await prisma.gisUat.groupBy({
|
||||
by: ["county"],
|
||||
where: { county: { not: null } },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
const countiesWithRules = await prisma.gisSyncRule.groupBy({
|
||||
by: ["county"],
|
||||
where: { county: { not: null } },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
scheduler: kvState?.value ?? { status: "not-started" },
|
||||
stats: {
|
||||
totalRules,
|
||||
activeRules,
|
||||
dueNow,
|
||||
withErrors,
|
||||
frequencyDistribution: Object.fromEntries(
|
||||
freqDist.map((f) => [f.frequency, f._count]),
|
||||
),
|
||||
totalCounties: totalCounties.length,
|
||||
countiesWithRules: countiesWithRules.length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare server";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
import {
|
||||
isWeekendWindow,
|
||||
getWeekendSyncActivity,
|
||||
triggerForceSync,
|
||||
} from "@/modules/parcel-sync/services/weekend-deep-sync";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const g = globalThis as { __parcelSyncRunning?: boolean };
|
||||
|
||||
const KV_NAMESPACE = "parcel-sync-weekend";
|
||||
const KV_KEY = "queue-state";
|
||||
|
||||
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
|
||||
type StepStatus = "pending" | "done" | "error";
|
||||
|
||||
type CityState = {
|
||||
siruta: string;
|
||||
name: string;
|
||||
county: string;
|
||||
priority: number;
|
||||
steps: Record<StepName, StepStatus>;
|
||||
lastActivity?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
type WeekendSyncState = {
|
||||
cities: CityState[];
|
||||
lastSessionDate?: string;
|
||||
totalSessions: number;
|
||||
completedCycles: number;
|
||||
};
|
||||
|
||||
const FRESH_STEPS: Record<StepName, StepStatus> = {
|
||||
sync_terenuri: "pending",
|
||||
sync_cladiri: "pending",
|
||||
import_nogeom: "pending",
|
||||
enrich: "pending",
|
||||
};
|
||||
|
||||
const DEFAULT_CITIES: Omit<CityState, "steps">[] = [
|
||||
{ siruta: "54975", name: "Cluj-Napoca", county: "Cluj", priority: 1 },
|
||||
{ siruta: "32394", name: "Bistri\u021Ba", county: "Bistri\u021Ba-N\u0103s\u0103ud", priority: 1 },
|
||||
{ siruta: "114319", name: "T\u00E2rgu Mure\u0219", county: "Mure\u0219", priority: 2 },
|
||||
{ siruta: "139704", name: "Zal\u0103u", county: "S\u0103laj", priority: 2 },
|
||||
{ siruta: "26564", name: "Oradea", county: "Bihor", priority: 2 },
|
||||
{ siruta: "9262", name: "Arad", county: "Arad", priority: 2 },
|
||||
{ siruta: "155243", name: "Timi\u0219oara", county: "Timi\u0219", priority: 2 },
|
||||
{ siruta: "143450", name: "Sibiu", county: "Sibiu", priority: 2 },
|
||||
{ siruta: "40198", name: "Bra\u0219ov", county: "Bra\u0219ov", priority: 2 },
|
||||
];
|
||||
|
||||
/** Initialize state with default cities if not present in DB */
|
||||
async function getOrCreateState(): Promise<WeekendSyncState> {
|
||||
const row = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||
});
|
||||
if (row?.value && typeof row.value === "object") {
|
||||
return row.value as unknown as WeekendSyncState;
|
||||
}
|
||||
// First access — initialize with defaults
|
||||
const state: WeekendSyncState = {
|
||||
cities: DEFAULT_CITIES.map((c) => ({ ...c, steps: { ...FRESH_STEPS } })),
|
||||
totalSessions: 0,
|
||||
completedCycles: 0,
|
||||
};
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||
create: {
|
||||
namespace: KV_NAMESPACE,
|
||||
key: KV_KEY,
|
||||
value: state as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/eterra/weekend-sync
|
||||
* Returns the current queue state.
|
||||
*/
|
||||
export async function GET() {
|
||||
// Auth handled by middleware (route is not excluded)
|
||||
const state = await getOrCreateState();
|
||||
const sirutas = state.cities.map((c) => c.siruta);
|
||||
|
||||
const counts = await prisma.gisFeature.groupBy({
|
||||
by: ["siruta", "layerId"],
|
||||
where: { siruta: { in: sirutas } },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const enrichedCounts = await prisma.gisFeature.groupBy({
|
||||
by: ["siruta"],
|
||||
where: { siruta: { in: sirutas }, enrichedAt: { not: null } },
|
||||
_count: { id: true },
|
||||
});
|
||||
|
||||
const enrichedMap = new Map(enrichedCounts.map((e) => [e.siruta, e._count.id]));
|
||||
|
||||
type CityStats = {
|
||||
terenuri: number;
|
||||
cladiri: number;
|
||||
total: number;
|
||||
enriched: number;
|
||||
};
|
||||
const statsMap = new Map<string, CityStats>();
|
||||
|
||||
for (const c of counts) {
|
||||
const existing = statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 };
|
||||
existing.total += c._count.id;
|
||||
if (c.layerId === "TERENURI_ACTIVE") existing.terenuri = c._count.id;
|
||||
if (c.layerId === "CLADIRI_ACTIVE") existing.cladiri = c._count.id;
|
||||
existing.enriched = enrichedMap.get(c.siruta) ?? 0;
|
||||
statsMap.set(c.siruta, existing);
|
||||
}
|
||||
|
||||
const citiesWithStats = state.cities.map((c) => ({
|
||||
...c,
|
||||
dbStats: statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 },
|
||||
}));
|
||||
|
||||
// Determine live sync status
|
||||
const running = !!g.__parcelSyncRunning;
|
||||
const activity = getWeekendSyncActivity();
|
||||
const inWindow = isWeekendWindow();
|
||||
const hasErrors = state.cities.some((c) =>
|
||||
(Object.values(c.steps) as StepStatus[]).some((s) => s === "error"),
|
||||
);
|
||||
|
||||
type SyncStatus = "running" | "error" | "waiting" | "idle";
|
||||
let syncStatus: SyncStatus = "idle";
|
||||
if (running) syncStatus = "running";
|
||||
else if (hasErrors) syncStatus = "error";
|
||||
else if (inWindow) syncStatus = "waiting";
|
||||
|
||||
return NextResponse.json({
|
||||
state: { ...state, cities: citiesWithStats },
|
||||
syncStatus,
|
||||
currentActivity: activity,
|
||||
inWeekendWindow: inWindow,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/eterra/weekend-sync
|
||||
* Modify the queue: add/remove cities, reset steps, change priority.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
// Auth handled by middleware (route is not excluded)
|
||||
const body = (await request.json()) as {
|
||||
action: "add" | "remove" | "reset" | "reset_all" | "set_priority" | "trigger";
|
||||
siruta?: string;
|
||||
name?: string;
|
||||
county?: string;
|
||||
priority?: number;
|
||||
onlySteps?: string[];
|
||||
};
|
||||
|
||||
// Trigger is handled separately — starts sync immediately
|
||||
if (body.action === "trigger") {
|
||||
const validSteps = ["sync_terenuri", "sync_cladiri", "import_nogeom", "enrich"] as const;
|
||||
const onlySteps = body.onlySteps?.filter((s): s is (typeof validSteps)[number] =>
|
||||
(validSteps as readonly string[]).includes(s),
|
||||
);
|
||||
const result = await triggerForceSync(
|
||||
onlySteps && onlySteps.length > 0 ? { onlySteps } : undefined,
|
||||
);
|
||||
if (!result.started) {
|
||||
return NextResponse.json(
|
||||
{ error: result.reason },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ ok: true, message: "Sincronizare pornita" });
|
||||
}
|
||||
|
||||
const state = await getOrCreateState();
|
||||
|
||||
switch (body.action) {
|
||||
case "add": {
|
||||
if (!body.siruta || !body.name) {
|
||||
return NextResponse.json(
|
||||
{ error: "siruta si name sunt obligatorii" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (state.cities.some((c) => c.siruta === body.siruta)) {
|
||||
return NextResponse.json(
|
||||
{ error: `${body.name} (${body.siruta}) e deja in coada` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
state.cities.push({
|
||||
siruta: body.siruta,
|
||||
name: body.name,
|
||||
county: body.county ?? "",
|
||||
priority: body.priority ?? 3,
|
||||
steps: { ...FRESH_STEPS },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "remove": {
|
||||
state.cities = state.cities.filter((c) => c.siruta !== body.siruta);
|
||||
break;
|
||||
}
|
||||
case "reset": {
|
||||
const city = state.cities.find((c) => c.siruta === body.siruta);
|
||||
if (city) {
|
||||
city.steps = { ...FRESH_STEPS };
|
||||
city.errorMessage = undefined;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "reset_all": {
|
||||
for (const city of state.cities) {
|
||||
city.steps = { ...FRESH_STEPS };
|
||||
city.errorMessage = undefined;
|
||||
}
|
||||
state.completedCycles = 0;
|
||||
break;
|
||||
}
|
||||
case "set_priority": {
|
||||
const city = state.cities.find((c) => c.siruta === body.siruta);
|
||||
if (city && body.priority != null) {
|
||||
city.priority = body.priority;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||
create: {
|
||||
namespace: KV_NAMESPACE,
|
||||
key: KV_KEY,
|
||||
value: state as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, cities: state.cities.length });
|
||||
}
|
||||
@@ -75,6 +75,34 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: "Parcela negasita in registrul eTerra" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Building cross-ref: check CLADIRI_ACTIVE in local DB for this parcel
|
||||
let hasBuilding = 0;
|
||||
let buildLegal = 0;
|
||||
const baseCad = cadRef.includes("-") ? cadRef.split("-")[0]! : cadRef;
|
||||
if (baseCad) {
|
||||
const cladiri = await prisma.gisFeature.findMany({
|
||||
where: {
|
||||
layerId: "CLADIRI_ACTIVE",
|
||||
siruta: feature.siruta,
|
||||
OR: [
|
||||
{ cadastralRef: { startsWith: baseCad + "-" } },
|
||||
{ cadastralRef: baseCad },
|
||||
],
|
||||
},
|
||||
select: { attributes: true },
|
||||
});
|
||||
for (const c of cladiri) {
|
||||
const attrs = c.attributes as Record<string, unknown>;
|
||||
hasBuilding = 1;
|
||||
if (
|
||||
Number(attrs.IS_LEGAL ?? 0) === 1 ||
|
||||
String(attrs.IS_LEGAL ?? "").toLowerCase() === "true"
|
||||
) {
|
||||
buildLegal = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to enrichment format (same as enrichFeatures uses)
|
||||
const enrichment = {
|
||||
NR_CAD: match.nrCad || cadRef,
|
||||
@@ -89,8 +117,8 @@ export async function POST(req: Request) {
|
||||
SOLICITANT: match.solicitant || "",
|
||||
INTRAVILAN: match.intravilan || "",
|
||||
CATEGORIE_FOLOSINTA: match.categorieFolosinta || "",
|
||||
HAS_BUILDING: 0,
|
||||
BUILD_LEGAL: 0,
|
||||
HAS_BUILDING: hasBuilding,
|
||||
BUILD_LEGAL: buildLegal,
|
||||
};
|
||||
|
||||
// Persist
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* GET /api/geoportal/monitor — tile infrastructure status
|
||||
* POST /api/geoportal/monitor — trigger actions (rebuild, warm-cache)
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const TILE_CACHE_INTERNAL = "http://tile-cache:80";
|
||||
const MARTIN_INTERNAL = "http://martin:3000";
|
||||
const PMTILES_URL = process.env.NEXT_PUBLIC_PMTILES_URL || "";
|
||||
// Server-side fetch needs absolute URL — resolve relative paths through tile-cache
|
||||
const PMTILES_FETCH_URL = PMTILES_URL.startsWith("/")
|
||||
? `${TILE_CACHE_INTERNAL}${PMTILES_URL.replace(/^\/tiles/, "")}`
|
||||
: PMTILES_URL;
|
||||
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || "";
|
||||
|
||||
type NginxStatus = {
|
||||
activeConnections: number;
|
||||
accepts: number;
|
||||
handled: number;
|
||||
requests: number;
|
||||
reading: number;
|
||||
writing: number;
|
||||
waiting: number;
|
||||
};
|
||||
|
||||
function parseNginxStatus(text: string): NginxStatus {
|
||||
const lines = text.trim().split("\n");
|
||||
const active = parseInt(lines[0]?.match(/\d+/)?.[0] ?? "0", 10);
|
||||
const counts = lines[2]?.trim().split(/\s+/).map(Number) ?? [0, 0, 0];
|
||||
const rw = lines[3]?.match(/\d+/g)?.map(Number) ?? [0, 0, 0];
|
||||
return {
|
||||
activeConnections: active,
|
||||
accepts: counts[0] ?? 0,
|
||||
handled: counts[1] ?? 0,
|
||||
requests: counts[2] ?? 0,
|
||||
reading: rw[0] ?? 0,
|
||||
writing: rw[1] ?? 0,
|
||||
waiting: rw[2] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url: string, timeoutMs = 5000): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { signal: controller.signal, cache: "no-store" });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Sample tile coordinates for cache testing (Romania, z8)
|
||||
const SAMPLE_TILES = [
|
||||
{ z: 8, x: 143, y: 91, source: "gis_uats_z8" },
|
||||
{ z: 17, x: 73640, y: 47720, source: "gis_terenuri" },
|
||||
];
|
||||
|
||||
export async function GET() {
|
||||
const result: Record<string, unknown> = {
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 1. Nginx status
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${TILE_CACHE_INTERNAL}/status`);
|
||||
if (res.ok) {
|
||||
result.nginx = parseNginxStatus(await res.text());
|
||||
} else {
|
||||
result.nginx = { error: `HTTP ${res.status}` };
|
||||
}
|
||||
} catch {
|
||||
result.nginx = { error: "tile-cache unreachable" };
|
||||
}
|
||||
|
||||
// 2. Martin catalog
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${MARTIN_INTERNAL}/catalog`);
|
||||
if (res.ok) {
|
||||
const catalog = await res.json() as { tiles?: Record<string, unknown> };
|
||||
const sources = Object.keys(catalog.tiles ?? {});
|
||||
result.martin = { status: "ok", sources, sourceCount: sources.length };
|
||||
} else {
|
||||
result.martin = { error: `HTTP ${res.status}` };
|
||||
}
|
||||
} catch {
|
||||
result.martin = { error: "martin unreachable" };
|
||||
}
|
||||
|
||||
// 3. PMTiles info
|
||||
if (PMTILES_URL) {
|
||||
try {
|
||||
const res = await fetchWithTimeout(PMTILES_FETCH_URL, 3000);
|
||||
result.pmtiles = {
|
||||
url: PMTILES_URL,
|
||||
status: res.ok ? "ok" : `HTTP ${res.status}`,
|
||||
size: res.headers.get("content-length")
|
||||
? `${(parseInt(res.headers.get("content-length") ?? "0", 10) / 1024 / 1024).toFixed(1)} MB`
|
||||
: "unknown",
|
||||
lastModified: res.headers.get("last-modified") ?? "unknown",
|
||||
};
|
||||
} catch {
|
||||
result.pmtiles = { url: PMTILES_URL, error: "unreachable" };
|
||||
}
|
||||
} else {
|
||||
result.pmtiles = { status: "not configured" };
|
||||
}
|
||||
|
||||
// 4. Cache test — request sample tiles and check X-Cache-Status
|
||||
const cacheTests: Record<string, string>[] = [];
|
||||
for (const tile of SAMPLE_TILES) {
|
||||
try {
|
||||
const url = `${TILE_CACHE_INTERNAL}/${tile.source}/${tile.z}/${tile.x}/${tile.y}`;
|
||||
const res = await fetchWithTimeout(url, 10000);
|
||||
cacheTests.push({
|
||||
tile: `${tile.source}/${tile.z}/${tile.x}/${tile.y}`,
|
||||
status: `${res.status}`,
|
||||
cache: res.headers.get("x-cache-status") ?? "unknown",
|
||||
});
|
||||
} catch {
|
||||
cacheTests.push({
|
||||
tile: `${tile.source}/${tile.z}/${tile.x}/${tile.y}`,
|
||||
status: "error",
|
||||
cache: "unreachable",
|
||||
});
|
||||
}
|
||||
}
|
||||
result.cacheTests = cacheTests;
|
||||
|
||||
// 5. Config summary
|
||||
result.config = {
|
||||
martinUrl: process.env.NEXT_PUBLIC_MARTIN_URL ?? "(not set)",
|
||||
pmtilesUrl: PMTILES_URL || "(not set)",
|
||||
n8nWebhook: N8N_WEBHOOK_URL ? "configured" : "not set",
|
||||
};
|
||||
|
||||
return NextResponse.json(result);
|
||||
}
|
||||
|
||||
async function getPmtilesInfo(): Promise<{ size: string; lastModified: string } | null> {
|
||||
if (!PMTILES_URL) return null;
|
||||
try {
|
||||
const res = await fetchWithTimeout(PMTILES_FETCH_URL, 3000);
|
||||
return {
|
||||
size: res.headers.get("content-length")
|
||||
? `${(parseInt(res.headers.get("content-length") ?? "0", 10) / 1024 / 1024).toFixed(1)} MB`
|
||||
: "unknown",
|
||||
lastModified: res.headers.get("last-modified") ?? "unknown",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json() as { action?: string };
|
||||
const action = body.action;
|
||||
|
||||
if (action === "rebuild") {
|
||||
// Get current PMTiles state before rebuild
|
||||
const before = await getPmtilesInfo();
|
||||
const result = await firePmtilesRebuild("manual-rebuild");
|
||||
if (!result.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Webhook PMTiles indisponibil — verifica N8N_WEBHOOK_URL si serviciul pmtiles-webhook" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: "rebuild",
|
||||
alreadyRunning: result.alreadyRunning ?? false,
|
||||
previousPmtiles: before,
|
||||
message: result.alreadyRunning
|
||||
? "Rebuild PMTiles deja in curs. Urmareste PMTiles last-modified."
|
||||
: "Rebuild PMTiles pornit. Dureaza ~8 min. Urmareste PMTiles last-modified.",
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "check-rebuild") {
|
||||
// Check if PMTiles was updated since a given timestamp
|
||||
const previousLastModified = (body as { previousLastModified?: string }).previousLastModified;
|
||||
const current = await getPmtilesInfo();
|
||||
const changed = !!current && !!previousLastModified && current.lastModified !== previousLastModified;
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: "check-rebuild",
|
||||
current,
|
||||
changed,
|
||||
message: changed
|
||||
? `Rebuild finalizat! PMTiles actualizat: ${current?.size}, ${current?.lastModified}`
|
||||
: "Rebuild in curs...",
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "warm-cache") {
|
||||
const sources = ["gis_terenuri", "gis_cladiri"];
|
||||
let total = 0;
|
||||
let hits = 0;
|
||||
let misses = 0;
|
||||
let errors = 0;
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const source of sources) {
|
||||
for (let x = 9200; x <= 9210; x++) {
|
||||
for (let y = 5960; y <= 5970; y++) {
|
||||
total++;
|
||||
promises.push(
|
||||
fetchWithTimeout(`${TILE_CACHE_INTERNAL}/${source}/14/${x}/${y}`, 30000)
|
||||
.then((res) => {
|
||||
const cache = res.headers.get("x-cache-status") ?? "";
|
||||
if (cache === "HIT") hits++;
|
||||
else misses++;
|
||||
})
|
||||
.catch(() => { errors++; }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: "warm-cache",
|
||||
total,
|
||||
hits,
|
||||
misses,
|
||||
errors,
|
||||
message: `${total} tile-uri procesate: ${hits} HIT, ${misses} MISS (nou incarcate), ${errors} erori`,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
@@ -67,25 +67,28 @@ export async function GET(req: Request) {
|
||||
// Search by cadastral reference
|
||||
const parcels = await prisma.$queryRaw`
|
||||
SELECT
|
||||
id,
|
||||
"cadastralRef",
|
||||
"areaValue",
|
||||
siruta,
|
||||
enrichment,
|
||||
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
|
||||
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
|
||||
FROM "GisFeature"
|
||||
WHERE geom IS NOT NULL
|
||||
AND "layerId" LIKE 'TERENURI%'
|
||||
AND ("cadastralRef" ILIKE ${pattern}
|
||||
OR enrichment::text ILIKE ${`%"NR_CAD":"${q}%`})
|
||||
ORDER BY "cadastralRef"
|
||||
f.id,
|
||||
f."cadastralRef",
|
||||
f."areaValue",
|
||||
f.siruta,
|
||||
f.enrichment,
|
||||
u.name as uat_name,
|
||||
ST_X(ST_Centroid(ST_Transform(f.geom, 4326))) as lng,
|
||||
ST_Y(ST_Centroid(ST_Transform(f.geom, 4326))) as lat
|
||||
FROM "GisFeature" f
|
||||
LEFT JOIN "GisUat" u ON u.siruta = f.siruta
|
||||
WHERE f.geom IS NOT NULL
|
||||
AND f."layerId" LIKE 'TERENURI%'
|
||||
AND (f."cadastralRef" ILIKE ${pattern}
|
||||
OR f.enrichment::text ILIKE ${`%"NR_CAD":"${q}%`})
|
||||
ORDER BY f."cadastralRef"
|
||||
LIMIT ${limit}
|
||||
` as Array<{
|
||||
id: string;
|
||||
cadastralRef: string | null;
|
||||
areaValue: number | null;
|
||||
siruta: string;
|
||||
uat_name: string | null;
|
||||
enrichment: Record<string, unknown> | null;
|
||||
lng: number;
|
||||
lat: number;
|
||||
@@ -94,11 +97,12 @@ export async function GET(req: Request) {
|
||||
for (const p of parcels) {
|
||||
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
|
||||
const area = p.areaValue ? `${Math.round(p.areaValue)} mp` : "";
|
||||
const uatLabel = p.uat_name ?? `SIRUTA ${p.siruta}`;
|
||||
results.push({
|
||||
id: `parcel-${p.id}`,
|
||||
type: "parcel",
|
||||
label: `Parcela ${nrCad}`,
|
||||
sublabel: [area, `SIRUTA ${p.siruta}`].filter(Boolean).join(" | "),
|
||||
label: `Parcela ${nrCad} — ${uatLabel}`,
|
||||
sublabel: [area, p.uat_name ? `SIRUTA ${p.siruta}` : ""].filter(Boolean).join(" | "),
|
||||
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
|
||||
});
|
||||
}
|
||||
@@ -106,25 +110,28 @@ export async function GET(req: Request) {
|
||||
// Search by owner name in enrichment JSON
|
||||
const parcels = await prisma.$queryRaw`
|
||||
SELECT
|
||||
id,
|
||||
"cadastralRef",
|
||||
"areaValue",
|
||||
siruta,
|
||||
enrichment,
|
||||
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
|
||||
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
|
||||
FROM "GisFeature"
|
||||
WHERE geom IS NOT NULL
|
||||
AND "layerId" LIKE 'TERENURI%'
|
||||
AND enrichment IS NOT NULL
|
||||
AND enrichment::text ILIKE ${pattern}
|
||||
ORDER BY "cadastralRef"
|
||||
f.id,
|
||||
f."cadastralRef",
|
||||
f."areaValue",
|
||||
f.siruta,
|
||||
f.enrichment,
|
||||
u.name as uat_name,
|
||||
ST_X(ST_Centroid(ST_Transform(f.geom, 4326))) as lng,
|
||||
ST_Y(ST_Centroid(ST_Transform(f.geom, 4326))) as lat
|
||||
FROM "GisFeature" f
|
||||
LEFT JOIN "GisUat" u ON u.siruta = f.siruta
|
||||
WHERE f.geom IS NOT NULL
|
||||
AND f."layerId" LIKE 'TERENURI%'
|
||||
AND f.enrichment IS NOT NULL
|
||||
AND f.enrichment::text ILIKE ${pattern}
|
||||
ORDER BY f."cadastralRef"
|
||||
LIMIT ${limit}
|
||||
` as Array<{
|
||||
id: string;
|
||||
cadastralRef: string | null;
|
||||
areaValue: number | null;
|
||||
siruta: string;
|
||||
uat_name: string | null;
|
||||
enrichment: Record<string, unknown> | null;
|
||||
lng: number;
|
||||
lat: number;
|
||||
@@ -133,11 +140,13 @@ export async function GET(req: Request) {
|
||||
for (const p of parcels) {
|
||||
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
|
||||
const owner = (p.enrichment?.PROPRIETARI as string) ?? "";
|
||||
const uatLabel = p.uat_name ?? `SIRUTA ${p.siruta}`;
|
||||
const ownerShort = owner.length > 60 ? owner.slice(0, 60) + "..." : owner;
|
||||
results.push({
|
||||
id: `parcel-${p.id}`,
|
||||
type: "parcel",
|
||||
label: `Parcela ${nrCad}`,
|
||||
sublabel: owner.length > 60 ? owner.slice(0, 60) + "..." : owner,
|
||||
label: `Parcela ${nrCad} — ${uatLabel}`,
|
||||
sublabel: [ownerShort, p.uat_name ? `SIRUTA ${p.siruta}` : ""].filter(Boolean).join(" | "),
|
||||
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* GET /api/notifications/app — list recent + unread count
|
||||
* PATCH /api/notifications/app — mark read / mark all read
|
||||
*
|
||||
* Body for PATCH:
|
||||
* { action: "mark-read", id: string }
|
||||
* { action: "mark-all-read" }
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getAppNotifications,
|
||||
getUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
} from "@/core/notifications/app-notifications";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "30", 10), 100);
|
||||
|
||||
const [notifications, unreadCount] = await Promise.all([
|
||||
getAppNotifications(limit),
|
||||
getUnreadCount(),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ notifications, unreadCount });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare notificari";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as { action: string; id?: string };
|
||||
|
||||
if (body.action === "mark-read" && body.id) {
|
||||
await markAsRead(body.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (body.action === "mark-all-read") {
|
||||
await markAllAsRead();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Actiune necunoscuta" }, { status: 400 });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : "Eroare notificari";
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* In-app notification service.
|
||||
*
|
||||
* Stores lightweight notifications in KeyValueStore (namespace "app-notifications").
|
||||
* Used for sync completion alerts, errors, etc.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/core/storage/prisma";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type AppNotificationType = "sync-complete" | "sync-error";
|
||||
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
type: AppNotificationType;
|
||||
title: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
readAt: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const NAMESPACE = "app-notifications";
|
||||
const MAX_AGE_DAYS = 30;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Create */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function createAppNotification(
|
||||
input: Omit<AppNotification, "id" | "createdAt" | "readAt">,
|
||||
): Promise<AppNotification> {
|
||||
const notification: AppNotification = {
|
||||
id: crypto.randomUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
readAt: null,
|
||||
...input,
|
||||
};
|
||||
|
||||
await prisma.keyValueStore.create({
|
||||
data: {
|
||||
namespace: NAMESPACE,
|
||||
key: notification.id,
|
||||
value: notification as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Read */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function getAppNotifications(limit = 30): Promise<AppNotification[]> {
|
||||
const rows = await prisma.keyValueStore.findMany({
|
||||
where: { namespace: NAMESPACE },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
const cutoff = Date.now() - MAX_AGE_DAYS * 86_400_000;
|
||||
const notifications: AppNotification[] = [];
|
||||
const staleIds: string[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const n = row.value as unknown as AppNotification;
|
||||
if (new Date(n.createdAt).getTime() < cutoff) {
|
||||
staleIds.push(row.id);
|
||||
} else {
|
||||
notifications.push(n);
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy cleanup of old notifications
|
||||
if (staleIds.length > 0) {
|
||||
void prisma.keyValueStore.deleteMany({
|
||||
where: { id: { in: staleIds } },
|
||||
});
|
||||
}
|
||||
|
||||
return notifications;
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
const rows = await prisma.$queryRaw<Array<{ count: number }>>`
|
||||
SELECT COUNT(*)::int as count
|
||||
FROM "KeyValueStore"
|
||||
WHERE namespace = ${NAMESPACE}
|
||||
AND value->>'readAt' IS NULL
|
||||
`;
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Update */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function markAsRead(id: string): Promise<void> {
|
||||
const row = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: id } },
|
||||
});
|
||||
if (!row) return;
|
||||
|
||||
const n = row.value as unknown as AppNotification;
|
||||
n.readAt = new Date().toISOString();
|
||||
|
||||
await prisma.keyValueStore.update({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: id } },
|
||||
data: { value: n as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
}
|
||||
|
||||
export async function markAllAsRead(): Promise<void> {
|
||||
const rows = await prisma.keyValueStore.findMany({
|
||||
where: { namespace: NAMESPACE },
|
||||
});
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updates = rows
|
||||
.filter((r) => {
|
||||
const n = r.value as unknown as AppNotification;
|
||||
return n.readAt === null;
|
||||
})
|
||||
.map((r) => {
|
||||
const n = r.value as unknown as AppNotification;
|
||||
n.readAt = now;
|
||||
return prisma.keyValueStore.update({
|
||||
where: { namespace_key: { namespace: NAMESPACE, key: r.key } },
|
||||
data: { value: n as unknown as Prisma.InputJsonValue },
|
||||
});
|
||||
});
|
||||
|
||||
if (updates.length > 0) {
|
||||
await prisma.$transaction(updates);
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,12 @@ export {
|
||||
getAllPreferences,
|
||||
runDigest,
|
||||
} from "./notification-service";
|
||||
export {
|
||||
createAppNotification,
|
||||
getAppNotifications,
|
||||
getUnreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
type AppNotification,
|
||||
type AppNotificationType,
|
||||
} from "./app-notifications";
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Next.js instrumentation hook — runs once at server startup.
|
||||
* Used to initialize background schedulers.
|
||||
*/
|
||||
export async function register() {
|
||||
// Only run on the server (not during build or in edge runtime)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
// ParcelSync auto-refresh scheduler DISABLED during GIS DB overhaul.
|
||||
// Re-enable by uncommenting the import below once the new schema is stable.
|
||||
// await import("@/modules/parcel-sync/services/auto-refresh-scheduler");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -58,6 +58,6 @@ export const config = {
|
||||
* - /favicon.ico, /robots.txt, /sitemap.xml
|
||||
* - Files with extensions (images, fonts, etc.)
|
||||
*/
|
||||
"/((?!api/auth|api/notifications/digest|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
|
||||
"/((?!api/auth|api/notifications/digest|api/eterra/auto-refresh|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -32,8 +32,6 @@ export function GeoportalModule() {
|
||||
const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null);
|
||||
const [selectionMode, setSelectionMode] = useState<SelectionMode>("off");
|
||||
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]);
|
||||
const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>();
|
||||
|
||||
const handleFeatureClick = useCallback((feature: ClickedFeature | null) => {
|
||||
// null = clicked on empty space, close panel
|
||||
if (!feature || !feature.properties) {
|
||||
@@ -45,7 +43,7 @@ export function GeoportalModule() {
|
||||
|
||||
const handleSearchResult = useCallback((result: SearchResult) => {
|
||||
if (result.coordinates) {
|
||||
setFlyTarget({ center: result.coordinates, zoom: result.type === "uat" ? 12 : 17 });
|
||||
mapHandleRef.current?.flyTo(result.coordinates, result.type === "uat" ? 12 : 17);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -67,8 +65,6 @@ export function GeoportalModule() {
|
||||
onFeatureClick={handleFeatureClick}
|
||||
onSelectionChange={setSelectedFeatures}
|
||||
layerVisibility={layerVisibility}
|
||||
center={flyTarget?.center}
|
||||
zoom={flyTarget?.zoom}
|
||||
/>
|
||||
|
||||
{/* Setup banner (auto-hides when ready) */}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardRef } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { Protocol as PmtilesProtocol } from "pmtiles";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
/* Ensure MapLibre CSS is loaded — static import fails with next/dynamic + standalone */
|
||||
@@ -15,6 +16,12 @@ if (typeof document !== "undefined") {
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
/* Register PMTiles protocol globally (once) for pmtiles:// source URLs */
|
||||
if (typeof window !== "undefined") {
|
||||
const pmtilesProto = new PmtilesProtocol();
|
||||
maplibregl.addProtocol("pmtiles", pmtilesProto.tile);
|
||||
}
|
||||
|
||||
import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -28,6 +35,7 @@ export type SelectionType = "off" | "click" | "rect" | "freehand";
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL || "/tiles";
|
||||
const DEFAULT_PMTILES_URL = process.env.NEXT_PUBLIC_PMTILES_URL || "";
|
||||
const DEFAULT_CENTER: [number, number] = [23.8, 46.1];
|
||||
const DEFAULT_ZOOM = 7;
|
||||
|
||||
@@ -58,6 +66,7 @@ const LAYER_IDS = {
|
||||
terenuriLabel: "l-terenuri-label",
|
||||
cladiriFill: "l-cladiri-fill",
|
||||
cladiriLine: "l-cladiri-line",
|
||||
cladiriLabel: "l-cladiri-label",
|
||||
selectionFill: "l-selection-fill",
|
||||
selectionLine: "l-selection-line",
|
||||
drawPolygonFill: "l-draw-polygon-fill",
|
||||
@@ -319,8 +328,8 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
LAYER_IDS.uatsZ12Fill, LAYER_IDS.uatsZ12Line, LAYER_IDS.uatsZ12Label,
|
||||
],
|
||||
administrativ: [LAYER_IDS.adminLineOuter, LAYER_IDS.adminLineInner],
|
||||
terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine, LAYER_IDS.terenuriLabel],
|
||||
cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine],
|
||||
terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine, LAYER_IDS.terenuriLabel, "l-terenuri-pm-fill", "l-terenuri-pm-line", "l-terenuri-pm-label"],
|
||||
cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine, LAYER_IDS.cladiriLabel, "l-cladiri-pm-fill", "l-cladiri-pm-line"],
|
||||
};
|
||||
for (const [group, layerIds] of Object.entries(mapping)) {
|
||||
const visible = vis[group] !== false;
|
||||
@@ -383,67 +392,160 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// === UAT z0-5: very coarse — lines only ===
|
||||
map.addSource(SOURCES.uatsZ0, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], minzoom: 0, maxzoom: 5 });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
|
||||
// === UAT sources: PMTiles (if configured) or Martin fallback ===
|
||||
const pmtilesUrl = DEFAULT_PMTILES_URL;
|
||||
const usePmtiles = !!pmtilesUrl;
|
||||
|
||||
// === UAT z5-8: coarse ===
|
||||
map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
|
||||
if (usePmtiles) {
|
||||
// Single PMTiles source contains all UAT + administrativ layers (z0-z14)
|
||||
const PM_SRC = "overview-pmtiles";
|
||||
map.addSource(PM_SRC, { type: "vector", url: `pmtiles://${pmtilesUrl}` });
|
||||
|
||||
// === UAT z8-12: moderate ===
|
||||
map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 1 } });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
|
||||
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
|
||||
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
||||
// z0-5: lines only
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
|
||||
|
||||
// === UAT z12+: full detail (no simplification) ===
|
||||
map.addSource(SOURCES.uatsZ12, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 16 });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 2 } });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
||||
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
|
||||
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
||||
// z5-8
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
|
||||
|
||||
// === Intravilan — double line (black outer + orange inner), no fill, z13+ ===
|
||||
map.addSource(SOURCES.administrativ, { type: "vector", tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 16 });
|
||||
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
|
||||
paint: { "line-color": "#000000", "line-width": 3 } });
|
||||
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
|
||||
paint: { "line-color": "#f97316", "line-width": 1.5 } });
|
||||
// z8-12
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 1 } });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: PM_SRC, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
|
||||
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
|
||||
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
||||
|
||||
// === Terenuri (parcels) — no simplification ===
|
||||
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 18 });
|
||||
map.addLayer({ id: LAYER_IDS.terenuriFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||
paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } });
|
||||
map.addLayer({ id: LAYER_IDS.terenuriLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||
paint: { "line-color": "#15803d", "line-width": 0.8 } });
|
||||
// Parcel cadastral number label
|
||||
map.addLayer({ id: LAYER_IDS.terenuriLabel, type: "symbol", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 16,
|
||||
// z12+: full detail from PMTiles
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 2 } });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: PM_SRC, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
||||
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
|
||||
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
||||
|
||||
// Intravilan from PMTiles
|
||||
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: PM_SRC, "source-layer": SOURCES.administrativ, minzoom: 13,
|
||||
paint: { "line-color": "#000000", "line-width": 3 } });
|
||||
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: PM_SRC, "source-layer": SOURCES.administrativ, minzoom: 13,
|
||||
paint: { "line-color": "#f97316", "line-width": 1.5 } });
|
||||
} else {
|
||||
// Fallback: Martin tile sources (existing behavior)
|
||||
|
||||
// z0-5: very coarse — lines only
|
||||
map.addSource(SOURCES.uatsZ0, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], minzoom: 0, maxzoom: 5 });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
|
||||
|
||||
// z5-8: coarse
|
||||
map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
|
||||
|
||||
// z8-12: moderate
|
||||
map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 1 } });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
|
||||
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
|
||||
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
||||
|
||||
// z12+: full detail (no simplification)
|
||||
map.addSource(SOURCES.uatsZ12, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 16 });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
||||
paint: { "line-color": "#7c3aed", "line-width": 2 } });
|
||||
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
|
||||
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
|
||||
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
|
||||
|
||||
// Intravilan — double line (black outer + orange inner), no fill, z13+
|
||||
map.addSource(SOURCES.administrativ, { type: "vector", tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 16 });
|
||||
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
|
||||
paint: { "line-color": "#000000", "line-width": 3 } });
|
||||
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
|
||||
paint: { "line-color": "#f97316", "line-width": 1.5 } });
|
||||
}
|
||||
|
||||
// === Terenuri (parcels) ===
|
||||
if (usePmtiles) {
|
||||
// PMTiles serves ALL zoom levels (z13-z18) — zero PostGIS load
|
||||
map.addLayer({ id: "l-terenuri-pm-fill", type: "fill", source: "overview-pmtiles", "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||
paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } });
|
||||
map.addLayer({ id: "l-terenuri-pm-line", type: "line", source: "overview-pmtiles", "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||
paint: { "line-color": "#15803d", "line-width": 0.8 } });
|
||||
map.addLayer({ id: "l-terenuri-pm-label", type: "symbol", source: "overview-pmtiles", "source-layer": SOURCES.terenuri, minzoom: 16,
|
||||
layout: {
|
||||
"text-field": ["coalesce", ["get", "cadastral_ref"], ""],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 10, "text-anchor": "center", "text-allow-overlap": false,
|
||||
"text-max-width": 8,
|
||||
},
|
||||
paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
|
||||
// Martin source registered but unused (selection uses PMTiles source now)
|
||||
// Kept as fallback reference — no tile requests since no layers target it
|
||||
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 17, maxzoom: 18 });
|
||||
} else {
|
||||
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 18 });
|
||||
map.addLayer({ id: LAYER_IDS.terenuriFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||
paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } });
|
||||
map.addLayer({ id: LAYER_IDS.terenuriLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||
paint: { "line-color": "#15803d", "line-width": 0.8 } });
|
||||
map.addLayer({ id: LAYER_IDS.terenuriLabel, type: "symbol", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 16,
|
||||
layout: {
|
||||
"text-field": ["coalesce", ["get", "cadastral_ref"], ""],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 10, "text-anchor": "center", "text-allow-overlap": false,
|
||||
"text-max-width": 8,
|
||||
},
|
||||
paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
|
||||
}
|
||||
|
||||
// === Cladiri (buildings) ===
|
||||
if (usePmtiles) {
|
||||
// PMTiles serves ALL zoom levels (z14-z18) — zero PostGIS load
|
||||
map.addLayer({ id: "l-cladiri-pm-fill", type: "fill", source: "overview-pmtiles", "source-layer": SOURCES.cladiri, minzoom: 14,
|
||||
paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } });
|
||||
map.addLayer({ id: "l-cladiri-pm-line", type: "line", source: "overview-pmtiles", "source-layer": SOURCES.cladiri, minzoom: 14,
|
||||
paint: { "line-color": "#1e3a5f", "line-width": 0.6 } });
|
||||
map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 17, maxzoom: 18 });
|
||||
} else {
|
||||
map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 18 });
|
||||
map.addLayer({ id: LAYER_IDS.cladiriFill, type: "fill", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
|
||||
paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } });
|
||||
map.addLayer({ id: LAYER_IDS.cladiriLine, type: "line", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
|
||||
paint: { "line-color": "#1e3a5f", "line-width": 0.6 } });
|
||||
}
|
||||
// Building body labels — extract suffix after last dash (e.g. "291479-C1" → "C1")
|
||||
map.addLayer({ id: LAYER_IDS.cladiriLabel, type: "symbol",
|
||||
source: usePmtiles ? "overview-pmtiles" : SOURCES.cladiri,
|
||||
"source-layer": SOURCES.cladiri, minzoom: 16,
|
||||
layout: {
|
||||
"text-field": ["coalesce", ["get", "cadastral_ref"], ""],
|
||||
"text-field": [
|
||||
"case",
|
||||
["has", "cadastral_ref"],
|
||||
["let", "ref", ["get", "cadastral_ref"],
|
||||
["let", "dashIdx", ["index-of", "-", ["var", "ref"]],
|
||||
["case",
|
||||
[">=", ["var", "dashIdx"], 0],
|
||||
["slice", ["var", "ref"], ["+", ["var", "dashIdx"], 1]],
|
||||
["var", "ref"],
|
||||
],
|
||||
],
|
||||
],
|
||||
"",
|
||||
],
|
||||
"text-font": ["Noto Sans Regular"],
|
||||
"text-size": 10, "text-anchor": "center", "text-allow-overlap": false,
|
||||
"text-max-width": 8,
|
||||
"text-size": 9, "text-anchor": "center", "text-allow-overlap": false,
|
||||
"text-max-width": 6,
|
||||
},
|
||||
paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
|
||||
|
||||
// === Cladiri (buildings) — no simplification ===
|
||||
map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 18 });
|
||||
map.addLayer({ id: LAYER_IDS.cladiriFill, type: "fill", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
|
||||
paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } });
|
||||
map.addLayer({ id: LAYER_IDS.cladiriLine, type: "line", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
|
||||
paint: { "line-color": "#1e3a5f", "line-width": 0.6 } });
|
||||
paint: { "text-color": "#1e3a5f", "text-halo-color": "#fff", "text-halo-width": 1 } });
|
||||
|
||||
// === Selection highlight ===
|
||||
map.addLayer({ id: LAYER_IDS.selectionFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||
// Use PMTiles source when available (has data at z13+), Martin only has z17+
|
||||
const selectionSrc = usePmtiles ? "overview-pmtiles" : SOURCES.terenuri;
|
||||
map.addLayer({ id: LAYER_IDS.selectionFill, type: "fill", source: selectionSrc, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||
filter: ["==", "object_id", "__NONE__"],
|
||||
paint: { "fill-color": "#f59e0b", "fill-opacity": 0.5 } });
|
||||
map.addLayer({ id: LAYER_IDS.selectionLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||
map.addLayer({ id: LAYER_IDS.selectionLine, type: "line", source: selectionSrc, "source-layer": SOURCES.terenuri, minzoom: 13,
|
||||
filter: ["==", "object_id", "__NONE__"],
|
||||
paint: { "line-color": "#d97706", "line-width": 2.5 } });
|
||||
|
||||
@@ -472,8 +574,10 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
});
|
||||
|
||||
/* ---- Click handler — NO popup, only callback ---- */
|
||||
// Include both Martin and PMTiles fill layers — filter() skips non-existent ones
|
||||
const clickableLayers = [
|
||||
LAYER_IDS.terenuriFill, LAYER_IDS.cladiriFill,
|
||||
"l-terenuri-pm-fill", "l-cladiri-pm-fill",
|
||||
];
|
||||
|
||||
map.on("click", (e) => {
|
||||
@@ -741,12 +845,6 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [resolvedMartinUrl, basemap]);
|
||||
|
||||
/* ---- Sync center/zoom prop changes (from search flyTo) ---- */
|
||||
useEffect(() => {
|
||||
if (!mapReady || !mapRef.current || !center) return;
|
||||
mapRef.current.flyTo({ center, zoom: zoom ?? mapRef.current.getZoom(), duration: 1500 });
|
||||
}, [center, zoom, mapReady]);
|
||||
|
||||
/* ---- Disable interactions when in drawing modes ---- */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
|
||||
@@ -106,7 +106,7 @@ export function SearchBar({ onResultSelect, className }: SearchBarProps) {
|
||||
if (results.length > 0) setOpen(true);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pl-8 pr-8 h-8 text-sm bg-background/95 backdrop-blur-sm"
|
||||
className="pl-8 pr-8 h-8 text-sm bg-background backdrop-blur-sm text-foreground"
|
||||
/>
|
||||
{loading && (
|
||||
<Loader2 className="absolute right-8 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
Clock,
|
||||
ArrowDownToLine,
|
||||
AlertTriangle,
|
||||
Moon,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
@@ -141,6 +144,23 @@ export function ExportTab({
|
||||
|
||||
const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0);
|
||||
|
||||
// Primary layers synced by background jobs — these determine freshness
|
||||
const PRIMARY_LAYERS = ["TERENURI_ACTIVE", "CLADIRI_ACTIVE", "LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"];
|
||||
const primaryLayers = dbLayersSummary.filter((l) =>
|
||||
PRIMARY_LAYERS.includes(l.id),
|
||||
);
|
||||
const hasData = dbTotalFeatures > 0;
|
||||
const canExportLocal = hasData;
|
||||
|
||||
const oldestSyncDate = primaryLayers.reduce(
|
||||
(oldest, l) => {
|
||||
if (!l.lastSynced) return oldest;
|
||||
if (!oldest || l.lastSynced < oldest) return l.lastSynced;
|
||||
return oldest;
|
||||
},
|
||||
null as Date | null,
|
||||
);
|
||||
|
||||
const progressPct =
|
||||
exportProgress?.total && exportProgress.total > 0
|
||||
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
|
||||
@@ -604,40 +624,48 @@ export function ExportTab({
|
||||
layere
|
||||
</span>
|
||||
{(() => {
|
||||
const freshCount = dbLayersSummary.filter(
|
||||
(l) => l.isFresh,
|
||||
).length;
|
||||
const staleCount = dbLayersSummary.length - freshCount;
|
||||
const oldestSync = dbLayersSummary.reduce(
|
||||
(oldest, l) => {
|
||||
if (!l.lastSynced) return oldest;
|
||||
if (!oldest || l.lastSynced < oldest) return l.lastSynced;
|
||||
return oldest;
|
||||
const staleLayers = primaryLayers.filter((l) => !l.isFresh);
|
||||
const freshLayers = primaryLayers.filter((l) => l.isFresh);
|
||||
const newestSync = primaryLayers.reduce(
|
||||
(newest, l) => {
|
||||
if (!l.lastSynced) return newest;
|
||||
if (!newest || l.lastSynced > newest) return l.lastSynced;
|
||||
return newest;
|
||||
},
|
||||
null as Date | null,
|
||||
);
|
||||
// Tooltip: list which layers are stale/fresh with dates
|
||||
const staleTooltip = staleLayers.length > 0
|
||||
? `Vechi: ${staleLayers.map((l) => `${l.label} (${l.lastSynced ? relativeTime(l.lastSynced) : "nesincronizat"})`).join(", ")}`
|
||||
: "";
|
||||
const freshTooltip = freshLayers.length > 0
|
||||
? `Proaspete: ${freshLayers.map((l) => `${l.label} (${l.lastSynced ? relativeTime(l.lastSynced) : ""})`).join(", ")}`
|
||||
: "";
|
||||
const fullTooltip = [staleTooltip, freshTooltip].filter(Boolean).join("\n");
|
||||
return (
|
||||
<>
|
||||
{staleCount === 0 ? (
|
||||
{staleLayers.length === 0 && primaryLayers.length > 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800"
|
||||
className="text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800 cursor-default"
|
||||
title={fullTooltip}
|
||||
>
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
Proaspete
|
||||
</Badge>
|
||||
) : (
|
||||
) : staleLayers.length > 0 ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-amber-600 border-amber-200 dark:text-amber-400 dark:border-amber-800"
|
||||
className="text-amber-600 border-amber-200 dark:text-amber-400 dark:border-amber-800 cursor-default"
|
||||
title={fullTooltip}
|
||||
>
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{staleCount} vechi
|
||||
{staleLayers.length} vechi
|
||||
</Badge>
|
||||
)}
|
||||
{oldestSync && (
|
||||
) : null}
|
||||
{newestSync && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Ultima sincronizare: {relativeTime(oldestSync)}
|
||||
Ultima sincronizare: {relativeTime(newestSync)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
@@ -649,52 +677,117 @@ export function ExportTab({
|
||||
)}
|
||||
|
||||
{/* Hero buttons */}
|
||||
{sirutaValid && session.connected ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
disabled={exporting}
|
||||
onClick={() => void handleExportBundle("base")}
|
||||
>
|
||||
{exporting && exportProgress?.phase !== "Detalii parcele" ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<FileDown className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">
|
||||
Descarcă Terenuri și Clădiri
|
||||
</div>
|
||||
<div className="text-xs opacity-70 font-normal">
|
||||
Sync + GPKG (din cache dacă e proaspăt)
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{sirutaValid && (session.connected || canExportLocal) ? (
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
// Build tooltip with layer details for hero buttons
|
||||
const layerLines = dbLayersSummary
|
||||
.filter((l) => l.count > 0)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map(
|
||||
(l) =>
|
||||
`${l.label}: ${l.count.toLocaleString("ro")} entitati${l.lastSynced ? ` (sync ${relativeTime(l.lastSynced)})` : ""}`,
|
||||
);
|
||||
const enriched = dbLayersSummary.reduce(
|
||||
(sum, l) => {
|
||||
const enrichCount =
|
||||
syncRuns.find(
|
||||
(r) => r.layerId === l.id && r.status === "done",
|
||||
)?.totalLocal ?? 0;
|
||||
return sum + enrichCount;
|
||||
},
|
||||
0,
|
||||
);
|
||||
const baseTooltip = layerLines.length > 0
|
||||
? `ZIP contine:\n• ${layerLines.join("\n• ")}\n\nFormate: GPKG + DXF per layer`
|
||||
: "Nicio data in DB";
|
||||
const magicTooltip = layerLines.length > 0
|
||||
? `ZIP contine:\n• ${layerLines.join("\n• ")}\n\nFormate: GPKG + DXF + CSV complet\n+ Raport calitate enrichment`
|
||||
: "Nicio data in DB";
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
|
||||
disabled={exporting}
|
||||
onClick={() => void handleExportBundle("magic")}
|
||||
>
|
||||
{exporting && exportProgress?.phase === "Detalii parcele" ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Magic</div>
|
||||
<div className="text-xs opacity-70 font-normal">
|
||||
Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
disabled={exporting || downloadingFromDb}
|
||||
title={baseTooltip}
|
||||
onClick={() =>
|
||||
canExportLocal
|
||||
? void handleDownloadFromDb("base")
|
||||
: void handleExportBundle("base")
|
||||
}
|
||||
>
|
||||
{(exporting || downloadingFromDb) &&
|
||||
exportProgress?.phase !== "Detalii parcele" ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : canExportLocal ? (
|
||||
<Database className="mr-2 h-5 w-5" />
|
||||
) : (
|
||||
<FileDown className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">
|
||||
Descarcă Terenuri și Clădiri
|
||||
</div>
|
||||
<div className="text-xs opacity-70 font-normal">
|
||||
{canExportLocal
|
||||
? `Din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
|
||||
: hasData
|
||||
? "Sync incremental + GPKG + DXF"
|
||||
: "Sync complet + GPKG + DXF"}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
|
||||
disabled={exporting || downloadingFromDb}
|
||||
title={magicTooltip}
|
||||
onClick={() =>
|
||||
canExportLocal
|
||||
? void handleDownloadFromDb("magic")
|
||||
: void handleExportBundle("magic")
|
||||
}
|
||||
>
|
||||
{(exporting || downloadingFromDb) &&
|
||||
exportProgress?.phase === "Detalii parcele" ? (
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-5 w-5" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Magic</div>
|
||||
<div className="text-xs opacity-70 font-normal">
|
||||
{canExportLocal
|
||||
? `GPKG + DXF + CSV din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
|
||||
: "Sync + îmbogățire + GPKG + DXF + CSV"}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{canExportLocal && session.connected && (
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline transition-colors"
|
||||
disabled={exporting}
|
||||
onClick={() => void handleExportBundle("base")}
|
||||
>
|
||||
<RefreshCw className="inline h-3 w-3 mr-1 -mt-0.5" />
|
||||
Re-sincronizează de pe eTerra
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
{!session.connected ? (
|
||||
{!session.connected && !canExportLocal ? (
|
||||
<>
|
||||
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Conectează-te la eTerra pentru a activa exportul.</p>
|
||||
@@ -878,7 +971,7 @@ export function ExportTab({
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
\u00cembogățire CF, proprietari, adrese —{" "}
|
||||
Îmbogățire CF, proprietari, adrese —{" "}
|
||||
<span className="font-medium text-teal-600 dark:text-teal-400">
|
||||
{(() => {
|
||||
// What will be in DB after sync + optional no-geom import:
|
||||
@@ -1072,7 +1165,7 @@ export function ExportTab({
|
||||
{noGeomScan.qualityBreakdown.empty > 0
|
||||
? `Din ${noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără geometrie, ~${noGeomScan.qualityBreakdown.useful.toLocaleString("ro-RO")} vor fi importate (imobile electronice cu CF). ${noGeomScan.qualityBreakdown.empty.toLocaleString("ro-RO")} vor fi filtrate (fără carte funciară, inactive sau fără date).`
|
||||
: "Vor fi importate în DB și incluse în CSV + Magic GPKG (coloana HAS_GEOMETRY=0/1)."}{" "}
|
||||
\u00cen GPKG de bază apar doar cele cu geometrie.
|
||||
În GPKG de bază apar doar cele cu geometrie.
|
||||
</p>
|
||||
)}
|
||||
{workflowPreview}
|
||||
@@ -1222,54 +1315,6 @@ export function ExportTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 3: Download from DB buttons */}
|
||||
{dbTotalFeatures > 0 && (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto py-2.5 justify-start"
|
||||
disabled={downloadingFromDb}
|
||||
onClick={() => void handleDownloadFromDb("base")}
|
||||
>
|
||||
{downloadingFromDb ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-semibold">
|
||||
Descarcă din DB — Bază
|
||||
</div>
|
||||
<div className="text-[10px] opacity-60 font-normal">
|
||||
GPKG terenuri + clădiri (instant, fără eTerra)
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
|
||||
disabled={downloadingFromDb}
|
||||
onClick={() => void handleDownloadFromDb("magic")}
|
||||
>
|
||||
{downloadingFromDb ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-semibold">
|
||||
Descarcă din DB — Magic
|
||||
</div>
|
||||
<div className="text-[10px] opacity-60 font-normal">
|
||||
GPKG + CSV + raport calitate (instant)
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!session.connected && dbTotalFeatures === 0 && (
|
||||
<p className="text-xs text-muted-foreground ml-6">
|
||||
Conectează-te la eTerra pentru a porni sincronizarea fundal,
|
||||
@@ -1280,6 +1325,33 @@ export function ExportTab({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Weekend Deep Sync + Monitor hints */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Moon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Municipii mari cu Magic complet?{" "}
|
||||
<Link
|
||||
href="/wds"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
Weekend Deep Sync
|
||||
</Link>
|
||||
{" "}— sincronizare automata Vin/Sam/Dum noaptea.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Activity className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
Rebuild PMTiles si status servicii?{" "}
|
||||
<Link
|
||||
href="/monitor"
|
||||
className="underline hover:text-foreground transition-colors"
|
||||
>
|
||||
Monitor
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Background sync progress */}
|
||||
{bgJobId && bgProgress && bgProgress.status !== "unknown" && (
|
||||
<Card
|
||||
@@ -1406,9 +1478,11 @@ export function ExportTab({
|
||||
setBgJobId(null);
|
||||
setBgProgress(null);
|
||||
setBgPhaseTrail([]);
|
||||
onSyncRefresh();
|
||||
onDbRefresh();
|
||||
}}
|
||||
>
|
||||
\u00cenchide
|
||||
Închide
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -363,7 +363,67 @@ export function MapTab({ siruta, sirutaValid, sessionConnected, syncLocalCount,
|
||||
}
|
||||
}
|
||||
|
||||
// Buildings: keep base layer visible with siruta filter (already applied above)
|
||||
// ── Enrichment overlay for BUILDINGS ──
|
||||
if (!map.getSource("gis_cladiri_status")) {
|
||||
map.addSource("gis_cladiri_status", {
|
||||
type: "vector",
|
||||
tiles: [`${martinBase}/gis_cladiri_status/{z}/{x}/{y}`],
|
||||
minzoom: 14,
|
||||
maxzoom: 18,
|
||||
});
|
||||
|
||||
// Data-driven fill: red = no legal docs, blue = has legal docs
|
||||
map.addLayer(
|
||||
{
|
||||
id: "l-ps-cladiri-fill",
|
||||
type: "fill",
|
||||
source: "gis_cladiri_status",
|
||||
"source-layer": "gis_cladiri_status",
|
||||
minzoom: 14,
|
||||
filter,
|
||||
paint: {
|
||||
"fill-color": [
|
||||
"case",
|
||||
["==", ["get", "build_legal"], 1],
|
||||
"#3b82f6", // blue: legal docs OK
|
||||
"#ef4444", // red: no legal docs
|
||||
],
|
||||
"fill-opacity": 0.55,
|
||||
},
|
||||
},
|
||||
"l-terenuri-fill",
|
||||
);
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: "l-ps-cladiri-line",
|
||||
type: "line",
|
||||
source: "gis_cladiri_status",
|
||||
"source-layer": "gis_cladiri_status",
|
||||
minzoom: 14,
|
||||
filter,
|
||||
paint: {
|
||||
"line-color": [
|
||||
"case",
|
||||
["==", ["get", "build_legal"], 1],
|
||||
"#1e40af", // dark blue: legal
|
||||
"#b91c1c", // dark red: no legal
|
||||
],
|
||||
"line-width": 1.5,
|
||||
},
|
||||
},
|
||||
"l-terenuri-fill",
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
if (map.getLayer("l-ps-cladiri-fill"))
|
||||
map.setFilter("l-ps-cladiri-fill", filter);
|
||||
if (map.getLayer("l-ps-cladiri-line"))
|
||||
map.setFilter("l-ps-cladiri-line", filter);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}, [mapReady, siruta, sirutaValid]);
|
||||
|
||||
/* ── Boundary cross-check: load mismatched parcels ─────────── */
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Self-contained auto-refresh scheduler for ParcelSync.
|
||||
*
|
||||
* Runs inside the existing Node.js process — no external dependencies.
|
||||
* Checks every 30 minutes; during the night window (1–5 AM) it picks
|
||||
* stale UATs one at a time with random delays between them.
|
||||
*
|
||||
* Activated by importing this module (side-effect). The globalThis guard
|
||||
* ensures only one scheduler runs per process, surviving HMR in dev.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { syncLayer } from "./sync-service";
|
||||
import { getLayerFreshness, isFresh } from "./enrich-service";
|
||||
import { isEterraAvailable } from "./eterra-health";
|
||||
import { isWeekendWindow, runWeekendDeepSync } from "./weekend-deep-sync";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Configuration */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** Night window: only run between these hours (server local time) */
|
||||
const NIGHT_START_HOUR = 1;
|
||||
const NIGHT_END_HOUR = 5;
|
||||
|
||||
/** How often to check if we should run (ms) */
|
||||
const CHECK_INTERVAL_MS = 30 * 60_000; // 30 minutes
|
||||
|
||||
/** Delay between UATs: 3–10s (delta sync is fast) */
|
||||
const MIN_DELAY_MS = 3_000;
|
||||
const MAX_DELAY_MS = 10_000;
|
||||
|
||||
/** Enrichment ratio threshold — UATs with >30% enriched get magic mode */
|
||||
const MAGIC_THRESHOLD = 0.3;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Singleton guard */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const g = globalThis as {
|
||||
__autoRefreshTimer?: ReturnType<typeof setInterval>;
|
||||
__parcelSyncRunning?: boolean; // single flag for all sync modes
|
||||
__autoRefreshLastRun?: string; // ISO date of last completed run
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Core logic */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function runAutoRefresh() {
|
||||
// Prevent concurrent runs (shared with weekend sync)
|
||||
if (g.__parcelSyncRunning) return;
|
||||
|
||||
const hour = new Date().getHours();
|
||||
const dayOfWeek = new Date().getDay(); // 0=Sun, 6=Sat
|
||||
const isWeekday = dayOfWeek >= 1 && dayOfWeek <= 5;
|
||||
if (!isWeekday || hour < NIGHT_START_HOUR || hour >= NIGHT_END_HOUR) return;
|
||||
|
||||
// Only run once per night (check date)
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
if (g.__autoRefreshLastRun === today) return;
|
||||
|
||||
const username = process.env.ETERRA_USERNAME;
|
||||
const password = process.env.ETERRA_PASSWORD;
|
||||
if (!username || !password) return;
|
||||
|
||||
if (!isEterraAvailable()) {
|
||||
console.log("[auto-refresh] eTerra indisponibil, skip.");
|
||||
return;
|
||||
}
|
||||
|
||||
g.__parcelSyncRunning = true;
|
||||
console.log("[auto-refresh] Pornire delta refresh nocturn (toate UAT-urile)...");
|
||||
|
||||
try {
|
||||
// Find all UATs with features + enrichment ratio
|
||||
const uats = await prisma.$queryRawUnsafe<
|
||||
Array<{ siruta: string; name: string | null; total: number; enriched: number }>
|
||||
>(
|
||||
`SELECT f.siruta, u.name, COUNT(*)::int as total,
|
||||
COUNT(*) FILTER (WHERE f."enrichedAt" IS NOT NULL)::int as enriched
|
||||
FROM "GisFeature" f LEFT JOIN "GisUat" u ON f.siruta = u.siruta
|
||||
WHERE f."layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND f."objectId" > 0
|
||||
GROUP BY f.siruta, u.name ORDER BY total DESC`,
|
||||
);
|
||||
|
||||
if (uats.length === 0) {
|
||||
console.log("[auto-refresh] Niciun UAT in DB, skip.");
|
||||
g.__autoRefreshLastRun = today;
|
||||
g.__parcelSyncRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[auto-refresh] ${uats.length} UAT-uri de procesat.`);
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (let i = 0; i < uats.length; i++) {
|
||||
const uat = uats[i]!;
|
||||
const uatName = uat.name ?? uat.siruta;
|
||||
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
||||
const isMagic = ratio > MAGIC_THRESHOLD;
|
||||
|
||||
// Small delay between UATs
|
||||
if (i > 0) {
|
||||
const delay = MIN_DELAY_MS + Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS);
|
||||
await sleep(delay);
|
||||
}
|
||||
|
||||
// Check we're still in the night window
|
||||
if (new Date().getHours() >= NIGHT_END_HOUR) {
|
||||
console.log(`[auto-refresh] Fereastra nocturna s-a inchis la ${i}/${uats.length} UATs.`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isEterraAvailable()) {
|
||||
console.log("[auto-refresh] eTerra a devenit indisponibil, opresc.");
|
||||
break;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
// Delta sync: quick-count + VALID_FROM for TERENURI + CLADIRI
|
||||
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName });
|
||||
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName });
|
||||
|
||||
let enrichNote = "";
|
||||
if (isMagic) {
|
||||
const { EterraClient } = await import("./eterra-client");
|
||||
const { enrichFeatures } = await import("./enrich-service");
|
||||
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
|
||||
const eRes = await enrichFeatures(client, uat.siruta);
|
||||
enrichNote = eRes.status === "done"
|
||||
? ` | enrich:${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
|
||||
: ` | enrich ERR:${eRes.error}`;
|
||||
}
|
||||
|
||||
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||
const tNote = tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
|
||||
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
|
||||
: "T:ok";
|
||||
const cNote = cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
|
||||
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
|
||||
: "C:ok";
|
||||
console.log(
|
||||
`[auto-refresh] [${i + 1}/${uats.length}] ${uatName} (${isMagic ? "magic" : "base"}): ${tNote}, ${cNote}${enrichNote} (${dur}s)`,
|
||||
);
|
||||
processed++;
|
||||
} catch (err) {
|
||||
errors++;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[auto-refresh] [${i + 1}/${uats.length}] ${uatName}: ERR ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
g.__autoRefreshLastRun = today;
|
||||
console.log(`[auto-refresh] Finalizat: ${processed}/${uats.length} UATs, ${errors} erori.`);
|
||||
|
||||
// Trigger PMTiles rebuild
|
||||
const { firePmtilesRebuild } = await import("./pmtiles-webhook");
|
||||
await firePmtilesRebuild("auto-refresh-complete", { uatCount: processed, errors });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[auto-refresh] Eroare generala: ${msg}`);
|
||||
} finally {
|
||||
g.__parcelSyncRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Weekend deep sync wrapper */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function runWeekendCheck() {
|
||||
if (g.__parcelSyncRunning) return;
|
||||
if (!isWeekendWindow()) return;
|
||||
|
||||
g.__parcelSyncRunning = true;
|
||||
try {
|
||||
await runWeekendDeepSync();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[weekend-sync] Eroare: ${msg}`);
|
||||
} finally {
|
||||
g.__parcelSyncRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Start scheduler (once per process) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
if (!g.__autoRefreshTimer) {
|
||||
g.__autoRefreshTimer = setInterval(() => {
|
||||
// Weekend nights (Fri/Sat/Sun 23-04): deep sync for large cities
|
||||
// Weekday nights (1-5 AM): incremental refresh for existing data
|
||||
if (isWeekendWindow()) {
|
||||
void runWeekendCheck();
|
||||
} else {
|
||||
void runAutoRefresh();
|
||||
}
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
// Also check once shortly after startup (60s delay to let everything init)
|
||||
setTimeout(() => {
|
||||
if (isWeekendWindow()) {
|
||||
void runWeekendCheck();
|
||||
} else {
|
||||
void runAutoRefresh();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const now = new Date();
|
||||
console.log(
|
||||
`[auto-refresh] Scheduler pornit — verificare la fiecare ${CHECK_INTERVAL_MS / 60_000} min`,
|
||||
);
|
||||
console.log(
|
||||
`[auto-refresh] Server time: ${now.toLocaleString("ro-RO")} (TZ=${process.env.TZ ?? "system"}, offset=${now.getTimezoneOffset()}min)`,
|
||||
);
|
||||
console.log(
|
||||
`[auto-refresh] Luni-Vineri ${NIGHT_START_HOUR}:00–${NIGHT_END_HOUR}:00: delta sync ALL UATs (quick-count + VALID_FROM + rolling doc)`,
|
||||
);
|
||||
console.log(
|
||||
`[auto-refresh] Weekend Vin/Sam/Dum 23:00–04:00: deep sync municipii (forceFullSync)`,
|
||||
);
|
||||
console.log(
|
||||
`[auto-refresh] ETERRA creds: ${process.env.ETERRA_USERNAME ? "OK" : "MISSING"}`,
|
||||
);
|
||||
}
|
||||
@@ -99,10 +99,21 @@ const formatAddress = (item?: any) => {
|
||||
const address = item?.immovableAddresses?.[0]?.address ?? null;
|
||||
if (!address) return "-";
|
||||
const parts: string[] = [];
|
||||
if (address.addressDescription) parts.push(address.addressDescription);
|
||||
if (address.street) parts.push(`Str. ${address.street}`);
|
||||
if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
|
||||
if (address.locality?.name) parts.push(address.locality.name);
|
||||
if (address.addressDescription) parts.push(String(address.addressDescription));
|
||||
// street can be a string or an object { name: "..." }
|
||||
const streetName =
|
||||
typeof address.street === "string"
|
||||
? address.street
|
||||
: address.street?.name ?? null;
|
||||
if (streetName) parts.push(`Str. ${streetName}`);
|
||||
if (address.streetNumber) parts.push(`Nr. ${address.streetNumber}`);
|
||||
else if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
|
||||
// locality can be a string or an object { name: "..." }
|
||||
const localityName =
|
||||
typeof address.locality === "string"
|
||||
? address.locality
|
||||
: address.locality?.name ?? null;
|
||||
if (localityName) parts.push(localityName);
|
||||
return parts.length ? parts.join(", ") : "-";
|
||||
};
|
||||
|
||||
@@ -124,6 +135,23 @@ export type FeatureEnrichment = {
|
||||
CATEGORIE_FOLOSINTA: string;
|
||||
HAS_BUILDING: number;
|
||||
BUILD_LEGAL: number;
|
||||
// Extended fields (extracted from existing API calls, zero overhead)
|
||||
/** "Intabulare, drept de PROPRIETATE, dobandit prin..." */
|
||||
TIP_INSCRIERE?: string;
|
||||
/** "hotarare judecatoreasca nr..." / "contract vanzare cumparare nr..." */
|
||||
ACT_PROPRIETATE?: string;
|
||||
/** "1/1" or fractional */
|
||||
COTA_PROPRIETATE?: string;
|
||||
/** Date of registration application (ISO) */
|
||||
DATA_CERERE?: string;
|
||||
/** Number of building bodies on this parcel */
|
||||
NR_CORPURI?: number;
|
||||
/** Comma-separated list: "C1:352mp, C2:248mp, C3:104mp" */
|
||||
CORPURI_DETALII?: string;
|
||||
/** 1 if condominium, 0 otherwise */
|
||||
IS_CONDOMINIUM?: number;
|
||||
/** Date parcel was created in eTerra (ISO) */
|
||||
DATA_CREARE?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -153,6 +181,242 @@ export async function enrichFeatures(
|
||||
};
|
||||
|
||||
try {
|
||||
// ── Quick delta check: skip ALL eTerra API calls if every feature is enriched & fresh ──
|
||||
const _thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const [_totalCount, _unenrichedCount] = await Promise.all([
|
||||
prisma.gisFeature.count({
|
||||
where: { layerId: "TERENURI_ACTIVE", siruta },
|
||||
}),
|
||||
prisma.gisFeature.count({
|
||||
where: {
|
||||
layerId: "TERENURI_ACTIVE",
|
||||
siruta,
|
||||
OR: [
|
||||
{ enrichedAt: null },
|
||||
{ enrichedAt: { lt: _thirtyDaysAgo } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
if (_totalCount > 0 && _unenrichedCount === 0) {
|
||||
// ── Rolling doc check: probe oldest-enriched features for new applications ──
|
||||
// VALID_FROM doesn't track documentation changes (ownership, CF).
|
||||
// Check 200 oldest-enriched parcels' documentation for recent activity.
|
||||
// If any have new registrations since enrichedAt → mark for re-enrichment.
|
||||
const ROLLING_BATCH = 200;
|
||||
const oldestEnriched = await prisma.gisFeature.findMany({
|
||||
where: {
|
||||
layerId: "TERENURI_ACTIVE",
|
||||
siruta,
|
||||
enrichedAt: { not: null },
|
||||
objectId: { gt: 0 },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
objectId: true,
|
||||
attributes: true,
|
||||
cadastralRef: true,
|
||||
enrichedAt: true,
|
||||
enrichment: true,
|
||||
},
|
||||
orderBy: { enrichedAt: "asc" },
|
||||
take: ROLLING_BATCH,
|
||||
});
|
||||
|
||||
if (oldestEnriched.length > 0) {
|
||||
options?.onProgress?.(0, _totalCount, "Verificare documentație recentă");
|
||||
|
||||
// Resolve workspace PK for doc fetch
|
||||
let rollingWsPk: number | null = null;
|
||||
for (const f of oldestEnriched) {
|
||||
const ws = (f.attributes as Record<string, unknown>).WORKSPACE_ID;
|
||||
if (ws != null) {
|
||||
const n = Number(ws);
|
||||
if (Number.isFinite(n) && n > 0) { rollingWsPk = n; break; }
|
||||
}
|
||||
}
|
||||
if (!rollingWsPk) {
|
||||
try {
|
||||
const row = await prisma.gisUat.findUnique({
|
||||
where: { siruta },
|
||||
select: { workspacePk: true },
|
||||
});
|
||||
if (row?.workspacePk && row.workspacePk > 0)
|
||||
rollingWsPk = row.workspacePk;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
let rollingUpdated = 0;
|
||||
if (rollingWsPk) {
|
||||
// Collect immovable PKs for the batch + map immPk → feature data
|
||||
const rollingPks: string[] = [];
|
||||
const enrichedAtMap = new Map<string, Date>();
|
||||
const immPkToFeatures = new Map<
|
||||
string,
|
||||
Array<{ id: string; enrichment: Record<string, unknown> | null }>
|
||||
>();
|
||||
for (const f of oldestEnriched) {
|
||||
const a = f.attributes as Record<string, unknown>;
|
||||
const immId = normalizeId(a.IMMOVABLE_ID);
|
||||
if (immId && f.enrichedAt) {
|
||||
rollingPks.push(immId);
|
||||
enrichedAtMap.set(immId, f.enrichedAt);
|
||||
const existing = immPkToFeatures.get(immId) ?? [];
|
||||
existing.push({
|
||||
id: f.id,
|
||||
enrichment: (f as { enrichment?: Record<string, unknown> | null })
|
||||
.enrichment ?? null,
|
||||
});
|
||||
immPkToFeatures.set(immId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch documentation in batches of 50 — detect AND resolve changes in-place
|
||||
const DOC_BATCH = 50;
|
||||
for (let i = 0; i < rollingPks.length; i += DOC_BATCH) {
|
||||
const batch = rollingPks.slice(i, i + DOC_BATCH);
|
||||
try {
|
||||
const docResp = await client.fetchDocumentationData(
|
||||
rollingWsPk,
|
||||
batch,
|
||||
);
|
||||
const regs: Array<{
|
||||
landbookIE?: number;
|
||||
nodeType?: string;
|
||||
nodeName?: string;
|
||||
nodeStatus?: number;
|
||||
application?: { appDate?: number };
|
||||
}> = docResp?.partTwoRegs ?? [];
|
||||
const docImmovables: Array<{
|
||||
immovablePk?: number;
|
||||
landbookIE?: number;
|
||||
}> = docResp?.immovables ?? [];
|
||||
|
||||
// Map landbookIE → immovablePk
|
||||
const lbToImm = new Map<string, string>();
|
||||
for (const di of docImmovables) {
|
||||
if (di.landbookIE && di.immovablePk)
|
||||
lbToImm.set(
|
||||
String(di.landbookIE),
|
||||
normalizeId(di.immovablePk),
|
||||
);
|
||||
}
|
||||
|
||||
// Collect max appDate + owner names per immovablePk
|
||||
const immToMaxApp = new Map<string, number>();
|
||||
const ownersByImm = new Map<string, string[]>();
|
||||
for (const reg of regs) {
|
||||
const lb = reg.landbookIE ? String(reg.landbookIE) : "";
|
||||
const immPk = lb ? lbToImm.get(lb) : undefined;
|
||||
if (!immPk) continue;
|
||||
const appDate = reg.application?.appDate;
|
||||
if (typeof appDate === "number" && appDate > 0) {
|
||||
const c = immToMaxApp.get(immPk) ?? 0;
|
||||
if (appDate > c) immToMaxApp.set(immPk, appDate);
|
||||
}
|
||||
// Collect current owner names (nodeType=P, not radiated)
|
||||
if (
|
||||
String(reg.nodeType ?? "").toUpperCase() === "P" &&
|
||||
reg.nodeName &&
|
||||
(reg.nodeStatus ?? 0) >= 0
|
||||
) {
|
||||
const owners = ownersByImm.get(immPk) ?? [];
|
||||
const name = String(reg.nodeName).trim();
|
||||
if (name && !owners.includes(name)) owners.push(name);
|
||||
ownersByImm.set(immPk, owners);
|
||||
}
|
||||
}
|
||||
|
||||
// Update features where appDate > enrichedAt — merge into existing enrichment
|
||||
const now = new Date();
|
||||
for (const [immPk, maxApp] of immToMaxApp) {
|
||||
const enrichedAt = enrichedAtMap.get(immPk);
|
||||
if (!enrichedAt || maxApp <= enrichedAt.getTime()) continue;
|
||||
const features = immPkToFeatures.get(immPk) ?? [];
|
||||
const owners = ownersByImm.get(immPk) ?? [];
|
||||
const ownerStr = owners.join("; ") || "-";
|
||||
const appDateIso = new Date(maxApp)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
for (const feat of features) {
|
||||
// Merge: keep existing enrichment, update doc-based fields
|
||||
const existing = feat.enrichment ?? {};
|
||||
const merged = {
|
||||
...existing,
|
||||
PROPRIETARI: ownerStr,
|
||||
DATA_CERERE: appDateIso,
|
||||
};
|
||||
await prisma.gisFeature.update({
|
||||
where: { id: feat.id },
|
||||
data: {
|
||||
enrichment:
|
||||
merged as unknown as Prisma.InputJsonValue,
|
||||
enrichedAt: now,
|
||||
},
|
||||
});
|
||||
rollingUpdated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Touch enrichedAt on checked features (even if unchanged) to rotate the batch
|
||||
const checkedIds = batch
|
||||
.flatMap((pk) => (immPkToFeatures.get(pk) ?? []).map((f) => f.id));
|
||||
if (checkedIds.length > 0) {
|
||||
await prisma.gisFeature.updateMany({
|
||||
where: { id: { in: checkedIds }, enrichedAt: { not: null } },
|
||||
data: { enrichedAt: now },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[enrich] Rolling doc check batch failed:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always return early — rolling check is self-contained
|
||||
const rollingNote = rollingUpdated > 0
|
||||
? `Rolling: ${rollingUpdated} parcele actualizate`
|
||||
: "Date deja complete";
|
||||
console.log(
|
||||
`[enrich] siruta=${siruta}: ${rollingNote} (checked ${oldestEnriched.length})`,
|
||||
);
|
||||
options?.onProgress?.(
|
||||
_totalCount,
|
||||
_totalCount,
|
||||
`Îmbogățire — ${rollingNote}`,
|
||||
);
|
||||
return {
|
||||
siruta,
|
||||
enrichedCount: _totalCount,
|
||||
totalFeatures: _totalCount,
|
||||
unenrichedCount: 0,
|
||||
buildingCrossRefs: rollingUpdated,
|
||||
status: "done",
|
||||
};
|
||||
} else {
|
||||
// No enriched features to check — early bailout
|
||||
options?.onProgress?.(
|
||||
_totalCount,
|
||||
_totalCount,
|
||||
"Îmbogățire — date deja complete",
|
||||
);
|
||||
return {
|
||||
siruta,
|
||||
enrichedCount: _totalCount,
|
||||
totalFeatures: _totalCount,
|
||||
unenrichedCount: 0,
|
||||
buildingCrossRefs: 0,
|
||||
status: "done",
|
||||
};
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[enrich] siruta=${siruta}: ${_unenrichedCount}/${_totalCount} features need enrichment`,
|
||||
);
|
||||
|
||||
// Load terenuri and cladiri from DB
|
||||
const terenuri = await prisma.gisFeature.findMany({
|
||||
where: { layerId: "TERENURI_ACTIVE", siruta },
|
||||
@@ -226,9 +490,14 @@ export async function enrichFeatures(
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// If still null, enrichment will fail gracefully with empty lists
|
||||
const workspacePkForApi = resolvedWsPk ?? 65;
|
||||
console.log(`[enrich] siruta=${siruta} workspacePk=${workspacePkForApi}`);
|
||||
if (!resolvedWsPk) {
|
||||
console.warn(
|
||||
`[enrich] siruta=${siruta}: workspace nu s-a rezolvat, folosesc fallback PK=${workspacePkForApi}`,
|
||||
);
|
||||
} else {
|
||||
console.log(`[enrich] siruta=${siruta} workspacePk=${workspacePkForApi}`);
|
||||
}
|
||||
|
||||
push({
|
||||
phase: "Pregătire îmbogățire",
|
||||
@@ -282,6 +551,10 @@ export async function enrichFeatures(
|
||||
if (baseRef) add(baseRef);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[enrich] siruta=${siruta}: ${terenuri.length} terenuri, ${cladiri.length} cladiri in DB, ${buildingMap.size} chei in buildingMap`,
|
||||
);
|
||||
|
||||
// ── Fetch immovable list from eTerra ──
|
||||
push({ phase: "Descărcare listă imobile", downloaded: 0 });
|
||||
const immovableListById = new Map<string, any>();
|
||||
@@ -334,10 +607,62 @@ export async function enrichFeatures(
|
||||
listPage += 1;
|
||||
}
|
||||
|
||||
// ── Fetch documentation/owner data ──
|
||||
if (immovableListById.size === 0) {
|
||||
console.warn(
|
||||
`[enrich] siruta=${siruta}: lista de imobile e GOALĂ (workspace=${workspacePkForApi}). ` +
|
||||
`Enrichment va continua dar toate parcelele vor avea date goale. ` +
|
||||
`Verifică workspace-ul corect pentru acest UAT.`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[enrich] siruta=${siruta}: ${immovableListById.size} imobile găsite`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Targeted doc fetch: only for features that need enrichment ──
|
||||
// Pre-filter: which immovable PKs actually need documentation?
|
||||
const allImmPks = Array.from(immovableListById.keys());
|
||||
const neededDocPks = new Set<string>();
|
||||
for (const f of terenuri) {
|
||||
if (f.enrichedAt != null) {
|
||||
const ej = f.enrichment as Record<string, unknown> | null;
|
||||
const _core = [
|
||||
"NR_CAD", "NR_CF", "PROPRIETARI", "PROPRIETARI_VECHI",
|
||||
"ADRESA", "CATEGORIE_FOLOSINTA", "HAS_BUILDING",
|
||||
];
|
||||
const ok =
|
||||
ej != null &&
|
||||
_core.every((k) => k in ej && ej[k] !== undefined) &&
|
||||
["NR_CF", "PROPRIETARI", "ADRESA", "CATEGORIE_FOLOSINTA"].some(
|
||||
(k) => ej[k] !== "-" && ej[k] !== "",
|
||||
) &&
|
||||
!Object.values(ej).some(
|
||||
(v) => typeof v === "string" && v.includes("[object Object]"),
|
||||
) &&
|
||||
Date.now() - new Date(f.enrichedAt).getTime() <=
|
||||
30 * 24 * 60 * 60 * 1000;
|
||||
if (ok) continue; // Already complete — skip doc fetch for this one
|
||||
}
|
||||
const fa = f.attributes as Record<string, unknown>;
|
||||
const fImmKey = normalizeId(fa.IMMOVABLE_ID);
|
||||
const fCadKey = normalizeCadRef(f.cadastralRef ?? "");
|
||||
const fItem =
|
||||
(fImmKey ? immovableListById.get(fImmKey) : undefined) ??
|
||||
(fCadKey ? immovableListByCad.get(fCadKey) : undefined);
|
||||
if (fItem?.immovablePk)
|
||||
neededDocPks.add(normalizeId(fItem.immovablePk));
|
||||
}
|
||||
// Use targeted set if we identified specific PKs, otherwise fall back to all
|
||||
const immovableIds =
|
||||
neededDocPks.size > 0 ? [...neededDocPks] : allImmPks;
|
||||
console.log(
|
||||
`[enrich] siruta=${siruta}: doc fetch for ${immovableIds.length}/${allImmPks.length} immovables (${neededDocPks.size > 0 ? "targeted" : "full"})`,
|
||||
);
|
||||
|
||||
push({ phase: "Descărcare documentații CF" });
|
||||
const docByImmovable = new Map<string, any>();
|
||||
const immovableIds = Array.from(immovableListById.keys());
|
||||
// Store raw registrations per landbookIE for extended enrichment fields
|
||||
const regsByLandbook = new Map<string, any[]>();
|
||||
const docBatchSize = 50;
|
||||
for (let i = 0; i < immovableIds.length; i += docBatchSize) {
|
||||
const batch = immovableIds.slice(i, i + docBatchSize);
|
||||
@@ -353,6 +678,13 @@ export async function enrichFeatures(
|
||||
const nodeMap = new Map<number, any>();
|
||||
for (const reg of regs) {
|
||||
if (reg?.nodeId != null) nodeMap.set(Number(reg.nodeId), reg);
|
||||
// Store all registrations by landbookIE for extended enrichment
|
||||
if (reg?.landbookIE) {
|
||||
const lbKey = String(reg.landbookIE);
|
||||
const existing = regsByLandbook.get(lbKey) ?? [];
|
||||
existing.push(reg);
|
||||
regsByLandbook.set(lbKey, existing);
|
||||
}
|
||||
}
|
||||
// Check if an entry or any ancestor "I" inscription is radiated
|
||||
const isRadiated = (entry: any, depth = 0): boolean => {
|
||||
@@ -392,22 +724,48 @@ export async function enrichFeatures(
|
||||
const attrs = feature.attributes as Record<string, unknown>;
|
||||
|
||||
// Skip features with complete enrichment (resume after crash/interruption).
|
||||
// Re-enrich if enrichment schema is incomplete (e.g., missing PROPRIETARI_VECHI
|
||||
// added in a later version).
|
||||
// Re-enrich if: schema incomplete, values are all "-" (empty), or older than 30 days.
|
||||
if (feature.enrichedAt != null) {
|
||||
const enrichJson = feature.enrichment as Record<string, unknown> | null;
|
||||
const isComplete =
|
||||
// Structural check: all 7 core fields must exist
|
||||
const coreFields = [
|
||||
"NR_CAD",
|
||||
"NR_CF",
|
||||
"PROPRIETARI",
|
||||
"PROPRIETARI_VECHI",
|
||||
"ADRESA",
|
||||
"CATEGORIE_FOLOSINTA",
|
||||
"HAS_BUILDING",
|
||||
];
|
||||
const structurallyComplete =
|
||||
enrichJson != null &&
|
||||
[
|
||||
"NR_CAD",
|
||||
"NR_CF",
|
||||
"PROPRIETARI",
|
||||
"PROPRIETARI_VECHI",
|
||||
"ADRESA",
|
||||
"CATEGORIE_FOLOSINTA",
|
||||
"HAS_BUILDING",
|
||||
].every((k) => k in enrichJson && enrichJson[k] !== undefined);
|
||||
if (isComplete) {
|
||||
coreFields.every((k) => k in enrichJson && enrichJson[k] !== undefined);
|
||||
|
||||
// Value check: at least some fields must have real data (not just "-")
|
||||
// A feature with ALL text fields === "-" is considered empty and needs re-enrichment
|
||||
const valueFields = ["NR_CF", "PROPRIETARI", "ADRESA", "CATEGORIE_FOLOSINTA"];
|
||||
const hasRealValues =
|
||||
enrichJson != null &&
|
||||
valueFields.some(
|
||||
(k) =>
|
||||
k in enrichJson &&
|
||||
enrichJson[k] !== undefined &&
|
||||
enrichJson[k] !== "-" &&
|
||||
enrichJson[k] !== "",
|
||||
);
|
||||
|
||||
// Corruption check: re-enrich if any field contains "[object Object]"
|
||||
const hasCorruptedValues =
|
||||
enrichJson != null &&
|
||||
Object.values(enrichJson).some(
|
||||
(v) => typeof v === "string" && v.includes("[object Object]"),
|
||||
);
|
||||
|
||||
// Age check: re-enrich if older than 30 days (catches eTerra updates)
|
||||
const ageMs = Date.now() - new Date(feature.enrichedAt).getTime();
|
||||
const isTooOld = ageMs > 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (structurallyComplete && hasRealValues && !isTooOld && !hasCorruptedValues) {
|
||||
enrichedCount += 1;
|
||||
if (index % 50 === 0) {
|
||||
options?.onProgress?.(
|
||||
@@ -418,9 +776,12 @@ export async function enrichFeatures(
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Stale enrichment — will be re-enriched below
|
||||
// Incomplete, empty, or stale — will be re-enriched below
|
||||
}
|
||||
|
||||
// Per-feature try-catch: one feature failing should not abort the whole UAT
|
||||
try {
|
||||
|
||||
const immovableId = attrs.IMMOVABLE_ID ?? "";
|
||||
const workspaceId = attrs.WORKSPACE_ID ?? "";
|
||||
const applicationId = (attrs.APPLICATION_ID as number) ?? null;
|
||||
@@ -474,13 +835,17 @@ export async function enrichFeatures(
|
||||
const folKey = `${workspaceId}:${immovableId}:${appId}`;
|
||||
let fol = folCache.get(folKey);
|
||||
if (!fol) {
|
||||
fol = await throttled(() =>
|
||||
client.fetchParcelFolosinte(
|
||||
workspaceId as string | number,
|
||||
immovableId as string | number,
|
||||
appId,
|
||||
),
|
||||
);
|
||||
try {
|
||||
fol = await throttled(() =>
|
||||
client.fetchParcelFolosinte(
|
||||
workspaceId as string | number,
|
||||
immovableId as string | number,
|
||||
appId,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
fol = [];
|
||||
}
|
||||
folCache.set(folKey, fol);
|
||||
}
|
||||
if (fol && fol.length > 0) {
|
||||
@@ -576,6 +941,59 @@ export async function enrichFeatures(
|
||||
: null);
|
||||
}
|
||||
|
||||
// Extended fields — extracted from existing data, zero extra API calls
|
||||
let tipInscriere = "";
|
||||
let actProprietate = "";
|
||||
let cotaProprietate = "";
|
||||
let dataCerere = "";
|
||||
// Extract registration details from already-fetched documentation
|
||||
const lbKey = landbookIE || cadRefRaw;
|
||||
const regsForParcel = regsByLandbook.get(String(lbKey)) ?? [];
|
||||
for (const reg of regsForParcel) {
|
||||
const nt = String(reg?.nodeType ?? "").toUpperCase();
|
||||
const nn = String(reg?.nodeName ?? "").trim();
|
||||
if (nt === "I" && nn && !tipInscriere) {
|
||||
tipInscriere = nn;
|
||||
const quota = reg?.registration?.actualQuota;
|
||||
if (quota) cotaProprietate = String(quota);
|
||||
}
|
||||
if (nt === "A" && nn && !actProprietate) {
|
||||
actProprietate = nn;
|
||||
}
|
||||
if (nt === "C" && !dataCerere) {
|
||||
const appDate = reg?.application?.appDate;
|
||||
if (typeof appDate === "number" && appDate > 0) {
|
||||
dataCerere = new Date(appDate).toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Building body details from local DB cladiri
|
||||
const cadRefBase = baseCadRef(cadRefRaw);
|
||||
let nrCorpuri = 0;
|
||||
const corpuriParts: string[] = [];
|
||||
for (const cFeature of cladiri) {
|
||||
const cAttrs = cFeature.attributes as Record<string, unknown>;
|
||||
const cRef = String(cAttrs.NATIONAL_CADASTRAL_REFERENCE ?? "");
|
||||
if (baseCadRef(cRef) === cadRefBase && cRef.includes("-")) {
|
||||
nrCorpuri++;
|
||||
const suffix = cRef.slice(cRef.lastIndexOf("-") + 1);
|
||||
const cArea = typeof cAttrs.AREA_VALUE === "number" ? cAttrs.AREA_VALUE : 0;
|
||||
corpuriParts.push(`${suffix}:${Math.round(cArea)}mp`);
|
||||
}
|
||||
}
|
||||
|
||||
// Condominium status and creation date from documentation
|
||||
const docImmovable = docKey ? docByImmovable.get(docKey) : undefined;
|
||||
const isCondominium = Number(
|
||||
(docImmovable as Record<string, unknown>)?.isCondominium ?? 0,
|
||||
);
|
||||
const createdDtm = attrs.CREATED_DTM;
|
||||
const dataCreare =
|
||||
typeof createdDtm === "number" && createdDtm > 0
|
||||
? new Date(createdDtm).toISOString().slice(0, 10)
|
||||
: "";
|
||||
|
||||
const enrichment: FeatureEnrichment = {
|
||||
NR_CAD: cadRefRaw,
|
||||
NR_CF: nrCF,
|
||||
@@ -589,8 +1007,16 @@ export async function enrichFeatures(
|
||||
SOLICITANT: solicitant,
|
||||
INTRAVILAN: intravilan,
|
||||
CATEGORIE_FOLOSINTA: categorie,
|
||||
HAS_BUILDING: hasBuilding,
|
||||
HAS_BUILDING: hasBuilding || (nrCorpuri > 0 ? 1 : 0),
|
||||
BUILD_LEGAL: buildLegal,
|
||||
TIP_INSCRIERE: tipInscriere || undefined,
|
||||
ACT_PROPRIETATE: actProprietate || undefined,
|
||||
COTA_PROPRIETATE: cotaProprietate || undefined,
|
||||
DATA_CERERE: dataCerere || undefined,
|
||||
NR_CORPURI: nrCorpuri,
|
||||
CORPURI_DETALII: corpuriParts.length > 0 ? corpuriParts.join(", ") : undefined,
|
||||
IS_CONDOMINIUM: isCondominium,
|
||||
DATA_CREARE: dataCreare || undefined,
|
||||
};
|
||||
|
||||
// Store enrichment in DB
|
||||
@@ -603,6 +1029,16 @@ export async function enrichFeatures(
|
||||
});
|
||||
|
||||
enrichedCount += 1;
|
||||
|
||||
} catch (featureErr) {
|
||||
// Log and continue — don't abort the whole UAT
|
||||
const cadRef = (attrs.NATIONAL_CADASTRAL_REFERENCE ?? "?") as string;
|
||||
const msg = featureErr instanceof Error ? featureErr.message : String(featureErr);
|
||||
console.warn(
|
||||
`[enrich] Feature ${index + 1}/${terenuri.length} (cad=${cadRef}) failed: ${msg}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (index % 10 === 0) {
|
||||
push({
|
||||
phase: "Îmbogățire parcele",
|
||||
|
||||
@@ -78,16 +78,16 @@ type SessionEntry = {
|
||||
};
|
||||
|
||||
const globalStore = globalThis as {
|
||||
__eterraSessionStore?: Map<string, SessionEntry>;
|
||||
__eterraCleanupTimer?: ReturnType<typeof setInterval>;
|
||||
__eterraClientCache?: Map<string, SessionEntry>;
|
||||
__eterraClientCleanupTimer?: ReturnType<typeof setInterval>;
|
||||
};
|
||||
const sessionStore =
|
||||
globalStore.__eterraSessionStore ?? new Map<string, SessionEntry>();
|
||||
globalStore.__eterraSessionStore = sessionStore;
|
||||
globalStore.__eterraClientCache ?? new Map<string, SessionEntry>();
|
||||
globalStore.__eterraClientCache = sessionStore;
|
||||
|
||||
// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
|
||||
if (!globalStore.__eterraCleanupTimer) {
|
||||
globalStore.__eterraCleanupTimer = setInterval(() => {
|
||||
if (!globalStore.__eterraClientCleanupTimer) {
|
||||
globalStore.__eterraClientCleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of sessionStore.entries()) {
|
||||
if (now - entry.lastUsed > 9 * 60_000) {
|
||||
@@ -130,7 +130,7 @@ export class EterraClient {
|
||||
private maxRetries: number;
|
||||
private username: string;
|
||||
private password: string;
|
||||
private reloginAttempted = false;
|
||||
private cacheKey: string;
|
||||
private layerFieldsCache = new Map<string, string[]>();
|
||||
|
||||
private constructor(
|
||||
@@ -147,6 +147,7 @@ export class EterraClient {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.maxRetries = maxRetries;
|
||||
this.cacheKey = makeCacheKey(username, password);
|
||||
}
|
||||
|
||||
/* ---- Factory --------------------------------------------------- */
|
||||
@@ -297,6 +298,81 @@ export class EterraClient {
|
||||
return this.countLayerWithParams(layer, params, true);
|
||||
}
|
||||
|
||||
/* ---- Incremental sync: fetch only OBJECTIDs -------------------- */
|
||||
|
||||
async fetchObjectIds(layer: LayerConfig, siruta: string): Promise<number[]> {
|
||||
const where = await this.buildWhere(layer, siruta);
|
||||
return this.fetchObjectIdsByWhere(layer, where);
|
||||
}
|
||||
|
||||
async fetchObjectIdsByWhere(
|
||||
layer: LayerConfig,
|
||||
where: string,
|
||||
): Promise<number[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("f", "json");
|
||||
params.set("where", where);
|
||||
params.set("returnIdsOnly", "true");
|
||||
const data = await this.queryLayer(layer, params, false);
|
||||
return data.objectIds ?? [];
|
||||
}
|
||||
|
||||
async fetchObjectIdsByGeometry(
|
||||
layer: LayerConfig,
|
||||
geometry: EsriGeometry,
|
||||
): Promise<number[]> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("f", "json");
|
||||
params.set("where", "1=1");
|
||||
params.set("returnIdsOnly", "true");
|
||||
this.applyGeometryParams(params, geometry);
|
||||
const data = await this.queryLayer(layer, params, true);
|
||||
return data.objectIds ?? [];
|
||||
}
|
||||
|
||||
/* ---- Fetch specific features by OBJECTID list ------------------- */
|
||||
|
||||
async fetchFeaturesByObjectIds(
|
||||
layer: LayerConfig,
|
||||
objectIds: number[],
|
||||
options?: {
|
||||
baseWhere?: string;
|
||||
outFields?: string;
|
||||
returnGeometry?: boolean;
|
||||
onProgress?: ProgressCallback;
|
||||
delayMs?: number;
|
||||
},
|
||||
): Promise<EsriFeature[]> {
|
||||
if (objectIds.length === 0) return [];
|
||||
const chunkSize = 500;
|
||||
const all: EsriFeature[] = [];
|
||||
const total = objectIds.length;
|
||||
for (let i = 0; i < objectIds.length; i += chunkSize) {
|
||||
const chunk = objectIds.slice(i, i + chunkSize);
|
||||
const idList = chunk.join(",");
|
||||
const idWhere = `OBJECTID IN (${idList})`;
|
||||
const where = options?.baseWhere
|
||||
? `(${options.baseWhere}) AND ${idWhere}`
|
||||
: idWhere;
|
||||
try {
|
||||
const features = await this.fetchAllLayerByWhere(layer, where, {
|
||||
outFields: options?.outFields ?? "*",
|
||||
returnGeometry: options?.returnGeometry ?? true,
|
||||
delayMs: options?.delayMs ?? 200,
|
||||
});
|
||||
all.push(...features);
|
||||
} catch (err) {
|
||||
// Log but continue with remaining chunks — partial results better than none
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
`[fetchFeaturesByObjectIds] Chunk ${Math.floor(i / chunkSize) + 1} failed (${chunk.length} IDs): ${msg}`,
|
||||
);
|
||||
}
|
||||
options?.onProgress?.(all.length, total);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
async listLayer(
|
||||
layer: LayerConfig,
|
||||
siruta: string,
|
||||
@@ -844,8 +920,7 @@ export class EterraClient {
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as AxiosError;
|
||||
if (err?.response?.status === 401 && !this.reloginAttempted) {
|
||||
this.reloginAttempted = true;
|
||||
if (err?.response?.status === 401) {
|
||||
await this.login(this.username, this.password);
|
||||
response = await this.requestWithRetry(() =>
|
||||
this.client.get(url, { timeout: this.timeoutMs }),
|
||||
@@ -909,23 +984,28 @@ export class EterraClient {
|
||||
);
|
||||
}
|
||||
|
||||
/** Touch session TTL in global store (prevents expiry during long pagination) */
|
||||
private touchSession(): void {
|
||||
const cached = sessionStore.get(this.cacheKey);
|
||||
if (cached) cached.lastUsed = Date.now();
|
||||
}
|
||||
|
||||
private async requestJson(
|
||||
request: () => Promise<{
|
||||
data: EsriQueryResponse | string;
|
||||
status: number;
|
||||
}>,
|
||||
): Promise<EsriQueryResponse> {
|
||||
this.touchSession();
|
||||
let response;
|
||||
try {
|
||||
response = await this.requestWithRetry(request);
|
||||
} catch (error) {
|
||||
const err = error as AxiosError;
|
||||
if (err?.response?.status === 401 && !this.reloginAttempted) {
|
||||
this.reloginAttempted = true;
|
||||
if (err?.response?.status === 401) {
|
||||
// Always attempt relogin on 401 (session may expire multiple times during long syncs)
|
||||
await this.login(this.username, this.password);
|
||||
response = await this.requestWithRetry(request);
|
||||
} else if (err?.response?.status === 401) {
|
||||
throw new Error("Session expired (401)");
|
||||
} else throw error;
|
||||
}
|
||||
const data = response.data as EsriQueryResponse | string;
|
||||
@@ -944,17 +1024,15 @@ export class EterraClient {
|
||||
private async requestRaw<T = any>(
|
||||
request: () => Promise<{ data: T | string; status: number }>,
|
||||
): Promise<T> {
|
||||
this.touchSession();
|
||||
let response;
|
||||
try {
|
||||
response = await this.requestWithRetry(request);
|
||||
} catch (error) {
|
||||
const err = error as AxiosError;
|
||||
if (err?.response?.status === 401 && !this.reloginAttempted) {
|
||||
this.reloginAttempted = true;
|
||||
if (err?.response?.status === 401) {
|
||||
await this.login(this.username, this.password);
|
||||
response = await this.requestWithRetry(request);
|
||||
} else if (err?.response?.status === 401) {
|
||||
throw new Error("Session expired (401)");
|
||||
} else throw error;
|
||||
}
|
||||
const data = response.data as T | string;
|
||||
|
||||
@@ -175,3 +175,33 @@ export const buildGpkg = async (options: GpkgBuildOptions): Promise<Buffer> => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
return buffer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a GPKG buffer to DXF using ogr2ogr.
|
||||
* Returns null if ogr2ogr is not available or conversion fails.
|
||||
*/
|
||||
export const gpkgToDxf = async (
|
||||
gpkgBuffer: Buffer,
|
||||
layerName: string,
|
||||
): Promise<Buffer | null> => {
|
||||
if (!hasOgr2Ogr()) return null;
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "eterra-dxf-"));
|
||||
const gpkgPath = path.join(tmpDir, "input.gpkg");
|
||||
const dxfPath = path.join(tmpDir, `${layerName}.dxf`);
|
||||
|
||||
try {
|
||||
await fs.writeFile(gpkgPath, gpkgBuffer);
|
||||
await runOgr(
|
||||
["-f", "DXF", dxfPath, gpkgPath, layerName],
|
||||
{ ...process.env, OGR_CT_FORCE_TRADITIONAL_GIS_ORDER: "YES" },
|
||||
);
|
||||
const buffer = Buffer.from(await fs.readFile(dxfPath));
|
||||
return buffer;
|
||||
} catch {
|
||||
// DXF conversion failed — not critical
|
||||
return null;
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -537,9 +537,12 @@ export async function syncNoGeometryParcels(
|
||||
}
|
||||
|
||||
if (staleIds.length > 0) {
|
||||
await prisma.gisFeature.deleteMany({
|
||||
where: { id: { in: staleIds } },
|
||||
});
|
||||
const BATCH = 30_000;
|
||||
for (let i = 0; i < staleIds.length; i += BATCH) {
|
||||
await prisma.gisFeature.deleteMany({
|
||||
where: { id: { in: staleIds.slice(i, i + BATCH) } },
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
`[no-geom-sync] Cleanup: removed ${staleIds.length} stale/invalid no-geom records`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Shared helper — triggers PMTiles rebuild via webhook after sync operations.
|
||||
* The webhook server (pmtiles-webhook systemd service on satra) runs
|
||||
* `docker run architools-tippecanoe` to regenerate overview tiles.
|
||||
*/
|
||||
|
||||
const WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || "";
|
||||
|
||||
export async function firePmtilesRebuild(
|
||||
event: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<{ ok: boolean; alreadyRunning?: boolean }> {
|
||||
if (!WEBHOOK_URL) {
|
||||
console.warn("[pmtiles-webhook] N8N_WEBHOOK_URL not configured — skipping rebuild trigger");
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
event,
|
||||
timestamp: new Date().toISOString(),
|
||||
...metadata,
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log(`[pmtiles-webhook] Rebuild triggered (event: ${event}, HTTP ${res.status})`);
|
||||
return { ok: true };
|
||||
}
|
||||
if (res.status === 409) {
|
||||
console.log(`[pmtiles-webhook] Rebuild already running (event: ${event})`);
|
||||
return { ok: true, alreadyRunning: true };
|
||||
}
|
||||
console.warn(`[pmtiles-webhook] Webhook returned HTTP ${res.status}`);
|
||||
return { ok: false };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[pmtiles-webhook] Failed: ${msg}`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { EterraClient } from "./eterra-client";
|
||||
import type { LayerConfig } from "./eterra-client";
|
||||
import type { LayerConfig, EsriFeature } from "./eterra-client";
|
||||
import { esriToGeojson } from "./esri-geojson";
|
||||
import { findLayerById, type LayerCatalogItem } from "./eterra-layers";
|
||||
import { fetchUatGeometry } from "./uat-geometry";
|
||||
@@ -29,6 +29,8 @@ export type SyncResult = {
|
||||
totalLocal: number;
|
||||
newFeatures: number;
|
||||
removedFeatures: number;
|
||||
/** Features with VALID_FROM changed (attribute update, no new OBJECTID) */
|
||||
validFromUpdated?: number;
|
||||
status: "done" | "error";
|
||||
error?: string;
|
||||
};
|
||||
@@ -116,50 +118,144 @@ export async function syncLayer(
|
||||
uatGeometry = await fetchUatGeometry(client, siruta);
|
||||
}
|
||||
|
||||
// Count remote features
|
||||
push({ phase: "Numărare remote" });
|
||||
let remoteCount: number;
|
||||
try {
|
||||
remoteCount = uatGeometry
|
||||
? await client.countLayerByGeometry(layer, uatGeometry)
|
||||
: await client.countLayer(layer, siruta);
|
||||
} catch {
|
||||
remoteCount = 0;
|
||||
}
|
||||
|
||||
push({ phase: "Verificare locală", total: remoteCount });
|
||||
|
||||
// Get local OBJECTIDs for this layer+siruta
|
||||
// Get local OBJECTIDs for this layer+siruta (only positive — skip no-geom)
|
||||
push({ phase: "Verificare locală" });
|
||||
const localFeatures = await prisma.gisFeature.findMany({
|
||||
where: { layerId, siruta },
|
||||
where: { layerId, siruta, objectId: { gt: 0 } },
|
||||
select: { objectId: true },
|
||||
});
|
||||
const localObjIds = new Set(localFeatures.map((f) => f.objectId));
|
||||
|
||||
// Fetch all remote features
|
||||
push({ phase: "Descărcare features", downloaded: 0, total: remoteCount });
|
||||
// ── Quick-count check: if remote count == local count, skip full OBJECTID fetch ──
|
||||
// Just do VALID_FROM delta for attribute changes (handled after download section).
|
||||
let remoteCount = 0;
|
||||
let remoteObjIds = new Set<number>();
|
||||
let newObjIdArray: number[] = [];
|
||||
let removedObjIds: number[] = [];
|
||||
let useFullSync = false;
|
||||
let quickCountMatch = false;
|
||||
|
||||
const allRemote = uatGeometry
|
||||
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
|
||||
total: remoteCount > 0 ? remoteCount : undefined,
|
||||
onProgress: (dl, tot) =>
|
||||
push({ phase: "Descărcare features", downloaded: dl, total: tot }),
|
||||
delayMs: 200,
|
||||
})
|
||||
: await client.fetchAllLayerByWhere(
|
||||
layer,
|
||||
await buildWhere(client, layer, siruta),
|
||||
{
|
||||
if (!options?.forceFullSync && localObjIds.size > 0) {
|
||||
push({ phase: "Verificare count remote" });
|
||||
let qCount = -1;
|
||||
try {
|
||||
qCount = uatGeometry
|
||||
? await client.countLayerByGeometry(layer, uatGeometry)
|
||||
: await client.countLayer(layer, siruta);
|
||||
} catch {
|
||||
// Count check is best-effort — fall through to OBJECTID comparison
|
||||
qCount = -1;
|
||||
}
|
||||
|
||||
if (qCount >= 0 && qCount === localObjIds.size) {
|
||||
// Counts match — very likely no new/removed features
|
||||
quickCountMatch = true;
|
||||
remoteCount = qCount;
|
||||
remoteObjIds = localObjIds; // Treat as identical
|
||||
newObjIdArray = [];
|
||||
removedObjIds = [];
|
||||
useFullSync = false;
|
||||
console.log(
|
||||
`[sync] Quick-count match: ${qCount} remote = ${localObjIds.size} local for ${layerId}/${siruta} — skipping OBJECTID fetch`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!quickCountMatch) {
|
||||
// Full OBJECTID comparison (original path)
|
||||
push({ phase: "Comparare ID-uri remote" });
|
||||
let remoteObjIdArray: number[];
|
||||
try {
|
||||
remoteObjIdArray = uatGeometry
|
||||
? await client.fetchObjectIdsByGeometry(layer, uatGeometry)
|
||||
: await client.fetchObjectIds(layer, siruta);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(
|
||||
`[syncLayer] fetchObjectIds failed for ${layerId}/${siruta}: ${msg} — falling back to full sync`,
|
||||
);
|
||||
remoteObjIdArray = [];
|
||||
}
|
||||
remoteObjIds = new Set(remoteObjIdArray);
|
||||
remoteCount = remoteObjIds.size;
|
||||
|
||||
// Compute delta
|
||||
newObjIdArray = [...remoteObjIds].filter((id) => !localObjIds.has(id));
|
||||
removedObjIds = [...localObjIds].filter(
|
||||
(id) => !remoteObjIds.has(id),
|
||||
);
|
||||
|
||||
// Decide: incremental (download only delta) or full sync
|
||||
const deltaRatio =
|
||||
remoteCount > 0 ? newObjIdArray.length / remoteCount : 1;
|
||||
useFullSync =
|
||||
options?.forceFullSync ||
|
||||
localObjIds.size === 0 ||
|
||||
deltaRatio > 0.5;
|
||||
}
|
||||
|
||||
let allRemote: EsriFeature[];
|
||||
|
||||
if (useFullSync) {
|
||||
// Full sync: download all features (first sync or forced)
|
||||
push({
|
||||
phase: "Descărcare features (complet)",
|
||||
downloaded: 0,
|
||||
total: remoteCount,
|
||||
});
|
||||
allRemote = uatGeometry
|
||||
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
|
||||
total: remoteCount > 0 ? remoteCount : undefined,
|
||||
onProgress: (dl, tot) =>
|
||||
push({
|
||||
phase: "Descărcare features",
|
||||
phase: "Descărcare features (complet)",
|
||||
downloaded: dl,
|
||||
total: tot,
|
||||
}),
|
||||
delayMs: 200,
|
||||
},
|
||||
);
|
||||
})
|
||||
: await client.fetchAllLayerByWhere(
|
||||
layer,
|
||||
await buildWhere(client, layer, siruta),
|
||||
{
|
||||
total: remoteCount > 0 ? remoteCount : undefined,
|
||||
onProgress: (dl, tot) =>
|
||||
push({
|
||||
phase: "Descărcare features (complet)",
|
||||
downloaded: dl,
|
||||
total: tot,
|
||||
}),
|
||||
delayMs: 200,
|
||||
},
|
||||
);
|
||||
} else if (newObjIdArray.length > 0) {
|
||||
// Incremental sync: download only the new features
|
||||
push({
|
||||
phase: "Descărcare features noi",
|
||||
downloaded: 0,
|
||||
total: newObjIdArray.length,
|
||||
});
|
||||
const baseWhere = uatGeometry
|
||||
? undefined
|
||||
: await buildWhere(client, layer, siruta);
|
||||
allRemote = await client.fetchFeaturesByObjectIds(
|
||||
layer,
|
||||
newObjIdArray,
|
||||
{
|
||||
baseWhere,
|
||||
onProgress: (dl, tot) =>
|
||||
push({
|
||||
phase: "Descărcare features noi",
|
||||
downloaded: dl,
|
||||
total: tot,
|
||||
}),
|
||||
delayMs: 200,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Nothing new to download
|
||||
allRemote = [];
|
||||
}
|
||||
|
||||
// Convert to GeoJSON for geometry storage
|
||||
const geojson = esriToGeojson(allRemote);
|
||||
@@ -169,19 +265,11 @@ export async function syncLayer(
|
||||
if (objId != null) geojsonByObjId.set(objId, f);
|
||||
}
|
||||
|
||||
// Determine which OBJECTIDs are new
|
||||
const remoteObjIds = new Set<number>();
|
||||
for (const f of allRemote) {
|
||||
const objId = f.attributes.OBJECTID as number | undefined;
|
||||
if (objId != null) remoteObjIds.add(objId);
|
||||
}
|
||||
|
||||
// For incremental sync, newObjIds = the delta we downloaded
|
||||
// For full sync, newObjIds = all remote (if forced) or only truly new
|
||||
const newObjIds = options?.forceFullSync
|
||||
? remoteObjIds
|
||||
: new Set([...remoteObjIds].filter((id) => !localObjIds.has(id)));
|
||||
const removedObjIds = [...localObjIds].filter(
|
||||
(id) => !remoteObjIds.has(id),
|
||||
);
|
||||
: new Set(newObjIdArray);
|
||||
|
||||
push({
|
||||
phase: "Salvare în baza de date",
|
||||
@@ -269,16 +357,107 @@ export async function syncLayer(
|
||||
// PostGIS not available yet — not critical, skip silently
|
||||
}
|
||||
|
||||
// Mark removed features
|
||||
if (removedObjIds.length > 0) {
|
||||
// Mark removed features (batch to avoid PostgreSQL 32767 bind variable limit)
|
||||
// Safety: if remote returned very few features compared to local, the session
|
||||
// likely expired mid-sync — skip deletion to avoid wiping valid data.
|
||||
const removedRatio = localObjIds.size > 0 ? removedObjIds.length / localObjIds.size : 0;
|
||||
if (removedObjIds.length > 0 && removedRatio > 0.8 && localObjIds.size > 100) {
|
||||
console.warn(
|
||||
`[sync] SKIP delete: ${removedObjIds.length}/${localObjIds.size} features (${Math.round(removedRatio * 100)}%) ` +
|
||||
`would be removed for ${layerId}/${siruta} — likely stale remote data. Aborting deletion.`,
|
||||
);
|
||||
} else if (removedObjIds.length > 0) {
|
||||
push({ phase: "Marcare șterse" });
|
||||
await prisma.gisFeature.deleteMany({
|
||||
where: {
|
||||
layerId,
|
||||
siruta,
|
||||
objectId: { in: removedObjIds },
|
||||
},
|
||||
});
|
||||
const BATCH = 30_000;
|
||||
for (let i = 0; i < removedObjIds.length; i += BATCH) {
|
||||
await prisma.gisFeature.deleteMany({
|
||||
where: {
|
||||
layerId,
|
||||
siruta,
|
||||
objectId: { in: removedObjIds.slice(i, i + BATCH) },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── VALID_FROM delta: detect attribute changes on existing features ──
|
||||
// Features whose VALID_FROM changed since our stored copy need re-enrichment.
|
||||
// This catches ownership/CF changes that don't create new OBJECTIDs.
|
||||
let validFromUpdated = 0;
|
||||
if (!useFullSync && newObjIdArray.length === 0 && removedObjIds.length === 0) {
|
||||
// Nothing new/removed — check if existing features changed via VALID_FROM
|
||||
// Fetch the max VALID_FROM we have stored locally
|
||||
const maxValidFrom = await prisma.$queryRawUnsafe<
|
||||
Array<{ max_vf: string | null }>
|
||||
>(
|
||||
`SELECT MAX((attributes->>'VALID_FROM')::bigint)::text as max_vf ` +
|
||||
`FROM "GisFeature" WHERE "layerId" = $1 AND siruta = $2 AND "objectId" > 0`,
|
||||
layerId,
|
||||
siruta,
|
||||
);
|
||||
const localMaxVf = maxValidFrom[0]?.max_vf;
|
||||
if (localMaxVf) {
|
||||
// Ask eTerra: any features with VALID_FROM > our max?
|
||||
const baseWhere = await buildWhere(client, layer, siruta);
|
||||
const vfWhere = `${baseWhere} AND VALID_FROM>${localMaxVf}`;
|
||||
try {
|
||||
const changed = uatGeometry
|
||||
? await client.fetchAllLayerByWhere(
|
||||
layer,
|
||||
`VALID_FROM>${localMaxVf}`,
|
||||
{
|
||||
outFields: "*",
|
||||
returnGeometry: true,
|
||||
delayMs: 200,
|
||||
geometry: uatGeometry,
|
||||
},
|
||||
)
|
||||
: await client.fetchAllLayerByWhere(layer, vfWhere, {
|
||||
outFields: "*",
|
||||
returnGeometry: true,
|
||||
delayMs: 200,
|
||||
});
|
||||
if (changed.length > 0) {
|
||||
push({ phase: `Actualizare ${changed.length} parcele modificate` });
|
||||
const changedGeojson = esriToGeojson(changed);
|
||||
const changedGeoMap = new Map<
|
||||
number,
|
||||
(typeof changedGeojson.features)[0]
|
||||
>();
|
||||
for (const f of changedGeojson.features) {
|
||||
const objId = f.properties.OBJECTID as number | undefined;
|
||||
if (objId != null) changedGeoMap.set(objId, f);
|
||||
}
|
||||
for (const feature of changed) {
|
||||
const objId = feature.attributes.OBJECTID as number;
|
||||
if (!objId) continue;
|
||||
const geoFeature = changedGeoMap.get(objId);
|
||||
const geom = geoFeature?.geometry;
|
||||
await prisma.gisFeature.updateMany({
|
||||
where: { layerId, objectId: objId },
|
||||
data: {
|
||||
attributes: feature.attributes as Prisma.InputJsonValue,
|
||||
geometry: geom
|
||||
? (geom as Prisma.InputJsonValue)
|
||||
: undefined,
|
||||
enrichedAt: null, // Force re-enrichment
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
validFromUpdated = changed.length;
|
||||
console.log(
|
||||
`[sync] VALID_FROM delta: ${changed.length} features updated for ${layerId}/${siruta}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Non-critical — VALID_FROM check is best-effort
|
||||
console.warn(
|
||||
`[sync] VALID_FROM check failed for ${layerId}/${siruta}:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update sync run
|
||||
@@ -312,6 +491,7 @@ export async function syncLayer(
|
||||
totalLocal: localCount,
|
||||
newFeatures: newObjIds.size,
|
||||
removedFeatures: removedObjIds.length,
|
||||
validFromUpdated,
|
||||
status: "done",
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,695 @@
|
||||
/**
|
||||
* Weekend Deep Sync — full Magic processing for large cities.
|
||||
*
|
||||
* Runs Fri/Sat/Sun nights 23:00–04:00. Processes cities in round-robin
|
||||
* (one step per city, then rotate) so progress is spread across cities.
|
||||
* State is persisted in KeyValueStore — survives restarts and continues
|
||||
* across multiple nights/weekends.
|
||||
*
|
||||
* Steps per city (each is resumable):
|
||||
* 1. sync_terenuri — syncLayer TERENURI_ACTIVE
|
||||
* 2. sync_cladiri — syncLayer CLADIRI_ACTIVE
|
||||
* 3. import_nogeom — import parcels without geometry
|
||||
* 4. enrich — enrichFeatures (slowest, naturally resumable)
|
||||
*/
|
||||
|
||||
import { PrismaClient, Prisma } from "@prisma/client";
|
||||
import { syncLayer } from "./sync-service";
|
||||
import { EterraClient } from "./eterra-client";
|
||||
import { isEterraAvailable } from "./eterra-health";
|
||||
import { sendEmail } from "@/core/notifications/email-service";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Live activity tracking (globalThis — same process) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const g = globalThis as {
|
||||
__weekendSyncActivity?: {
|
||||
city: string;
|
||||
step: string;
|
||||
startedAt: string;
|
||||
} | null;
|
||||
__parcelSyncRunning?: boolean;
|
||||
};
|
||||
|
||||
export function getWeekendSyncActivity() {
|
||||
return g.__weekendSyncActivity ?? null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* City queue configuration */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export type CityConfig = {
|
||||
siruta: string;
|
||||
name: string;
|
||||
county: string;
|
||||
priority: number; // lower = higher priority
|
||||
};
|
||||
|
||||
/** Initial queue — priority 1 = first processed */
|
||||
const DEFAULT_CITIES: CityConfig[] = [
|
||||
{ siruta: "54975", name: "Cluj-Napoca", county: "Cluj", priority: 1 },
|
||||
{ siruta: "32394", name: "Bistri\u021Ba", county: "Bistri\u021Ba-N\u0103s\u0103ud", priority: 1 },
|
||||
{ siruta: "114319", name: "T\u00E2rgu Mure\u0219", county: "Mure\u0219", priority: 2 },
|
||||
{ siruta: "139704", name: "Zal\u0103u", county: "S\u0103laj", priority: 2 },
|
||||
{ siruta: "26564", name: "Oradea", county: "Bihor", priority: 2 },
|
||||
{ siruta: "9262", name: "Arad", county: "Arad", priority: 2 },
|
||||
{ siruta: "155243", name: "Timi\u0219oara", county: "Timi\u0219", priority: 2 },
|
||||
{ siruta: "143450", name: "Sibiu", county: "Sibiu", priority: 2 },
|
||||
{ siruta: "40198", name: "Bra\u0219ov", county: "Bra\u0219ov", priority: 2 },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step definitions */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STEPS = [
|
||||
"sync_terenuri",
|
||||
"sync_cladiri",
|
||||
"import_nogeom",
|
||||
"enrich",
|
||||
] as const;
|
||||
|
||||
type StepName = (typeof STEPS)[number];
|
||||
type StepStatus = "pending" | "done" | "error";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Persisted state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type CityState = {
|
||||
siruta: string;
|
||||
name: string;
|
||||
county: string;
|
||||
priority: number;
|
||||
steps: Record<StepName, StepStatus>;
|
||||
lastActivity?: string;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
type WeekendSyncState = {
|
||||
cities: CityState[];
|
||||
lastSessionDate?: string;
|
||||
totalSessions: number;
|
||||
completedCycles: number; // how many full cycles (all cities done)
|
||||
};
|
||||
|
||||
const KV_NAMESPACE = "parcel-sync-weekend";
|
||||
const KV_KEY = "queue-state";
|
||||
|
||||
async function loadState(): Promise<WeekendSyncState> {
|
||||
const row = await prisma.keyValueStore.findUnique({
|
||||
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||
});
|
||||
if (row?.value && typeof row.value === "object") {
|
||||
return row.value as unknown as WeekendSyncState;
|
||||
}
|
||||
// Initialize with default cities
|
||||
return {
|
||||
cities: DEFAULT_CITIES.map((c) => ({
|
||||
...c,
|
||||
steps: {
|
||||
sync_terenuri: "pending",
|
||||
sync_cladiri: "pending",
|
||||
import_nogeom: "pending",
|
||||
enrich: "pending",
|
||||
},
|
||||
})),
|
||||
totalSessions: 0,
|
||||
completedCycles: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveState(state: WeekendSyncState): Promise<void> {
|
||||
// Retry once on failure — state persistence is critical for resume
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
await prisma.keyValueStore.upsert({
|
||||
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
|
||||
update: { value: state as unknown as Prisma.InputJsonValue },
|
||||
create: {
|
||||
namespace: KV_NAMESPACE,
|
||||
key: KV_KEY,
|
||||
value: state as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt === 0) {
|
||||
console.warn("[weekend-sync] saveState retry...");
|
||||
await sleep(2000);
|
||||
} else {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[weekend-sync] saveState failed: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Time window */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const WEEKEND_START_HOUR = 23;
|
||||
const WEEKEND_END_HOUR = 4;
|
||||
const PAUSE_BETWEEN_STEPS_MS = 60_000 + Math.random() * 60_000; // 60-120s
|
||||
|
||||
/** Check if current time is within the weekend sync window */
|
||||
export function isWeekendWindow(): boolean {
|
||||
const now = new Date();
|
||||
const day = now.getDay(); // 0=Sun, 5=Fri, 6=Sat
|
||||
const hour = now.getHours();
|
||||
|
||||
// Fri 23:00+ or Sat 23:00+ or Sun 23:00+
|
||||
if ((day === 5 || day === 6 || day === 0) && hour >= WEEKEND_START_HOUR) {
|
||||
return true;
|
||||
}
|
||||
// Sat 00-04 (continuation of Friday night) or Sun 00-04 or Mon 00-04
|
||||
if ((day === 6 || day === 0 || day === 1) && hour < WEEKEND_END_HOUR) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Check if still within the window (called during processing) */
|
||||
function stillInWindow(force?: boolean): boolean {
|
||||
if (force) return true; // Manual trigger — no time restriction
|
||||
const hour = new Date().getHours();
|
||||
// We can be in 23,0,1,2,3 — stop at 4
|
||||
if (hour >= WEEKEND_END_HOUR && hour < WEEKEND_START_HOUR) return false;
|
||||
return isWeekendWindow();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Step executors */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function executeStep(
|
||||
city: CityState,
|
||||
step: StepName,
|
||||
client: EterraClient,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const start = Date.now();
|
||||
|
||||
switch (step) {
|
||||
case "sync_terenuri": {
|
||||
const res = await syncLayer(
|
||||
process.env.ETERRA_USERNAME!,
|
||||
process.env.ETERRA_PASSWORD!,
|
||||
city.siruta,
|
||||
"TERENURI_ACTIVE",
|
||||
{ uatName: city.name, forceFullSync: true },
|
||||
);
|
||||
// Also sync admin layers (lightweight, non-fatal)
|
||||
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
|
||||
try {
|
||||
await syncLayer(
|
||||
process.env.ETERRA_USERNAME!,
|
||||
process.env.ETERRA_PASSWORD!,
|
||||
city.siruta,
|
||||
adminLayer,
|
||||
{ uatName: city.name },
|
||||
);
|
||||
} catch {
|
||||
// admin layers are best-effort
|
||||
}
|
||||
}
|
||||
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||
return {
|
||||
success: res.status === "done",
|
||||
message: `Terenuri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) + intravilan [${dur}s]`,
|
||||
};
|
||||
}
|
||||
|
||||
case "sync_cladiri": {
|
||||
const res = await syncLayer(
|
||||
process.env.ETERRA_USERNAME!,
|
||||
process.env.ETERRA_PASSWORD!,
|
||||
city.siruta,
|
||||
"CLADIRI_ACTIVE",
|
||||
{ uatName: city.name, forceFullSync: true },
|
||||
);
|
||||
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||
return {
|
||||
success: res.status === "done",
|
||||
message: `Cl\u0103diri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) [${dur}s]`,
|
||||
};
|
||||
}
|
||||
|
||||
case "import_nogeom": {
|
||||
const { syncNoGeometryParcels } = await import("./no-geom-sync");
|
||||
const res = await syncNoGeometryParcels(client, city.siruta);
|
||||
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||
return {
|
||||
success: res.status !== "error",
|
||||
message: `No-geom: ${res.imported} importate, ${res.skipped} skip [${dur}s]`,
|
||||
};
|
||||
}
|
||||
|
||||
case "enrich": {
|
||||
const { enrichFeatures } = await import("./enrich-service");
|
||||
const res = await enrichFeatures(client, city.siruta);
|
||||
const dur = ((Date.now() - start) / 1000).toFixed(1);
|
||||
return {
|
||||
success: res.status === "done",
|
||||
message: res.status === "done"
|
||||
? `Enrichment: ${res.enrichedCount}/${res.totalFeatures ?? "?"} (${dur}s)`
|
||||
: `Enrichment eroare: ${res.error ?? "necunoscuta"} (${dur}s)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main runner */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type SessionLog = {
|
||||
city: string;
|
||||
step: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function runWeekendDeepSync(options?: {
|
||||
force?: boolean;
|
||||
onlySteps?: StepName[];
|
||||
}): Promise<void> {
|
||||
const force = options?.force ?? false;
|
||||
const activeSteps = options?.onlySteps ?? STEPS;
|
||||
const username = process.env.ETERRA_USERNAME;
|
||||
const password = process.env.ETERRA_PASSWORD;
|
||||
if (!username || !password) return;
|
||||
|
||||
if (!isEterraAvailable()) {
|
||||
console.log("[weekend-sync] eTerra indisponibil, skip.");
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await loadState();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Prevent running twice in the same session (force bypasses)
|
||||
if (!force && state.lastSessionDate === today) return;
|
||||
|
||||
state.totalSessions++;
|
||||
state.lastSessionDate = today;
|
||||
|
||||
// Ensure new default cities are added if config expanded
|
||||
for (const dc of DEFAULT_CITIES) {
|
||||
if (!state.cities.some((c) => c.siruta === dc.siruta)) {
|
||||
state.cities.push({
|
||||
...dc,
|
||||
steps: {
|
||||
sync_terenuri: "pending",
|
||||
sync_cladiri: "pending",
|
||||
import_nogeom: "pending",
|
||||
enrich: "pending",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sessionStart = Date.now();
|
||||
const log: SessionLog[] = [];
|
||||
let stepsCompleted = 0;
|
||||
|
||||
console.log(
|
||||
`[weekend-sync] Sesiune #${state.totalSessions} pornita. ${state.cities.length} orase in coada.`,
|
||||
);
|
||||
|
||||
// Sort cities: priority first, then shuffle within same priority
|
||||
const sorted = [...state.cities].sort((a, b) => {
|
||||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||||
return Math.random() - 0.5; // random within same priority
|
||||
});
|
||||
|
||||
// Round-robin: iterate through steps, for each step iterate through cities
|
||||
for (const stepName of activeSteps) {
|
||||
// Find cities that still need this step
|
||||
const needsStep = sorted.filter((c) => c.steps[stepName] === "pending");
|
||||
if (needsStep.length === 0) continue;
|
||||
|
||||
for (const city of needsStep) {
|
||||
// Check time window
|
||||
if (!stillInWindow(force)) {
|
||||
console.log("[weekend-sync] Fereastra s-a inchis, opresc.");
|
||||
g.__weekendSyncActivity = null;
|
||||
await saveState(state);
|
||||
await sendStatusEmail(state, log, sessionStart);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check eTerra health
|
||||
if (!isEterraAvailable()) {
|
||||
console.log("[weekend-sync] eTerra indisponibil, opresc.");
|
||||
g.__weekendSyncActivity = null;
|
||||
await saveState(state);
|
||||
await sendStatusEmail(state, log, sessionStart);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pause between steps
|
||||
if (stepsCompleted > 0) {
|
||||
const pause = 60_000 + Math.random() * 60_000;
|
||||
console.log(
|
||||
`[weekend-sync] Pauza ${Math.round(pause / 1000)}s inainte de ${city.name} / ${stepName}`,
|
||||
);
|
||||
await sleep(pause);
|
||||
}
|
||||
|
||||
// Execute step — fresh client per step (sessions expire after ~10 min)
|
||||
console.log(`[weekend-sync] ${city.name}: ${stepName}...`);
|
||||
g.__weekendSyncActivity = {
|
||||
city: city.name,
|
||||
step: stepName,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
const client = await EterraClient.create(username, password);
|
||||
const result = await executeStep(city, stepName, client);
|
||||
city.steps[stepName] = result.success ? "done" : "error";
|
||||
if (!result.success) {
|
||||
city.errorMessage = result.message;
|
||||
await sendStepErrorEmail(city, stepName, result.message);
|
||||
}
|
||||
city.lastActivity = new Date().toISOString();
|
||||
log.push({
|
||||
city: city.name,
|
||||
step: stepName,
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
});
|
||||
console.log(
|
||||
`[weekend-sync] ${city.name}: ${stepName} → ${result.success ? "OK" : "EROARE"} — ${result.message}`,
|
||||
);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
city.steps[stepName] = "error";
|
||||
city.errorMessage = msg;
|
||||
city.lastActivity = new Date().toISOString();
|
||||
log.push({
|
||||
city: city.name,
|
||||
step: stepName,
|
||||
success: false,
|
||||
message: msg,
|
||||
});
|
||||
console.error(
|
||||
`[weekend-sync] ${city.name}: ${stepName} EROARE: ${msg}`,
|
||||
);
|
||||
await sendStepErrorEmail(city, stepName, msg);
|
||||
}
|
||||
g.__weekendSyncActivity = null;
|
||||
|
||||
stepsCompleted++;
|
||||
// Save state after each step (crash safety)
|
||||
await saveState(state);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all cities completed all steps → new cycle
|
||||
const allDone = state.cities.every((c) =>
|
||||
STEPS.every((s) => c.steps[s] === "done"),
|
||||
);
|
||||
if (allDone) {
|
||||
state.completedCycles++;
|
||||
// Reset for next cycle
|
||||
for (const city of state.cities) {
|
||||
for (const step of STEPS) {
|
||||
city.steps[step] = "pending";
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
`[weekend-sync] Ciclu complet #${state.completedCycles}! Reset pentru urmatorul ciclu.`,
|
||||
);
|
||||
// Notify N8N to rebuild PMTiles (overview tiles for geoportal)
|
||||
await fireSyncWebhook(state.completedCycles);
|
||||
}
|
||||
|
||||
await saveState(state);
|
||||
await sendStatusEmail(state, log, sessionStart);
|
||||
console.log(`[weekend-sync] Sesiune finalizata. ${stepsCompleted} pasi executati.`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Immediate error email */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function sendStepErrorEmail(
|
||||
city: CityState,
|
||||
step: StepName,
|
||||
errorMsg: string,
|
||||
): Promise<void> {
|
||||
const emailTo = process.env.WEEKEND_SYNC_EMAIL;
|
||||
if (!emailTo) return;
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleString("ro-RO", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
const stepLabel: Record<StepName, string> = {
|
||||
sync_terenuri: "Sync Terenuri",
|
||||
sync_cladiri: "Sync Cladiri",
|
||||
import_nogeom: "Import No-Geom",
|
||||
enrich: "Enrichment",
|
||||
};
|
||||
|
||||
const html = `
|
||||
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto">
|
||||
<h2 style="color:#ef4444;margin-bottom:4px">Weekend Sync — Eroare</h2>
|
||||
<p style="color:#6b7280;margin-top:0">${timeStr}</p>
|
||||
<table style="border-collapse:collapse;width:100%;border:1px solid #fecaca;border-radius:6px;background:#fef2f2">
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:600;color:#374151">Oras</td>
|
||||
<td style="padding:8px 12px">${city.name} (${city.county})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:600;color:#374151">Pas</td>
|
||||
<td style="padding:8px 12px">${stepLabel[step]}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 12px;font-weight:600;color:#374151">Eroare</td>
|
||||
<td style="padding:8px 12px;color:#dc2626;word-break:break-word">${errorMsg}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="color:#9ca3af;font-size:11px;margin-top:16px">
|
||||
Generat automat de ArchiTools Weekend Sync
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await sendEmail({
|
||||
to: emailTo,
|
||||
subject: `[ArchiTools] WDS Eroare: ${city.name} — ${stepLabel[step]}`,
|
||||
html,
|
||||
});
|
||||
console.log(`[weekend-sync] Email eroare trimis: ${city.name}/${step}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[weekend-sync] Nu s-a putut trimite email eroare: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Email status report */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function sendStatusEmail(
|
||||
state: WeekendSyncState,
|
||||
log: SessionLog[],
|
||||
sessionStart: number,
|
||||
): Promise<void> {
|
||||
const emailTo = process.env.WEEKEND_SYNC_EMAIL;
|
||||
if (!emailTo) return;
|
||||
|
||||
try {
|
||||
const duration = Date.now() - sessionStart;
|
||||
const durMin = Math.round(duration / 60_000);
|
||||
const durStr =
|
||||
durMin >= 60
|
||||
? `${Math.floor(durMin / 60)}h ${durMin % 60}m`
|
||||
: `${durMin}m`;
|
||||
|
||||
const now = new Date();
|
||||
const dayNames = [
|
||||
"Duminic\u0103",
|
||||
"Luni",
|
||||
"Mar\u021Bi",
|
||||
"Miercuri",
|
||||
"Joi",
|
||||
"Vineri",
|
||||
"S\u00E2mb\u0103t\u0103",
|
||||
];
|
||||
const dayName = dayNames[now.getDay()] ?? "";
|
||||
const dateStr = now.toLocaleDateString("ro-RO", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
// Build city progress table
|
||||
const cityRows = state.cities
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map((c) => {
|
||||
const doneCount = STEPS.filter((s) => c.steps[s] === "done").length;
|
||||
const errorCount = STEPS.filter((s) => c.steps[s] === "error").length;
|
||||
const icon =
|
||||
doneCount === STEPS.length
|
||||
? "\u2713"
|
||||
: doneCount > 0
|
||||
? "\u25D0"
|
||||
: "\u25CB";
|
||||
const color =
|
||||
doneCount === STEPS.length
|
||||
? "#22c55e"
|
||||
: errorCount > 0
|
||||
? "#ef4444"
|
||||
: doneCount > 0
|
||||
? "#f59e0b"
|
||||
: "#9ca3af";
|
||||
const stepDetail = STEPS.map(
|
||||
(s) =>
|
||||
`<span style="color:${c.steps[s] === "done" ? "#22c55e" : c.steps[s] === "error" ? "#ef4444" : "#9ca3af"}">${s.replace("_", " ")}</span>`,
|
||||
).join(" \u2192 ");
|
||||
return `<tr>
|
||||
<td style="padding:4px 8px;color:${color};font-size:16px">${icon}</td>
|
||||
<td style="padding:4px 8px;font-weight:600">${c.name}</td>
|
||||
<td style="padding:4px 8px;color:#6b7280;font-size:12px">${c.county}</td>
|
||||
<td style="padding:4px 8px">${doneCount}/${STEPS.length}</td>
|
||||
<td style="padding:4px 8px;font-size:11px">${stepDetail}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
// Build session log
|
||||
const logRows =
|
||||
log.length > 0
|
||||
? log
|
||||
.map(
|
||||
(l) =>
|
||||
`<tr>
|
||||
<td style="padding:2px 6px;font-size:12px">${l.success ? "\u2713" : "\u2717"}</td>
|
||||
<td style="padding:2px 6px;font-size:12px">${l.city}</td>
|
||||
<td style="padding:2px 6px;font-size:12px;color:#6b7280">${l.step}</td>
|
||||
<td style="padding:2px 6px;font-size:11px;color:#6b7280">${l.message}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("\n")
|
||||
: '<tr><td colspan="4" style="padding:8px;color:#9ca3af;font-size:12px">Niciun pas executat in aceasta sesiune</td></tr>';
|
||||
|
||||
const html = `
|
||||
<div style="font-family:system-ui,sans-serif;max-width:700px;margin:0 auto">
|
||||
<h2 style="color:#1f2937;margin-bottom:4px">Weekend Sync — ${dayName} ${dateStr}</h2>
|
||||
<p style="color:#6b7280;margin-top:0">Durata sesiune: ${durStr} | Sesiunea #${state.totalSessions} | Cicluri complete: ${state.completedCycles}</p>
|
||||
|
||||
<h3 style="color:#374151;margin-bottom:8px">Progres per ora\u0219</h3>
|
||||
<table style="border-collapse:collapse;width:100%;border:1px solid #e5e7eb;border-radius:6px">
|
||||
<thead><tr style="background:#f9fafb">
|
||||
<th style="padding:6px 8px;text-align:left;font-size:12px"></th>
|
||||
<th style="padding:6px 8px;text-align:left;font-size:12px">Ora\u0219</th>
|
||||
<th style="padding:6px 8px;text-align:left;font-size:12px">Jude\u021B</th>
|
||||
<th style="padding:6px 8px;text-align:left;font-size:12px">Pa\u0219i</th>
|
||||
<th style="padding:6px 8px;text-align:left;font-size:12px">Detaliu</th>
|
||||
</tr></thead>
|
||||
<tbody>${cityRows}</tbody>
|
||||
</table>
|
||||
|
||||
<h3 style="color:#374151;margin-top:16px;margin-bottom:8px">Activitate sesiune curent\u0103</h3>
|
||||
<table style="border-collapse:collapse;width:100%;border:1px solid #e5e7eb">
|
||||
<tbody>${logRows}</tbody>
|
||||
</table>
|
||||
|
||||
<p style="color:#9ca3af;font-size:11px;margin-top:16px">
|
||||
Generat automat de ArchiTools Weekend Sync
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await sendEmail({
|
||||
to: emailTo,
|
||||
subject: `[ArchiTools] Weekend Sync — ${dayName} ${dateStr}`,
|
||||
html,
|
||||
});
|
||||
console.log(`[weekend-sync] Email status trimis la ${emailTo}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[weekend-sync] Nu s-a putut trimite email: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Manual force trigger */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Trigger a sync run outside the weekend window.
|
||||
* Resets error steps, clears lastSessionDate, and starts immediately.
|
||||
* Uses an extended night window (22:00–05:00) for the stillInWindow check.
|
||||
*/
|
||||
export async function triggerForceSync(options?: {
|
||||
onlySteps?: StepName[];
|
||||
}): Promise<{ started: boolean; reason?: string }> {
|
||||
if (g.__parcelSyncRunning) {
|
||||
return { started: false, reason: "O sincronizare ruleaza deja" };
|
||||
}
|
||||
|
||||
const username = process.env.ETERRA_USERNAME;
|
||||
const password = process.env.ETERRA_PASSWORD;
|
||||
if (!username || !password) {
|
||||
return { started: false, reason: "ETERRA credentials lipsesc" };
|
||||
}
|
||||
|
||||
if (!isEterraAvailable()) {
|
||||
return { started: false, reason: "eTerra indisponibil" };
|
||||
}
|
||||
|
||||
// Reset error steps + lastSessionDate in DB so the run proceeds
|
||||
const state = await loadState();
|
||||
for (const city of state.cities) {
|
||||
for (const step of STEPS) {
|
||||
if (city.steps[step] === "error") {
|
||||
city.steps[step] = "pending";
|
||||
city.errorMessage = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.lastSessionDate = undefined;
|
||||
await saveState(state);
|
||||
|
||||
// Start in background — don't block the API response
|
||||
g.__parcelSyncRunning = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const stepNames = options?.onlySteps;
|
||||
console.log(
|
||||
`[weekend-sync] Force sync declansat manual.${stepNames ? ` Steps: ${stepNames.join(", ")}` : ""}`,
|
||||
);
|
||||
await runWeekendDeepSync({ force: true, onlySteps: stepNames });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[weekend-sync] Force sync eroare: ${msg}`);
|
||||
} finally {
|
||||
g.__parcelSyncRunning = false;
|
||||
}
|
||||
})();
|
||||
|
||||
return { started: true };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* PMTiles Webhook — trigger rebuild after sync cycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
async function fireSyncWebhook(cycle: number): Promise<void> {
|
||||
const { firePmtilesRebuild } = await import("./pmtiles-webhook");
|
||||
await firePmtilesRebuild("weekend-sync-cycle-complete", { cycle });
|
||||
}
|
||||
@@ -166,11 +166,21 @@ export function RegistryEntryDetail({
|
||||
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
||||
const [monitorConfigOpen, setMonitorConfigOpen] = useState(false);
|
||||
const [monitorEditMode, setMonitorEditMode] = useState(false);
|
||||
|
||||
// Auto-detect if recipient matches a known authority
|
||||
// Authority for existing tracking or auto-detected from recipient
|
||||
const trackingAuthority = useMemo(() => {
|
||||
if (!entry) return undefined;
|
||||
if (entry.externalStatusTracking) {
|
||||
return getAuthority(entry.externalStatusTracking.authorityId) ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
}, [entry]);
|
||||
|
||||
// Auto-detect if recipient matches a known authority (only when no tracking)
|
||||
const matchedAuthority = useMemo(() => {
|
||||
if (!entry) return undefined;
|
||||
if (entry.externalStatusTracking?.active) return undefined;
|
||||
if (entry.externalStatusTracking) return undefined;
|
||||
if (!entry.recipientRegNumber) return undefined;
|
||||
return findAuthorityForContact(entry.recipient);
|
||||
}, [entry]);
|
||||
@@ -757,14 +767,47 @@ export function RegistryEntryDetail({
|
||||
)}
|
||||
|
||||
{/* ── External status monitoring ── */}
|
||||
{entry.externalStatusTracking?.active && (
|
||||
<ExternalStatusSection
|
||||
entry={entry}
|
||||
/>
|
||||
{entry.externalStatusTracking && (
|
||||
<>
|
||||
<ExternalStatusSection
|
||||
entry={entry}
|
||||
onEdit={() => {
|
||||
setMonitorEditMode(true);
|
||||
setMonitorConfigOpen(true);
|
||||
}}
|
||||
/>
|
||||
{trackingAuthority && (
|
||||
<StatusMonitorConfig
|
||||
open={monitorConfigOpen && monitorEditMode}
|
||||
onOpenChange={(open) => {
|
||||
setMonitorConfigOpen(open);
|
||||
if (!open) setMonitorEditMode(false);
|
||||
}}
|
||||
entry={entry}
|
||||
authority={trackingAuthority}
|
||||
editMode
|
||||
onActivate={async (tracking) => {
|
||||
try {
|
||||
await fetch("/api/registratura", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: entry.id,
|
||||
updates: { externalStatusTracking: tracking },
|
||||
}),
|
||||
});
|
||||
window.location.reload();
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Auto-detect: suggest monitoring activation ── */}
|
||||
{matchedAuthority && !entry.externalStatusTracking?.active && (
|
||||
{matchedAuthority && !entry.externalStatusTracking && (
|
||||
<div className="rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-950/20 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Radio className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
|
||||
@@ -780,7 +823,10 @@ export function RegistryEntryDetail({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 h-6 text-xs"
|
||||
onClick={() => setMonitorConfigOpen(true)}
|
||||
onClick={() => {
|
||||
setMonitorEditMode(false);
|
||||
setMonitorConfigOpen(true);
|
||||
}}
|
||||
>
|
||||
Configureaza monitorizarea
|
||||
</Button>
|
||||
@@ -788,12 +834,11 @@ export function RegistryEntryDetail({
|
||||
</div>
|
||||
|
||||
<StatusMonitorConfig
|
||||
open={monitorConfigOpen}
|
||||
open={monitorConfigOpen && !monitorEditMode}
|
||||
onOpenChange={setMonitorConfigOpen}
|
||||
entry={entry}
|
||||
authority={matchedAuthority}
|
||||
onActivate={async (tracking) => {
|
||||
// Save tracking to entry via API
|
||||
try {
|
||||
await fetch("/api/registratura", {
|
||||
method: "PUT",
|
||||
@@ -892,26 +937,55 @@ const STATUS_COLORS: Record<ExternalDocStatus, string> = {
|
||||
necunoscut: "bg-muted text-muted-foreground",
|
||||
};
|
||||
|
||||
function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
|
||||
function ExternalStatusSection({
|
||||
entry,
|
||||
onEdit,
|
||||
}: {
|
||||
entry: RegistryEntry;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const tracking = entry.externalStatusTracking;
|
||||
if (!tracking) return null;
|
||||
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [checkResult, setCheckResult] = useState<{
|
||||
changed: boolean;
|
||||
error: string | null;
|
||||
newStatus?: string;
|
||||
} | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [liveTracking, setLiveTracking] = useState(tracking);
|
||||
const authority = getAuthority(tracking.authorityId);
|
||||
|
||||
const handleManualCheck = useCallback(async () => {
|
||||
setChecking(true);
|
||||
setCheckResult(null);
|
||||
try {
|
||||
await fetch("/api/registratura/status-check/single", {
|
||||
const res = await fetch("/api/registratura/status-check/single", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ entryId: entry.id }),
|
||||
});
|
||||
// Reload page to show updated status
|
||||
window.location.reload();
|
||||
} catch {
|
||||
// Ignore — user will see if it worked on reload
|
||||
const data = (await res.json()) as {
|
||||
changed: boolean;
|
||||
error: string | null;
|
||||
newStatus?: string;
|
||||
tracking?: typeof tracking;
|
||||
};
|
||||
setCheckResult({
|
||||
changed: data.changed,
|
||||
error: data.error,
|
||||
newStatus: data.newStatus,
|
||||
});
|
||||
if (data.tracking) {
|
||||
setLiveTracking(data.tracking);
|
||||
}
|
||||
} catch (err) {
|
||||
setCheckResult({
|
||||
changed: false,
|
||||
error: err instanceof Error ? err.message : "Eroare conexiune",
|
||||
});
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
@@ -928,80 +1002,154 @@ function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
|
||||
return `acum ${days}z`;
|
||||
};
|
||||
|
||||
const handleToggleActive = useCallback(async () => {
|
||||
setToggling(true);
|
||||
try {
|
||||
const updated = { ...liveTracking, active: !liveTracking.active };
|
||||
await fetch("/api/registratura", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: entry.id,
|
||||
updates: { externalStatusTracking: updated },
|
||||
}),
|
||||
});
|
||||
setLiveTracking(updated);
|
||||
} catch {
|
||||
// Best effort
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
}, [entry.id, liveTracking]);
|
||||
|
||||
const t = liveTracking;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Monitorizare status extern
|
||||
{!t.active && (
|
||||
<span className="ml-1.5 text-[10px] font-normal normal-case">(oprita)</span>
|
||||
)}
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={handleManualCheck}
|
||||
disabled={checking}
|
||||
>
|
||||
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} />
|
||||
{checking ? "Se verifică..." : "Verifică acum"}
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
Modifica
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn("h-6 px-2 text-xs", !t.active && "text-green-600")}
|
||||
onClick={handleToggleActive}
|
||||
disabled={toggling}
|
||||
>
|
||||
{t.active ? (
|
||||
<><BellOff className="h-3 w-3 mr-1" />Opreste</>
|
||||
) : (
|
||||
<><Bell className="h-3 w-3 mr-1" />Reactiveaza</>
|
||||
)}
|
||||
</Button>
|
||||
{t.active && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={handleManualCheck}
|
||||
disabled={checking}
|
||||
>
|
||||
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} />
|
||||
{checking ? "Se verifica..." : "Verifica acum"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline check result */}
|
||||
{checkResult && (
|
||||
<div className={cn(
|
||||
"rounded border px-2.5 py-1.5 text-xs mb-2",
|
||||
checkResult.error
|
||||
? "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400"
|
||||
: checkResult.changed
|
||||
? "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400"
|
||||
: "border-muted bg-muted/30 text-muted-foreground",
|
||||
)}>
|
||||
{checkResult.error
|
||||
? `Eroare: ${checkResult.error}`
|
||||
: checkResult.changed
|
||||
? `Status actualizat: ${EXTERNAL_STATUS_LABELS[checkResult.newStatus as ExternalDocStatus] ?? checkResult.newStatus}`
|
||||
: "Nicio schimbare detectata"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Authority + status badge */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{authority?.name ?? tracking.authorityId}
|
||||
{authority?.name ?? t.authorityId}
|
||||
</span>
|
||||
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[tracking.semanticStatus])}>
|
||||
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[t.semanticStatus])}>
|
||||
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
|
||||
{EXTERNAL_STATUS_LABELS[tracking.semanticStatus]}
|
||||
{EXTERNAL_STATUS_LABELS[t.semanticStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Last check time */}
|
||||
{tracking.lastCheckAt && (
|
||||
{t.lastCheckAt && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Ultima verificare: {relativeTime(tracking.lastCheckAt)}
|
||||
Ultima verificare: {relativeTime(t.lastCheckAt)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{tracking.lastError && (
|
||||
<p className="text-[10px] text-red-500">{tracking.lastError}</p>
|
||||
{t.lastError && (
|
||||
<p className="text-[10px] text-red-500">{t.lastError}</p>
|
||||
)}
|
||||
|
||||
{/* Latest status row */}
|
||||
{tracking.lastStatusRow && (
|
||||
{t.lastStatusRow && (
|
||||
<div className="rounded border bg-muted/30 p-2 text-xs space-y-1">
|
||||
<div className="flex gap-3">
|
||||
<span>
|
||||
<span className="text-muted-foreground">Sursa:</span>{" "}
|
||||
{tracking.lastStatusRow.sursa}
|
||||
{t.lastStatusRow.sursa}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">→</span>{" "}
|
||||
{tracking.lastStatusRow.destinatie}
|
||||
{t.lastStatusRow.destinatie}
|
||||
</span>
|
||||
</div>
|
||||
{tracking.lastStatusRow.modRezolvare && (
|
||||
{t.lastStatusRow.modRezolvare && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Rezolvare:</span>{" "}
|
||||
<span className="font-medium">{tracking.lastStatusRow.modRezolvare}</span>
|
||||
<span className="font-medium">{t.lastStatusRow.modRezolvare}</span>
|
||||
</div>
|
||||
)}
|
||||
{tracking.lastStatusRow.comentarii && (
|
||||
{t.lastStatusRow.comentarii && (
|
||||
<div className="text-muted-foreground">
|
||||
{tracking.lastStatusRow.comentarii}
|
||||
{t.lastStatusRow.comentarii}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-muted-foreground">
|
||||
{tracking.lastStatusRow.dataVenire} {tracking.lastStatusRow.oraVenire}
|
||||
{t.lastStatusRow.dataVenire} {t.lastStatusRow.oraVenire}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tracking config info */}
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Nr: {t.regNumber} | Data: {t.regDate} | Deponent: {t.petitionerName}
|
||||
</div>
|
||||
|
||||
{/* History toggle */}
|
||||
{tracking.history.length > 0 && (
|
||||
{t.history.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -1012,12 +1160,12 @@ function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
Istoric ({tracking.history.length} schimbări)
|
||||
Istoric ({t.history.length} schimbari)
|
||||
</button>
|
||||
|
||||
{showHistory && (
|
||||
<div className="mt-1 space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{[...tracking.history].reverse().map((change, i) => (
|
||||
{[...t.history].reverse().map((change, i) => (
|
||||
<div
|
||||
key={`${change.timestamp}-${i}`}
|
||||
className="rounded border bg-muted/20 p-1.5 text-[10px]"
|
||||
|
||||
@@ -29,6 +29,8 @@ interface StatusMonitorConfigProps {
|
||||
entry: RegistryEntry;
|
||||
authority: AuthorityConfig;
|
||||
onActivate: (tracking: ExternalStatusTracking) => void;
|
||||
/** When true, pre-fills from existing tracking data for editing */
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
export function StatusMonitorConfig({
|
||||
@@ -37,30 +39,35 @@ export function StatusMonitorConfig({
|
||||
entry,
|
||||
authority,
|
||||
onActivate,
|
||||
editMode,
|
||||
}: StatusMonitorConfigProps) {
|
||||
const existing = entry.externalStatusTracking;
|
||||
const [petitionerName, setPetitionerName] = useState("");
|
||||
const [regNumber, setRegNumber] = useState(
|
||||
entry.recipientRegNumber ?? "",
|
||||
);
|
||||
const [regDate, setRegDate] = useState("");
|
||||
|
||||
// Convert YYYY-MM-DD to dd.mm.yyyy
|
||||
// Pre-fill: edit mode uses existing tracking, otherwise entry fields
|
||||
useEffect(() => {
|
||||
if (entry.recipientRegDate) {
|
||||
const parts = entry.recipientRegDate.split("-");
|
||||
if (parts.length === 3) {
|
||||
setRegDate(`${parts[2]}.${parts[1]}.${parts[0]}`);
|
||||
if (editMode && existing) {
|
||||
setPetitionerName(existing.petitionerName);
|
||||
setRegNumber(existing.regNumber);
|
||||
setRegDate(existing.regDate);
|
||||
} else {
|
||||
setRegNumber(entry.recipientRegNumber ?? "");
|
||||
if (entry.recipientRegDate) {
|
||||
const parts = entry.recipientRegDate.split("-");
|
||||
if (parts.length === 3) {
|
||||
setRegDate(`${parts[2]}.${parts[1]}.${parts[0]}`);
|
||||
}
|
||||
}
|
||||
const saved = localStorage.getItem(
|
||||
`status-monitor-petitioner:${authority.id}`,
|
||||
);
|
||||
if (saved) setPetitionerName(saved);
|
||||
}
|
||||
}, [entry.recipientRegDate]);
|
||||
|
||||
// Load saved petitioner name from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem(
|
||||
`status-monitor-petitioner:${authority.id}`,
|
||||
);
|
||||
if (saved) setPetitionerName(saved);
|
||||
}, [authority.id]);
|
||||
}, [editMode, existing, entry.recipientRegNumber, entry.recipientRegDate, authority.id]);
|
||||
|
||||
const canActivate =
|
||||
petitionerName.trim().length >= 3 &&
|
||||
@@ -74,19 +81,28 @@ export function StatusMonitorConfig({
|
||||
petitionerName.trim(),
|
||||
);
|
||||
|
||||
const tracking: ExternalStatusTracking = {
|
||||
authorityId: authority.id,
|
||||
petitionerName: petitionerName.trim(),
|
||||
regNumber: regNumber.trim(),
|
||||
regDate: regDate.trim(),
|
||||
lastCheckAt: null,
|
||||
lastStatusRow: null,
|
||||
statusHash: "",
|
||||
semanticStatus: "necunoscut",
|
||||
history: [],
|
||||
active: true,
|
||||
lastError: null,
|
||||
};
|
||||
const tracking: ExternalStatusTracking = editMode && existing
|
||||
? {
|
||||
...existing,
|
||||
petitionerName: petitionerName.trim(),
|
||||
regNumber: regNumber.trim(),
|
||||
regDate: regDate.trim(),
|
||||
active: true,
|
||||
lastError: null,
|
||||
}
|
||||
: {
|
||||
authorityId: authority.id,
|
||||
petitionerName: petitionerName.trim(),
|
||||
regNumber: regNumber.trim(),
|
||||
regDate: regDate.trim(),
|
||||
lastCheckAt: null,
|
||||
lastStatusRow: null,
|
||||
statusHash: "",
|
||||
semanticStatus: "necunoscut",
|
||||
history: [],
|
||||
active: true,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
onActivate(tracking);
|
||||
onOpenChange(false);
|
||||
@@ -98,11 +114,12 @@ export function StatusMonitorConfig({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4" />
|
||||
Monitorizare status extern
|
||||
{editMode ? "Modifica monitorizarea" : "Monitorizare status extern"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{authority.name} suporta verificarea automata a statusului.
|
||||
Configureaza datele de mai jos pentru a activa monitorizarea.
|
||||
{editMode
|
||||
? "Modifica datele de monitorizare. Istoricul se pastreaza."
|
||||
: `${authority.name} suporta verificarea automata a statusului. Configureaza datele de mai jos pentru a activa monitorizarea.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -152,7 +169,7 @@ export function StatusMonitorConfig({
|
||||
Anuleaza
|
||||
</Button>
|
||||
<Button onClick={handleActivate} disabled={!canActivate}>
|
||||
Activeaza monitorizarea
|
||||
{editMode ? "Salveaza" : "Activeaza monitorizarea"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -229,6 +229,14 @@ export async function runStatusCheck(
|
||||
tracking.statusHash = newHash;
|
||||
tracking.semanticStatus = checkResult.newStatus;
|
||||
|
||||
// Auto-deactivate monitoring when resolved or rejected
|
||||
if (
|
||||
checkResult.newStatus === "solutionat" ||
|
||||
checkResult.newStatus === "respins"
|
||||
) {
|
||||
tracking.active = false;
|
||||
}
|
||||
|
||||
// Cap history at 50
|
||||
tracking.history.push(change);
|
||||
if (tracking.history.length > 50) {
|
||||
@@ -436,6 +444,15 @@ export async function checkSingleEntry(
|
||||
tracking.lastStatusRow = result.newRow;
|
||||
tracking.statusHash = newHash;
|
||||
tracking.semanticStatus = result.newStatus;
|
||||
|
||||
// Auto-deactivate monitoring when resolved or rejected
|
||||
if (
|
||||
result.newStatus === "solutionat" ||
|
||||
result.newStatus === "respins"
|
||||
) {
|
||||
tracking.active = false;
|
||||
}
|
||||
|
||||
tracking.history.push(change);
|
||||
if (tracking.history.length > 50) {
|
||||
tracking.history = tracking.history.slice(-50);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { useAuth } from "@/core/auth";
|
||||
import { signIn, signOut } from "next-auth/react";
|
||||
import { ThemeToggle } from "@/shared/components/common/theme-toggle";
|
||||
import { NotificationBell } from "./notification-bell";
|
||||
|
||||
interface HeaderProps {
|
||||
onToggleSidebar?: () => void;
|
||||
@@ -35,6 +36,7 @@ export function Header({ onToggleSidebar }: HeaderProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationBell />
|
||||
<ThemeToggle />
|
||||
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Bell, Check, CheckCheck, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/components/ui/popover";
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
||||
import type { AppNotification } from "@/core/notifications/app-notifications";
|
||||
|
||||
const POLL_INTERVAL = 60_000; // 60s
|
||||
|
||||
function relativeTime(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 1) return "acum";
|
||||
if (mins < 60) return `acum ${mins} min`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `acum ${hours} ore`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return "ieri";
|
||||
return `acum ${days} zile`;
|
||||
}
|
||||
|
||||
export function NotificationBell() {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/notifications/app?limit=1");
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { unreadCount: number };
|
||||
setUnreadCount(data.unreadCount);
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/notifications/app?limit=30");
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as {
|
||||
notifications: AppNotification[];
|
||||
unreadCount: number;
|
||||
};
|
||||
setNotifications(data.notifications);
|
||||
setUnreadCount(data.unreadCount);
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Poll unread count
|
||||
useEffect(() => {
|
||||
fetchUnreadCount();
|
||||
const id = setInterval(fetchUnreadCount, POLL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchUnreadCount]);
|
||||
|
||||
// Fetch full list when popover opens
|
||||
useEffect(() => {
|
||||
if (open) fetchAll();
|
||||
}, [open, fetchAll]);
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
await fetch("/api/notifications/app", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "mark-read", id }),
|
||||
});
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, readAt: new Date().toISOString() } : n)),
|
||||
);
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
await fetch("/api/notifications/app", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "mark-all-read" }),
|
||||
});
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => ({ ...n, readAt: n.readAt ?? new Date().toISOString() })),
|
||||
);
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative">
|
||||
<Bell className="h-4 w-4" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-4 min-w-4 rounded-full bg-destructive text-[10px] font-medium text-white flex items-center justify-center px-1">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<span className="text-sm font-medium">Notificari</span>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||
>
|
||||
<CheckCheck className="h-3 w-3" />
|
||||
Marcheaza toate ca citite
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<ScrollArea className="max-h-80">
|
||||
{loading && notifications.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Se incarca...
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
Nicio notificare
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
onClick={() => !n.readAt && handleMarkRead(n.id)}
|
||||
className={`w-full flex items-start gap-2.5 px-3 py-2.5 text-left border-b border-border/30 last:border-0 hover:bg-muted/50 transition-colors ${
|
||||
!n.readAt ? "bg-primary/5" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{n.type === "sync-error" ? (
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className={`text-sm truncate ${!n.readAt ? "font-medium" : ""}`}>
|
||||
{n.title}
|
||||
</p>
|
||||
{!n.readAt && (
|
||||
<span className="shrink-0 h-2 w-2 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{n.message}</p>
|
||||
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
|
||||
{relativeTime(n.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM nginx:1.27-alpine
|
||||
RUN mkdir -p /var/cache/nginx/tiles
|
||||
COPY nginx/tile-cache.conf /etc/nginx/conf.d/default.conf
|
||||
@@ -0,0 +1,22 @@
|
||||
# Stage 1: build tippecanoe from source
|
||||
FROM alpine:3.20 AS builder
|
||||
RUN apk add --no-cache git g++ make sqlite-dev zlib-dev bash
|
||||
RUN git clone --depth 1 https://github.com/felt/tippecanoe.git /src/tippecanoe
|
||||
WORKDIR /src/tippecanoe
|
||||
RUN make -j$(nproc) && make install
|
||||
|
||||
# Stage 2: runtime with GDAL + tippecanoe + mc
|
||||
FROM ghcr.io/osgeo/gdal:alpine-normal-latest
|
||||
|
||||
COPY --from=builder /usr/local/bin/tippecanoe /usr/local/bin/tippecanoe
|
||||
COPY --from=builder /usr/local/bin/tile-join /usr/local/bin/tile-join
|
||||
|
||||
# Install MinIO client
|
||||
RUN apk add --no-cache curl bash && \
|
||||
curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc && \
|
||||
chmod +x /usr/local/bin/mc
|
||||
|
||||
COPY scripts/rebuild-overview-tiles.sh /opt/rebuild.sh
|
||||
RUN chmod +x /opt/rebuild.sh
|
||||
|
||||
ENTRYPOINT ["/opt/rebuild.sh"]
|
||||
Reference in New Issue
Block a user