ddf27d9b17
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>
737 lines
30 KiB
TypeScript
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>
|
|
);
|
|
}
|