mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
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!
88 lines
3.0 KiB
TypeScript
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>
|
|
);
|
|
}
|