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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<MonitorData | null>(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 (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<Card title="Actiuni">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<ActionButton
|
||||||
|
label="Rebuild PMTiles"
|
||||||
|
description="Regenereaza tile-urile overview din PostGIS"
|
||||||
|
loading={actionLoading === "rebuild"}
|
||||||
|
onClick={() => triggerAction("rebuild")}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="Warm Cache"
|
||||||
|
description="Pre-incarca tile-uri frecvente in nginx cache"
|
||||||
|
loading={actionLoading === "warm-cache"}
|
||||||
|
onClick={() => triggerAction("warm-cache")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{actionMsg && (
|
||||||
|
<p className="mt-3 text-sm px-3 py-2 rounded bg-muted">{actionMsg}</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 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 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Response> {
|
||||||
|
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<string, unknown> = {
|
||||||
|
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<string, unknown> };
|
||||||
|
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<string, string>[] = [];
|
||||||
|
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<void>[] = [];
|
||||||
|
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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user