feat(monitor): add Sync All Romania + live GIS stats
- /api/eterra/stats: lightweight polling endpoint (8 parallel Prisma queries, 30s poll) - /api/eterra/sync-all-counties: iterates all counties in DB sequentially, syncs TERENURI + CLADIRI + INTRAVILAN + enrichment (magic mode) per UAT - Monitor page: live stat cards (UATs, parcels, buildings, DB size), Sync All Romania button with progress tracking at county+UAT level - Concurrency guard: blocks county sync while all-Romania sync runs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+253
-157
@@ -21,6 +21,18 @@ type EterraSessionStatus = {
|
||||
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);
|
||||
@@ -33,6 +45,7 @@ export default function MonitorPage() {
|
||||
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);
|
||||
|
||||
@@ -79,6 +92,20 @@ export default function MonitorPage() {
|
||||
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 {
|
||||
@@ -300,184 +327,241 @@ export default function MonitorPage() {
|
||||
) : <Skeleton />}
|
||||
</Card>
|
||||
|
||||
{/* Actions + Log */}
|
||||
<Card title="Actiuni">
|
||||
{/* eTerra session indicator */}
|
||||
<div className="mb-4 pb-3 border-b border-border/50 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 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 h-2 rounded-full ${eterraSession.connected ? "bg-green-400" : "bg-red-400"}`} />
|
||||
<span className="text-sm">
|
||||
{eterraSession.connected
|
||||
? `eTerra: ${eterraSession.username}`
|
||||
: "eTerra: deconectat"}
|
||||
<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>
|
||||
{eterraSession.connected && eterraSession.activeJobCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">({eterraSession.activeJobCount} job activ)</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>
|
||||
{eterraSession.connected ? (
|
||||
<button
|
||||
onClick={handleEterraDisconnect}
|
||||
className="text-xs px-2 py-1 rounded border border-border hover:bg-destructive/10 hover:text-destructive transition-colors"
|
||||
>
|
||||
Deconecteaza
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowLoginForm((v) => !v)}
|
||||
className="text-xs px-2 py-1 rounded border border-border hover:border-primary/50 hover:bg-primary/10 transition-colors"
|
||||
>
|
||||
Conecteaza
|
||||
</button>
|
||||
)}
|
||||
{eterraSession.eterraMaintenance && (
|
||||
<span className="text-xs text-yellow-400">Mentenanta</span>
|
||||
)}
|
||||
</div>
|
||||
{showLoginForm && !eterraSession.connected && (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">Utilizator eTerra</label>
|
||||
{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-40 rounded-md border border-border bg-background px-2 text-sm"
|
||||
placeholder="user@ancpi"
|
||||
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
|
||||
placeholder="Utilizator eTerra"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-muted-foreground">Parola</label>
|
||||
<input
|
||||
type="password"
|
||||
value={eterraPwd}
|
||||
onChange={(e) => setEterraPwd(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleEterraConnect(); }}
|
||||
className="h-8 w-40 rounded-md border border-border bg-background px-2 text-sm"
|
||||
placeholder="parola"
|
||||
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>
|
||||
<button
|
||||
onClick={handleEterraConnect}
|
||||
disabled={eterraConnecting || !eterraUser.trim() || !eterraPwd.trim()}
|
||||
className="h-8 px-3 rounded-md bg-primary text-primary-foreground text-xs hover:bg-primary/90 disabled:opacity-50"
|
||||
)}
|
||||
</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>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Sincronizare eTerra</h3>
|
||||
<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"
|
||||
>
|
||||
{eterraConnecting ? "..." : "Login"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mb-4">
|
||||
<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}
|
||||
/>
|
||||
<SyncTestButton
|
||||
label="Refresh ALL UATs"
|
||||
description="Delta sync pe toate cele 43 UATs (magic unde e cazul)"
|
||||
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 (baza)"
|
||||
description="Doar sync 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 (magic complet)"
|
||||
description="Magic + no-geom pe Feleacu care are deja enrichment (57582)"
|
||||
siruta="57582"
|
||||
mode="magic"
|
||||
includeNoGeometry={true}
|
||||
actionKey="delta-feleacu-magic"
|
||||
actionLoading={actionLoading}
|
||||
setActionLoading={setActionLoading}
|
||||
addLog={addLog}
|
||||
pollRef={pollRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* County sync */}
|
||||
<div className="flex items-end gap-3 mt-2 pt-3 border-t border-border/50">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">Sync pe judet</span>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
{logs.length > 0 && (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<span className="text-xs font-medium text-muted-foreground">Log activitate</span>
|
||||
<button onClick={() => setLogs([])} className="text-xs text-muted-foreground hover:text-foreground">Sterge</button>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="flex items-start gap-2 px-3 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>
|
||||
))}
|
||||
<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 ? (
|
||||
@@ -530,6 +614,18 @@ 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;
|
||||
}) {
|
||||
|
||||
@@ -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,289 @@
|
||||
/**
|
||||
* 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";
|
||||
|
||||
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
|
||||
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 });
|
||||
await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName });
|
||||
|
||||
// LIMITE_INTRAV_DYNAMIC — best effort
|
||||
try {
|
||||
await syncLayer(username, password, uat.siruta, "LIMITE_INTRAV_DYNAMIC", { uatName });
|
||||
} 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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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}`);
|
||||
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`;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Concurrency guard */
|
||||
const g = globalThis as { __countySyncRunning?: string };
|
||||
const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean };
|
||||
|
||||
export async function POST(req: Request) {
|
||||
let body: { county?: string };
|
||||
@@ -51,6 +51,13 @@ export async function POST(req: Request) {
|
||||
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}` },
|
||||
|
||||
Reference in New Issue
Block a user