From 91fb23bc53ba66588761ad32944a85995a7c1830 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sat, 28 Mar 2026 10:14:28 +0200 Subject: [PATCH] feat(geoportal): live tile infrastructure monitor at /monitor Dashboard page showing: - nginx tile-cache status (connections, requests) - Martin tile server sources - PMTiles file info (size, last modified) - Cache HIT/MISS test on sample tiles - Configuration summary Action buttons: - Rebuild PMTiles (triggers N8N webhook) - Warm Cache (fetches common tiles from container) Auto-refreshes every 30 seconds. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/(modules)/monitor/page.tsx | 244 +++++++++++++++++++++++++ src/app/api/geoportal/monitor/route.ts | 184 +++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 src/app/(modules)/monitor/page.tsx create mode 100644 src/app/api/geoportal/monitor/route.ts diff --git a/src/app/(modules)/monitor/page.tsx b/src/app/(modules)/monitor/page.tsx new file mode 100644 index 0000000..0de35be --- /dev/null +++ b/src/app/(modules)/monitor/page.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { useState, useEffect, useCallback } 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 }; +}; + +export default function MonitorPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [actionMsg, setActionMsg] = useState(""); + const [actionLoading, setActionLoading] = useState(""); + + 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 triggerAction = async (action: string) => { + setActionLoading(action); + setActionMsg(""); + try { + const res = await fetch("/api/geoportal/monitor", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + const result = await res.json() as { message?: string; error?: string }; + setActionMsg(result.message ?? result.error ?? "Done"); + } catch { + setActionMsg("Eroare la trimitere"); + } + setActionLoading(""); + setTimeout(refresh, 2000); + }; + + return ( +
+
+
+

Tile Infrastructure Monitor

+

+ {data?.timestamp ? `Ultima actualizare: ${new Date(data.timestamp).toLocaleTimeString("ro-RO")}` : "Se incarca..."} +

+
+ +
+ + {/* Status Cards */} +
+ {/* Nginx Card */} + + {data?.nginx?.error ? ( + + ) : data?.nginx ? ( +
+ +
+ + + + + +
+
+ ) : } +
+ + {/* Martin Card */} + + {data?.martin?.error ? ( + + ) : data?.martin ? ( +
+ +
+ {data.martin.sources?.map((s) => ( + {s} + ))} +
+
+ ) : } +
+ + {/* PMTiles Card */} + + {data?.pmtiles?.error ? ( + + ) : data?.pmtiles?.status === "not configured" ? ( + + ) : data?.pmtiles ? ( +
+ + +
+ ) : } +
+
+ + {/* Cache Test Results */} + + {data?.cacheTests ? ( +
+ + + + + + + + + + {data.cacheTests.map((t, i) => ( + + + + + + ))} + +
TileHTTPCache
{t.tile}{t.status} + + {t.cache} + +
+
+ ) : } +
+ + {/* Actions */} + +
+ triggerAction("rebuild")} + /> + triggerAction("warm-cache")} + /> +
+ {actionMsg && ( +

{actionMsg}

+ )} +
+ + {/* Config */} + + {data?.config ? ( +
+
MARTIN_URL: {data.config.martinUrl}
+
PMTILES_URL: {data.config.pmtilesUrl}
+
N8N_WEBHOOK: {data.config.n8nWebhook}
+
+ ) : } +
+
+ ); +} + +/* ---- Sub-components ---- */ + +function Card({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +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 ( + + + {label} + + ); +} + +function Stat({ label, value }: { label: string; value?: string | number | null }) { + return ( +
+
{label}
+
{value ?? "-"}
+
+ ); +} + +function Skeleton() { + return
; +} + +function ActionButton({ label, description, loading, onClick }: { + label: string; description: string; loading: boolean; onClick: () => void; +}) { + return ( + + ); +} diff --git a/src/app/api/geoportal/monitor/route.ts b/src/app/api/geoportal/monitor/route.ts new file mode 100644 index 0000000..4f786f1 --- /dev/null +++ b/src/app/api/geoportal/monitor/route.ts @@ -0,0 +1,184 @@ +/** + * GET /api/geoportal/monitor — tile infrastructure status + * POST /api/geoportal/monitor — trigger actions (rebuild, warm-cache) + */ +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const TILE_CACHE_INTERNAL = "http://tile-cache:80"; +const MARTIN_INTERNAL = "http://martin:3000"; +const PMTILES_URL = process.env.NEXT_PUBLIC_PMTILES_URL || ""; +const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || ""; + +type NginxStatus = { + activeConnections: number; + accepts: number; + handled: number; + requests: number; + reading: number; + writing: number; + waiting: number; +}; + +function parseNginxStatus(text: string): NginxStatus { + const lines = text.trim().split("\n"); + const active = parseInt(lines[0]?.match(/\d+/)?.[0] ?? "0", 10); + const counts = lines[2]?.trim().split(/\s+/).map(Number) ?? [0, 0, 0]; + const rw = lines[3]?.match(/\d+/g)?.map(Number) ?? [0, 0, 0]; + return { + activeConnections: active, + accepts: counts[0] ?? 0, + handled: counts[1] ?? 0, + requests: counts[2] ?? 0, + reading: rw[0] ?? 0, + writing: rw[1] ?? 0, + waiting: rw[2] ?? 0, + }; +} + +async function fetchWithTimeout(url: string, timeoutMs = 5000): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { signal: controller.signal, cache: "no-store" }); + } finally { + clearTimeout(timer); + } +} + +// Sample tile coordinates for cache testing (Romania, z8) +const SAMPLE_TILES = [ + { z: 8, x: 143, y: 91, source: "gis_uats_z8" }, + { z: 14, x: 9205, y: 5965, source: "gis_terenuri" }, +]; + +export async function GET() { + const result: Record = { + timestamp: new Date().toISOString(), + }; + + // 1. Nginx status + try { + const res = await fetchWithTimeout(`${TILE_CACHE_INTERNAL}/status`); + if (res.ok) { + result.nginx = parseNginxStatus(await res.text()); + } else { + result.nginx = { error: `HTTP ${res.status}` }; + } + } catch { + result.nginx = { error: "tile-cache unreachable" }; + } + + // 2. Martin catalog + try { + const res = await fetchWithTimeout(`${MARTIN_INTERNAL}/catalog`); + if (res.ok) { + const catalog = await res.json() as { tiles?: Record }; + const sources = Object.keys(catalog.tiles ?? {}); + result.martin = { status: "ok", sources, sourceCount: sources.length }; + } else { + result.martin = { error: `HTTP ${res.status}` }; + } + } catch { + result.martin = { error: "martin unreachable" }; + } + + // 3. PMTiles info + if (PMTILES_URL) { + try { + const res = await fetchWithTimeout(PMTILES_URL, 3000); + result.pmtiles = { + url: PMTILES_URL, + status: res.ok ? "ok" : `HTTP ${res.status}`, + 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 { + result.pmtiles = { url: PMTILES_URL, error: "unreachable" }; + } + } else { + result.pmtiles = { status: "not configured" }; + } + + // 4. Cache test — request sample tiles and check X-Cache-Status + const cacheTests: Record[] = []; + for (const tile of SAMPLE_TILES) { + try { + const url = `${TILE_CACHE_INTERNAL}/${tile.source}/${tile.z}/${tile.x}/${tile.y}`; + const res = await fetchWithTimeout(url, 10000); + cacheTests.push({ + tile: `${tile.source}/${tile.z}/${tile.x}/${tile.y}`, + status: `${res.status}`, + cache: res.headers.get("x-cache-status") ?? "unknown", + }); + } catch { + cacheTests.push({ + tile: `${tile.source}/${tile.z}/${tile.x}/${tile.y}`, + status: "error", + cache: "unreachable", + }); + } + } + result.cacheTests = cacheTests; + + // 5. Config summary + result.config = { + martinUrl: process.env.NEXT_PUBLIC_MARTIN_URL ?? "(not set)", + pmtilesUrl: PMTILES_URL || "(not set)", + n8nWebhook: N8N_WEBHOOK_URL ? "configured" : "not set", + }; + + return NextResponse.json(result); +} + +export async function POST(request: NextRequest) { + const body = await request.json() as { action?: string }; + const action = body.action; + + if (action === "rebuild") { + if (!N8N_WEBHOOK_URL) { + return NextResponse.json({ error: "N8N_WEBHOOK_URL not configured" }, { status: 400 }); + } + try { + await fetch(N8N_WEBHOOK_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event: "manual-rebuild", + timestamp: new Date().toISOString(), + }), + }); + return NextResponse.json({ ok: true, message: "Rebuild triggered via N8N" }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: `Webhook failed: ${msg}` }, { status: 500 }); + } + } + + 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; + 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++) { + promises.push( + fetchWithTimeout(`${TILE_CACHE_INTERNAL}/${source}/14/${x}/${y}`, 30000) + .then(() => { warmed++; }) + .catch(() => { /* noop */ }), + ); + } + } + } + await Promise.all(promises); + return NextResponse.json({ ok: true, message: `Cache warmed: ${warmed} tiles` }); + } + + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); +}