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) <noreply@anthropic.com>
This commit is contained in:
@@ -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: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,90 +4,93 @@ import { useEffect, useState } from "react";
|
|||||||
import { Database, Loader2, CheckCircle2, AlertTriangle, Zap } from "lucide-react";
|
import { Database, Loader2, CheckCircle2, AlertTriangle, Zap } from "lucide-react";
|
||||||
import { Button } from "@/shared/components/ui/button";
|
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() {
|
export function SetupBanner() {
|
||||||
const [status, setStatus] = useState<Status>("checking");
|
const [steps, setSteps] = useState<Step[]>([]);
|
||||||
const [message, setMessage] = useState("");
|
const [running, setRunning] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [doneMsg, setDoneMsg] = useState("");
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const pending: Step[] = [];
|
||||||
try {
|
try {
|
||||||
// Check views
|
|
||||||
const vr = await fetch("/api/geoportal/setup-views").then((r) => r.json());
|
const vr = await fetch("/api/geoportal/setup-views").then((r) => r.json());
|
||||||
if (!vr.ready) {
|
pending.push({ label: "View-uri UAT zoom", endpoint: "/api/geoportal/setup-views", done: vr.ready });
|
||||||
setStatus("views-needed");
|
|
||||||
setMessage(`${vr.missing?.length ?? 0} view-uri PostGIS lipsesc`);
|
const or1 = await fetch("/api/geoportal/optimize-views").then((r) => r.json());
|
||||||
return;
|
pending.push({ label: "Geometrie pre-calculata", endpoint: "/api/geoportal/optimize-views", done: or1.optimized });
|
||||||
}
|
|
||||||
// Check optimization
|
const or2 = await fetch("/api/geoportal/optimize-tiles").then((r) => r.json());
|
||||||
const or2 = await fetch("/api/geoportal/optimize-views").then((r) => r.json());
|
pending.push({ label: "Tile-uri slim (fara JSON greu)", endpoint: "/api/geoportal/optimize-tiles", done: or2.optimized });
|
||||||
if (!or2.optimized) {
|
} catch { /* noop */ }
|
||||||
setStatus("optimize-needed");
|
setSteps(pending);
|
||||||
setMessage("View-urile UAT calculeaza geometria on-the-fly (CPU intensiv). Optimizeaza pentru performanta.");
|
setChecking(false);
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStatus("ready");
|
|
||||||
} catch {
|
|
||||||
setStatus("ready");
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const runAction = async (endpoint: string) => {
|
const pendingSteps = steps.filter((s) => !s.done);
|
||||||
setStatus("running");
|
if (checking || pendingSteps.length === 0) return null;
|
||||||
|
|
||||||
|
const runAll = async () => {
|
||||||
|
setError("");
|
||||||
|
for (const step of pendingSteps) {
|
||||||
|
setRunning(step.label);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(endpoint, { method: "POST" });
|
const r = await fetch(step.endpoint, { method: "POST" });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (d.status === "ok") {
|
if (d.status !== "ok") {
|
||||||
setStatus("done");
|
setError(`${step.label}: ${d.error ?? "Eroare"}`);
|
||||||
setMessage("Restart Martin pe server: docker restart martin");
|
setRunning("");
|
||||||
} else {
|
return;
|
||||||
setStatus("error");
|
|
||||||
setError(d.error ?? "Eroare");
|
|
||||||
}
|
}
|
||||||
|
step.done = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus("error");
|
setError(`${step.label}: ${e instanceof Error ? e.message : "Eroare"}`);
|
||||||
setError(e instanceof Error ? e.message : "Eroare retea");
|
setRunning("");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
setRunning("");
|
||||||
|
setDoneMsg("Optimizari aplicate. Restart Martin: docker restart martin");
|
||||||
|
setSteps([...steps]);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status === "ready" || status === "checking") return null;
|
if (doneMsg) {
|
||||||
|
|
||||||
if (status === "done") {
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-green-700 dark:text-green-400">
|
<div className="bg-green-500/10 border border-green-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-green-700 dark:text-green-400">
|
||||||
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
<CheckCircle2 className="h-4 w-4 shrink-0" />
|
||||||
<span>{message}</span>
|
<span>{doneMsg}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "error") {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-red-700 dark:text-red-400">
|
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs text-red-700 dark:text-red-400">
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
{error}
|
<span>{error}</span>
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-xs ml-auto" onClick={runAll}>Reincearca</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isViews = status === "views-needed";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs">
|
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg px-3 py-2 flex items-center gap-2 text-xs">
|
||||||
{isViews
|
<Zap className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
||||||
? <Database className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" />
|
<span className="text-amber-700 dark:text-amber-300 flex-1">
|
||||||
: <Zap className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" />}
|
{running
|
||||||
<span className="text-amber-700 dark:text-amber-300 flex-1">{message}</span>
|
? `Se aplica: ${running}...`
|
||||||
|
: `${pendingSteps.length} optimizari necesare: ${pendingSteps.map((s) => s.label).join(", ")}`}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline" size="sm" className="h-6 text-xs shrink-0"
|
variant="outline" size="sm" className="h-6 text-xs shrink-0"
|
||||||
disabled={status === "running"}
|
disabled={!!running} onClick={runAll}
|
||||||
onClick={() => runAction(isViews ? "/api/geoportal/setup-views" : "/api/geoportal/optimize-views")}
|
|
||||||
>
|
>
|
||||||
{status === "running" && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
{running ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Database className="h-3 w-3 mr-1" />}
|
||||||
{isViews ? "Creaza view-uri" : "Optimizeaza"}
|
{running ? "Se aplica..." : "Aplica toate"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user