diff --git a/src/app/api/eterra/health/route.ts b/src/app/api/eterra/health/route.ts new file mode 100644 index 0000000..a1900fe --- /dev/null +++ b/src/app/api/eterra/health/route.ts @@ -0,0 +1,27 @@ +/** + * GET /api/eterra/health — eTerra platform availability check. + * + * Returns health status without requiring authentication. + * Triggers a fresh check if cached result is stale. + * + * POST /api/eterra/health — force an immediate fresh check. + */ + +import { NextResponse } from "next/server"; +import { + getEterraHealth, + checkEterraHealthNow, +} from "@/modules/parcel-sync/services/eterra-health"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + const health = getEterraHealth(); + return NextResponse.json(health); +} + +export async function POST() { + const health = await checkEterraHealthNow(); + return NextResponse.json(health); +} diff --git a/src/app/api/eterra/session/route.ts b/src/app/api/eterra/session/route.ts index 156569e..37872d1 100644 --- a/src/app/api/eterra/session/route.ts +++ b/src/app/api/eterra/session/route.ts @@ -7,16 +7,24 @@ import { getSessionCredentials, getSessionStatus, } from "@/modules/parcel-sync/services/session-store"; +import { getEterraHealth } from "@/modules/parcel-sync/services/eterra-health"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** - * GET /api/eterra/session — returns current server-side session status. - * Any client can call this to check if eTerra is connected. + * GET /api/eterra/session — returns current server-side session status + * enriched with eTerra platform health info. */ export async function GET() { - return NextResponse.json(getSessionStatus()); + const status = getSessionStatus(); + const health = getEterraHealth(); + return NextResponse.json({ + ...status, + eterraAvailable: health.available, + eterraMaintenance: health.maintenance, + eterraHealthMessage: health.message, + }); } /** @@ -70,6 +78,19 @@ export async function POST(req: Request) { ); } + // Block login when eTerra is in maintenance + const health = getEterraHealth(); + if (!health.available && health.maintenance) { + return NextResponse.json( + { + error: + "eTerra este în mentenanță — conectarea este dezactivată temporar", + maintenance: true, + }, + { status: 503 }, + ); + } + // Check if already connected with same credentials const existing = getSessionCredentials(); if (existing && existing.username === username) { diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 7673266..cfe1a73 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -75,6 +75,12 @@ type SessionStatus = { connectedAt?: string; activeJobCount: number; activeJobPhase?: string; + /** eTerra platform health */ + eterraAvailable?: boolean; + /** True when eTerra is in maintenance */ + eterraMaintenance?: boolean; + /** Human-readable health message */ + eterraHealthMessage?: string; }; type ExportProgress = { @@ -153,9 +159,11 @@ function ConnectionPill({ "hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", session.connected ? "border-emerald-200 bg-emerald-50/80 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-400" - : connectionError - ? "border-rose-200 bg-rose-50/80 text-rose-600 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400" - : "border-muted-foreground/20 bg-muted/50 text-muted-foreground", + : session.eterraMaintenance + ? "border-amber-200 bg-amber-50/80 text-amber-600 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-400" + : connectionError + ? "border-rose-200 bg-rose-50/80 text-rose-600 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400" + : "border-muted-foreground/20 bg-muted/50 text-muted-foreground", )} > {connecting ? ( @@ -165,6 +173,8 @@ function ConnectionPill({ + ) : session.eterraMaintenance ? ( + ) : connectionError ? ( ) : ( @@ -175,9 +185,11 @@ function ConnectionPill({ ? "Se conectează…" : session.connected ? "eTerra" - : connectionError - ? "Eroare" - : "Deconectat"} + : session.eterraMaintenance + ? "Mentenanță" + : connectionError + ? "Eroare" + : "Deconectat"} @@ -214,25 +226,52 @@ function ConnectionPill({ )} - {/* Info when not connected */} - {!session.connected && !connectionError && ( -
-

Conexiunea se face automat când începi să scrii un UAT.

-

- Credențialele sunt preluate din configurarea serverului. -

+ {/* Maintenance banner */} + {!session.connected && session.eterraMaintenance && ( +
+
+ +
+

+ eTerra este în mentenanță +

+

+ Platforma ANCPI nu este disponibilă momentan. Conectarea va fi + reactivată automat când serviciul revine online. +

+ {session.eterraHealthMessage && ( +

+ {session.eterraHealthMessage} +

+ )} +
+
)} - {/* Error detail */} - {!session.connected && connectionError && ( -
-

- Conexiunea automată a eșuat. Verifică credențialele din - variabilele de mediu (ETERRA_USERNAME / ETERRA_PASSWORD). -

-
- )} + {/* Info when not connected (and not in maintenance) */} + {!session.connected && + !connectionError && + !session.eterraMaintenance && ( +
+

Conexiunea se face automat când începi să scrii un UAT.

+

+ Credențialele sunt preluate din configurarea serverului. +

+
+ )} + + {/* Error detail (only when NOT maintenance — to avoid confusing users) */} + {!session.connected && + connectionError && + !session.eterraMaintenance && ( +
+

+ Conexiunea automată a eșuat. Verifică credențialele din + variabilele de mediu (ETERRA_USERNAME / ETERRA_PASSWORD). +

+
+ )} {/* Connected — active jobs info + disconnect */} {session.connected && ( @@ -444,7 +483,17 @@ export function ParcelSyncModule() { try { const res = await fetch("/api/eterra/session"); const data = (await res.json()) as SessionStatus; - setSession(data); + setSession((prev) => { + // If eTerra was in maintenance but is now back online, reset auto-connect + if ( + prev.eterraMaintenance && + data.eterraAvailable && + !data.eterraMaintenance + ) { + autoConnectAttempted.current = false; + } + return data; + }); if (data.connected) setConnectionError(""); return data; } catch { @@ -539,6 +588,8 @@ export function ParcelSyncModule() { const triggerAutoConnect = useCallback(async () => { if (session.connected || connecting || autoConnectAttempted.current) return; + // Don't attempt login when eTerra is in maintenance + if (session.eterraMaintenance) return; autoConnectAttempted.current = true; setConnecting(true); setConnectionError(""); @@ -551,9 +602,20 @@ export function ParcelSyncModule() { const data = (await res.json()) as { success?: boolean; error?: string; + maintenance?: boolean; }; if (data.success) { await fetchSession(); + } else if (data.maintenance) { + // eTerra is in maintenance — set flag, DON'T show as connection error + setSession((prev) => ({ + ...prev, + eterraMaintenance: true, + eterraAvailable: false, + eterraHealthMessage: data.error ?? "eTerra în mentenanță", + })); + // Allow retry later when maintenance ends + autoConnectAttempted.current = false; } else { setConnectionError(data.error ?? "Eroare conectare"); } @@ -561,7 +623,7 @@ export function ParcelSyncModule() { setConnectionError("Eroare rețea"); } setConnecting(false); - }, [session.connected, connecting, fetchSession]); + }, [session.connected, session.eterraMaintenance, connecting, fetchSession]); /* ════════════════════════════════════════════════════════════ */ /* Disconnect */ diff --git a/src/modules/parcel-sync/services/eterra-health.ts b/src/modules/parcel-sync/services/eterra-health.ts new file mode 100644 index 0000000..4aa3b07 --- /dev/null +++ b/src/modules/parcel-sync/services/eterra-health.ts @@ -0,0 +1,219 @@ +/** + * eTerra Health Check — server-side singleton. + * + * Periodically pings eTerra to detect availability / maintenance. + * The result is cached for `CHECK_INTERVAL_MS` to avoid hammering the server. + * + * When eTerra is down, the session route will block login attempts and + * the UI will show a friendly "mentenanță" message instead of login errors. + */ + +import axios from "axios"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +export type EterraHealthStatus = { + /** Whether eTerra responded within timeout */ + available: boolean; + /** Human message for the UI */ + message: string; + /** ISO timestamp of last successful check */ + lastCheckedAt: string; + /** ISO timestamp of last time eTerra was actually reachable */ + lastAvailableAt: string | null; + /** How many consecutive failures */ + consecutiveFailures: number; + /** Whether we detected a maintenance page (vs generic timeout/error) */ + maintenance: boolean; +}; + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +const BASE_URL = "https://eterra.ancpi.ro/eterra"; + +/** How often to re-check (3 minutes) */ +const CHECK_INTERVAL_MS = 3 * 60 * 1000; + +/** Timeout for the health ping itself (10 seconds) */ +const PING_TIMEOUT_MS = 10_000; + +/** After this many failures, consider it definitely maintenance */ +const MAINTENANCE_THRESHOLD = 2; + +/** + * Keywords in eTerra HTML responses that indicate maintenance. + * Romanian maintenance pages often contain these. + */ +const MAINTENANCE_KEYWORDS = [ + "mentenan", + "maintenance", + "indisponibil", + "temporar", + "lucrări", + "lucrari", + "întrerupere", + "intrerupere", + "revenim", + "service unavailable", + "503", +]; + +/* ------------------------------------------------------------------ */ +/* Global singleton state */ +/* ------------------------------------------------------------------ */ + +type HealthState = { + status: EterraHealthStatus; + lastCheckStarted: number; +}; + +const g = globalThis as { __eterraHealthState?: HealthState }; + +function getState(): HealthState { + if (!g.__eterraHealthState) { + g.__eterraHealthState = { + status: { + available: true, // Optimistic default: assume available until checked + message: "Verificare în curs…", + lastCheckedAt: new Date(0).toISOString(), + lastAvailableAt: null, + consecutiveFailures: 0, + maintenance: false, + }, + lastCheckStarted: 0, + }; + } + return g.__eterraHealthState; +} + +/* ------------------------------------------------------------------ */ +/* Core check */ +/* ------------------------------------------------------------------ */ + +async function performHealthCheck(): Promise { + const state = getState(); + const now = Date.now(); + state.lastCheckStarted = now; + + try { + // A lightweight GET to the eTerra root — we only care about reachability. + // We follow redirects (login page redirect is fine — means eTerra is UP). + const response = await axios.get(BASE_URL, { + timeout: PING_TIMEOUT_MS, + maxRedirects: 5, + validateStatus: () => true, // Accept any HTTP status as "response received" + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + }, + }); + + const status = response.status; + const body = + typeof response.data === "string" ? response.data.toLowerCase() : ""; + + // Check if the page content indicates maintenance + const isMaintenance = + status === 503 || MAINTENANCE_KEYWORDS.some((kw) => body.includes(kw)); + + if (isMaintenance) { + state.status = { + available: false, + message: "eTerra este în mentenanță programată", + lastCheckedAt: new Date().toISOString(), + lastAvailableAt: state.status.lastAvailableAt, + consecutiveFailures: state.status.consecutiveFailures + 1, + maintenance: true, + }; + } else if (status >= 200 && status < 500) { + // 2xx/3xx/4xx = server is responsive (4xx like 401/403 = login required = UP) + state.status = { + available: true, + message: "eTerra disponibil", + lastCheckedAt: new Date().toISOString(), + lastAvailableAt: new Date().toISOString(), + consecutiveFailures: 0, + maintenance: false, + }; + } else { + // 5xx (but not 503 maintenance) — server error + state.status = { + available: false, + message: `eTerra indisponibil (HTTP ${status})`, + lastCheckedAt: new Date().toISOString(), + lastAvailableAt: state.status.lastAvailableAt, + consecutiveFailures: state.status.consecutiveFailures + 1, + maintenance: + state.status.consecutiveFailures + 1 >= MAINTENANCE_THRESHOLD, + }; + } + } catch (error) { + const err = error as { code?: string; message?: string }; + const isTimeout = + err.code === "ECONNABORTED" || + err.code === "ETIMEDOUT" || + err.code === "ECONNREFUSED" || + err.code === "ECONNRESET" || + err.code === "ENOTFOUND"; + + const failures = state.status.consecutiveFailures + 1; + + state.status = { + available: false, + message: isTimeout + ? "eTerra nu răspunde (timeout)" + : `eTerra indisponibil: ${err.message ?? "eroare necunoscută"}`, + lastCheckedAt: new Date().toISOString(), + lastAvailableAt: state.status.lastAvailableAt, + consecutiveFailures: failures, + maintenance: failures >= MAINTENANCE_THRESHOLD, + }; + } + + return state.status; +} + +/* ------------------------------------------------------------------ */ +/* Public API */ +/* ------------------------------------------------------------------ */ + +/** + * Get current eTerra health status. + * Triggers a fresh check if the cached one is stale. + * Returns immediately with the cached value (the check runs in background). + */ +export function getEterraHealth(): EterraHealthStatus { + const state = getState(); + const now = Date.now(); + const age = now - new Date(state.status.lastCheckedAt).getTime(); + + // If stale, trigger a background refresh (non-blocking) + if (age > CHECK_INTERVAL_MS) { + // Prevent parallel checks + if (now - state.lastCheckStarted > PING_TIMEOUT_MS + 2000) { + void performHealthCheck(); + } + } + + return state.status; +} + +/** + * Force an immediate health check (blocking). + * Used when explicitly requested or on first load. + */ +export async function checkEterraHealthNow(): Promise { + return performHealthCheck(); +} + +/** + * Returns true if eTerra is believed to be available for login. + * Quick synchronous check for use in session route guards. + */ +export function isEterraAvailable(): boolean { + return getState().status.available; +} diff --git a/src/modules/parcel-sync/services/session-store.ts b/src/modules/parcel-sync/services/session-store.ts index 0ad4010..9920723 100644 --- a/src/modules/parcel-sync/services/session-store.ts +++ b/src/modules/parcel-sync/services/session-store.ts @@ -32,6 +32,12 @@ export type SessionStatus = { activeJobCount: number; /** First running job's phase (for UI hint) */ activeJobPhase?: string; + /** eTerra platform health */ + eterraAvailable?: boolean; + /** True if eTerra is detected as being in maintenance */ + eterraMaintenance?: boolean; + /** Human-readable health message */ + eterraHealthMessage?: string; }; /* ------------------------------------------------------------------ */