feat(wds): live status banner, auto-poll, and instant error emails

- 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) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-30 01:41:55 +03:00
parent 82a225de67
commit 4410e968db
3 changed files with 240 additions and 23 deletions
+116 -14
View File
@@ -13,6 +13,9 @@ import {
Clock, Clock,
MapPin, MapPin,
Search, Search,
AlertTriangle,
WifiOff,
Activity,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input"; import { Input } from "@/shared/components/ui/input";
@@ -50,6 +53,14 @@ type QueueState = {
completedCycles: number; completedCycles: number;
}; };
type SyncStatus = "running" | "error" | "waiting" | "idle";
type CurrentActivity = {
city: string;
step: string;
startedAt: string;
} | null;
const STEPS: StepName[] = [ const STEPS: StepName[] = [
"sync_terenuri", "sync_terenuri",
"sync_cladiri", "sync_cladiri",
@@ -64,6 +75,10 @@ const STEP_LABELS: Record<StepName, string> = {
enrich: "Enrichment", enrich: "Enrichment",
}; };
/** Auto-poll intervals */
const POLL_ACTIVE_MS = 15_000; // 15s when running
const POLL_IDLE_MS = 60_000; // 60s when idle
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Page */ /* Page */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -73,6 +88,13 @@ export default function WeekendDeepSyncPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
// Live status
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
const [currentActivity, setCurrentActivity] = useState<CurrentActivity>(null);
const [inWeekendWindow, setInWeekendWindow] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
// UAT autocomplete for adding cities // UAT autocomplete for adding cities
type UatEntry = { siruta: string; name: string; county?: string }; type UatEntry = { siruta: string; name: string; county?: string };
const [uatData, setUatData] = useState<UatEntry[]>([]); const [uatData, setUatData] = useState<UatEntry[]>([]);
@@ -84,17 +106,33 @@ export default function WeekendDeepSyncPage() {
const fetchState = useCallback(async () => { const fetchState = useCallback(async () => {
try { try {
const res = await fetch("/api/eterra/weekend-sync"); 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); setState(data.state);
} catch { setSyncStatus(data.syncStatus ?? "idle");
/* silent */ 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); setLoading(false);
}, []); }, []);
// Initial load + UAT list
useEffect(() => { useEffect(() => {
void fetchState(); void fetchState();
// Load UAT list for autocomplete
fetch("/api/eterra/uats") fetch("/api/eterra/uats")
.then((r) => r.json()) .then((r) => r.json())
.then((data: { uats?: UatEntry[] }) => { .then((data: { uats?: UatEntry[] }) => {
@@ -108,6 +146,13 @@ export default function WeekendDeepSyncPage() {
}); });
}, [fetchState]); }, [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 // UAT autocomplete filter
const normalizeText = (text: string) => const normalizeText = (text: string) =>
text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim(); text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
@@ -137,14 +182,21 @@ export default function WeekendDeepSyncPage() {
const doAction = async (body: Record<string, unknown>) => { const doAction = async (body: Record<string, unknown>) => {
setActionLoading(true); setActionLoading(true);
try { try {
await fetch("/api/eterra/weekend-sync", { const res = await fetch("/api/eterra/weekend-sync", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), 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(); await fetchState();
} catch { } catch (err) {
/* silent */ const msg = err instanceof Error ? err.message : "Actiune esuata";
setFetchError(msg);
} }
setActionLoading(false); setActionLoading(false);
}; };
@@ -193,16 +245,60 @@ export default function WeekendDeepSyncPage() {
23:00-04:00 23:00-04:00
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
{lastRefresh && (
<span className="text-[10px] text-muted-foreground">
{lastRefresh.toLocaleTimeString("ro-RO", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</span>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => void fetchState()} onClick={() => void fetchState()}
disabled={loading} disabled={loading}
> >
<RefreshCw className="h-4 w-4 mr-1" /> <RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
Reincarca Reincarca
</Button> </Button>
</div> </div>
</div>
{/* Connection error banner */}
{fetchError && (
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-400">
<WifiOff className="h-4 w-4 shrink-0" />
<span>{fetchError}</span>
</div>
)}
{/* Live status banner */}
{syncStatus === "running" && (
<div className="flex items-center gap-2 rounded-md border border-indigo-200 bg-indigo-50 px-4 py-2.5 text-sm text-indigo-700 dark:border-indigo-800 dark:bg-indigo-950/30 dark:text-indigo-400">
<Activity className="h-4 w-4 shrink-0 animate-pulse" />
<span className="font-medium">Sincronizarea ruleaza</span>
{currentActivity && (
<span>
{currentActivity.city} / {STEP_LABELS[currentActivity.step as StepName] ?? currentActivity.step}
</span>
)}
<Loader2 className="h-3.5 w-3.5 ml-auto animate-spin opacity-50" />
</div>
)}
{syncStatus === "error" && (
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-400">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="font-medium">Erori in ultimul ciclu</span>
<span>
{cities.filter((c) => STEPS.some((s) => c.steps[s] === "error")).map((c) => c.name).join(", ")}
</span>
</div>
)}
{syncStatus === "waiting" && !fetchError && (
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-400">
<Clock className="h-4 w-4 shrink-0" />
<span>Fereastra weekend activa se asteapta urmatorul slot de procesare</span>
</div>
)}
{/* Stats bar */} {/* Stats bar */}
<Card> <Card>
@@ -250,14 +346,16 @@ export default function WeekendDeepSyncPage() {
).length; ).length;
const hasError = STEPS.some((s) => city.steps[s] === "error"); const hasError = STEPS.some((s) => city.steps[s] === "error");
const allDone = doneCount === STEPS.length; const allDone = doneCount === STEPS.length;
const isActive = currentActivity?.city === city.name;
return ( return (
<Card <Card
key={city.siruta} key={city.siruta}
className={cn( className={cn(
"transition-colors", "transition-colors",
allDone && "border-emerald-200 dark:border-emerald-800", isActive && "border-indigo-300 ring-1 ring-indigo-200 dark:border-indigo-700 dark:ring-indigo-800",
hasError && "border-rose-200 dark:border-rose-800", allDone && !isActive && "border-emerald-200 dark:border-emerald-800",
hasError && !isActive && "border-rose-200 dark:border-rose-800",
)} )}
> >
<CardContent className="py-3 px-4 space-y-2"> <CardContent className="py-3 px-4 space-y-2">
@@ -336,19 +434,23 @@ export default function WeekendDeepSyncPage() {
<div className="flex gap-1.5"> <div className="flex gap-1.5">
{STEPS.map((step) => { {STEPS.map((step) => {
const status = city.steps[step]; const status = city.steps[step];
const isRunning = isActive && currentActivity?.step === step;
return ( return (
<div <div
key={step} key={step}
className={cn( className={cn(
"flex-1 rounded-md border px-2 py-1.5 text-center text-[11px] transition-colors", "flex-1 rounded-md border px-2 py-1.5 text-center text-[11px] transition-colors",
status === "done" && isRunning &&
"bg-indigo-50 border-indigo-300 text-indigo-700 dark:bg-indigo-950/30 dark:border-indigo-700 dark:text-indigo-400 animate-pulse",
!isRunning && status === "done" &&
"bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/30 dark:border-emerald-800 dark:text-emerald-400", "bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/30 dark:border-emerald-800 dark:text-emerald-400",
status === "error" && !isRunning && status === "error" &&
"bg-rose-50 border-rose-200 text-rose-700 dark:bg-rose-950/30 dark:border-rose-800 dark:text-rose-400", "bg-rose-50 border-rose-200 text-rose-700 dark:bg-rose-950/30 dark:border-rose-800 dark:text-rose-400",
status === "pending" && !isRunning && status === "pending" &&
"bg-muted/30 border-muted text-muted-foreground", "bg-muted/30 border-muted text-muted-foreground",
)} )}
> >
{isRunning && <Loader2 className="h-3 w-3 inline mr-1 animate-spin" />}
{STEP_LABELS[step]} {STEP_LABELS[step]}
</div> </div>
); );
@@ -478,7 +580,7 @@ export default function WeekendDeepSyncPage() {
<p> <p>
Sincronizarea ruleaza automat Vineri, Sambata si Duminica noaptea Sincronizarea ruleaza automat Vineri, Sambata si Duminica noaptea
(23:00-04:00). Procesarea e intercalata intre orase si se reia de (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.
</p> </p>
<p> <p>
Prioritate: P1 = primele procesate, P2 = urmatoarele, P3 = adaugate Prioritate: P1 = primele procesate, P2 = urmatoarele, P3 = adaugate
+23
View File
@@ -1,8 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { PrismaClient, Prisma } from "@prisma/client"; import { PrismaClient, Prisma } from "@prisma/client";
import {
isWeekendWindow,
getWeekendSyncActivity,
} from "@/modules/parcel-sync/services/weekend-deep-sync";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const g = globalThis as { __parcelSyncRunning?: boolean };
const KV_NAMESPACE = "parcel-sync-weekend"; const KV_NAMESPACE = "parcel-sync-weekend";
const KV_KEY = "queue-state"; 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 }, 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({ return NextResponse.json({
state: { ...state, cities: citiesWithStats }, state: { ...state, cities: citiesWithStats },
syncStatus,
currentActivity: activity,
inWeekendWindow: inWindow,
}); });
} }
@@ -22,6 +22,22 @@ import { sendEmail } from "@/core/notifications/email-service";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); 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 */ /* City queue configuration */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -315,6 +331,7 @@ export async function runWeekendDeepSync(): Promise<void> {
// Check time window // Check time window
if (!stillInWindow()) { if (!stillInWindow()) {
console.log("[weekend-sync] Fereastra s-a inchis, opresc."); console.log("[weekend-sync] Fereastra s-a inchis, opresc.");
g.__weekendSyncActivity = null;
await saveState(state); await saveState(state);
await sendStatusEmail(state, log, sessionStart); await sendStatusEmail(state, log, sessionStart);
return; return;
@@ -323,6 +340,7 @@ export async function runWeekendDeepSync(): Promise<void> {
// Check eTerra health // Check eTerra health
if (!isEterraAvailable()) { if (!isEterraAvailable()) {
console.log("[weekend-sync] eTerra indisponibil, opresc."); console.log("[weekend-sync] eTerra indisponibil, opresc.");
g.__weekendSyncActivity = null;
await saveState(state); await saveState(state);
await sendStatusEmail(state, log, sessionStart); await sendStatusEmail(state, log, sessionStart);
return; return;
@@ -339,11 +357,19 @@ export async function runWeekendDeepSync(): Promise<void> {
// Execute step — fresh client per step (sessions expire after ~10 min) // Execute step — fresh client per step (sessions expire after ~10 min)
console.log(`[weekend-sync] ${city.name}: ${stepName}...`); console.log(`[weekend-sync] ${city.name}: ${stepName}...`);
g.__weekendSyncActivity = {
city: city.name,
step: stepName,
startedAt: new Date().toISOString(),
};
try { try {
const client = await EterraClient.create(username, password); const client = await EterraClient.create(username, password);
const result = await executeStep(city, stepName, client); const result = await executeStep(city, stepName, client);
city.steps[stepName] = result.success ? "done" : "error"; 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(); city.lastActivity = new Date().toISOString();
log.push({ log.push({
city: city.name, city: city.name,
@@ -368,7 +394,9 @@ export async function runWeekendDeepSync(): Promise<void> {
console.error( console.error(
`[weekend-sync] ${city.name}: ${stepName} EROARE: ${msg}`, `[weekend-sync] ${city.name}: ${stepName} EROARE: ${msg}`,
); );
await sendStepErrorEmail(city, stepName, msg);
} }
g.__weekendSyncActivity = null;
stepsCompleted++; stepsCompleted++;
// Save state after each step (crash safety) // Save state after each step (crash safety)
@@ -400,6 +428,70 @@ export async function runWeekendDeepSync(): Promise<void> {
console.log(`[weekend-sync] Sesiune finalizata. ${stepsCompleted} pasi executati.`); console.log(`[weekend-sync] Sesiune finalizata. ${stepsCompleted} pasi executati.`);
} }
/* ------------------------------------------------------------------ */
/* Immediate error email */
/* ------------------------------------------------------------------ */
async function sendStepErrorEmail(
city: CityState,
step: StepName,
errorMsg: string,
): Promise<void> {
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<StepName, string> = {
sync_terenuri: "Sync Terenuri",
sync_cladiri: "Sync Cladiri",
import_nogeom: "Import No-Geom",
enrich: "Enrichment",
};
const html = `
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto">
<h2 style="color:#ef4444;margin-bottom:4px">Weekend Sync — Eroare</h2>
<p style="color:#6b7280;margin-top:0">${timeStr}</p>
<table style="border-collapse:collapse;width:100%;border:1px solid #fecaca;border-radius:6px;background:#fef2f2">
<tr>
<td style="padding:8px 12px;font-weight:600;color:#374151">Oras</td>
<td style="padding:8px 12px">${city.name} (${city.county})</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:600;color:#374151">Pas</td>
<td style="padding:8px 12px">${stepLabel[step]}</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:600;color:#374151">Eroare</td>
<td style="padding:8px 12px;color:#dc2626;word-break:break-word">${errorMsg}</td>
</tr>
</table>
<p style="color:#9ca3af;font-size:11px;margin-top:16px">
Generat automat de ArchiTools Weekend Sync
</p>
</div>
`;
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 */ /* Email status report */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */