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:
+116
-14
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user