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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
|
||||||
type MonitorData = {
|
type MonitorData = {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -14,8 +14,10 @@ type MonitorData = {
|
|||||||
export default function MonitorPage() {
|
export default function MonitorPage() {
|
||||||
const [data, setData] = useState<MonitorData | null>(null);
|
const [data, setData] = useState<MonitorData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionMsg, setActionMsg] = useState("");
|
|
||||||
const [actionLoading, setActionLoading] = useState("");
|
const [actionLoading, setActionLoading] = useState("");
|
||||||
|
const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]);
|
||||||
|
const rebuildPrevRef = useRef<string | null>(null);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -34,19 +36,82 @@ export default function MonitorPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const triggerAction = async (action: string) => {
|
const addLog = useCallback((type: "info" | "ok" | "error" | "wait", msg: string) => {
|
||||||
setActionLoading(action);
|
setLogs((prev) => [{ time: new Date().toLocaleTimeString("ro-RO"), type, msg }, ...prev.slice(0, 49)]);
|
||||||
setActionMsg("");
|
}, []);
|
||||||
|
|
||||||
|
// 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 {
|
try {
|
||||||
const res = await fetch("/api/geoportal/monitor", {
|
const res = await fetch("/api/geoportal/monitor", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ action }),
|
body: JSON.stringify({ action: "rebuild" }),
|
||||||
});
|
});
|
||||||
const result = await res.json() as { message?: string; error?: string };
|
const result = await res.json() as { ok?: boolean; error?: string; previousPmtiles?: { lastModified: string }; webhookStatus?: number };
|
||||||
setActionMsg(result.message ?? result.error ?? "Done");
|
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 {
|
} 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("");
|
setActionLoading("");
|
||||||
setTimeout(refresh, 2000);
|
setTimeout(refresh, 2000);
|
||||||
@@ -155,24 +220,45 @@ export default function MonitorPage() {
|
|||||||
) : <Skeleton />}
|
) : <Skeleton />}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions + Log */}
|
||||||
<Card title="Actiuni">
|
<Card title="Actiuni">
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3 mb-4">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Rebuild PMTiles"
|
label="Rebuild PMTiles"
|
||||||
description="Regenereaza tile-urile overview din PostGIS"
|
description="Regenereaza tile-urile overview din PostGIS (~8 min)"
|
||||||
loading={actionLoading === "rebuild"}
|
loading={actionLoading === "rebuild"}
|
||||||
onClick={() => triggerAction("rebuild")}
|
onClick={triggerRebuild}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
label="Warm Cache"
|
label="Warm Cache"
|
||||||
description="Pre-incarca tile-uri frecvente in nginx cache"
|
description="Pre-incarca tile-uri frecvente in nginx cache"
|
||||||
loading={actionLoading === "warm-cache"}
|
loading={actionLoading === "warm-cache"}
|
||||||
onClick={() => triggerAction("warm-cache")}
|
onClick={triggerWarmCache}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{actionMsg && (
|
{logs.length > 0 && (
|
||||||
<p className="mt-3 text-sm px-3 py-2 rounded bg-muted">{actionMsg}</p>
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Log activitate</span>
|
||||||
|
<button onClick={() => setLogs([])} className="text-xs text-muted-foreground hover:text-foreground">Sterge</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{logs.map((log, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-2 px-3 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>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,21 @@ export async function GET() {
|
|||||||
return NextResponse.json(result);
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
const body = await request.json() as { action?: string };
|
const body = await request.json() as { action?: string };
|
||||||
const action = body.action;
|
const action = body.action;
|
||||||
@@ -143,8 +158,10 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!N8N_WEBHOOK_URL) {
|
if (!N8N_WEBHOOK_URL) {
|
||||||
return NextResponse.json({ error: "N8N_WEBHOOK_URL not configured" }, { status: 400 });
|
return NextResponse.json({ error: "N8N_WEBHOOK_URL not configured" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
// Get current PMTiles state before rebuild
|
||||||
|
const before = await getPmtilesInfo();
|
||||||
try {
|
try {
|
||||||
await fetch(N8N_WEBHOOK_URL, {
|
const webhookRes = await fetch(N8N_WEBHOOK_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -152,32 +169,68 @@ export async function POST(request: NextRequest) {
|
|||||||
timestamp: new Date().toISOString(),
|
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) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(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") {
|
if (action === "warm-cache") {
|
||||||
// Warm cache from within the container (tile-cache is on same Docker network)
|
|
||||||
const sources = ["gis_terenuri", "gis_cladiri"];
|
const sources = ["gis_terenuri", "gis_cladiri"];
|
||||||
let warmed = 0;
|
let total = 0;
|
||||||
|
let hits = 0;
|
||||||
|
let misses = 0;
|
||||||
|
let errors = 0;
|
||||||
const promises: Promise<void>[] = [];
|
const promises: Promise<void>[] = [];
|
||||||
for (const source of sources) {
|
for (const source of sources) {
|
||||||
// Bucharest area z14
|
|
||||||
for (let x = 9200; x <= 9210; x++) {
|
for (let x = 9200; x <= 9210; x++) {
|
||||||
for (let y = 5960; y <= 5970; y++) {
|
for (let y = 5960; y <= 5970; y++) {
|
||||||
|
total++;
|
||||||
promises.push(
|
promises.push(
|
||||||
fetchWithTimeout(`${TILE_CACHE_INTERNAL}/${source}/14/${x}/${y}`, 30000)
|
fetchWithTimeout(`${TILE_CACHE_INTERNAL}/${source}/14/${x}/${y}`, 30000)
|
||||||
.then(() => { warmed++; })
|
.then((res) => {
|
||||||
.catch(() => { /* noop */ }),
|
const cache = res.headers.get("x-cache-status") ?? "";
|
||||||
|
if (cache === "HIT") hits++;
|
||||||
|
else misses++;
|
||||||
|
})
|
||||||
|
.catch(() => { errors++; }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await Promise.all(promises);
|
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 });
|
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||||
|
|||||||
Reference in New Issue
Block a user