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:
diegosouzapw
2026-02-15 10:46:11 -03:00
parent bc95095f82
commit 034f1a7e1d
8 changed files with 567 additions and 2 deletions

View 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>
);
}

View 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
View 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&apos;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>
);
}

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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,

View File

@@ -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>
);
}