Files
ArchiTools/src/app/(modules)/monitor/page.tsx
T
Claude VM ddf27d9b17 fix(webhook): treat HTTP 409 (rebuild already running) as success, not error
The pmtiles-webhook returns 409 when a rebuild is already in progress.
Previously this was treated as a failure, showing 'Webhook PMTiles
indisponibil' error to the user. Now 409 is handled as a valid state
with appropriate messaging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:29:01 +03:00

737 lines
30 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef } from "react";
type MonitorData = {
timestamp: string;
nginx?: { activeConnections?: number; requests?: number; reading?: number; writing?: number; waiting?: number; error?: string };
martin?: { status?: string; sources?: string[]; sourceCount?: number; error?: string };
pmtiles?: { url?: string; status?: string; size?: string; lastModified?: string; error?: string };
cacheTests?: { tile: string; status: string; cache: string }[];
config?: { martinUrl?: string; pmtilesUrl?: string; n8nWebhook?: string };
};
type EterraSessionStatus = {
connected: boolean;
username?: string;
connectedAt?: string;
activeJobCount: number;
eterraAvailable?: boolean;
eterraMaintenance?: boolean;
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() {
const [data, setData] = useState<MonitorData | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState("");
const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]);
const [counties, setCounties] = useState<string[]>([]);
const [selectedCounty, setSelectedCounty] = useState("");
const [eterraSession, setEterraSession] = useState<EterraSessionStatus>({ connected: false, activeJobCount: 0 });
const [eterraConnecting, setEterraConnecting] = useState(false);
const [showLoginForm, setShowLoginForm] = useState(false);
const [eterraUser, setEterraUser] = useState("");
const [eterraPwd, setEterraPwd] = useState("");
const [gisStats, setGisStats] = useState<GisStats | null>(null);
const rebuildPrevRef = useRef<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/geoportal/monitor");
if (res.ok) setData(await res.json());
} catch { /* noop */ }
setLoading(false);
}, []);
useEffect(() => { refresh(); }, [refresh]);
// Auto-refresh every 30s
useEffect(() => {
const interval = setInterval(refresh, 30_000);
return () => clearInterval(interval);
}, [refresh]);
const addLog = useCallback((type: "info" | "ok" | "error" | "wait", msg: string) => {
setLogs((prev) => [{ time: new Date().toLocaleTimeString("ro-RO"), type, msg }, ...prev.slice(0, 49)]);
}, []);
// Fetch counties for sync selector
useEffect(() => {
fetch("/api/eterra/counties")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: { counties: string[] }) => setCounties(d.counties ?? []))
.catch(() => {});
}, []);
// eTerra session status — poll every 30s
const fetchEterraSession = useCallback(async () => {
try {
const res = await fetch("/api/eterra/session");
if (res.ok) setEterraSession(await res.json() as EterraSessionStatus);
} catch { /* noop */ }
}, []);
useEffect(() => {
void fetchEterraSession();
const interval = setInterval(() => void fetchEterraSession(), 30_000);
return () => clearInterval(interval);
}, [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 () => {
setEterraConnecting(true);
try {
const payload: Record<string, string> = { action: "connect" };
if (eterraUser.trim()) payload.username = eterraUser.trim();
if (eterraPwd.trim()) payload.password = eterraPwd.trim();
const res = await fetch("/api/eterra/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const d = await res.json() as { success?: boolean; error?: string };
if (d.success) {
await fetchEterraSession();
addLog("ok", "eTerra conectat");
setShowLoginForm(false);
setEterraPwd("");
} else {
addLog("error", `eTerra: ${d.error ?? "Eroare conectare"}`);
}
} catch {
addLog("error", "eTerra: eroare retea");
}
setEterraConnecting(false);
};
const handleEterraDisconnect = async () => {
const res = await fetch("/api/eterra/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "disconnect" }),
});
const d = await res.json() as { success?: boolean; error?: string };
if (d.success) {
setEterraSession({ connected: false, activeJobCount: 0 });
addLog("info", "eTerra deconectat");
} else {
addLog("error", `Deconectare: ${d.error ?? "Eroare"}`);
}
};
// Cleanup poll on unmount
useEffect(() => {
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, []);
const triggerRebuild = async () => {
setActionLoading("rebuild");
addLog("info", "Se trimite webhook la N8N...");
try {
const res = await fetch("/api/geoportal/monitor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "rebuild" }),
});
const result = await res.json() as { ok?: boolean; error?: string; alreadyRunning?: boolean; previousPmtiles?: { lastModified: string } };
if (!result.ok) {
addLog("error", result.error ?? "Eroare necunoscuta");
setActionLoading("");
return;
}
addLog("ok", result.alreadyRunning
? "Rebuild deja in curs. Se monitorizeaza..."
: "Webhook trimis. Rebuild pornit...");
rebuildPrevRef.current = result.previousPmtiles?.lastModified ?? null;
// Poll every 15s to check if PMTiles was updated
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const checkRes = await fetch("/api/geoportal/monitor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "check-rebuild", previousLastModified: rebuildPrevRef.current }),
});
const check = await checkRes.json() as { changed?: boolean; current?: { size: string; lastModified: string } };
if (check.changed) {
addLog("ok", `Rebuild finalizat! PMTiles: ${check.current?.size}, actualizat: ${check.current?.lastModified}`);
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setActionLoading("");
refresh();
}
} catch { /* continue polling */ }
}, 15_000);
// Timeout after 90 min (z18 builds can take 45-60 min)
setTimeout(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
addLog("error", "Timeout: rebuild nu s-a finalizat in 90 minute");
setActionLoading("");
}
}, 90 * 60_000);
} catch {
addLog("error", "Nu s-a putut trimite webhook-ul");
setActionLoading("");
}
};
const triggerWarmCache = async () => {
setActionLoading("warm-cache");
addLog("info", "Se incarca tile-uri in cache...");
try {
const res = await fetch("/api/geoportal/monitor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "warm-cache" }),
});
const result = await res.json() as { ok?: boolean; error?: string; total?: number; hits?: number; misses?: number; errors?: number; message?: string };
if (result.ok) {
addLog("ok", result.message ?? "Cache warming finalizat");
} else {
addLog("error", result.error ?? "Eroare");
}
} catch {
addLog("error", "Eroare la warm cache");
}
setActionLoading("");
setTimeout(refresh, 2000);
};
return (
<div className="mx-auto max-w-5xl p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Tile Infrastructure Monitor</h1>
<p className="text-sm text-muted-foreground">
{data?.timestamp ? `Ultima actualizare: ${new Date(data.timestamp).toLocaleTimeString("ro-RO")}` : "Se incarca..."}
</p>
</div>
<button
onClick={refresh}
disabled={loading}
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "..." : "Reincarca"}
</button>
</div>
{/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Nginx Card */}
<Card title="nginx Tile Cache">
{data?.nginx?.error ? (
<StatusBadge status="error" label={data.nginx.error} />
) : data?.nginx ? (
<div className="space-y-2 text-sm">
<StatusBadge status="ok" label="Online" />
<div className="grid grid-cols-2 gap-1 mt-2">
<Stat label="Conexiuni active" value={data.nginx.activeConnections} />
<Stat label="Total requests" value={data.nginx.requests?.toLocaleString()} />
<Stat label="Reading" value={data.nginx.reading} />
<Stat label="Writing" value={data.nginx.writing} />
<Stat label="Waiting" value={data.nginx.waiting} />
</div>
</div>
) : <Skeleton />}
</Card>
{/* Martin Card */}
<Card title="Martin Tile Server">
{data?.martin?.error ? (
<StatusBadge status="error" label={data.martin.error} />
) : data?.martin ? (
<div className="space-y-2 text-sm">
<StatusBadge status="ok" label={`${data.martin.sourceCount} surse active`} />
<div className="mt-2 space-y-1">
{data.martin.sources?.map((s) => (
<span key={s} className="inline-block mr-1 mb-1 px-2 py-0.5 rounded bg-muted text-xs">{s}</span>
))}
</div>
</div>
) : <Skeleton />}
</Card>
{/* PMTiles Card */}
<Card title="PMTiles Overview">
{data?.pmtiles?.error ? (
<StatusBadge status="error" label={data.pmtiles.error} />
) : data?.pmtiles?.status === "not configured" ? (
<StatusBadge status="warn" label="Nu e configurat" />
) : data?.pmtiles ? (
<div className="space-y-2 text-sm">
<StatusBadge status="ok" label={data.pmtiles.size ?? "OK"} />
<Stat label="Ultima modificare" value={data.pmtiles.lastModified} />
</div>
) : <Skeleton />}
</Card>
</div>
{/* Cache Test Results */}
<Card title="Cache Test">
{data?.cacheTests ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="py-2 pr-4">Tile</th>
<th className="py-2 pr-4">HTTP</th>
<th className="py-2">Cache</th>
</tr>
</thead>
<tbody>
{data.cacheTests.map((t, i) => (
<tr key={i} className="border-b border-border/50">
<td className="py-2 pr-4 font-mono text-xs">{t.tile}</td>
<td className="py-2 pr-4">{t.status}</td>
<td className="py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
t.cache === "HIT" ? "bg-green-500/20 text-green-400" :
t.cache === "MISS" ? "bg-yellow-500/20 text-yellow-400" :
"bg-red-500/20 text-red-400"
}`}>
{t.cache}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : <Skeleton />}
</Card>
{/* eTerra Connection + Live Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Connection card */}
<Card title="Conexiune eTerra">
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${
eterraSession.eterraMaintenance ? "bg-yellow-400 animate-pulse" :
eterraSession.connected ? "bg-green-400" : "bg-red-400"
}`} />
<span className="text-sm font-medium">
{eterraSession.eterraMaintenance ? "Mentenanta" :
eterraSession.connected ? (eterraSession.username || "Conectat") : "Deconectat"}
</span>
</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 ? (
<button
onClick={handleEterraDisconnect}
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
</button>
) : (
<button
onClick={() => setShowLoginForm((v) => !v)}
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"
>
{showLoginForm ? "Anuleaza" : "Conecteaza"}
</button>
)}
</div>
{showLoginForm && !eterraSession.connected && (
<div className="space-y-2 pt-1">
<input
type="text"
value={eterraUser}
onChange={(e) => setEterraUser(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
placeholder="Utilizator eTerra"
/>
<input
type="password"
value={eterraPwd}
onChange={(e) => setEterraPwd(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleEterraConnect(); }}
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
placeholder="Parola"
/>
<button
onClick={handleEterraConnect}
disabled={eterraConnecting || !eterraUser.trim() || !eterraPwd.trim()}
className="w-full h-8 rounded-md bg-primary text-primary-foreground text-xs hover:bg-primary/90 disabled:opacity-50"
>
{eterraConnecting ? "Se conecteaza..." : "Login"}
</button>
</div>
)}
</div>
</Card>
{/* 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
label="Rebuild PMTiles"
description="Regenereaza tile-urile overview din PostGIS (~45-60 min)"
loading={actionLoading === "rebuild"}
onClick={triggerRebuild}
/>
<ActionButton
label="Warm Cache"
description="Pre-incarca tile-uri frecvente in nginx cache"
loading={actionLoading === "warm-cache"}
onClick={triggerWarmCache}
/>
</div>
</div>
{/* Sync actions */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sincronizare eTerra</h3>
<a
href="/sync-management"
className="text-xs text-primary hover:underline"
>
Gestioneaza reguli sync
</a>
</div>
<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
label="Refresh ALL UATs"
description={`Delta sync pe toate ${gisStats?.totalUats ?? "?"} UAT-urile din DB`}
siruta=""
mode="base"
includeNoGeometry={false}
actionKey="refresh-all"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
customEndpoint="/api/eterra/refresh-all"
/>
<SyncTestButton
label="Test Delta — Cluj-Napoca"
description="Parcele + cladiri existente, fara magic (54975)"
siruta="54975"
mode="base"
includeNoGeometry={false}
actionKey="delta-cluj-base"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
/>
<SyncTestButton
label="Test Delta — Feleacu"
description="Magic + no-geom, cu enrichment (57582)"
siruta="57582"
mode="magic"
includeNoGeometry={true}
actionKey="delta-feleacu-magic"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
/>
</div>
</div>
{/* County sync */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Sync pe judet</h3>
<div className="flex items-end gap-3">
<select
value={selectedCounty}
onChange={(e) => setSelectedCounty(e.target.value)}
className="h-9 w-52 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Alege judet...</option>
{counties.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<SyncTestButton
label={selectedCounty ? `Sync ${selectedCounty}` : "Sync Judet"}
description="TERENURI + CLADIRI + INTRAVILAN pentru tot judetul"
siruta=""
mode="base"
includeNoGeometry={false}
actionKey="sync-county"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
customEndpoint="/api/eterra/sync-county"
customBody={{ county: selectedCounty }}
disabled={!selectedCounty}
/>
</div>
</div>
</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) => (
<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={`shrink-0 ${
log.type === "ok" ? "text-green-400" :
log.type === "error" ? "text-red-400" :
log.type === "wait" ? "text-yellow-400" :
"text-blue-400"
}`}>
{log.type === "ok" ? "[OK]" : log.type === "error" ? "[ERR]" : log.type === "wait" ? "[...]" : "[i]"}
</span>
<span>{log.msg}</span>
</div>
))}
</div>
</div>
)}
{/* Config */}
<Card title="Configuratie">
{data?.config ? (
<div className="space-y-1 text-sm font-mono">
<div><span className="text-muted-foreground">MARTIN_URL:</span> {data.config.martinUrl}</div>
<div><span className="text-muted-foreground">PMTILES_URL:</span> {data.config.pmtilesUrl}</div>
<div><span className="text-muted-foreground">N8N_WEBHOOK:</span> {data.config.n8nWebhook}</div>
</div>
) : <Skeleton />}
</Card>
</div>
);
}
/* ---- Sub-components ---- */
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-border bg-card p-4">
<h2 className="text-sm font-semibold text-muted-foreground mb-3">{title}</h2>
{children}
</div>
);
}
function StatusBadge({ status, label }: { status: "ok" | "error" | "warn"; label: string }) {
const colors = {
ok: "bg-green-500/20 text-green-400",
error: "bg-red-500/20 text-red-400",
warn: "bg-yellow-500/20 text-yellow-400",
};
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium ${colors[status]}`}>
<span className={`w-2 h-2 rounded-full ${status === "ok" ? "bg-green-400" : status === "error" ? "bg-red-400" : "bg-yellow-400"}`} />
{label}
</span>
);
}
function Stat({ label, value }: { label: string; value?: string | number | null }) {
return (
<div>
<div className="text-muted-foreground text-xs">{label}</div>
<div className="font-medium">{value ?? "-"}</div>
</div>
);
}
function Skeleton() {
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 }: {
label: string; description: string; loading: boolean; onClick: () => void;
}) {
return (
<button
onClick={onClick}
disabled={loading}
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
>
<span className="font-medium text-sm">{loading ? "Se ruleaza..." : label}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</button>
);
}
function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint, customBody, disabled }: {
label: string; description: string; siruta: string; mode: "base" | "magic";
includeNoGeometry: boolean; actionKey: string; actionLoading: string;
setActionLoading: (v: string) => void;
addLog: (type: "info" | "ok" | "error" | "wait", msg: string) => void;
pollRef: React.MutableRefObject<ReturnType<typeof setInterval> | null>;
customEndpoint?: string;
customBody?: Record<string, unknown>;
disabled?: boolean;
}) {
const startTimeRef = useRef<number>(0);
const formatElapsed = () => {
if (!startTimeRef.current) return "";
const s = Math.round((Date.now() - startTimeRef.current) / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
};
return (
<button
onClick={async () => {
setActionLoading(actionKey);
startTimeRef.current = Date.now();
addLog("info", `[${label}] Pornire...`);
try {
const endpoint = customEndpoint ?? "/api/eterra/sync-background";
const body = customEndpoint ? (customBody ?? {}) : { siruta, mode, includeNoGeometry };
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await res.json() as { jobId?: string; error?: string };
if (!res.ok) {
addLog("error", `[${label}] ${d.error ?? "Eroare start"}`);
setActionLoading(""); return;
}
addLog("ok", `[${label}] Job: ${d.jobId?.slice(0, 8)}`);
const jid = d.jobId;
let lastPhase = "";
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const pr = await fetch(`/api/eterra/progress?jobId=${jid}`);
const pg = await pr.json() as { status?: string; phase?: string; downloaded?: number; total?: number; note?: string; message?: string };
const pct = pg.total ? Math.round(((pg.downloaded ?? 0) / pg.total) * 100) : 0;
const elapsed = formatElapsed();
const phaseChanged = pg.phase !== lastPhase;
if (phaseChanged) lastPhase = pg.phase ?? "";
// Only log phase changes and completion to keep log clean
if (phaseChanged || pg.status === "done" || pg.status === "error") {
const noteStr = pg.note ? `${pg.note}` : "";
addLog(
pg.status === "done" ? "ok" : pg.status === "error" ? "error" : "wait",
`[${label}] ${elapsed} | ${pg.phase ?? "..."} (${pct}%)${noteStr}`,
);
}
if (pg.status === "done" || pg.status === "error") {
const totalTime = formatElapsed();
addLog(pg.status === "done" ? "ok" : "error",
`[${label}] TOTAL: ${totalTime}${pg.message ? " — " + pg.message : ""}`,
);
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setActionLoading("");
}
} catch { /* continue */ }
}, 3000);
setTimeout(() => {
if (pollRef.current) {
clearInterval(pollRef.current); pollRef.current = null;
addLog("error", `[${label}] Timeout 3h (${formatElapsed()})`);
setActionLoading("");
}
}, 3 * 60 * 60_000);
} catch { addLog("error", `[${label}] Eroare retea`); setActionLoading(""); }
}}
disabled={!!actionLoading || !!disabled}
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
>
<span className="font-medium text-sm">{actionLoading === actionKey ? "Se ruleaza..." : label}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</button>
);
}