From 60919122d98aaab2190eac14304adcac30f08066 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Tue, 24 Mar 2026 12:26:08 +0200 Subject: [PATCH] feat(geoportal): one-click optimize-tiles + unified setup banner New endpoint POST /api/geoportal/optimize-tiles: - Slims gis_features view (drops attributes, enrichment, timestamps) - Cascades to gis_terenuri, gis_cladiri, gis_administrativ, gis_documentatii - Makes vector tiles dramatically smaller Setup banner now checks 3 optimizations: 1. UAT zoom views (gis_uats_z0/z5/z8/z12) 2. Pre-computed geometry (geom_z0/z5/z8 columns) 3. Slim tile views (no JSON columns) One "Aplica toate" button runs all pending steps sequentially. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/geoportal/optimize-tiles/route.ts | 81 ++++++++++++++ .../geoportal/components/setup-banner.tsx | 103 +++++++++--------- 2 files changed, 134 insertions(+), 50 deletions(-) create mode 100644 src/app/api/geoportal/optimize-tiles/route.ts diff --git a/src/app/api/geoportal/optimize-tiles/route.ts b/src/app/api/geoportal/optimize-tiles/route.ts new file mode 100644 index 0000000..c090955 --- /dev/null +++ b/src/app/api/geoportal/optimize-tiles/route.ts @@ -0,0 +1,81 @@ +/** + * POST /api/geoportal/optimize-tiles + * + * Slims down gis_features/terenuri/cladiri/administrativ views + * by removing heavy JSON columns (attributes, enrichment, timestamps). + * Makes Martin vector tiles much smaller and faster. + * + * Safe to re-run (CREATE OR REPLACE VIEW). + */ +import { NextResponse } from "next/server"; +import { prisma } from "@/core/storage/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const STEPS = [ + { + name: "gis_features (slim)", + sql: `CREATE OR REPLACE VIEW gis_features AS + SELECT id, "layerId" AS layer_id, siruta, "objectId" AS object_id, + "cadastralRef" AS cadastral_ref, "areaValue" AS area_value, + "isActive" AS is_active, geom + FROM "GisFeature" WHERE geom IS NOT NULL`, + }, + { + name: "gis_terenuri", + sql: `CREATE OR REPLACE VIEW gis_terenuri AS + SELECT * FROM gis_features + WHERE layer_id LIKE 'TERENURI%' OR layer_id LIKE 'CADGEN_LAND%'`, + }, + { + name: "gis_cladiri", + sql: `CREATE OR REPLACE VIEW gis_cladiri AS + SELECT * FROM gis_features + WHERE layer_id LIKE 'CLADIRI%' OR layer_id LIKE 'CADGEN_BUILDING%'`, + }, + { + name: "gis_administrativ", + sql: `CREATE OR REPLACE VIEW gis_administrativ AS + SELECT * FROM gis_features + WHERE layer_id LIKE 'LIMITE%' OR layer_id LIKE 'SPECIAL_AREAS%'`, + }, + { + name: "gis_documentatii", + sql: `CREATE OR REPLACE VIEW gis_documentatii AS + SELECT * FROM gis_features + WHERE layer_id LIKE 'EXPERTIZA%' OR layer_id LIKE 'ZONE_INTERES%' OR layer_id LIKE 'RECEPTII%'`, + }, +]; + +export async function POST() { + const results: string[] = []; + try { + for (const step of STEPS) { + await prisma.$executeRawUnsafe(step.sql); + results.push(`${step.name} OK`); + } + return NextResponse.json({ status: "ok", results }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare"; + return NextResponse.json({ status: "error", results, error: msg }, { status: 500 }); + } +} + +export async function GET() { + try { + // Check if views are slim (no 'attributes' column) + const cols = await prisma.$queryRaw` + SELECT column_name FROM information_schema.columns + WHERE table_name = 'gis_features' AND table_schema = 'public' + ORDER BY ordinal_position + ` as Array<{ column_name: string }>; + const hasAttributes = cols.some((c) => c.column_name === "attributes"); + return NextResponse.json({ + optimized: !hasAttributes, + columns: cols.map((c) => c.column_name), + }); + } catch { + return NextResponse.json({ optimized: false, columns: [] }); + } +} diff --git a/src/modules/geoportal/components/setup-banner.tsx b/src/modules/geoportal/components/setup-banner.tsx index bd20cf0..d83f403 100644 --- a/src/modules/geoportal/components/setup-banner.tsx +++ b/src/modules/geoportal/components/setup-banner.tsx @@ -4,90 +4,93 @@ import { useEffect, useState } from "react"; import { Database, Loader2, CheckCircle2, AlertTriangle, Zap } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; -type Status = "checking" | "views-needed" | "optimize-needed" | "running" | "done" | "error" | "ready"; +type Step = { label: string; endpoint: string; done: boolean }; export function SetupBanner() { - const [status, setStatus] = useState("checking"); - const [message, setMessage] = useState(""); + const [steps, setSteps] = useState([]); + const [running, setRunning] = useState(""); const [error, setError] = useState(""); + const [doneMsg, setDoneMsg] = useState(""); + const [checking, setChecking] = useState(true); useEffect(() => { (async () => { + const pending: Step[] = []; try { - // Check views const vr = await fetch("/api/geoportal/setup-views").then((r) => r.json()); - if (!vr.ready) { - setStatus("views-needed"); - setMessage(`${vr.missing?.length ?? 0} view-uri PostGIS lipsesc`); - return; - } - // Check optimization - const or2 = await fetch("/api/geoportal/optimize-views").then((r) => r.json()); - if (!or2.optimized) { - setStatus("optimize-needed"); - setMessage("View-urile UAT calculeaza geometria on-the-fly (CPU intensiv). Optimizeaza pentru performanta."); - return; - } - setStatus("ready"); - } catch { - setStatus("ready"); - } + pending.push({ label: "View-uri UAT zoom", endpoint: "/api/geoportal/setup-views", done: vr.ready }); + + const or1 = await fetch("/api/geoportal/optimize-views").then((r) => r.json()); + pending.push({ label: "Geometrie pre-calculata", endpoint: "/api/geoportal/optimize-views", done: or1.optimized }); + + const or2 = await fetch("/api/geoportal/optimize-tiles").then((r) => r.json()); + pending.push({ label: "Tile-uri slim (fara JSON greu)", endpoint: "/api/geoportal/optimize-tiles", done: or2.optimized }); + } catch { /* noop */ } + setSteps(pending); + setChecking(false); })(); }, []); - const runAction = async (endpoint: string) => { - setStatus("running"); - try { - const r = await fetch(endpoint, { method: "POST" }); - const d = await r.json(); - if (d.status === "ok") { - setStatus("done"); - setMessage("Restart Martin pe server: docker restart martin"); - } else { - setStatus("error"); - setError(d.error ?? "Eroare"); + const pendingSteps = steps.filter((s) => !s.done); + if (checking || pendingSteps.length === 0) return null; + + const runAll = async () => { + setError(""); + for (const step of pendingSteps) { + setRunning(step.label); + try { + const r = await fetch(step.endpoint, { method: "POST" }); + const d = await r.json(); + if (d.status !== "ok") { + setError(`${step.label}: ${d.error ?? "Eroare"}`); + setRunning(""); + return; + } + step.done = true; + } catch (e) { + setError(`${step.label}: ${e instanceof Error ? e.message : "Eroare"}`); + setRunning(""); + return; } - } catch (e) { - setStatus("error"); - setError(e instanceof Error ? e.message : "Eroare retea"); } + setRunning(""); + setDoneMsg("Optimizari aplicate. Restart Martin: docker restart martin"); + setSteps([...steps]); }; - if (status === "ready" || status === "checking") return null; - - if (status === "done") { + if (doneMsg) { return (
- {message} + {doneMsg}
); } - if (status === "error") { + if (error) { return (
- {error} + {error} +
); } - const isViews = status === "views-needed"; - return (
- {isViews - ? - : } - {message} + + + {running + ? `Se aplica: ${running}...` + : `${pendingSteps.length} optimizari necesare: ${pendingSteps.map((s) => s.label).join(", ")}`} +
);