From 034f1a7e1da343632e06490b12775e7b56ffdba4 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 15 Feb 2026 10:46:11 -0300 Subject: [PATCH] =?UTF-8?q?feat(pages):=20Phase=208=20=E2=80=94=20Missing?= =?UTF-8?q?=20Flows=20&=20Pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/(dashboard)/dashboard/health/page.js | 302 ++++++++++++++++++ src/app/api/monitoring/health/route.js | 58 ++++ src/app/forbidden/page.js | 43 +++ src/app/forgot-password/page.js | 91 ++++++ src/app/login/page.js | 6 + src/shared/components/MaintenanceBanner.js | 63 ++++ src/shared/components/Sidebar.js | 3 +- .../components/layouts/DashboardLayout.js | 3 +- 8 files changed, 567 insertions(+), 2 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/health/page.js create mode 100644 src/app/api/monitoring/health/route.js create mode 100644 src/app/forbidden/page.js create mode 100644 src/app/forgot-password/page.js create mode 100644 src/shared/components/MaintenanceBanner.js diff --git a/src/app/(dashboard)/dashboard/health/page.js b/src/app/(dashboard)/dashboard/health/page.js new file mode 100644 index 0000000000..b2d8bfa842 --- /dev/null +++ b/src/app/(dashboard)/dashboard/health/page.js @@ -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 ( +
+
+
+

Loading health data...

+
+
+ ); + } + + if (error && !data) { + return ( +
+
+ error +

Failed to load health data: {error}

+ +
+
+ ); + } + + const { system, providerHealth, rateLimitStatus, lockouts } = data; + const cbEntries = Object.entries(providerHealth || {}); + const lockoutEntries = Object.entries(lockouts || {}); + + return ( +
+ {/* Header */} +
+
+

System Health

+

+ Real-time monitoring of your OmniRoute instance +

+
+
+ {lastRefresh && ( + + Updated {lastRefresh.toLocaleTimeString()} + + )} + +
+
+ + {/* Status Banner */} +
+ + {data.status === "healthy" ? "check_circle" : "error"} + + + {data.status === "healthy" ? "All systems operational" : "System issues detected"} + +
+ + {/* System Info Cards */} +
+ +
+
+ timer +
+ Uptime +
+

{formatUptime(system.uptime)}

+
+ + +
+
+ info +
+ Version +
+

v{system.version}

+

Node {system.nodeVersion}

+
+ + +
+
+ memory +
+ Memory (RSS) +
+

+ {formatBytes(system.memoryUsage?.rss || 0)} +

+

+ Heap: {formatBytes(system.memoryUsage?.heapUsed || 0)} /{" "} + {formatBytes(system.memoryUsage?.heapTotal || 0)} +

+
+ + +
+
+ dns +
+ Providers +
+

{cbEntries.length}

+

+ {cbEntries.filter(([, v]) => v.state === "CLOSED").length} healthy +

+
+
+ + {/* Provider Health */} + +

+ + health_and_safety + + Provider Health +

+ {cbEntries.length === 0 ? ( +

+ No circuit breaker data available. Make some requests first. +

+ ) : ( +
+ {cbEntries.map(([provider, cb]) => { + const style = CB_COLORS[cb.state] || CB_COLORS.CLOSED; + return ( +
+
+ {provider} + {style.label} +
+
+ Failures: {cb.failures || 0} + {cb.lastFailure && ( + + Last: {new Date(cb.lastFailure).toLocaleTimeString()} + + )} +
+
+ ); + })} +
+ )} +
+ + {/* Rate Limit Status */} + {rateLimitStatus && Object.keys(rateLimitStatus).length > 0 && ( + +

+ speed + Rate Limit Status +

+
+ + + + + + + + + + {Object.entries(rateLimitStatus).map(([provider, status]) => ( + + + + + + ))} + +
ProviderStatusRequests
{provider} + + {status.limited ? "Limited" : "OK"} + + + {status.requestsInWindow || 0} / {status.limit || "∞"} +
+
+
+ )} + + {/* Active Lockouts */} + {lockoutEntries.length > 0 && ( + +

+ lock + Active Lockouts +

+
+ {lockoutEntries.map(([key, lockout]) => ( +
+
+ {key} + {lockout.reason && ( + ({lockout.reason}) + )} +
+ {lockout.until && ( + + Until {new Date(lockout.until).toLocaleTimeString()} + + )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/app/api/monitoring/health/route.js b/src/app/api/monitoring/health/route.js new file mode 100644 index 0000000000..2d0263914f --- /dev/null +++ b/src/app/api/monitoring/health/route.js @@ -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 }); + } +} diff --git a/src/app/forbidden/page.js b/src/app/forbidden/page.js new file mode 100644 index 0000000000..4a700ccf7e --- /dev/null +++ b/src/app/forbidden/page.js @@ -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 ( +
+
+ 403 +
+

Access Denied

+

+ You don't have permission to access this resource. Check your API key or contact the + administrator. +

+ + Go to Dashboard + +
+ ); +} diff --git a/src/app/forgot-password/page.js b/src/app/forgot-password/page.js new file mode 100644 index 0000000000..487cee2bfe --- /dev/null +++ b/src/app/forgot-password/page.js @@ -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 ( +
+
+
+

Reset Password

+

Choose a method to recover access to your dashboard

+
+ + {/* Method 1: CLI Reset */} + +
+
+ terminal +
+
+

Method 1: CLI Reset

+

+ Run the following command on the server where OmniRoute is running: +

+
+ npx omniroute reset-password +
+

+ This will prompt you to set a new password. The server must be stopped first. +

+
+
+
+ + {/* Method 2: Database Reset */} + +
+
+ database +
+
+

Method 2: Manual Reset

+

+ Delete the password from the database and set a new one on startup: +

+
    +
  1. Stop the OmniRoute server
  2. +
  3. + Set a new password in your{" "} + .env file: +
    + INITIAL_PASSWORD=your_new_password +
    +
  4. +
  5. + Delete{" "} + + data/settings.json + {" "} + (or remove the{" "} + passwordHash{" "} + field) +
  6. +
  7. Restart the server — it will use the new password
  8. +
+
+
+
+ +
+ + arrow_back + Back to Login + +
+
+
+ ); +} diff --git a/src/app/login/page.js b/src/app/login/page.js index fb8408fe63..f114147c7a 100644 --- a/src/app/login/page.js +++ b/src/app/login/page.js @@ -111,6 +111,12 @@ export default function LoginPage() {

Default password is 123456

+ +

+ + Forgot password? + +

diff --git a/src/shared/components/MaintenanceBanner.js b/src/shared/components/MaintenanceBanner.js new file mode 100644 index 0000000000..9f792b87be --- /dev/null +++ b/src/shared/components/MaintenanceBanner.js @@ -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 ( +
+
+ + warning + + {message} +
+ +
+ ); +} diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 3c6c41fe14..0c355a844e 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -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, diff --git a/src/shared/components/layouts/DashboardLayout.js b/src/shared/components/layouts/DashboardLayout.js index 42793619b2..07af78335a 100644 --- a/src/shared/components/layouts/DashboardLayout.js +++ b/src/shared/components/layouts/DashboardLayout.js @@ -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" >
setSidebarOpen(true)} /> +
@@ -68,4 +70,3 @@ export default function DashboardLayout({ children }) {
); } -