From 413a9f2a694e5bdd3f6227ea85c6113d50d20001 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 15 Feb 2026 16:26:01 -0300 Subject: [PATCH] feat: add ModelAvailabilityBadge component and retheme landing page - Add compact ModelAvailabilityBadge with popover for model status monitoring, cooldown clearing, and auto-refresh polling - Retheme landing page from warm orange tones to cool blue-grey palette across FlowAnimation, Footer, and related components - Update accent color from orange (#f97815) to rose (#E54D5E) --- .../components/ModelAvailabilityBadge.js | 194 ++++++++++++++++++ .../(dashboard)/dashboard/providers/page.js | 40 ++-- .../usage/components/ProviderLimits/index.js | 4 +- src/app/globals.css | 88 ++++---- .../landing/components/AnimatedBackground.js | 6 +- src/app/landing/components/FlowAnimation.js | 20 +- src/app/landing/components/Footer.js | 20 +- src/app/landing/components/GetStarted.js | 18 +- src/app/landing/components/HeroSection.js | 12 +- src/app/landing/components/HowItWorks.js | 14 +- src/app/landing/components/Navigation.js | 10 +- src/app/landing/page.js | 16 +- src/shared/components/Sidebar.js | 2 +- src/shared/components/analytics/charts.js | 25 ++- 14 files changed, 335 insertions(+), 134 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js new file mode 100644 index 0000000000..7daa1f4f6c --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityBadge.js @@ -0,0 +1,194 @@ +"use client"; + +/** + * ModelAvailabilityBadge — compact inline status indicator + * + * Replaces the full ModelAvailabilityPanel card with a small badge + * that shows green when all models are operational, or amber/red + * when there are issues, with a hover popover for details. + */ + +import { useState, useEffect, useCallback, useRef } from "react"; +import { Button } from "@/shared/components"; +import { useNotificationStore } from "@/store/notificationStore"; + +const STATUS_CONFIG = { + available: { icon: "check_circle", color: "#22c55e", label: "Available" }, + cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" }, + unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" }, + unknown: { icon: "help", color: "#6b7280", label: "Unknown" }, +}; + +export default function ModelAvailabilityBadge() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(false); + const [clearing, setClearing] = useState(null); + const ref = useRef(null); + const notify = useNotificationStore(); + + const fetchStatus = useCallback(async () => { + try { + const res = await fetch("/api/models/availability"); + if (res.ok) { + const json = await res.json(); + setData(json); + } + } catch { + // silent fail — will retry + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchStatus(); + const interval = setInterval(fetchStatus, 30000); + return () => clearInterval(interval); + }, [fetchStatus]); + + // Close popover on outside click + useEffect(() => { + const handleClick = (e) => { + if (ref.current && !ref.current.contains(e.target)) { + setExpanded(false); + } + }; + if (expanded) document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [expanded]); + + const handleClearCooldown = async (provider, model) => { + setClearing(`${provider}:${model}`); + try { + const res = await fetch("/api/models/availability", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "clearCooldown", provider, model }), + }); + if (res.ok) { + notify.success(`Cooldown cleared for ${model}`); + await fetchStatus(); + } else { + notify.error("Failed to clear cooldown"); + } + } catch { + notify.error("Failed to clear cooldown"); + } finally { + setClearing(null); + } + }; + + if (loading) return null; + + const models = data?.models || []; + const unavailableCount = + data?.unavailableCount || models.filter((m) => m.status !== "available").length; + const isHealthy = unavailableCount === 0; + + // Group unhealthy models by provider + const byProvider = {}; + models.forEach((m) => { + if (m.status === "available") return; + const key = m.provider || "unknown"; + if (!byProvider[key]) byProvider[key] = []; + byProvider[key].push(m); + }); + + return ( +
+ + + {/* Expanded popover */} + {expanded && ( +
+
+
+ + {isHealthy ? "verified" : "warning"} + + Model Status +
+ +
+ +
+ {isHealthy ? ( +

+ All models are responding normally. +

+ ) : ( +
+ {Object.entries(byProvider).map(([provider, provModels]) => ( +
+

+ {provider} +

+
+ {provModels.map((m) => { + const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown; + const isClearing = clearing === `${m.provider}:${m.model}`; + return ( +
+
+ + {status.icon} + + + {m.model} + +
+ {m.status === "cooldown" && ( + + )} +
+ ); + })} +
+
+ ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 478c63fcd8..bcf68e117a 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -13,7 +13,7 @@ import { import Link from "next/link"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { useNotificationStore } from "@/store/notificationStore"; -import ModelAvailabilityPanel from "./components/ModelAvailabilityPanel"; +import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; // Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard function getStatusDisplay(connected, error, errorCode) { @@ -203,22 +203,25 @@ export default function ProvidersPage() {

OAuth Providers

- +
+ + +
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => ( @@ -400,9 +403,6 @@ export default function ProvidersPage() {
)} - - {/* Model Availability */} - ); } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 9f609a99df..7e7ff7b46e 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -350,10 +350,10 @@ export default function ProviderLimits() { className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-semibold cursor-pointer" style={{ border: active - ? "1px solid var(--primary, #f97815)" + ? "1px solid var(--primary, #E54D5E)" : "1px solid rgba(255,255,255,0.12)", background: active ? "rgba(249,120,21,0.14)" : "transparent", - color: active ? "var(--primary, #f97815)" : "var(--text-muted)", + color: active ? "var(--primary, #E54D5E)" : "var(--text-muted)", }} > {tier.label} diff --git a/src/app/globals.css b/src/app/globals.css index ab0f4ee4b3..c10f260d95 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,40 +3,40 @@ @custom-variant dark (&:where(.dark, .dark *)); -/* macOS-inspired Color Palette with Terracotta Primary */ +/* OpenClaw × ClawHub Color Palette */ :root { - /* Primary - Warm Coral/Terracotta */ - --color-primary: #d97757; - --color-primary-hover: #c56243; + /* Primary - Coral Red (OpenClaw) */ + --color-primary: #e54d5e; + --color-primary-hover: #c93d4e; /* Light theme */ - --color-bg: #fbf9f6; - --color-bg-alt: #f5f1ed; + --color-bg: #f9f9fb; + --color-bg-alt: #f0f0f5; --color-surface: #ffffff; - --color-sidebar: rgba(246, 246, 246, 0.8); - --color-border: rgba(0, 0, 0, 0.1); - --color-text-main: #383733; - --color-text-muted: #75736e; + --color-sidebar: rgba(245, 245, 250, 0.8); + --color-border: rgba(0, 0, 0, 0.08); + --color-text-main: #1a1a2e; + --color-text-muted: #71717a; - /* Shadows - subtle macOS style */ + /* Shadows */ --shadow-soft: 0 1px 3px rgba(0, 0, 0, 0.02), 0 4px 12px rgba(0, 0, 0, 0.015); - --shadow-warm: 0 2px 12px -2px rgba(217, 119, 87, 0.12); - --shadow-elevated: 0 12px 28px -4px rgba(60, 50, 45, 0.06); + --shadow-warm: 0 2px 12px -2px rgba(229, 77, 94, 0.12); + --shadow-elevated: 0 12px 28px -4px rgba(20, 20, 40, 0.06); } .dark { - /* Dark theme */ - --color-bg: #191918; - --color-bg-alt: #1f1f1e; - --color-surface: #242423; - --color-sidebar: rgba(30, 30, 30, 0.8); - --color-border: rgba(255, 255, 255, 0.1); - --color-text-main: #ecebe8; - --color-text-muted: #9e9d99; + /* Dark theme (ClawHub deep) */ + --color-bg: #0b0e14; + --color-bg-alt: #111520; + --color-surface: #161b22; + --color-sidebar: rgba(16, 20, 30, 0.8); + --color-border: rgba(255, 255, 255, 0.08); + --color-text-main: #e6e6ef; + --color-text-muted: #a1a1aa; - /* Dark shadows - subtle macOS style */ + /* Dark shadows */ --shadow-soft: 0 1px 3px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.1); - --shadow-warm: 0 2px 12px -2px rgba(217, 119, 87, 0.15); + --shadow-warm: 0 2px 12px -2px rgba(229, 77, 94, 0.15); --shadow-elevated: 0 12px 28px -4px rgba(0, 0, 0, 0.3); } @@ -54,18 +54,18 @@ --color-text-muted: var(--color-text-muted); /* Static colors (for explicit light/dark usage) */ - --color-bg-light: #fbf9f6; - --color-bg-dark: #191918; + --color-bg-light: #f9f9fb; + --color-bg-dark: #0b0e14; --color-surface-light: #ffffff; - --color-surface-dark: #242423; - --color-sidebar-light: #f0efec; - --color-sidebar-dark: #1f1f1e; - --color-border-light: #e6e4dd; - --color-border-dark: #333331; - --color-text-main-light: #383733; - --color-text-main-dark: #ecebe8; - --color-text-muted-light: #75736e; - --color-text-muted-dark: #9e9d99; + --color-surface-dark: #161b22; + --color-sidebar-light: #ededf2; + --color-sidebar-dark: #111520; + --color-border-light: #e2e2ea; + --color-border-dark: #2d333b; + --color-text-main-light: #1a1a2e; + --color-text-main-dark: #e6e6ef; + --color-text-muted-light: #71717a; + --color-text-muted-dark: #a1a1aa; /* Shadows */ --shadow-soft: var(--shadow-soft); @@ -88,7 +88,7 @@ body { /* Selection */ ::selection { - background-color: rgba(217, 119, 87, 0.2); + background-color: rgba(229, 77, 94, 0.2); color: var(--color-primary); } @@ -142,11 +142,11 @@ body { /* Hero gradient */ .bg-hero-gradient { - background: linear-gradient(180deg, #f5f1ed 0%, #fefcfb 100%); + background: linear-gradient(180deg, #f0f0f5 0%, #f9f9fb 100%); } .dark .bg-hero-gradient { - background: linear-gradient(180deg, #1f1f1e 0%, #191918 100%); + background: linear-gradient(180deg, #111520 0%, #0b0e14 100%); } /* Material Symbols */ @@ -219,15 +219,15 @@ button .material-symbols-outlined, 0%, 100% { box-shadow: - 0 0 5px rgba(217, 119, 87, 0.3), - 0 0 10px rgba(217, 119, 87, 0.2); - border-color: rgba(217, 119, 87, 0.5); + 0 0 5px rgba(229, 77, 94, 0.3), + 0 0 10px rgba(229, 77, 94, 0.2); + border-color: rgba(229, 77, 94, 0.5); } 50% { box-shadow: - 0 0 10px rgba(217, 119, 87, 0.5), - 0 0 20px rgba(217, 119, 87, 0.3); - border-color: rgba(217, 119, 87, 0.8); + 0 0 10px rgba(229, 77, 94, 0.5), + 0 0 20px rgba(229, 77, 94, 0.3); + border-color: rgba(229, 77, 94, 0.8); } } @@ -243,7 +243,7 @@ button .material-symbols-outlined, } .dark .bg-vibrancy { - background: rgba(30, 30, 30, 0.72); + background: rgba(16, 20, 30, 0.72); } /* macOS Traffic Lights */ diff --git a/src/app/landing/components/AnimatedBackground.js b/src/app/landing/components/AnimatedBackground.js index 2f434cef64..f115df45b9 100644 --- a/src/app/landing/components/AnimatedBackground.js +++ b/src/app/landing/components/AnimatedBackground.js @@ -9,13 +9,13 @@ export default function AnimatedBackground() {
{/* Animated gradient orbs */} -
+
@@ -24,7 +24,7 @@ export default function AnimatedBackground() { className="absolute inset-0" style={{ background: - "radial-gradient(circle at center, transparent 0%, rgba(24, 20, 17, 0.4) 100%)", + "radial-gradient(circle at center, transparent 0%, rgba(11, 14, 20, 0.4) 100%)", }} />
diff --git a/src/app/landing/components/FlowAnimation.js b/src/app/landing/components/FlowAnimation.js index dc68f6a85d..a5b501c6b9 100644 --- a/src/app/landing/components/FlowAnimation.js +++ b/src/app/landing/components/FlowAnimation.js @@ -29,10 +29,10 @@ export default function FlowAnimation() { return (
{/* OmniRoute Hub - Center */} -
- hub +
+ hub OmniRoute -
+
{/* CLI Tools - Left side */} @@ -42,7 +42,7 @@ export default function FlowAnimation() { key={tool.id} className="flex items-center gap-3 opacity-70 hover:opacity-100 transition-opacity group" > -
+
{tool.name} @@ -132,7 +132,7 @@ export default function FlowAnimation() {
@@ -142,7 +142,7 @@ export default function FlowAnimation() {
{/* Mobile fallback */} -
+

Interactive diagram visible on desktop

diff --git a/src/app/landing/components/Footer.js b/src/app/landing/components/Footer.js index aacaa5f434..a42968b0dc 100644 --- a/src/app/landing/components/Footer.js +++ b/src/app/landing/components/Footer.js @@ -2,13 +2,13 @@ export default function Footer() { return ( -