mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
feat(pages): Phase 8 — Missing Flows & Pages
8.1 — 403 Forbidden Page - New: src/app/forbidden/page.js - Gradient code + Access Denied message + Dashboard link - Consistent design with not-found.js 8.2 — Password Recovery Flow - New: src/app/forgot-password/page.js - Two methods: CLI reset + manual database reset - Added 'Forgot password?' link to login page 8.3 — Health/Status Dashboard - New: src/app/(dashboard)/dashboard/health/page.js - New: src/app/api/monitoring/health/route.js - Cards: Uptime, Version, Memory, Provider count - Provider health (circuit breaker states with color coding) - Rate limit status table - Active lockouts list - Auto-refresh every 15s - Added Health nav link to Sidebar 8.4 — Maintenance Banner - New: src/shared/components/MaintenanceBanner.js - Auto-detects server health issues every 10s - Shows/hides automatically, dismissible - Wired into DashboardLayout 8.5 — Empty States - Verified EmptyState component applied in 6+ pages - Providers page has its own empty handling Bonus: - Fixed stale GitHub link in Sidebar (decolua → diegosouzapw) Tests: 295 pass | Build: success
This commit is contained in:
302
src/app/(dashboard)/dashboard/health/page.js
Normal file
302
src/app/(dashboard)/dashboard/health/page.js
Normal file
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Health Dashboard — Phase 8.3
|
||||
*
|
||||
* System health overview with cards for:
|
||||
* - System status (uptime, version, memory)
|
||||
* - Provider health (circuit breaker states)
|
||||
* - Rate limit status
|
||||
* - Active lockouts
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const CB_COLORS = {
|
||||
CLOSED: { bg: "bg-green-500/10", text: "text-green-500", label: "Healthy" },
|
||||
OPEN: { bg: "bg-red-500/10", text: "text-red-500", label: "Open" },
|
||||
HALF_OPEN: { bg: "bg-amber-500/10", text: "text-amber-500", label: "Half-Open" },
|
||||
};
|
||||
|
||||
export default function HealthPage() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [lastRefresh, setLastRefresh] = useState(null);
|
||||
|
||||
const fetchHealth = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring/health");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
setError(null);
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHealth();
|
||||
const interval = setInterval(fetchHealth, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchHealth]);
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
<p className="text-text-muted mt-4">Loading health data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-6 text-center">
|
||||
<span className="material-symbols-outlined text-red-500 text-[32px] mb-2">error</span>
|
||||
<p className="text-red-400">Failed to load health data: {error}</p>
|
||||
<button
|
||||
onClick={fetchHealth}
|
||||
className="mt-4 px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { system, providerHealth, rateLimitStatus, lockouts } = data;
|
||||
const cbEntries = Object.entries(providerHealth || {});
|
||||
const lockoutEntries = Object.entries(lockouts || {});
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-main">System Health</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Real-time monitoring of your OmniRoute instance
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{lastRefresh && (
|
||||
<span className="text-xs text-text-muted">
|
||||
Updated {lastRefresh.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={fetchHealth}
|
||||
className="p-2 rounded-lg bg-surface hover:bg-surface/80 text-text-muted hover:text-text-main transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Banner */}
|
||||
<div
|
||||
className={`rounded-xl p-4 flex items-center gap-3 ${
|
||||
data.status === "healthy"
|
||||
? "bg-green-500/10 border border-green-500/20"
|
||||
: "bg-red-500/10 border border-red-500/20"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[24px] ${
|
||||
data.status === "healthy" ? "text-green-500" : "text-red-500"
|
||||
}`}
|
||||
>
|
||||
{data.status === "healthy" ? "check_circle" : "error"}
|
||||
</span>
|
||||
<span className={data.status === "healthy" ? "text-green-400" : "text-red-400"}>
|
||||
{data.status === "healthy" ? "All systems operational" : "System issues detected"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* System Info Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 text-primary">
|
||||
<span className="material-symbols-outlined text-[18px]">timer</span>
|
||||
</div>
|
||||
<span className="text-sm text-text-muted">Uptime</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-text-main">{formatUptime(system.uptime)}</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<span className="material-symbols-outlined text-[18px]">info</span>
|
||||
</div>
|
||||
<span className="text-sm text-text-muted">Version</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-text-main">v{system.version}</p>
|
||||
<p className="text-xs text-text-muted mt-1">Node {system.nodeVersion}</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-purple-500/10 text-purple-500">
|
||||
<span className="material-symbols-outlined text-[18px]">memory</span>
|
||||
</div>
|
||||
<span className="text-sm text-text-muted">Memory (RSS)</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-text-main">
|
||||
{formatBytes(system.memoryUsage?.rss || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Heap: {formatBytes(system.memoryUsage?.heapUsed || 0)} /{" "}
|
||||
{formatBytes(system.memoryUsage?.heapTotal || 0)}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<span className="material-symbols-outlined text-[18px]">dns</span>
|
||||
</div>
|
||||
<span className="text-sm text-text-muted">Providers</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-text-main">{cbEntries.length}</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
{cbEntries.filter(([, v]) => v.state === "CLOSED").length} healthy
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Provider Health */}
|
||||
<Card className="p-5">
|
||||
<h2 className="text-lg font-semibold text-text-main mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-primary">
|
||||
health_and_safety
|
||||
</span>
|
||||
Provider Health
|
||||
</h2>
|
||||
{cbEntries.length === 0 ? (
|
||||
<p className="text-sm text-text-muted text-center py-4">
|
||||
No circuit breaker data available. Make some requests first.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{cbEntries.map(([provider, cb]) => {
|
||||
const style = CB_COLORS[cb.state] || CB_COLORS.CLOSED;
|
||||
return (
|
||||
<div key={provider} className={`rounded-lg p-3 ${style.bg} border border-white/5`}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-text-main">{provider}</span>
|
||||
<span className={`text-xs font-semibold ${style.text}`}>{style.label}</span>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
Failures: {cb.failures || 0}
|
||||
{cb.lastFailure && (
|
||||
<span className="ml-2">
|
||||
Last: {new Date(cb.lastFailure).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Rate Limit Status */}
|
||||
{rateLimitStatus && Object.keys(rateLimitStatus).length > 0 && (
|
||||
<Card className="p-5">
|
||||
<h2 className="text-lg font-semibold text-text-main mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-amber-500">speed</span>
|
||||
Rate Limit Status
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-text-muted text-left border-b border-white/5">
|
||||
<th className="pb-2 font-medium">Provider</th>
|
||||
<th className="pb-2 font-medium">Status</th>
|
||||
<th className="pb-2 font-medium">Requests</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(rateLimitStatus).map(([provider, status]) => (
|
||||
<tr key={provider} className="border-b border-white/5 last:border-0">
|
||||
<td className="py-2 text-text-main">{provider}</td>
|
||||
<td className="py-2">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
status.limited
|
||||
? "bg-red-500/10 text-red-400"
|
||||
: "bg-green-500/10 text-green-400"
|
||||
}`}
|
||||
>
|
||||
{status.limited ? "Limited" : "OK"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-text-muted">
|
||||
{status.requestsInWindow || 0} / {status.limit || "∞"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Active Lockouts */}
|
||||
{lockoutEntries.length > 0 && (
|
||||
<Card className="p-5">
|
||||
<h2 className="text-lg font-semibold text-text-main mb-4 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-red-500">lock</span>
|
||||
Active Lockouts
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{lockoutEntries.map(([key, lockout]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg p-3 bg-red-500/5 border border-red-500/10 flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-text-main">{key}</span>
|
||||
{lockout.reason && (
|
||||
<span className="text-xs text-text-muted ml-2">({lockout.reason})</span>
|
||||
)}
|
||||
</div>
|
||||
{lockout.until && (
|
||||
<span className="text-xs text-red-400">
|
||||
Until {new Date(lockout.until).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/app/api/monitoring/health/route.js
Normal file
58
src/app/api/monitoring/health/route.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import { APP_CONFIG } from "@/shared/constants/config";
|
||||
|
||||
/**
|
||||
* GET /api/monitoring/health — System health overview
|
||||
*
|
||||
* Returns system info, provider health (circuit breakers),
|
||||
* rate limit status, and database stats.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const { getAllCircuitBreakerStatuses } =
|
||||
await import("@/../../src/shared/utils/circuitBreaker.js");
|
||||
const { getAllRateLimitStatus } =
|
||||
await import("@omniroute/open-sse/services/rateLimitManager.js");
|
||||
const { getAllModelLockouts } = await import("@omniroute/open-sse/services/accountFallback.js");
|
||||
|
||||
const settings = await getSettings();
|
||||
const circuitBreakers = getAllCircuitBreakerStatuses();
|
||||
const rateLimitStatus = getAllRateLimitStatus();
|
||||
const lockouts = getAllModelLockouts();
|
||||
|
||||
// System info
|
||||
const system = {
|
||||
version: APP_CONFIG.version,
|
||||
nodeVersion: process.version,
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: process.memoryUsage(),
|
||||
pid: process.pid,
|
||||
platform: process.platform,
|
||||
};
|
||||
|
||||
// Provider health summary
|
||||
const providerHealth = {};
|
||||
for (const [key, cb] of Object.entries(circuitBreakers)) {
|
||||
providerHealth[key] = {
|
||||
state: cb.state,
|
||||
failures: cb.failures,
|
||||
lastFailure: cb.lastFailure,
|
||||
nextRetry: cb.nextRetry,
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "healthy",
|
||||
timestamp: new Date().toISOString(),
|
||||
system,
|
||||
providerHealth,
|
||||
rateLimitStatus,
|
||||
lockouts,
|
||||
setupComplete: settings?.setupComplete || false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API] GET /api/monitoring/health error:", error);
|
||||
return NextResponse.json({ status: "error", error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
43
src/app/forbidden/page.js
Normal file
43
src/app/forbidden/page.js
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 403 Forbidden Page — Phase 8.1
|
||||
*
|
||||
* Displayed when access is denied due to:
|
||||
* - Invalid API key
|
||||
* - IP not in allowlist
|
||||
* - Rate limit exceeded
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-6 bg-[var(--bg-primary,#0a0a0f)] text-[var(--text-primary,#e0e0e0)] text-center">
|
||||
<div
|
||||
className="text-[96px] font-extrabold leading-none mb-2"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #ef4444 0%, #f97316 50%, #eab308 100%)",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
}}
|
||||
>
|
||||
403
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold mb-2">Access Denied</h1>
|
||||
<p className="text-[15px] text-[var(--text-secondary,#888)] max-w-[400px] leading-relaxed mb-8">
|
||||
You don't have permission to access this resource. Check your API key or contact the
|
||||
administrator.
|
||||
</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="px-8 py-3 rounded-[10px] text-white text-sm font-semibold no-underline transition-all duration-200 shadow-[0_4px_16px_rgba(99,102,241,0.3)] hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
|
||||
}}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/app/forgot-password/page.js
Normal file
91
src/app/forgot-password/page.js
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Forgot Password Page — Phase 8.2
|
||||
*
|
||||
* Provides two recovery methods:
|
||||
* 1. CLI reset via omniroute-reset-password command
|
||||
* 2. Manual database reset instructions
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg p-4">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary mb-2">Reset Password</h1>
|
||||
<p className="text-text-muted">Choose a method to recover access to your dashboard</p>
|
||||
</div>
|
||||
|
||||
{/* Method 1: CLI Reset */}
|
||||
<Card className="mb-4">
|
||||
<div className="flex items-start gap-4 p-2">
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-primary/10 text-primary shrink-0 mt-0.5">
|
||||
<span className="material-symbols-outlined text-[20px]">terminal</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold mb-1">Method 1: CLI Reset</h2>
|
||||
<p className="text-sm text-text-muted mb-3">
|
||||
Run the following command on the server where OmniRoute is running:
|
||||
</p>
|
||||
<div className="bg-black/30 rounded-lg p-3 mb-3 font-mono text-sm text-green-400 border border-white/5">
|
||||
<code>npx omniroute reset-password</code>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
This will prompt you to set a new password. The server must be stopped first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Method 2: Database Reset */}
|
||||
<Card className="mb-6">
|
||||
<div className="flex items-start gap-4 p-2">
|
||||
<div className="flex items-center justify-center size-10 rounded-lg bg-amber-500/10 text-amber-500 shrink-0 mt-0.5">
|
||||
<span className="material-symbols-outlined text-[20px]">database</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold mb-1">Method 2: Manual Reset</h2>
|
||||
<p className="text-sm text-text-muted mb-3">
|
||||
Delete the password from the database and set a new one on startup:
|
||||
</p>
|
||||
<ol className="text-sm text-text-muted space-y-2 list-decimal list-inside mb-3">
|
||||
<li>Stop the OmniRoute server</li>
|
||||
<li>
|
||||
Set a new password in your{" "}
|
||||
<code className="bg-black/30 px-1 rounded text-text-main">.env</code> file:
|
||||
<div className="bg-black/30 rounded-lg p-2 mt-1 font-mono text-xs text-green-400 border border-white/5">
|
||||
INITIAL_PASSWORD=your_new_password
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
Delete{" "}
|
||||
<code className="bg-black/30 px-1 rounded text-text-main">
|
||||
data/settings.json
|
||||
</code>{" "}
|
||||
(or remove the{" "}
|
||||
<code className="bg-black/30 px-1 rounded text-text-main">passwordHash</code>{" "}
|
||||
field)
|
||||
</li>
|
||||
<li>Restart the server — it will use the new password</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -111,6 +111,12 @@ export default function LoginPage() {
|
||||
<p className="text-xs text-center text-text-muted mt-2">
|
||||
Default password is <code className="bg-sidebar px-1 rounded">123456</code>
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-center mt-1">
|
||||
<a href="/forgot-password" className="text-primary hover:underline">
|
||||
Forgot password?
|
||||
</a>
|
||||
</p>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
63
src/shared/components/MaintenanceBanner.js
Normal file
63
src/shared/components/MaintenanceBanner.js
Normal file
@@ -0,0 +1,63 @@
|
||||
"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, useCallback } from "react";
|
||||
|
||||
export default function MaintenanceBanner() {
|
||||
const [show, setShow] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring/health", {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
if (res.ok) {
|
||||
// Server is healthy — hide banner if shown
|
||||
if (show) {
|
||||
setShow(false);
|
||||
setMessage("");
|
||||
}
|
||||
} else {
|
||||
setShow(true);
|
||||
setMessage("Server is experiencing issues. Some features may be unavailable.");
|
||||
}
|
||||
} catch {
|
||||
setShow(true);
|
||||
setMessage("Server is unreachable. Reconnecting...");
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check health every 10 seconds
|
||||
const interval = setInterval(checkHealth, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkHealth]);
|
||||
|
||||
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={() => setShow(false)}
|
||||
className="p-1 rounded hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const navItems = [
|
||||
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
|
||||
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
|
||||
{ href: "/dashboard/usage", label: "Usage", icon: "bar_chart" },
|
||||
{ href: "/dashboard/health", label: "Health", icon: "health_and_safety" },
|
||||
{ href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" },
|
||||
];
|
||||
|
||||
@@ -25,7 +26,7 @@ const systemItems = [{ href: "/dashboard/settings", label: "Settings", icon: "se
|
||||
const helpItems = [
|
||||
{ href: "/docs", label: "Docs", icon: "menu_book" },
|
||||
{
|
||||
href: "https://github.com/decolua/omniroute/issues",
|
||||
href: "https://github.com/diegosouzapw/OmniRoute/issues",
|
||||
label: "Issues",
|
||||
icon: "bug_report",
|
||||
external: true,
|
||||
|
||||
@@ -5,6 +5,7 @@ import Sidebar from "../Sidebar";
|
||||
import Header from "../Header";
|
||||
import Breadcrumbs from "../Breadcrumbs";
|
||||
import NotificationToast from "../NotificationToast";
|
||||
import MaintenanceBanner from "../MaintenanceBanner";
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed";
|
||||
|
||||
@@ -55,6 +56,7 @@ export default function DashboardLayout({ children }) {
|
||||
className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300"
|
||||
>
|
||||
<Header onMenuClick={() => setSidebarOpen(true)} />
|
||||
<MaintenanceBanner />
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 lg:p-10">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Breadcrumbs />
|
||||
@@ -68,4 +70,3 @@ export default function DashboardLayout({ children }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user