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,
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<StepName, string> = {
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<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
type UatEntry = { siruta: string; name: string; county?: string };
const [uatData, setUatData] = useState<UatEntry[]>([]);
@@ -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<string, unknown>) => {
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,16 +245,60 @@ export default function WeekendDeepSyncPage() {
23:00-04:00
</p>
</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
variant="ghost"
size="sm"
onClick={() => void fetchState()}
disabled={loading}
>
<RefreshCw className="h-4 w-4 mr-1" />
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
Reincarca
</Button>
</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 */}
<Card>
@@ -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 (
<Card
key={city.siruta}
className={cn(
"transition-colors",
allDone && "border-emerald-200 dark:border-emerald-800",
hasError && "border-rose-200 dark:border-rose-800",
isActive && "border-indigo-300 ring-1 ring-indigo-200 dark:border-indigo-700 dark:ring-indigo-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">
@@ -336,19 +434,23 @@ export default function WeekendDeepSyncPage() {
<div className="flex gap-1.5">
{STEPS.map((step) => {
const status = city.steps[step];
const isRunning = isActive && currentActivity?.step === step;
return (
<div
key={step}
className={cn(
"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",
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",
status === "pending" &&
!isRunning && status === "pending" &&
"bg-muted/30 border-muted text-muted-foreground",
)}
>
{isRunning && <Loader2 className="h-3 w-3 inline mr-1 animate-spin" />}
{STEP_LABELS[step]}
</div>
);
@@ -478,7 +580,7 @@ export default function WeekendDeepSyncPage() {
<p>
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.
</p>
<p>
Prioritate: P1 = primele procesate, P2 = urmatoarele, P3 = adaugate
+23
View File
@@ -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,
});
}
@@ -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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
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<void> {
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 */
/* ------------------------------------------------------------------ */