Files
OmniRoute/src/shared/components/MaintenanceBanner.tsx
Hernan Javier Ardila Sanchez fd26e601a2 fix(dashboard): use lightweight ping endpoint for MaintenanceBanner (fixes #3040) (#3043)
Integrated into release/v3.8.8. Applied review fixes: moved the SELECT 1 into a pingDb() db helper (no raw SQL in route, Hard Rule #5) + the 503 catch no longer leaks err.message (Hard Rule #12). Thanks @herjarsa!
2026-06-01 14:30:17 -03:00

88 lines
3.0 KiB
TypeScript

"use client";
/**
* Maintenance Banner — Phase 8.4
*
* Shows a warning banner at the top of the dashboard when the server
* is restarting or in maintenance mode. Auto-dismisses when the server
* comes back online.
*/
import { useState, useEffect } from "react";
import { useRef } from "react";
import { useTranslations } from "next-intl";
export default function MaintenanceBanner() {
const [show, setShow] = useState(false);
const [message, setMessage] = useState("");
const consecutiveFailuresRef = useRef(0);
const dismissedUntilRecoveryRef = useRef(false);
const t = useTranslations("common");
useEffect(() => {
const checkHealth = async () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort("Health check timeout"), 8000);
try {
// Use lightweight liveness probe (single SELECT 1) instead of the
// heavy /api/monitoring/health observability endpoint. The heavy
// endpoint can exceed the 8s client timeout under normal load
// (e.g. Logs page 3s polling), causing false-positive banners.
const res = await fetch("/api/health/ping", {
signal: controller.signal,
cache: "no-store",
});
if (res.ok) {
consecutiveFailuresRef.current = 0;
dismissedUntilRecoveryRef.current = false;
setShow(false);
setMessage("");
} else {
consecutiveFailuresRef.current += 1;
// Require at least 2 failed checks to avoid transient false positives.
if (consecutiveFailuresRef.current >= 2 && !dismissedUntilRecoveryRef.current) {
setShow(true);
setMessage(t("maintenanceServerIssues"));
}
}
} catch {
consecutiveFailuresRef.current += 1;
if (consecutiveFailuresRef.current >= 2 && !dismissedUntilRecoveryRef.current) {
setShow(true);
setMessage(t("maintenanceServerUnreachable"));
}
} finally {
clearTimeout(timeoutId);
}
};
// Run immediately on mount, then every 10 seconds
checkHealth();
const interval = setInterval(checkHealth, 10000);
return () => clearInterval(interval);
}, [t]);
if (!show) return null;
return (
<div className="bg-amber-500/10 border-b border-amber-500/20 px-4 py-2.5 flex items-center justify-between gap-3 animate-in slide-in-from-top">
<div className="flex items-center gap-2.5">
<span className="material-symbols-outlined text-amber-500 text-[18px] animate-pulse">
warning
</span>
<span className="text-sm text-amber-200">{message}</span>
</div>
<button
onClick={() => {
dismissedUntilRecoveryRef.current = true;
setShow(false);
}}
className="p-1 rounded hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
aria-label={t("close")}
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
);
}