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