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;
|
||||
}) {
|
||||
|
||||
Reference in New Issue
Block a user