From 4410e968dbe87fe88e3f161375623dbd8119d0c6 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Mon, 30 Mar 2026 01:41:55 +0300 Subject: [PATCH] feat(wds): live status banner, auto-poll, and instant error emails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-poll every 15s when sync is running, 60s when idle - Live status banner: running (with city/step), error list, weekend window waiting, connection error - Highlight active city card and currently-running step with pulse animation - Send immediate error email per failed step (not just at session end) - Expose syncStatus/currentActivity/inWeekendWindow in API response - Stop silently swallowing fetch/action errors — show them in the UI Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/(modules)/wds/page.tsx | 146 +++++++++++++++--- src/app/api/eterra/weekend-sync/route.ts | 23 +++ .../parcel-sync/services/weekend-deep-sync.ts | 94 ++++++++++- 3 files changed, 240 insertions(+), 23 deletions(-) diff --git a/src/app/(modules)/wds/page.tsx b/src/app/(modules)/wds/page.tsx index 49fcf58..8b33469 100644 --- a/src/app/(modules)/wds/page.tsx +++ b/src/app/(modules)/wds/page.tsx @@ -13,6 +13,9 @@ import { Clock, MapPin, Search, + AlertTriangle, + WifiOff, + Activity, } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; @@ -50,6 +53,14 @@ type QueueState = { completedCycles: number; }; +type SyncStatus = "running" | "error" | "waiting" | "idle"; + +type CurrentActivity = { + city: string; + step: string; + startedAt: string; +} | null; + const STEPS: StepName[] = [ "sync_terenuri", "sync_cladiri", @@ -64,6 +75,10 @@ const STEP_LABELS: Record = { enrich: "Enrichment", }; +/** Auto-poll intervals */ +const POLL_ACTIVE_MS = 15_000; // 15s when running +const POLL_IDLE_MS = 60_000; // 60s when idle + /* ------------------------------------------------------------------ */ /* Page */ /* ------------------------------------------------------------------ */ @@ -73,6 +88,13 @@ export default function WeekendDeepSyncPage() { const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); + // Live status + const [syncStatus, setSyncStatus] = useState("idle"); + const [currentActivity, setCurrentActivity] = useState(null); + const [inWeekendWindow, setInWeekendWindow] = useState(false); + const [fetchError, setFetchError] = useState(null); + const [lastRefresh, setLastRefresh] = useState(null); + // UAT autocomplete for adding cities type UatEntry = { siruta: string; name: string; county?: string }; const [uatData, setUatData] = useState([]); @@ -84,17 +106,33 @@ export default function WeekendDeepSyncPage() { const fetchState = useCallback(async () => { try { const res = await fetch("/api/eterra/weekend-sync"); - const data = (await res.json()) as { state: QueueState | null }; + if (!res.ok) { + setFetchError(`Server: ${res.status} ${res.statusText}`); + setLoading(false); + return; + } + const data = (await res.json()) as { + state: QueueState | null; + syncStatus?: SyncStatus; + currentActivity?: CurrentActivity; + inWeekendWindow?: boolean; + }; setState(data.state); - } catch { - /* silent */ + setSyncStatus(data.syncStatus ?? "idle"); + setCurrentActivity(data.currentActivity ?? null); + setInWeekendWindow(data.inWeekendWindow ?? false); + setFetchError(null); + setLastRefresh(new Date()); + } catch (err) { + const msg = err instanceof Error ? err.message : "Conexiune esuata"; + setFetchError(msg); } setLoading(false); }, []); + // Initial load + UAT list useEffect(() => { void fetchState(); - // Load UAT list for autocomplete fetch("/api/eterra/uats") .then((r) => r.json()) .then((data: { uats?: UatEntry[] }) => { @@ -108,6 +146,13 @@ export default function WeekendDeepSyncPage() { }); }, [fetchState]); + // Auto-poll: 15s when running, 60s otherwise + useEffect(() => { + const interval = syncStatus === "running" ? POLL_ACTIVE_MS : POLL_IDLE_MS; + const timer = setInterval(() => void fetchState(), interval); + return () => clearInterval(timer); + }, [fetchState, syncStatus]); + // UAT autocomplete filter const normalizeText = (text: string) => text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim(); @@ -137,14 +182,21 @@ export default function WeekendDeepSyncPage() { const doAction = async (body: Record) => { setActionLoading(true); try { - await fetch("/api/eterra/weekend-sync", { + const res = await fetch("/api/eterra/weekend-sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + setFetchError(data.error ?? `Eroare: ${res.status}`); + } else { + setFetchError(null); + } await fetchState(); - } catch { - /* silent */ + } catch (err) { + const msg = err instanceof Error ? err.message : "Actiune esuata"; + setFetchError(msg); } setActionLoading(false); }; @@ -193,17 +245,61 @@ export default function WeekendDeepSyncPage() { 23:00-04:00

- +
+ {lastRefresh && ( + + {lastRefresh.toLocaleTimeString("ro-RO", { hour: "2-digit", minute: "2-digit", second: "2-digit" })} + + )} + +
+ {/* Connection error banner */} + {fetchError && ( +
+ + {fetchError} +
+ )} + + {/* Live status banner */} + {syncStatus === "running" && ( +
+ + Sincronizarea ruleaza + {currentActivity && ( + + — {currentActivity.city} / {STEP_LABELS[currentActivity.step as StepName] ?? currentActivity.step} + + )} + +
+ )} + {syncStatus === "error" && ( +
+ + Erori in ultimul ciclu + + — {cities.filter((c) => STEPS.some((s) => c.steps[s] === "error")).map((c) => c.name).join(", ")} + +
+ )} + {syncStatus === "waiting" && !fetchError && ( +
+ + Fereastra weekend activa — se asteapta urmatorul slot de procesare +
+ )} + {/* Stats bar */} @@ -250,14 +346,16 @@ export default function WeekendDeepSyncPage() { ).length; const hasError = STEPS.some((s) => city.steps[s] === "error"); const allDone = doneCount === STEPS.length; + const isActive = currentActivity?.city === city.name; return ( @@ -336,19 +434,23 @@ export default function WeekendDeepSyncPage() {
{STEPS.map((step) => { const status = city.steps[step]; + const isRunning = isActive && currentActivity?.step === step; return (
+ {isRunning && } {STEP_LABELS[step]}
); @@ -478,7 +580,7 @@ export default function WeekendDeepSyncPage() {

Sincronizarea ruleaza automat Vineri, Sambata si Duminica noaptea (23:00-04:00). Procesarea e intercalata intre orase si se reia de - unde a ramas. + unde a ramas. Pagina se actualizeaza automat la fiecare {syncStatus === "running" ? "15" : "60"} secunde.

Prioritate: P1 = primele procesate, P2 = urmatoarele, P3 = adaugate diff --git a/src/app/api/eterra/weekend-sync/route.ts b/src/app/api/eterra/weekend-sync/route.ts index 02a8c98..441e74a 100644 --- a/src/app/api/eterra/weekend-sync/route.ts +++ b/src/app/api/eterra/weekend-sync/route.ts @@ -1,8 +1,14 @@ import { NextResponse } from "next/server"; import { PrismaClient, Prisma } from "@prisma/client"; +import { + isWeekendWindow, + getWeekendSyncActivity, +} from "@/modules/parcel-sync/services/weekend-deep-sync"; const prisma = new PrismaClient(); +const g = globalThis as { __parcelSyncRunning?: boolean }; + const KV_NAMESPACE = "parcel-sync-weekend"; const KV_KEY = "queue-state"; @@ -116,8 +122,25 @@ export async function GET() { dbStats: statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 }, })); + // Determine live sync status + const running = !!g.__parcelSyncRunning; + const activity = getWeekendSyncActivity(); + const inWindow = isWeekendWindow(); + const hasErrors = state.cities.some((c) => + (Object.values(c.steps) as StepStatus[]).some((s) => s === "error"), + ); + + type SyncStatus = "running" | "error" | "waiting" | "idle"; + let syncStatus: SyncStatus = "idle"; + if (running) syncStatus = "running"; + else if (hasErrors) syncStatus = "error"; + else if (inWindow) syncStatus = "waiting"; + return NextResponse.json({ state: { ...state, cities: citiesWithStats }, + syncStatus, + currentActivity: activity, + inWeekendWindow: inWindow, }); } diff --git a/src/modules/parcel-sync/services/weekend-deep-sync.ts b/src/modules/parcel-sync/services/weekend-deep-sync.ts index 8a67f99..4746a59 100644 --- a/src/modules/parcel-sync/services/weekend-deep-sync.ts +++ b/src/modules/parcel-sync/services/weekend-deep-sync.ts @@ -22,6 +22,22 @@ import { sendEmail } from "@/core/notifications/email-service"; const prisma = new PrismaClient(); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +/* ------------------------------------------------------------------ */ +/* Live activity tracking (globalThis — same process) */ +/* ------------------------------------------------------------------ */ + +const g = globalThis as { + __weekendSyncActivity?: { + city: string; + step: string; + startedAt: string; + } | null; +}; + +export function getWeekendSyncActivity() { + return g.__weekendSyncActivity ?? null; +} + /* ------------------------------------------------------------------ */ /* City queue configuration */ /* ------------------------------------------------------------------ */ @@ -315,6 +331,7 @@ export async function runWeekendDeepSync(): Promise { // Check time window if (!stillInWindow()) { console.log("[weekend-sync] Fereastra s-a inchis, opresc."); + g.__weekendSyncActivity = null; await saveState(state); await sendStatusEmail(state, log, sessionStart); return; @@ -323,6 +340,7 @@ export async function runWeekendDeepSync(): Promise { // Check eTerra health if (!isEterraAvailable()) { console.log("[weekend-sync] eTerra indisponibil, opresc."); + g.__weekendSyncActivity = null; await saveState(state); await sendStatusEmail(state, log, sessionStart); return; @@ -339,11 +357,19 @@ export async function runWeekendDeepSync(): Promise { // Execute step — fresh client per step (sessions expire after ~10 min) console.log(`[weekend-sync] ${city.name}: ${stepName}...`); + g.__weekendSyncActivity = { + city: city.name, + step: stepName, + startedAt: new Date().toISOString(), + }; try { const client = await EterraClient.create(username, password); const result = await executeStep(city, stepName, client); city.steps[stepName] = result.success ? "done" : "error"; - if (!result.success) city.errorMessage = result.message; + if (!result.success) { + city.errorMessage = result.message; + await sendStepErrorEmail(city, stepName, result.message); + } city.lastActivity = new Date().toISOString(); log.push({ city: city.name, @@ -368,7 +394,9 @@ export async function runWeekendDeepSync(): Promise { console.error( `[weekend-sync] ${city.name}: ${stepName} EROARE: ${msg}`, ); + await sendStepErrorEmail(city, stepName, msg); } + g.__weekendSyncActivity = null; stepsCompleted++; // Save state after each step (crash safety) @@ -400,6 +428,70 @@ export async function runWeekendDeepSync(): Promise { console.log(`[weekend-sync] Sesiune finalizata. ${stepsCompleted} pasi executati.`); } +/* ------------------------------------------------------------------ */ +/* Immediate error email */ +/* ------------------------------------------------------------------ */ + +async function sendStepErrorEmail( + city: CityState, + step: StepName, + errorMsg: string, +): Promise { + const emailTo = process.env.WEEKEND_SYNC_EMAIL; + if (!emailTo) return; + + try { + const now = new Date(); + const timeStr = now.toLocaleString("ro-RO", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + const stepLabel: Record = { + sync_terenuri: "Sync Terenuri", + sync_cladiri: "Sync Cladiri", + import_nogeom: "Import No-Geom", + enrich: "Enrichment", + }; + + const html = ` +

+

Weekend Sync — Eroare

+

${timeStr}

+ + + + + + + + + + + + + +
Oras${city.name} (${city.county})
Pas${stepLabel[step]}
Eroare${errorMsg}
+

+ Generat automat de ArchiTools Weekend Sync +

+
+ `; + + await sendEmail({ + to: emailTo, + subject: `[ArchiTools] WDS Eroare: ${city.name} — ${stepLabel[step]}`, + html, + }); + console.log(`[weekend-sync] Email eroare trimis: ${city.name}/${step}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[weekend-sync] Nu s-a putut trimite email eroare: ${msg}`); + } +} + /* ------------------------------------------------------------------ */ /* Email status report */ /* ------------------------------------------------------------------ */