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
+146 -50
View File
@@ -21,6 +21,18 @@ type EterraSessionStatus = {
eterraHealthMessage?: string; 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() { export default function MonitorPage() {
const [data, setData] = useState<MonitorData | null>(null); const [data, setData] = useState<MonitorData | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -33,6 +45,7 @@ export default function MonitorPage() {
const [showLoginForm, setShowLoginForm] = useState(false); const [showLoginForm, setShowLoginForm] = useState(false);
const [eterraUser, setEterraUser] = useState(""); const [eterraUser, setEterraUser] = useState("");
const [eterraPwd, setEterraPwd] = useState(""); const [eterraPwd, setEterraPwd] = useState("");
const [gisStats, setGisStats] = useState<GisStats | null>(null);
const rebuildPrevRef = useRef<string | null>(null); const rebuildPrevRef = useRef<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -79,6 +92,20 @@ export default function MonitorPage() {
return () => clearInterval(interval); return () => clearInterval(interval);
}, [fetchEterraSession]); }, [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 () => { const handleEterraConnect = async () => {
setEterraConnecting(true); setEterraConnecting(true);
try { try {
@@ -300,76 +327,109 @@ export default function MonitorPage() {
) : <Skeleton />} ) : <Skeleton />}
</Card> </Card>
{/* Actions + Log */} {/* eTerra Connection + Live Stats */}
<Card title="Actiuni"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* eTerra session indicator */} {/* Connection card */}
<div className="mb-4 pb-3 border-b border-border/50 space-y-2"> <Card title="Conexiune eTerra">
<div className="flex items-center gap-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <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={`w-2.5 h-2.5 rounded-full shrink-0 ${
<span className="text-sm"> eterraSession.eterraMaintenance ? "bg-yellow-400 animate-pulse" :
{eterraSession.connected eterraSession.connected ? "bg-green-400" : "bg-red-400"
? `eTerra: ${eterraSession.username}` }`} />
: "eTerra: deconectat"} <span className="text-sm font-medium">
{eterraSession.eterraMaintenance ? "Mentenanta" :
eterraSession.connected ? (eterraSession.username || "Conectat") : "Deconectat"}
</span> </span>
{eterraSession.connected && eterraSession.activeJobCount > 0 && (
<span className="text-xs text-muted-foreground">({eterraSession.activeJobCount} job activ)</span>
)}
</div> </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 ? ( {eterraSession.connected ? (
<button <button
onClick={handleEterraDisconnect} onClick={handleEterraDisconnect}
className="text-xs px-2 py-1 rounded border border-border hover:bg-destructive/10 hover:text-destructive transition-colors" 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 Deconecteaza
</button> </button>
) : ( ) : (
<button <button
onClick={() => setShowLoginForm((v) => !v)} 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" 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"
> >
Conecteaza {showLoginForm ? "Anuleaza" : "Conecteaza"}
</button> </button>
)} )}
{eterraSession.eterraMaintenance && (
<span className="text-xs text-yellow-400">Mentenanta</span>
)}
</div> </div>
{showLoginForm && !eterraSession.connected && ( {showLoginForm && !eterraSession.connected && (
<div className="flex items-end gap-2"> <div className="space-y-2 pt-1">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Utilizator eTerra</label>
<input <input
type="text" type="text"
value={eterraUser} value={eterraUser}
onChange={(e) => setEterraUser(e.target.value)} onChange={(e) => setEterraUser(e.target.value)}
className="h-8 w-40 rounded-md border border-border bg-background px-2 text-sm" className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
placeholder="user@ancpi" placeholder="Utilizator eTerra"
/> />
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Parola</label>
<input <input
type="password" type="password"
value={eterraPwd} value={eterraPwd}
onChange={(e) => setEterraPwd(e.target.value)} onChange={(e) => setEterraPwd(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleEterraConnect(); }} onKeyDown={(e) => { if (e.key === "Enter") handleEterraConnect(); }}
className="h-8 w-40 rounded-md border border-border bg-background px-2 text-sm" className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
placeholder="parola" placeholder="Parola"
/> />
</div>
<button <button
onClick={handleEterraConnect} onClick={handleEterraConnect}
disabled={eterraConnecting || !eterraUser.trim() || !eterraPwd.trim()} 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" className="w-full h-8 rounded-md bg-primary text-primary-foreground text-xs hover:bg-primary/90 disabled:opacity-50"
> >
{eterraConnecting ? "..." : "Login"} {eterraConnecting ? "Se conecteaza..." : "Login"}
</button> </button>
</div> </div>
)} )}
</div> </div>
</Card>
<div className="flex flex-wrap gap-3 mb-4"> {/* 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 <ActionButton
label="Rebuild PMTiles" label="Rebuild PMTiles"
description="Regenereaza tile-urile overview din PostGIS (~45-60 min)" description="Regenereaza tile-urile overview din PostGIS (~45-60 min)"
@@ -382,9 +442,29 @@ export default function MonitorPage() {
loading={actionLoading === "warm-cache"} loading={actionLoading === "warm-cache"}
onClick={triggerWarmCache} 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 <SyncTestButton
label="Refresh ALL UATs" label="Refresh ALL UATs"
description="Delta sync pe toate cele 43 UATs (magic unde e cazul)" description={`Delta sync pe toate ${gisStats?.totalUats ?? "?"} UAT-urile din DB`}
siruta="" siruta=""
mode="base" mode="base"
includeNoGeometry={false} includeNoGeometry={false}
@@ -396,8 +476,8 @@ export default function MonitorPage() {
customEndpoint="/api/eterra/refresh-all" customEndpoint="/api/eterra/refresh-all"
/> />
<SyncTestButton <SyncTestButton
label="Test Delta — Cluj-Napoca (baza)" label="Test Delta — Cluj-Napoca"
description="Doar sync parcele+cladiri existente, fara magic (54975)" description="Parcele + cladiri existente, fara magic (54975)"
siruta="54975" siruta="54975"
mode="base" mode="base"
includeNoGeometry={false} includeNoGeometry={false}
@@ -408,8 +488,8 @@ export default function MonitorPage() {
pollRef={pollRef} pollRef={pollRef}
/> />
<SyncTestButton <SyncTestButton
label="Test Delta — Feleacu (magic complet)" label="Test Delta — Feleacu"
description="Magic + no-geom pe Feleacu care are deja enrichment (57582)" description="Magic + no-geom, cu enrichment (57582)"
siruta="57582" siruta="57582"
mode="magic" mode="magic"
includeNoGeometry={true} includeNoGeometry={true}
@@ -420,11 +500,12 @@ export default function MonitorPage() {
pollRef={pollRef} pollRef={pollRef}
/> />
</div> </div>
</div>
{/* County sync */} {/* County sync */}
<div className="flex items-end gap-3 mt-2 pt-3 border-t border-border/50"> <div>
<div className="flex flex-col gap-1"> <h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Sync pe judet</h3>
<span className="text-xs text-muted-foreground">Sync pe judet</span> <div className="flex items-end gap-3">
<select <select
value={selectedCounty} value={selectedCounty}
onChange={(e) => setSelectedCounty(e.target.value)} onChange={(e) => setSelectedCounty(e.target.value)}
@@ -435,7 +516,6 @@ export default function MonitorPage() {
<option key={c} value={c}>{c}</option> <option key={c} value={c}>{c}</option>
))} ))}
</select> </select>
</div>
<SyncTestButton <SyncTestButton
label={selectedCounty ? `Sync ${selectedCounty}` : "Sync Judet"} label={selectedCounty ? `Sync ${selectedCounty}` : "Sync Judet"}
description="TERENURI + CLADIRI + INTRAVILAN pentru tot judetul" description="TERENURI + CLADIRI + INTRAVILAN pentru tot judetul"
@@ -452,15 +532,20 @@ export default function MonitorPage() {
disabled={!selectedCounty} disabled={!selectedCounty}
/> />
</div> </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>
<div className="max-h-48 overflow-y-auto"> </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) => ( {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"> <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="text-muted-foreground shrink-0 font-mono">{log.time}</span>
<span className={`shrink-0 ${ <span className={`shrink-0 ${
log.type === "ok" ? "text-green-400" : log.type === "ok" ? "text-green-400" :
@@ -476,7 +561,6 @@ export default function MonitorPage() {
</div> </div>
</div> </div>
)} )}
</Card>
{/* Config */} {/* Config */}
<Card title="Configuratie"> <Card title="Configuratie">
@@ -530,6 +614,18 @@ function Skeleton() {
return <div className="h-16 rounded bg-muted/50 animate-pulse" />; 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 }: { function ActionButton({ label, description, loading, onClick }: {
label: string; description: string; loading: boolean; onClick: () => void; label: string; description: string; loading: boolean; onClick: () => void;
}) { }) {
+86
View File
@@ -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`;
}
+8 -1
View File
@@ -26,7 +26,7 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
/* Concurrency guard */ /* Concurrency guard */
const g = globalThis as { __countySyncRunning?: string }; const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean };
export async function POST(req: Request) { export async function POST(req: Request) {
let body: { county?: string }; let body: { county?: string };
@@ -51,6 +51,13 @@ export async function POST(req: Request) {
return Response.json({ error: "Judetul lipseste" }, { status: 400 }); 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) { if (g.__countySyncRunning) {
return Response.json( return Response.json(
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` }, { error: `Sync judet deja in curs: ${g.__countySyncRunning}` },