From 73456c1424e2a5b8f0d8fb8e41d9996c872807bf Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sat, 28 Mar 2026 11:59:35 +0200 Subject: [PATCH] feat(monitor): activity log with rebuild polling + warm cache details - Rebuild: shows webhook status, then polls every 15s until PMTiles last-modified changes, then shows success with new size/timestamp - Warm cache: shows HIT/MISS/error breakdown after completion - Activity log panel with timestamps, color-coded status, scrollable - 15-minute timeout on rebuild polling Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/(modules)/monitor/page.tsx | 118 +++++++++++++++++++++---- src/app/api/geoportal/monitor/route.ts | 71 +++++++++++++-- 2 files changed, 164 insertions(+), 25 deletions(-) diff --git a/src/app/(modules)/monitor/page.tsx b/src/app/(modules)/monitor/page.tsx index 0de35be..7fe723c 100644 --- a/src/app/(modules)/monitor/page.tsx +++ b/src/app/(modules)/monitor/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; type MonitorData = { timestamp: string; @@ -14,8 +14,10 @@ type MonitorData = { export default function MonitorPage() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); - const [actionMsg, setActionMsg] = useState(""); const [actionLoading, setActionLoading] = useState(""); + const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]); + const rebuildPrevRef = useRef(null); + const pollRef = useRef | null>(null); const refresh = useCallback(async () => { setLoading(true); @@ -34,19 +36,82 @@ export default function MonitorPage() { return () => clearInterval(interval); }, [refresh]); - const triggerAction = async (action: string) => { - setActionLoading(action); - setActionMsg(""); + const addLog = useCallback((type: "info" | "ok" | "error" | "wait", msg: string) => { + setLogs((prev) => [{ time: new Date().toLocaleTimeString("ro-RO"), type, msg }, ...prev.slice(0, 49)]); + }, []); + + // 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 }), + body: JSON.stringify({ action: "rebuild" }), }); - const result = await res.json() as { message?: string; error?: string }; - setActionMsg(result.message ?? result.error ?? "Done"); + const result = await res.json() as { ok?: boolean; error?: string; previousPmtiles?: { lastModified: string }; webhookStatus?: number }; + if (!result.ok) { + addLog("error", result.error ?? "Eroare necunoscuta"); + setActionLoading(""); + return; + } + addLog("ok", `Webhook trimis (HTTP ${result.webhookStatus}). Rebuild in curs...`); + 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 15 min + setTimeout(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + addLog("error", "Timeout: rebuild nu s-a finalizat in 15 minute"); + setActionLoading(""); + } + }, 15 * 60_000); } catch { - setActionMsg("Eroare la trimitere"); + 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); @@ -155,24 +220,45 @@ export default function MonitorPage() { ) : } - {/* Actions */} + {/* Actions + Log */} -
+
triggerAction("rebuild")} + onClick={triggerRebuild} /> triggerAction("warm-cache")} + onClick={triggerWarmCache} />
- {actionMsg && ( -

{actionMsg}

+ {logs.length > 0 && ( +
+
+ Log activitate + +
+
+ {logs.map((log, i) => ( +
+ {log.time} + + {log.type === "ok" ? "[OK]" : log.type === "error" ? "[ERR]" : log.type === "wait" ? "[...]" : "[i]"} + + {log.msg} +
+ ))} +
+
)} diff --git a/src/app/api/geoportal/monitor/route.ts b/src/app/api/geoportal/monitor/route.ts index 4f786f1..97132b6 100644 --- a/src/app/api/geoportal/monitor/route.ts +++ b/src/app/api/geoportal/monitor/route.ts @@ -135,6 +135,21 @@ export async function GET() { return NextResponse.json(result); } +async function getPmtilesInfo(): Promise<{ size: string; lastModified: string } | null> { + if (!PMTILES_URL) return null; + try { + const res = await fetchWithTimeout(PMTILES_URL, 3000); + return { + size: res.headers.get("content-length") + ? `${(parseInt(res.headers.get("content-length") ?? "0", 10) / 1024 / 1024).toFixed(1)} MB` + : "unknown", + lastModified: res.headers.get("last-modified") ?? "unknown", + }; + } catch { + return null; + } +} + export async function POST(request: NextRequest) { const body = await request.json() as { action?: string }; const action = body.action; @@ -143,8 +158,10 @@ export async function POST(request: NextRequest) { if (!N8N_WEBHOOK_URL) { return NextResponse.json({ error: "N8N_WEBHOOK_URL not configured" }, { status: 400 }); } + // Get current PMTiles state before rebuild + const before = await getPmtilesInfo(); try { - await fetch(N8N_WEBHOOK_URL, { + const webhookRes = await fetch(N8N_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -152,32 +169,68 @@ export async function POST(request: NextRequest) { timestamp: new Date().toISOString(), }), }); - return NextResponse.json({ ok: true, message: "Rebuild triggered via N8N" }); + return NextResponse.json({ + ok: true, + action: "rebuild", + webhookStatus: webhookRes.status, + previousPmtiles: before, + message: `Webhook trimis la N8N (HTTP ${webhookRes.status}). Rebuild-ul ruleaza ~8 min. Urmareste PMTiles last-modified.`, + }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - return NextResponse.json({ error: `Webhook failed: ${msg}` }, { status: 500 }); + return NextResponse.json({ error: `Webhook esuat: ${msg}` }, { status: 500 }); } } + if (action === "check-rebuild") { + // Check if PMTiles was updated since a given timestamp + const previousLastModified = (body as { previousLastModified?: string }).previousLastModified; + const current = await getPmtilesInfo(); + const changed = !!current && !!previousLastModified && current.lastModified !== previousLastModified; + return NextResponse.json({ + ok: true, + action: "check-rebuild", + current, + changed, + message: changed + ? `Rebuild finalizat! PMTiles actualizat: ${current?.size}, ${current?.lastModified}` + : "Rebuild in curs...", + }); + } + if (action === "warm-cache") { - // Warm cache from within the container (tile-cache is on same Docker network) const sources = ["gis_terenuri", "gis_cladiri"]; - let warmed = 0; + let total = 0; + let hits = 0; + let misses = 0; + let errors = 0; const promises: Promise[] = []; for (const source of sources) { - // Bucharest area z14 for (let x = 9200; x <= 9210; x++) { for (let y = 5960; y <= 5970; y++) { + total++; promises.push( fetchWithTimeout(`${TILE_CACHE_INTERNAL}/${source}/14/${x}/${y}`, 30000) - .then(() => { warmed++; }) - .catch(() => { /* noop */ }), + .then((res) => { + const cache = res.headers.get("x-cache-status") ?? ""; + if (cache === "HIT") hits++; + else misses++; + }) + .catch(() => { errors++; }), ); } } } await Promise.all(promises); - return NextResponse.json({ ok: true, message: `Cache warmed: ${warmed} tiles` }); + return NextResponse.json({ + ok: true, + action: "warm-cache", + total, + hits, + misses, + errors, + message: `${total} tile-uri procesate: ${hits} HIT, ${misses} MISS (nou incarcate), ${errors} erori`, + }); } return NextResponse.json({ error: "Unknown action" }, { status: 400 });