feat(parcel-sync): eTerra health check + maintenance detection
- New eterra-health.ts service: pings eTerra periodically (3min), detects maintenance (503, keywords), tracks consecutive failures - New /api/eterra/health endpoint for explicit health queries - Session route blocks login when eTerra is in maintenance (503 response) - GET /api/eterra/session now includes eterraAvailable/eterraMaintenance - ConnectionPill shows amber 'Mentenanță' state with AlertTriangle icon instead of confusing red error when eTerra is down - Auto-connect skips when maintenance detected, retries when back online - 30s session poll auto-detects recovery and re-enables auto-connect
This commit is contained in:
@@ -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<EterraHealthStatus> {
|
||||
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<EterraHealthStatus> {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in New Issue
Block a user