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:
Claude VM
2026-04-08 11:42:01 +03:00
parent 7bc9e67e96
commit 34be6c58bc
4 changed files with 636 additions and 158 deletions
+253 -157
View File
@@ -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;
}) {