Files
ArchiTools/src/app/api/geoportal/monitor/route.ts
T
AI Assistant 91fb23bc53 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>
2026-03-28 10:14:28 +02:00

185 lines
5.7 KiB
TypeScript

/**
* 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 });
}