diff --git a/src/app/(dashboard)/dashboard/DashboardPage.module.css b/src/app/(dashboard)/dashboard/DashboardPage.module.css index 8046e82d..1f378a87 100644 --- a/src/app/(dashboard)/dashboard/DashboardPage.module.css +++ b/src/app/(dashboard)/dashboard/DashboardPage.module.css @@ -43,8 +43,8 @@ :global(.dark) .console::before { background: - radial-gradient(circle at 74% 4%, rgba(241, 112, 82, 0.07), transparent 28%), - linear-gradient(180deg, rgba(33, 40, 51, 0.42), transparent 82%); + radial-gradient(ellipse 46% 36% at 74% 4%, rgba(241, 112, 82, 0.11), transparent 70%), + linear-gradient(180deg, rgba(31, 36, 44, 0.5), transparent 82%); } .intro { @@ -122,6 +122,98 @@ line-height: 1.45; } +.quickStats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.75rem; +} + +.statCard, +.statSkeleton { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + grid-template-rows: auto auto 1fr; + column-gap: 0.75rem; + min-width: 0; + min-height: 6.75rem; + padding: 0.9rem 1rem; + border: 1px solid var(--console-rule); + border-radius: 0.75rem; + background: color-mix(in srgb, var(--console-panel-strong) 88%, transparent); +} + +.statCard { + position: relative; + overflow: hidden; + transition: border-color 180ms ease, background-color 180ms ease, transform 180ms ease; +} + +.statCard::after { + position: absolute; + inset: auto 0 0; + height: 1px; + content: ""; + background: linear-gradient(to right, var(--console-signal), transparent 58%); + opacity: 0.48; +} + +.statCard:hover { + border-color: color-mix(in srgb, var(--console-signal) 40%, var(--console-rule)); + background: color-mix(in srgb, var(--console-panel-strong) 97%, transparent); + transform: translateY(-0.1rem); +} + +.statIcon { + display: grid; + grid-row: 1 / span 2; + width: 2rem; + height: 2rem; + place-items: center; + border: 1px solid color-mix(in srgb, var(--console-signal) 30%, var(--console-rule)); + border-radius: 0.5rem; + background: var(--console-signal-soft); + color: var(--console-signal); + font-size: 1rem; +} + +.statLabel { + overflow: hidden; + margin: 0; + color: var(--console-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.075em; + line-height: 1.35; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.statValue { + margin: 0.15rem 0 0; + color: var(--console-ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: clamp(1.25rem, 2vw, 1.55rem); + font-weight: 700; + letter-spacing: -0.045em; + line-height: 1; +} + +.statDescription { + grid-column: 1 / -1; + align-self: end; + margin: 0.65rem 0 0; + color: var(--console-muted); + font-size: 0.6875rem; + line-height: 1.35; +} + +.statSkeleton { + grid-template-columns: auto minmax(0, 1fr); + align-items: start; +} + .accessPanel, .keysPanel { border: 1px solid var(--console-rule) !important; @@ -237,6 +329,185 @@ color: var(--console-ink) !important; } +.quickConnect { + position: relative; + z-index: 1; + display: grid; + gap: 0.875rem; + margin-top: 1rem; + padding: 1rem; + border: 1px solid var(--console-rule); + border-radius: 0.75rem; + background: color-mix(in srgb, var(--console-panel) 76%, transparent); +} + +.quickConnectHeader, +.quickConnectIntro { + display: flex; + align-items: center; +} + +.quickConnectHeader { + justify-content: space-between; + gap: 1rem; +} + +.quickConnectIntro { + min-width: 0; + gap: 0.75rem; +} + +.quickConnectIcon { + display: grid; + width: 2rem; + height: 2rem; + flex: 0 0 auto; + place-items: center; + border: 1px solid color-mix(in srgb, var(--console-signal) 30%, var(--console-rule)); + border-radius: 0.5rem; + background: var(--console-signal-soft); + color: var(--console-signal); +} + +.quickConnectTitle, +.quickConnectDescription, +.connectGuidance { + margin: 0; +} + +.quickConnectTitle { + color: var(--console-ink); + font-size: 0.8125rem; + font-weight: 700; + line-height: 1.25; +} + +.quickConnectDescription { + margin-top: 0.2rem; + color: var(--console-muted); + font-size: 0.75rem; + line-height: 1.45; +} + +.connectStatus { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + gap: 0.4rem; + color: var(--console-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.04em; + line-height: 1.2; + text-transform: uppercase; + white-space: nowrap; +} + +.connectStatusIndicator { + width: 0.4rem; + height: 0.4rem; + border-radius: 50%; + background: var(--color-success); + box-shadow: 0 0 0 0.18rem color-mix(in srgb, var(--color-success) 18%, transparent); +} + +.connectStatus[data-state="action-required"] .connectStatusIndicator { + background: var(--console-signal); + box-shadow: 0 0 0 0.18rem var(--console-signal-soft); +} + +.snippetTabs { + display: flex; + gap: 0.35rem; + overflow-x: auto; + padding-bottom: 0.1rem; + scrollbar-width: none; +} + +.snippetTabs::-webkit-scrollbar { + display: none; +} + +.snippetTabs button { + flex: 0 0 auto; + padding: 0.35rem 0.65rem; + border: 1px solid transparent; + border-radius: 0.375rem; + color: var(--console-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.6875rem; + font-weight: 700; + line-height: 1; + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.snippetTabs button:hover { + background: color-mix(in srgb, var(--console-panel-strong) 72%, transparent); + color: var(--console-ink); +} + +.snippetTabs button[data-active="true"] { + border-color: color-mix(in srgb, var(--console-signal) 38%, var(--console-rule)); + background: var(--console-signal-soft); + color: var(--console-signal); +} + +.snippetPanel { + position: relative; + min-width: 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--console-rule) 92%, transparent); + border-radius: 0.625rem; + background: color-mix(in srgb, var(--console-canvas) 50%, var(--console-panel) 50%); +} + +.snippetPre { + max-height: 11rem; + overflow: auto; + margin: 0; + padding: 0.875rem 3rem 0.875rem 0.875rem; + color: var(--console-ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.75rem; + line-height: 1.6; + tab-size: 2; + white-space: pre; +} + +.snippetCopy { + position: absolute; + top: 0.5rem; + right: 0.5rem; + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border: 1px solid transparent; + border-radius: 0.5rem; + background: color-mix(in srgb, var(--console-panel-strong) 78%, transparent); + color: var(--console-muted); + transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.snippetCopy:hover { + border-color: color-mix(in srgb, var(--console-signal) 35%, var(--console-rule)); + background: var(--console-signal-soft); + color: var(--console-signal); +} + +.connectGuidance { + color: var(--console-muted); + font-size: 0.75rem; + line-height: 1.45; +} + +.connectGuidance code { + color: var(--console-ink); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.6875rem; +} + .keysPanel { position: relative; } @@ -354,6 +625,10 @@ min-height: auto; } + .quickStats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .accessPanel::before { display: none; } @@ -369,11 +644,13 @@ } @media (prefers-reduced-motion: reduce) { + .statCard, .routeLocal, .routeRemote { transition: none; } + .statCard:hover, .routeLocal:hover, .routeRemote:hover { transform: none; diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 78298b15..5ac6d557 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -6,6 +6,8 @@ import { Button, Card, ConfirmModal, Input, Modal, Toggle } from "@/shared/compo import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { formatVietnamDateTime } from "@/shared/utils/dateTime"; import EndpointRow from "./components/EndpointRow"; +import QuickStatsBar, { QuickStatsBarSkeleton } from "./components/QuickStatsBar"; +import QuickConnectSnippet from "./components/QuickConnectSnippet"; import SecurityWarning from "./components/SecurityWarning"; import styles from "../DashboardPage.module.css"; @@ -24,6 +26,9 @@ export default function APIPageClient({ isAdmin }) { const [confirmState, setConfirmState] = useState(null); const [requireApiKey, setRequireApiKey] = useState(false); const [visibleKeys, setVisibleKeys] = useState(new Set()); + const [quickStats, setQuickStats] = useState(null); + const [quickStatsLoading, setQuickStatsLoading] = useState(true); + const [providerSummary, setProviderSummary] = useState(null); const baseUrl = useSyncExternalStore(subscribeToBrowserLocation, getBaseUrl, () => "/v1"); const isRemoteHost = useSyncExternalStore(subscribeToBrowserLocation, getRemoteHost, () => false); @@ -57,13 +62,48 @@ export default function APIPageClient({ isAdmin }) { } }, []); + const fetchQuickStats = useCallback(async () => { + setQuickStatsLoading(true); + try { + const [statsResponse, providersResponse] = await Promise.all([ + fetch("/api/usage/stats?period=today"), + fetch("/api/providers/client?pageSize=500"), + ]); + + if (!statsResponse.ok) throw new Error("Failed to fetch usage statistics"); + + const statsData = await statsResponse.json(); + setQuickStats(statsData); + + if (providersResponse.ok) { + const providersData = await providersResponse.json(); + const connections = providersData.connections || []; + const activeConnections = connections.filter( + (connection) => connection.isActive && connection.testStatus === "active", + ); + setProviderSummary({ + active: activeConnections.length, + total: providersData.pagination?.total ?? connections.length, + }); + } else { + setProviderSummary(null); + } + } catch { + setQuickStats(null); + setProviderSummary(null); + } finally { + setQuickStatsLoading(false); + } + }, []); + useEffect(() => { const initialize = window.setTimeout(() => { void fetchKeys(); + void fetchQuickStats(); if (isAdmin) void loadSettings(); }, 0); return () => window.clearTimeout(initialize); - }, [fetchKeys, isAdmin, loadSettings]); + }, [fetchKeys, fetchQuickStats, isAdmin, loadSettings]); const handleRequireApiKey = async (value) => { try { @@ -167,6 +207,7 @@ export default function APIPageClient({ isAdmin }) {
+
@@ -184,6 +225,7 @@ export default function APIPageClient({ isAdmin }) { : requireApiKey ? "Local access is ready and API key enforcement is enabled." : "Local access is ready. Enable API key enforcement to protect requests."; + const hasActiveApiKey = keys.some((key) => key.isActive !== false); return (
@@ -200,6 +242,12 @@ export default function APIPageClient({ isAdmin }) {

{copied ? "Value copied to clipboard." : ""}

+ {quickStatsLoading ? ( + + ) : ( + + )} + {loadError && (
{loadError} @@ -225,6 +273,13 @@ export default function APIPageClient({ isAdmin }) { onCopy={copy} className={`${styles.routeLocal} ${styles.routeInput}`} /> + diff --git a/src/app/(dashboard)/dashboard/endpoint/components/QuickConnectSnippet.js b/src/app/(dashboard)/dashboard/endpoint/components/QuickConnectSnippet.js new file mode 100644 index 00000000..a346fed2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/components/QuickConnectSnippet.js @@ -0,0 +1,123 @@ +"use client"; + +import { useState } from "react"; +import PropTypes from "prop-types"; +import styles from "../../DashboardPage.module.css"; + +const SNIPPET_TABS = [ + { id: "curl", label: "cURL" }, + { id: "node", label: "Node.js" }, + { id: "python", label: "Python" }, +]; + +const normalizeEndpoint = (baseUrl) => (baseUrl || "/v1").replace(/\/+$/, ""); + +function buildSnippet(language, baseUrl) { + const endpoint = normalizeEndpoint(baseUrl); + + switch (language) { + case "node": + return `import OpenAI from "openai"; + +const client = new OpenAI({ + baseURL: ${JSON.stringify(endpoint)}, + apiKey: process.env.NINEROUTER_API_KEY, +}); + +const models = await client.models.list(); +console.log(models.data);`; + case "python": + return `from openai import OpenAI +import os + +client = OpenAI( + base_url=${JSON.stringify(endpoint)}, + api_key=os.environ["NINEROUTER_API_KEY"], +) + +models = client.models.list() +print(models.data)`; + default: + return `export NINEROUTER_API_KEY="sk-your-api-key" + +curl -s "${endpoint}/models" \\ + -H "Authorization: Bearer $NINEROUTER_API_KEY"`; + } +} + +export default function QuickConnectSnippet({ baseUrl, hasApiKey, requireApiKey, copied, onCopy }) { + const [activeTab, setActiveTab] = useState("curl"); + const snippet = buildSnippet(activeTab, baseUrl); + const activeTabLabel = SNIPPET_TABS.find((tab) => tab.id === activeTab)?.label || "cURL"; + const copyId = `quick_connect_${activeTab}`; + const needsKey = requireApiKey && !hasApiKey; + + return ( +
+
+
+ +
+

Make a first request

+

Set your key as an environment variable, then list models to confirm that your client is connected.

+
+
+
+
+
+ +
+ {SNIPPET_TABS.map((tab) => ( + + ))} +
+ +
+
{snippet}
+ +
+ + {needsKey && ( +

+ Create an active key in the credential registry below, then replace sk-your-api-key before running the sample. +

+ )} +
+ ); +} + +QuickConnectSnippet.propTypes = { + baseUrl: PropTypes.string.isRequired, + hasApiKey: PropTypes.bool.isRequired, + requireApiKey: PropTypes.bool.isRequired, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, +}; diff --git a/src/app/(dashboard)/dashboard/endpoint/components/QuickStatsBar.js b/src/app/(dashboard)/dashboard/endpoint/components/QuickStatsBar.js new file mode 100644 index 00000000..07dbd303 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/components/QuickStatsBar.js @@ -0,0 +1,96 @@ +"use client"; + +import PropTypes from "prop-types"; +import styles from "../../DashboardPage.module.css"; + +const integerFormatter = new Intl.NumberFormat("en-US"); +const compactFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +function toNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; +} + +const statDefinitions = [ + { + key: "requests", + label: "Today's requests", + icon: "trending_up", + description: "Since local midnight", + getValue: (stats) => integerFormatter.format(toNumber(stats.totalRequests)), + }, + { + key: "input", + label: "Input tokens", + icon: "input", + description: "Prompt volume today", + getValue: (stats) => compactFormatter.format(toNumber(stats.totalPromptTokens)), + }, + { + key: "output", + label: "Output tokens", + icon: "output", + description: "Completion volume today", + getValue: (stats) => compactFormatter.format(toNumber(stats.totalCompletionTokens)), + }, + { + key: "providers", + label: "Active providers", + icon: "dns", + description: "Healthy connections", + getValue: (_stats, providerSummary) => providerSummary + ? `${providerSummary.active} / ${providerSummary.total}` + : "—", + }, +]; + +export function QuickStatsBarSkeleton() { + return ( +
+ Loading today's gateway statistics. + {statDefinitions.map((stat) => ( +
+ ); +} + +export default function QuickStatsBar({ stats, providerSummary = null }) { + if (!stats) return null; + + return ( +
+

Today's gateway overview

+ {statDefinitions.map((stat) => ( +
+ +

{stat.label}

+

{stat.getValue(stats, providerSummary)}

+

{stat.description}

+
+ ))} +
+ ); +} + +QuickStatsBar.propTypes = { + stats: PropTypes.shape({ + totalRequests: PropTypes.number, + totalPromptTokens: PropTypes.number, + totalCompletionTokens: PropTypes.number, + }), + providerSummary: PropTypes.shape({ + active: PropTypes.number.isRequired, + total: PropTypes.number.isRequired, + }), +}; \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index 9c4f8bd3..d81ab47d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -207,6 +207,28 @@ body { -moz-osx-font-smoothing: grayscale; } +/* Dashboard canvas: keep the application background in the same restrained + slate-and-coral family as its control surfaces. */ +.dashboard-shell { + background: var(--color-bg); +} + +.dashboard-main { + background: var(--color-bg); +} + +.dark .dashboard-shell { + background: #141619; +} + +.dark .dashboard-main { + background-color: #141619; + background-image: + radial-gradient(ellipse 58% 42% at 78% -8%, rgba(241, 112, 82, 0.1), transparent 72%), + radial-gradient(ellipse 46% 34% at 8% 110%, rgba(94, 112, 133, 0.08), transparent 74%), + linear-gradient(180deg, rgba(24, 27, 31, 0.55), rgba(20, 22, 25, 0)); +} + /* Selection - brand-tinted */ ::selection { background-color: rgba(237, 101, 72, 0.25); @@ -441,6 +463,13 @@ button:disabled, opacity: 0.16; } +.dark .dashboard-main .landing-grid { + background-image: + linear-gradient(to right, rgba(165, 177, 192, 0.08) 1px, transparent 1px), + linear-gradient(to bottom, rgba(165, 177, 192, 0.08) 1px, transparent 1px); + opacity: 1; +} + /* React Flow controls: match app theme */ .react-flow-controls-custom { background: var(--color-surface); diff --git a/src/shared/components/layouts/DashboardLayout.js b/src/shared/components/layouts/DashboardLayout.js index c41c0d7b..773967c4 100644 --- a/src/shared/components/layouts/DashboardLayout.js +++ b/src/shared/components/layouts/DashboardLayout.js @@ -38,7 +38,7 @@ export default function DashboardLayout({ children }) { const removeNotification = useNotificationStore((state) => state.removeNotification); return ( -
+
{notifications.map((n) => { const style = getToastStyle(n.type); @@ -91,7 +91,7 @@ export default function DashboardLayout({ children }) {
{/* Main content */} -
+
{/* Faint grid background */}