From 14714aa9f58f6ebb6e0e487b52b55ad922747ac9 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 15 Feb 2026 16:11:56 -0300 Subject: [PATCH 1/5] feat: refactor dashboard with shared UI component library Introduce reusable shared components (Button, Card, Modal, Table, StatusBadge, EmptyState) and refactor dashboard pages to use them. Extract client components from server pages, centralize provider constants, and update CI Node.js matrix from 18 to 20. --- .github/workflows/ci.yml | 4 +- .../(dashboard)/dashboard/HomePageClient.js | 241 ++++++++++++++++++ .../(dashboard)/dashboard/analytics/page.js | 29 +++ .../dashboard/endpoint/EndpointPageClient.js | 213 ++++++---------- src/app/(dashboard)/dashboard/page.js | 4 +- src/app/(dashboard)/dashboard/usage/page.js | 27 +- src/lib/usage/costCalculator.js | 27 +- src/shared/components/Header.js | 11 +- src/shared/components/Sidebar.js | 10 +- src/shared/constants/pricing.js | 229 +++++++++++++++++ 10 files changed, 635 insertions(+), 160 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/HomePageClient.js create mode 100644 src/app/(dashboard)/dashboard/analytics/page.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4b57c85d7..e0d47c71c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18, 22] + node-version: [20, 22] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -59,7 +59,7 @@ jobs: needs: build strategy: matrix: - node-version: [18, 22] + node-version: [20, 22] env: JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation API_KEY_SECRET: ci-test-api-key-secret-long diff --git a/src/app/(dashboard)/dashboard/HomePageClient.js b/src/app/(dashboard)/dashboard/HomePageClient.js new file mode 100644 index 0000000000..4348fc9fb6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/HomePageClient.js @@ -0,0 +1,241 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback } from "react"; +import PropTypes from "prop-types"; +import Image from "next/image"; +import Link from "next/link"; +import { Card, CardSkeleton } from "@/shared/components"; +import { AI_PROVIDERS } from "@/shared/constants/providers"; + +export default function HomePageClient({ machineId }) { + const [providerConnections, setProviderConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [baseUrl, setBaseUrl] = useState("/v1"); + + useEffect(() => { + if (typeof window !== "undefined") { + setBaseUrl(`${window.location.origin}/v1`); + } + }, []); + + const fetchData = useCallback(async () => { + try { + const [connRes] = await Promise.all([fetch("/api/connections")]); + if (connRes.ok) { + const connData = await connRes.json(); + setProviderConnections(connData); + } + } catch (e) { + console.log("Error fetching data:", e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const providerStats = useMemo(() => { + return Object.entries(AI_PROVIDERS).map(([providerId, providerInfo]) => { + const connections = providerConnections.filter((conn) => conn.provider === providerId); + const connected = connections.filter( + (conn) => + conn.isActive !== false && + (conn.testStatus === "active" || + conn.testStatus === "success" || + conn.testStatus === "unknown") + ).length; + const errors = connections.filter( + (conn) => + conn.isActive !== false && + (conn.testStatus === "error" || + conn.testStatus === "expired" || + conn.testStatus === "unavailable") + ).length; + + return { + id: providerId, + provider: providerInfo, + total: connections.length, + connected, + errors, + }; + }); + }, [providerConnections]); + + const quickStartLinks = [ + { label: "Documentation", href: "/docs" }, + { label: "OpenAI API compatibility", href: "/docs#api-reference" }, + { label: "Cherry/Codex compatibility", href: "/docs#client-compatibility" }, + { label: "Report issue", href: "https://github.com/decolua/omniroute/issues", external: true }, + ]; + + if (loading) { + return ( +
+ + +
+ ); + } + + const currentEndpoint = baseUrl; + + return ( +
+ {/* Quick Start */} + +
+
+

Quick Start

+

+ First-time setup checklist for API clients and IDE tools. +

+
+ +
    +
  1. + 1. Create API key +

    + Generate one key per environment to isolate usage and revoke safely. +

    +
  2. +
  3. + 2. Connect provider account +

    + Configure providers in Dashboard and validate with Test Connection. +

    +
  4. +
  5. + 3. Use endpoint +

    + Point clients to {currentEndpoint} and send requests to{" "} + /chat/completions. +

    +
  6. +
  7. + 4. Monitor usage +

    + Track requests, tokens, errors, and cost in Usage and Request Logger. +

    +
  8. +
+ +
+ {quickStartLinks.map((link) => ( + + + {link.external ? "open_in_new" : "arrow_forward"} + + {link.label} + + ))} +
+
+
+ + {/* Providers Overview */} + +
+
+

Providers Overview

+

+ {providerStats.filter((item) => item.total > 0).length} configured of{" "} + {providerStats.length} available providers +

+
+ + settings + Manage Providers + +
+ +
+ {providerStats.map((item) => ( + + ))} +
+
+
+ ); +} + +HomePageClient.propTypes = { + machineId: PropTypes.string, +}; + +function ProviderOverviewCard({ item }) { + const [imgError, setImgError] = useState(false); + + const statusVariant = + item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted"; + + return ( + +
+
+ {imgError ? ( + + {item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()} + + ) : ( + {item.provider.name} setImgError(true)} + /> + )} +
+ +
+

{item.provider.name}

+

+ {item.total === 0 + ? "Not configured" + : `${item.connected} active · ${item.errors} error`} +

+
+ + #{item.total} +
+ + ); +} + +ProviderOverviewCard.propTypes = { + item: PropTypes.shape({ + id: PropTypes.string.isRequired, + provider: PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + color: PropTypes.string, + textIcon: PropTypes.string, + }).isRequired, + total: PropTypes.number.isRequired, + connected: PropTypes.number.isRequired, + errors: PropTypes.number.isRequired, + }).isRequired, +}; diff --git a/src/app/(dashboard)/dashboard/analytics/page.js b/src/app/(dashboard)/dashboard/analytics/page.js new file mode 100644 index 0000000000..e262ae3eaa --- /dev/null +++ b/src/app/(dashboard)/dashboard/analytics/page.js @@ -0,0 +1,29 @@ +"use client"; + +import { useState, Suspense } from "react"; +import { UsageAnalytics, CardSkeleton, SegmentedControl } from "@/shared/components"; +import EvalsTab from "../usage/components/EvalsTab"; + +export default function AnalyticsPage() { + const [activeTab, setActiveTab] = useState("overview"); + + return ( +
+ + + {activeTab === "overview" && ( + }> + + + )} + {activeTab === "evals" && } +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 95de570cd1..825051f022 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -367,141 +367,94 @@ export default function APIPageClient({ machineId }) { {copied === "endpoint_url" ? "Copied!" : "Copy"} - - {/* Quick Start */} - -
-
-

Quick Start

-

- First-time setup checklist for API clients and IDE tools. -

-
- -
    -
  1. - 1. Create API key -

    - Generate one key per environment to isolate usage and revoke safely. -

    -
  2. -
  3. - 2. Connect provider account -

    - Configure providers in Dashboard and validate with Test Connection. -

    -
  4. -
  5. - 3. Use endpoint -

    - Point clients to {currentEndpoint} and send requests to{" "} - /chat/completions. -

    -
  6. -
  7. - 4. Monitor usage -

    - Track requests, tokens, errors, and cost in Usage and Request Logger. -

    -
  8. -
- -
- {quickStartLinks.map((link) => ( - - - {link.external ? "open_in_new" : "arrow_forward"} - - {link.label} - - ))} -
-
-
- - {/* API Keys */} - -
-

API Keys

- -
- - {keys.length === 0 ? ( -
-
- vpn_key + {/* Registered Keys — collapsible section inside API Endpoint card */} +
+ -
- ) : ( -
- {keys.map((key) => ( -
-
-

{key.name}

-
- {key.key} - -
-

- Created {new Date(key.createdAt).toLocaleDateString()} -

-
- +
+
+ Registered Keys + + {keys.length} {keys.length === 1 ? "key" : "keys"} +
- ))} -
- )} - +

+ Manage API keys used to authenticate requests to this endpoint +

+
+ + expand_more + + - {/* Providers Overview */} - -
-
-

Providers Overview

-

- {providerStats.filter((item) => item.total > 0).length} configured of{" "} - {providerStats.length} available providers -

-
-
+ {expandedEndpoint === "keys" && ( +
+
+

+ Each key isolates usage tracking and can be revoked independently. +

+ +
-
- {providerStats.map((item) => ( - setSelectedProvider(item)} - /> - ))} + {keys.length === 0 ? ( +
+
+ vpn_key +
+

No API keys yet

+

+ Create your first API key to get started +

+ +
+ ) : ( +
+ {keys.map((key) => ( +
+
+

{key.name}

+
+ {key.key} + +
+

+ Created {new Date(key.createdAt).toLocaleDateString()} +

+
+ +
+ ))} +
+ )} +
+ )}
diff --git a/src/app/(dashboard)/dashboard/page.js b/src/app/(dashboard)/dashboard/page.js index 9818aa88fc..9b7ae9e971 100644 --- a/src/app/(dashboard)/dashboard/page.js +++ b/src/app/(dashboard)/dashboard/page.js @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; import { getMachineId } from "@/shared/utils/machine"; import { getSettings } from "@/lib/localDb"; -import EndpointPageClient from "./endpoint/EndpointPageClient"; +import HomePageClient from "./HomePageClient"; // Must be dynamic — depends on DB state (setupComplete) that changes at runtime export const dynamic = "force-dynamic"; @@ -12,5 +12,5 @@ export default async function DashboardPage() { redirect("/dashboard/onboarding"); } const machineId = await getMachineId(); - return ; + return ; } diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js index ab5796c98e..ea431e4972 100644 --- a/src/app/(dashboard)/dashboard/usage/page.js +++ b/src/app/(dashboard)/dashboard/usage/page.js @@ -1,46 +1,31 @@ "use client"; import { useState, Suspense } from "react"; -import { - UsageAnalytics, - RequestLoggerV2, - ProxyLogger, - CardSkeleton, - SegmentedControl, -} from "@/shared/components"; +import { RequestLoggerV2, ProxyLogger, CardSkeleton, SegmentedControl } from "@/shared/components"; import ProviderLimits from "./components/ProviderLimits"; import SessionsTab from "./components/SessionsTab"; import RateLimitStatus from "./components/RateLimitStatus"; import BudgetTelemetryCards from "./components/BudgetTelemetryCards"; import BudgetTab from "./components/BudgetTab"; -import EvalsTab from "./components/EvalsTab"; export default function UsagePage() { - const [activeTab, setActiveTab] = useState("overview"); + const [activeTab, setActiveTab] = useState("logs"); return (
{/* Content */} - {activeTab === "overview" && ( - }> - - - - )} {activeTab === "logs" && } {activeTab === "proxy-logs" && } {activeTab === "limits" && ( @@ -52,8 +37,12 @@ export default function UsagePage() {
)} {activeTab === "sessions" && } - {activeTab === "budget" && } - {activeTab === "evals" && } + {activeTab === "budget" && ( + <> + + + + )}
); } diff --git a/src/lib/usage/costCalculator.js b/src/lib/usage/costCalculator.js index e9d1c6f752..739086e72a 100644 --- a/src/lib/usage/costCalculator.js +++ b/src/lib/usage/costCalculator.js @@ -8,6 +8,23 @@ * @module lib/usage/costCalculator */ +/** + * Normalize model name — strip provider path prefixes. + * Examples: + * "openai/gpt-oss-120b" → "gpt-oss-120b" + * "accounts/fireworks/models/gpt-oss-120b" → "gpt-oss-120b" + * "deepseek-ai/DeepSeek-R1" → "DeepSeek-R1" + * "gpt-oss-120b" → "gpt-oss-120b" (no-op) + * + * @param {string} model + * @returns {string} + */ +function normalizeModelName(model) { + if (!model || !model.includes("/")) return model; + const parts = model.split("/"); + return parts[parts.length - 1]; +} + /** * Calculate cost for a usage entry. * @@ -21,7 +38,15 @@ export async function calculateCost(provider, model, tokens) { try { const { getPricingForModel } = await import("@/lib/localDb.js"); - const pricing = await getPricingForModel(provider, model); + + // Try exact match first, then normalized model name + let pricing = await getPricingForModel(provider, model); + if (!pricing) { + const normalized = normalizeModelName(model); + if (normalized !== model) { + pricing = await getPricingForModel(provider, normalized); + } + } if (!pricing) return 0; let cost = 0; diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index ff1e30d342..739fa23c3c 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -72,14 +72,21 @@ const getPageInfo = (pathname) => { description: "Monitor your API usage, token consumption, and request logs", breadcrumbs: [], }; + if (pathname.includes("/analytics")) + return { + title: "Analytics", + description: "Charts, trends, and evaluation insights", + breadcrumbs: [], + }; if (pathname.includes("/cli-tools")) return { title: "CLI Tools", description: "Configure CLI tools", breadcrumbs: [] }; + if (pathname === "/dashboard") + return { title: "Home", description: "Welcome to OmniRoute", breadcrumbs: [] }; if (pathname.includes("/endpoint")) return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] }; if (pathname.includes("/profile")) return { title: "Settings", description: "Manage your preferences", breadcrumbs: [] }; - if (pathname === "/dashboard") - return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] }; + return { title: "", description: "", breadcrumbs: [] }; }; diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 7a401c4250..7a444dfeff 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -11,10 +11,12 @@ import { ConfirmModal } from "./Modal"; import CloudSyncStatus from "./CloudSyncStatus"; const navItems = [ + { href: "/dashboard", label: "Home", icon: "home", exact: true }, { href: "/dashboard/endpoint", label: "Endpoint", icon: "api" }, { href: "/dashboard/providers", label: "Providers", icon: "dns" }, { href: "/dashboard/combos", label: "Combos", icon: "layers" }, { href: "/dashboard/usage", label: "Usage", icon: "bar_chart" }, + { href: "/dashboard/analytics", label: "Analytics", icon: "analytics" }, { href: "/dashboard/health", label: "Health", icon: "health_and_safety" }, { href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" }, ]; @@ -51,9 +53,9 @@ export default function Sidebar({ onClose, collapsed = false, onToggleCollapse } .catch(() => {}); }, []); - const isActive = (href) => { - if (href === "/dashboard/endpoint") { - return pathname === "/dashboard" || pathname.startsWith("/dashboard/endpoint"); + const isActive = (href, exact) => { + if (exact) { + return pathname === href; } return pathname.startsWith(href); }; @@ -87,7 +89,7 @@ export default function Sidebar({ onClose, collapsed = false, onToggleCollapse } }; const renderNavLink = (item) => { - const active = !item.external && isActive(item.href); + const active = !item.external && isActive(item.href, item.exact); const className = cn( "flex items-center gap-3 rounded-lg transition-all group", collapsed ? "justify-center px-2 py-2.5" : "px-4 py-2", diff --git a/src/shared/constants/pricing.js b/src/shared/constants/pricing.js index 39798be6b4..c2dd41b7d0 100644 --- a/src/shared/constants/pricing.js +++ b/src/shared/constants/pricing.js @@ -272,6 +272,13 @@ export const DEFAULT_PRICING = { reasoning: 37.5, cache_creation: 5.0, }, + "claude-opus-4-6-thinking": { + input: 5.0, + output: 25.0, + cached: 0.5, + reasoning: 37.5, + cache_creation: 5.0, + }, }, // GitHub Copilot (gh) @@ -517,6 +524,228 @@ export const DEFAULT_PRICING = { cache_creation: 0.5, }, }, + + // ─── Free-tier API Key Providers (nominal $0 pricing) ─── + + // Groq + groq: { + "openai/gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "llama-3.3-70b-versatile": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "meta-llama/llama-4-maverick-17b-128e-instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "qwen/qwen3-32b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + }, + + // Fireworks + fireworks: { + "accounts/fireworks/models/gpt-oss-120b": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "accounts/fireworks/models/deepseek-v3p1": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "accounts/fireworks/models/llama-v3p3-70b-instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "accounts/fireworks/models/qwen3-235b-a22b": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + }, + + // Cerebras + cerebras: { + "gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "zai-glm-4.7": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "llama-3.3-70b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "llama-4-scout-17b-16e-instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "qwen-3-235b-a22b-instruct-2507": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "qwen-3-32b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + }, + + // Nvidia + nvidia: { + "openai/gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "moonshotai/kimi-k2.5": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "z-ai/glm4.7": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "deepseek-ai/deepseek-v3.2": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "nvidia/llama-3.3-70b-instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "meta/llama-4-maverick-17b-128e-instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "deepseek/deepseek-r1": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + }, + + // Nebius + nebius: { + "openai/gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "meta-llama/Llama-3.3-70B-Instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + }, + + // SiliconFlow + siliconflow: { + "openai/gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "deepseek-ai/DeepSeek-V3.2": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "deepseek-ai/DeepSeek-V3.1": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "deepseek-ai/DeepSeek-R1": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "Qwen/Qwen3-235B-A22B-Instruct-2507": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "Qwen/Qwen3-Coder-480B-A35B-Instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "Qwen/Qwen3-32B": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "moonshotai/Kimi-K2.5": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "zai-org/GLM-4.7": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "baidu/ERNIE-4.5-300B-A47B": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + }, + + // Hyperbolic + hyperbolic: { + "openai/gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "Qwen/QwQ-32B": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "deepseek-ai/DeepSeek-R1": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "deepseek-ai/DeepSeek-V3": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, + "meta-llama/Llama-3.3-70B-Instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "meta-llama/Llama-3.2-3B-Instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "Qwen/Qwen2.5-72B-Instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "Qwen/Qwen2.5-Coder-32B-Instruct": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + "NousResearch/Hermes-3-Llama-3.1-70B": { + input: 0, + output: 0, + cached: 0, + reasoning: 0, + cache_creation: 0, + }, + }, + + // Kiro (AWS) + kiro: { + "claude-sonnet-4.5": { + input: 3.0, + output: 15.0, + cached: 1.5, + reasoning: 15.0, + cache_creation: 3.0, + }, + "claude-haiku-4.5": { + input: 0.5, + output: 2.5, + cached: 0.25, + reasoning: 2.5, + cache_creation: 0.5, + }, + }, }; /** From 413a9f2a694e5bdd3f6227ea85c6113d50d20001 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 15 Feb 2026 16:26:01 -0300 Subject: [PATCH 2/5] 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 ( -