feat: initial OmniRoute release (rebranded from 9router)

This project is inspired by and originally forked from 9router by decolua
(https://github.com/decolua/9router).

Full rebrand: 9router → OmniRoute across all source code, configuration,
Docker, documentation, and assets.
This commit is contained in:
diegosouzapw
2026-02-13 16:26:43 -03:00
commit 699329944e
430 changed files with 69016 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
"use client";
import { cn } from "@/shared/utils/cn";
export default function Avatar({ src, alt = "Avatar", name, size = "md", className }) {
const sizes = {
xs: "size-6 text-xs",
sm: "size-8 text-sm",
md: "size-10 text-base",
lg: "size-12 text-lg",
xl: "size-16 text-xl",
};
// Get initials from name
const getInitials = (name) => {
if (!name) return "?";
const parts = name.split(" ");
if (parts.length >= 2) {
return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
// Generate color from name
const getColorFromName = (name) => {
if (!name) return "bg-primary";
const colors = [
"bg-red-500",
"bg-orange-500",
"bg-amber-500",
"bg-yellow-500",
"bg-lime-500",
"bg-green-500",
"bg-emerald-500",
"bg-teal-500",
"bg-cyan-500",
"bg-sky-500",
"bg-blue-500",
"bg-indigo-500",
"bg-violet-500",
"bg-purple-500",
"bg-fuchsia-500",
"bg-pink-500",
"bg-rose-500",
];
const index = name.charCodeAt(0) % colors.length;
return colors[index];
};
if (src) {
return (
<div
className={cn(
"rounded-full bg-cover bg-center bg-no-repeat",
"ring-2 ring-white dark:ring-surface-dark shadow-sm",
sizes[size],
className
)}
style={{ backgroundImage: `url(${src})` }}
role="img"
aria-label={alt}
/>
);
}
return (
<div
className={cn(
"rounded-full flex items-center justify-center font-semibold text-white",
"ring-2 ring-white dark:ring-surface-dark shadow-sm",
sizes[size],
getColorFromName(name),
className
)}
role="img"
aria-label={alt}
>
{getInitials(name)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import { cn } from "@/shared/utils/cn";
const variants = {
default: "bg-black/5 dark:bg-white/10 text-text-muted",
primary: "bg-primary/10 text-primary",
success: "bg-green-500/10 text-green-600 dark:text-green-400",
warning: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
error: "bg-red-500/10 text-red-600 dark:text-red-400",
info: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
};
const sizes = {
sm: "px-2 py-0.5 text-[10px]",
md: "px-2.5 py-1 text-xs",
lg: "px-3 py-1.5 text-sm",
};
export default function Badge({
children,
variant = "default",
size = "md",
dot = false,
icon,
className,
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full font-semibold",
variants[variant],
sizes[size],
className
)}
>
{dot && (
<span
aria-hidden="true"
className={cn(
"size-1.5 rounded-full",
variant === "success" && "bg-green-500",
variant === "warning" && "bg-yellow-500",
variant === "error" && "bg-red-500",
variant === "info" && "bg-blue-500",
variant === "primary" && "bg-primary",
variant === "default" && "bg-gray-500"
)}
/>
)}
{icon && (
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{icon}
</span>
)}
{children}
</span>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { cn } from "@/shared/utils/cn";
const variants = {
primary: "bg-gradient-to-b from-primary to-primary-hover text-white shadow-sm",
secondary:
"bg-white dark:bg-white/10 border border-black/10 dark:border-white/10 text-text-main hover:bg-black/5 dark:hover:bg-white/5",
outline: "border border-black/15 dark:border-white/15 text-text-main hover:bg-black/5",
ghost: "text-text-muted hover:bg-black/5 dark:hover:bg-white/5 hover:text-text-main",
danger: "bg-red-500 text-white hover:bg-red-600 shadow-sm",
};
const sizes = {
sm: "h-7 px-3 text-xs rounded-md",
md: "h-9 px-4 text-sm rounded-lg",
lg: "h-11 px-6 text-sm rounded-lg",
};
export default function Button({
children,
variant = "primary",
size = "md",
icon,
iconRight,
disabled = false,
loading = false,
fullWidth = false,
className,
...props
}) {
return (
<button
type="button"
className={cn(
"inline-flex items-center justify-center gap-2 font-medium transition-all duration-200 cursor-pointer",
"active:scale-[0.99] disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100",
variants[variant],
sizes[size],
fullWidth && "w-full",
className
)}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...props}
>
{loading ? (
<span
className="material-symbols-outlined animate-spin text-[18px] pointer-events-none"
aria-hidden="true"
>
progress_activity
</span>
) : icon ? (
<span
className="material-symbols-outlined text-[18px] pointer-events-none"
aria-hidden="true"
>
{icon}
</span>
) : null}
{children}
{iconRight && !loading && (
<span
className="material-symbols-outlined text-[18px] pointer-events-none"
aria-hidden="true"
>
{iconRight}
</span>
)}
</button>
);
}

View File

@@ -0,0 +1,112 @@
"use client";
import { cn } from "@/shared/utils/cn";
export default function Card({
children,
title,
subtitle,
icon,
action,
padding = "md",
hover = false,
className,
...props
}) {
const paddings = {
none: "",
xs: "p-3",
sm: "p-4",
md: "p-6",
lg: "p-8",
};
return (
<div
className={cn(
"bg-surface",
"border border-black/5 dark:border-white/5",
"rounded-lg shadow-sm",
hover && "hover:shadow-md hover:border-primary/30 transition-all cursor-pointer",
paddings[padding],
className
)}
{...props}
>
{(title || action) && (
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
{icon && (
<div className="p-2 rounded-lg bg-bg text-text-muted">
<span className="material-symbols-outlined text-[20px]">{icon}</span>
</div>
)}
<div>
{title && <h3 className="text-text-main font-semibold">{title}</h3>}
{subtitle && <p className="text-sm text-text-muted">{subtitle}</p>}
</div>
</div>
{action}
</div>
)}
{children}
</div>
);
}
// Sub-component: Bordered section inside Card
Card.Section = function CardSection({ children, className, ...props }) {
return (
<div
className={cn(
"p-4 rounded-lg",
"bg-black/[0.02] dark:bg-white/[0.02]",
"border border-black/5 dark:border-white/5",
className
)}
{...props}
>
{children}
</div>
);
};
// Sub-component: Hoverable row inside Card
Card.Row = function CardRow({ children, className, ...props }) {
return (
<div
className={cn(
"p-3 -mx-3 px-3 transition-colors",
"border-b border-black/5 dark:border-white/5 last:border-b-0",
"hover:bg-black/[0.02] dark:hover:bg-white/[0.02]",
className
)}
{...props}
>
{children}
</div>
);
};
// Sub-component: List item with hover actions (macOS style)
Card.ListItem = function CardListItem({ children, actions, className, ...props }) {
return (
<div
className={cn(
"group flex items-center justify-between p-3 -mx-3 px-3",
"border-b border-black/[0.03] dark:border-white/[0.03] last:border-b-0",
"hover:bg-black/[0.02] dark:hover:bg-white/[0.02]",
"transition-colors",
className
)}
{...props}
>
<div className="flex-1 min-w-0">{children}</div>
{actions && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
{actions}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,194 @@
"use client";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
/**
* Cursor Auth Modal
* Auto-detect and import token from Cursor IDE's local SQLite database
*/
export default function CursorAuthModal({ isOpen, onSuccess, onClose }) {
const [accessToken, setAccessToken] = useState("");
const [machineId, setMachineId] = useState("");
const [error, setError] = useState(null);
const [importing, setImporting] = useState(false);
const [autoDetecting, setAutoDetecting] = useState(false);
const [autoDetected, setAutoDetected] = useState(false);
// Auto-detect tokens when modal opens
useEffect(() => {
if (!isOpen) return;
const autoDetect = async () => {
setAutoDetecting(true);
setError(null);
setAutoDetected(false);
try {
const res = await fetch("/api/oauth/cursor/auto-import");
const data = await res.json();
if (data.found) {
setAccessToken(data.accessToken);
setMachineId(data.machineId);
setAutoDetected(true);
} else {
setError(data.error || "Could not auto-detect tokens");
}
} catch (err) {
setError("Failed to auto-detect tokens");
} finally {
setAutoDetecting(false);
}
};
autoDetect();
}, [isOpen]);
const handleImportToken = async () => {
if (!accessToken.trim()) {
setError("Please enter an access token");
return;
}
if (!machineId.trim()) {
setError("Please enter a machine ID");
return;
}
setImporting(true);
setError(null);
try {
const res = await fetch("/api/oauth/cursor/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
accessToken: accessToken.trim(),
machineId: machineId.trim(),
}),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Import failed");
}
// Success - close modal and trigger refresh
onSuccess?.();
onClose();
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
};
return (
<Modal isOpen={isOpen} title="Connect Cursor IDE" onClose={onClose}>
<div className="flex flex-col gap-4">
{/* Auto-detecting state */}
{autoDetecting && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Auto-detecting tokens...</h3>
<p className="text-sm text-text-muted">Reading from Cursor IDE database</p>
</div>
)}
{/* Form (shown after auto-detect completes) */}
{!autoDetecting && (
<>
{/* Success message if auto-detected */}
{autoDetected && (
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-green-600 dark:text-green-400">
check_circle
</span>
<p className="text-sm text-green-800 dark:text-green-200">
Tokens auto-detected from Cursor IDE successfully!
</p>
</div>
</div>
)}
{/* Info message if not auto-detected */}
{!autoDetected && !error && (
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">
info
</span>
<p className="text-sm text-blue-800 dark:text-blue-200">
Cursor IDE not detected. Please paste your tokens manually.
</p>
</div>
</div>
)}
{/* Access Token Input */}
<div>
<label className="block text-sm font-medium mb-2">
Access Token <span className="text-red-500">*</span>
</label>
<textarea
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="Access token will be auto-filled..."
rows={3}
className="w-full px-3 py-2 text-sm font-mono border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
</div>
{/* Machine ID Input */}
<div>
<label className="block text-sm font-medium mb-2">
Machine ID <span className="text-red-500">*</span>
</label>
<Input
value={machineId}
onChange={(e) => setMachineId(e.target.value)}
placeholder="Machine ID will be auto-filled..."
className="font-mono text-sm"
/>
</div>
{/* Error Display */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-2">
<Button
onClick={handleImportToken}
fullWidth
disabled={importing || !accessToken.trim() || !machineId.trim()}
>
{importing ? "Importing..." : "Import Token"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</>
)}
</div>
</Modal>
);
}
CursorAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,162 @@
"use client";
import Link from "next/link";
import { APP_CONFIG } from "@/shared/constants/config";
const footerLinks = {
product: [
{ label: "Features", href: "#features" },
{ label: "Pricing", href: "#pricing" },
{ label: "Changelog", href: "https://github.com/decolua/omniroute/releases", external: true },
],
resources: [
{ label: "Documentation", href: "/docs" },
{ label: "API Reference", href: "/docs#api-reference" },
{
label: "Help Center",
href: "https://github.com/decolua/omniroute/discussions",
external: true,
},
],
company: [
{ label: "About", href: "https://github.com/decolua/omniroute", external: true },
{ label: "Blog", href: "https://github.com/decolua/omniroute/releases", external: true },
{
label: "Contact",
href: "https://github.com/decolua/omniroute/issues/new/choose",
external: true,
},
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
],
};
export default function Footer() {
const renderFooterLink = (link) => {
if (link.external) {
return (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary transition-colors"
>
{link.label}
</a>
);
}
return (
<Link href={link.href} className="hover:text-primary transition-colors">
{link.label}
</Link>
);
};
return (
<footer className="bg-bg border-t border-border pt-16 pb-12">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-10 mb-12">
{/* Brand */}
<div className="col-span-2 lg:col-span-2">
<div className="flex items-center gap-2 mb-6">
<div className="size-6 text-primary">
<svg className="w-full h-full" fill="currentColor" viewBox="0 0 48 48">
<path
clipRule="evenodd"
d="M12.0799 24L4 19.2479L9.95537 8.75216L18.04 13.4961L18.0446 4H29.9554L29.96 13.4961L38.0446 8.75216L44 19.2479L35.92 24L44 28.7521L38.0446 39.2479L29.96 34.5039L29.9554 44H18.0446L18.04 34.5039L9.95537 39.2479L4 28.7521L12.0799 24Z"
fillRule="evenodd"
/>
</svg>
</div>
<span className="text-xl font-bold text-text-main">{APP_CONFIG.name}</span>
</div>
<p className="text-text-muted mb-6 max-w-sm font-light">
The unified interface for modern AI infrastructure. Secure, observable, and scalable.
</p>
{/* Social links */}
<div className="flex gap-4">
<a
href="https://github.com/decolua/omniroute/discussions"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary transition-colors"
aria-label="Community Discussions"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z" />
</svg>
</a>
<a
href="https://github.com/decolua/omniroute"
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary transition-colors"
aria-label="GitHub"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
</svg>
</a>
</div>
</div>
{/* Product */}
<div>
<h4 className="font-semibold text-text-main mb-4">Product</h4>
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.product.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li>
))}
</ul>
</div>
{/* Resources */}
<div>
<h4 className="font-semibold text-text-main mb-4">Resources</h4>
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.resources.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li>
))}
</ul>
</div>
{/* Company */}
<div>
<h4 className="font-semibold text-text-main mb-4">Company</h4>
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.company.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li>
))}
</ul>
</div>
</div>
{/* Bottom */}
<div className="border-t border-border pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-sm text-text-muted">
© {new Date().getFullYear()} {APP_CONFIG.name} Inc. All rights reserved.
</p>
<div className="flex gap-6 text-sm text-text-muted">
<a href="/docs" className="hover:text-primary transition-colors">
Documentation
</a>
<Link href="/terms" className="hover:text-primary transition-colors">
Terms
</Link>
<Link href="/privacy" className="hover:text-primary transition-colors">
Privacy
</Link>
<a
href="https://github.com/decolua/omniroute/blob/main/LICENSE"
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary transition-colors"
>
License
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import PropTypes from "prop-types";
import { ThemeToggle } from "@/shared/components";
import {
OAUTH_PROVIDERS,
APIKEY_PROVIDERS,
FREE_PROVIDERS,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
const getPageInfo = (pathname) => {
if (!pathname) return { title: "", description: "", breadcrumbs: [] };
// Provider detail page: /dashboard/providers/[id]
const providerMatch = pathname.match(/\/providers\/([^/]+)$/);
if (providerMatch) {
const providerId = providerMatch[1];
const providerInfo =
OAUTH_PROVIDERS[providerId] || FREE_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId];
if (providerInfo) {
return {
title: providerInfo.name,
description: "",
breadcrumbs: [
{ label: "Providers", href: "/dashboard/providers" },
{ label: providerInfo.name, image: `/providers/${providerInfo.id}.png` },
],
};
}
if (providerId.startsWith(OPENAI_COMPATIBLE_PREFIX)) {
return {
title: "OpenAI Compatible",
description: "",
breadcrumbs: [
{ label: "Providers", href: "/dashboard/providers" },
{ label: "OpenAI Compatible", image: "/providers/oai-cc.png" },
],
};
}
if (providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) {
return {
title: "Anthropic Compatible",
description: "",
breadcrumbs: [
{ label: "Providers", href: "/dashboard/providers" },
{ label: "Anthropic Compatible", image: "/providers/anthropic-m.png" },
],
};
}
}
if (pathname.includes("/providers"))
return {
title: "Providers",
description: "Manage your AI provider connections",
breadcrumbs: [],
};
if (pathname.includes("/combos"))
return { title: "Combos", description: "Model combos with fallback", breadcrumbs: [] };
if (pathname.includes("/usage"))
return {
title: "Usage & Analytics",
description: "Monitor your API usage, token consumption, and request logs",
breadcrumbs: [],
};
if (pathname.includes("/cli-tools"))
return { title: "CLI Tools", description: "Configure CLI tools", 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: [] };
};
export default function Header({ onMenuClick, showMenuButton = true }) {
const pathname = usePathname();
const router = useRouter();
const { title, description, breadcrumbs } = getPageInfo(pathname);
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", { method: "POST" });
if (res.ok) {
router.push("/login");
router.refresh();
}
} catch (err) {
console.error("Failed to logout:", err);
}
};
return (
<header className="flex items-center justify-between px-8 py-5 border-b border-black/5 dark:border-white/5 bg-bg/80 backdrop-blur-xl z-10 sticky top-0">
{/* Mobile menu button */}
<div className="flex items-center gap-3 lg:hidden">
{showMenuButton && (
<button
onClick={onMenuClick}
className="text-text-main hover:text-primary transition-colors"
>
<span className="material-symbols-outlined">menu</span>
</button>
)}
</div>
{/* Page title with breadcrumbs - desktop */}
<div className="hidden lg:flex flex-col">
{breadcrumbs.length > 0 ? (
<div className="flex items-center gap-2">
{breadcrumbs.map((crumb, index) => (
<div
key={`${crumb.label}-${crumb.href || "current"}`}
className="flex items-center gap-2"
>
{index > 0 && (
<span className="material-symbols-outlined text-text-muted text-base">
chevron_right
</span>
)}
{crumb.href ? (
<Link
href={crumb.href}
className="text-text-muted hover:text-primary transition-colors"
>
{crumb.label}
</Link>
) : (
<div className="flex items-center gap-2">
{crumb.image && (
<Image
src={crumb.image}
alt={crumb.label}
width={28}
height={28}
className="object-contain rounded max-w-[28px] max-h-[28px]"
sizes="28px"
onError={(e) => {
e.currentTarget.style.display = "none";
}}
/>
)}
<h1 className="text-2xl font-semibold text-text-main tracking-tight">
{crumb.label}
</h1>
</div>
)}
</div>
))}
</div>
) : title ? (
<div>
<h1 className="text-2xl font-semibold text-text-main tracking-tight">{title}</h1>
{description && <p className="text-sm text-text-muted">{description}</p>}
</div>
) : null}
</div>
{/* Right actions */}
<div className="flex items-center gap-3 ml-auto">
{/* Theme toggle */}
<ThemeToggle />
{/* Logout button */}
<button
onClick={handleLogout}
className="flex items-center justify-center p-2 rounded-lg text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-all"
title="Logout"
>
<span className="material-symbols-outlined">logout</span>
</button>
</div>
</header>
);
}
Header.propTypes = {
onMenuClick: PropTypes.func,
showMenuButton: PropTypes.bool,
};

View File

@@ -0,0 +1,89 @@
"use client";
import { useId } from "react";
import { cn } from "@/shared/utils/cn";
export default function Input({
label,
type = "text",
placeholder,
value,
onChange,
error,
hint,
icon,
disabled = false,
required = false,
className,
inputClassName,
id: externalId,
...props
}) {
const generatedId = useId();
const inputId = externalId || generatedId;
const errorId = error ? `${inputId}-error` : undefined;
const hintId = hint && !error ? `${inputId}-hint` : undefined;
const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined;
return (
<div className={cn("flex flex-col gap-1.5", className)}>
{label && (
<label htmlFor={inputId} className="text-sm font-medium text-text-main">
{label}
{required && (
<span className="text-red-500 ml-1" aria-hidden="true">
*
</span>
)}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none text-text-muted">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
{icon}
</span>
</div>
)}
<input
id={inputId}
type={type}
placeholder={placeholder}
value={value}
onChange={onChange}
disabled={disabled}
required={required}
aria-required={required || undefined}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cn(
"w-full py-2 px-3 text-sm text-text-main",
"bg-white dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-md",
"placeholder-text-muted/60",
"focus:ring-1 focus:ring-primary/30 focus:border-primary/50 focus:outline-none",
"transition-all shadow-inner disabled:opacity-50 disabled:cursor-not-allowed",
// iOS zoom fix
"text-[16px] sm:text-sm",
icon && "pl-10",
error ? "border-red-500 focus:border-red-500 focus:ring-red-500/20" : "",
inputClassName
)}
{...props}
/>
</div>
{error && (
<p id={errorId} className="text-xs text-red-500 flex items-center gap-1" role="alert">
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
error
</span>
{error}
</p>
)}
{hint && !error && (
<p id={hintId} className="text-xs text-text-muted">
{hint}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,389 @@
"use client";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
/**
* Kiro Auth Method Selection Modal
* Auto-detects token from AWS SSO cache or allows manual import
*/
export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
const [selectedMethod, setSelectedMethod] = useState(null);
const [idcStartUrl, setIdcStartUrl] = useState("");
const [idcRegion, setIdcRegion] = useState("us-east-1");
const [refreshToken, setRefreshToken] = useState("");
const [error, setError] = useState(null);
const [importing, setImporting] = useState(false);
const [autoDetecting, setAutoDetecting] = useState(false);
const [autoDetected, setAutoDetected] = useState(false);
// Auto-detect token when import method is selected
useEffect(() => {
if (selectedMethod !== "import" || !isOpen) return;
const autoDetect = async () => {
setAutoDetecting(true);
setError(null);
setAutoDetected(false);
try {
const res = await fetch("/api/oauth/kiro/auto-import");
const data = await res.json();
if (data.found) {
setRefreshToken(data.refreshToken);
setAutoDetected(true);
} else {
setError(data.error || "Could not auto-detect token");
}
} catch (err) {
setError("Failed to auto-detect token");
} finally {
setAutoDetecting(false);
}
};
autoDetect();
}, [selectedMethod, isOpen]);
const handleMethodSelect = (method) => {
setSelectedMethod(method);
setError(null);
};
const handleBack = () => {
setSelectedMethod(null);
setError(null);
};
const handleImportToken = async () => {
if (!refreshToken.trim()) {
setError("Please enter a refresh token");
return;
}
setImporting(true);
setError(null);
try {
const res = await fetch("/api/oauth/kiro/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: refreshToken.trim() }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Import failed");
}
// Success - close modal
onClose();
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
};
const handleIdcContinue = () => {
if (!idcStartUrl.trim()) {
setError("Please enter your IDC start URL");
return;
}
onMethodSelect("idc", { startUrl: idcStartUrl.trim(), region: idcRegion });
};
const handleSocialLogin = (provider) => {
onMethodSelect("social", { provider });
};
return (
<Modal isOpen={isOpen} title="Connect Kiro" onClose={onClose} size="lg">
<div className="flex flex-col gap-4">
{/* Method Selection */}
{!selectedMethod && (
<div className="space-y-3">
<p className="text-sm text-text-muted mb-4">Choose your authentication method:</p>
{/* AWS Builder ID */}
<button
onClick={() => onMethodSelect("builder-id")}
className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
>
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">shield</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">AWS Builder ID</h3>
<p className="text-sm text-text-muted">
Recommended for most users. Free AWS account required.
</p>
</div>
</div>
</button>
{/* AWS IAM Identity Center (IDC) - HIDDEN */}
<button
onClick={() => handleMethodSelect("idc")}
className="hidden w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
>
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">business</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">AWS IAM Identity Center</h3>
<p className="text-sm text-text-muted">
For enterprise users with custom AWS IAM Identity Center.
</p>
</div>
</div>
</button>
{/* Google Social Login - HIDDEN */}
<button
onClick={() => handleMethodSelect("social-google")}
className="hidden w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
>
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">
account_circle
</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">Google Account</h3>
<p className="text-sm text-text-muted">
Login with your Google account (manual callback).
</p>
</div>
</div>
</button>
{/* GitHub Social Login - HIDDEN */}
<button
onClick={() => handleMethodSelect("social-github")}
className="hidden w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
>
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">code</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">GitHub Account</h3>
<p className="text-sm text-text-muted">
Login with your GitHub account (manual callback).
</p>
</div>
</div>
</button>
{/* Import Token */}
<button
onClick={() => handleMethodSelect("import")}
className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
>
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">file_upload</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">Import Token</h3>
<p className="text-sm text-text-muted">Paste refresh token from Kiro IDE.</p>
</div>
</div>
</button>
</div>
)}
{/* IDC Configuration */}
{selectedMethod === "idc" && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
IDC Start URL <span className="text-red-500">*</span>
</label>
<Input
value={idcStartUrl}
onChange={(e) => setIdcStartUrl(e.target.value)}
placeholder="https://your-org.awsapps.com/start"
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted mt-1">
Your organization&apos;s AWS IAM Identity Center URL
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">AWS Region</label>
<Input
value={idcRegion}
onChange={(e) => setIdcRegion(e.target.value)}
placeholder="us-east-1"
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted mt-1">
AWS region for your Identity Center (default: us-east-1)
</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<Button onClick={handleIdcContinue} fullWidth>
Continue
</Button>
<Button onClick={handleBack} variant="ghost" fullWidth>
Back
</Button>
</div>
</div>
)}
{/* Social Login Info (Google) */}
{selectedMethod === "social-google" && (
<div className="space-y-4">
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg border border-amber-200 dark:border-amber-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-amber-600 dark:text-amber-400">
info
</span>
<div className="flex-1 text-sm">
<p className="font-medium text-amber-900 dark:text-amber-100 mb-1">
Manual Callback Required
</p>
<p className="text-amber-800 dark:text-amber-200">
After login, you&apos;ll need to copy the callback URL from your browser and
paste it back here.
</p>
</div>
</div>
</div>
<div className="flex gap-2">
<Button onClick={() => handleSocialLogin("google")} fullWidth>
Continue with Google
</Button>
<Button onClick={handleBack} variant="ghost" fullWidth>
Back
</Button>
</div>
</div>
)}
{/* Social Login Info (GitHub) */}
{selectedMethod === "social-github" && (
<div className="space-y-4">
<div className="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg border border-amber-200 dark:border-amber-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-amber-600 dark:text-amber-400">
info
</span>
<div className="flex-1 text-sm">
<p className="font-medium text-amber-900 dark:text-amber-100 mb-1">
Manual Callback Required
</p>
<p className="text-amber-800 dark:text-amber-200">
After login, you&apos;ll need to copy the callback URL from your browser and
paste it back here.
</p>
</div>
</div>
</div>
<div className="flex gap-2">
<Button onClick={() => handleSocialLogin("github")} fullWidth>
Continue with GitHub
</Button>
<Button onClick={handleBack} variant="ghost" fullWidth>
Back
</Button>
</div>
</div>
)}
{/* Import Token */}
{selectedMethod === "import" && (
<div className="space-y-4">
{/* Auto-detecting state */}
{autoDetecting && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Auto-detecting token...</h3>
<p className="text-sm text-text-muted">Reading from AWS SSO cache</p>
</div>
)}
{/* Form (shown after auto-detect completes) */}
{!autoDetecting && (
<>
{/* Success message if auto-detected */}
{autoDetected && (
<div className="bg-green-50 dark:bg-green-900/20 p-3 rounded-lg border border-green-200 dark:border-green-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-green-600 dark:text-green-400">
check_circle
</span>
<p className="text-sm text-green-800 dark:text-green-200">
Token auto-detected from Kiro IDE successfully!
</p>
</div>
</div>
)}
{/* Info message if not auto-detected */}
{!autoDetected && !error && (
<div className="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex gap-2">
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">
info
</span>
<p className="text-sm text-blue-800 dark:text-blue-200">
Kiro IDE not detected. Please paste your refresh token manually.
</p>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">
Refresh Token <span className="text-red-500">*</span>
</label>
<Input
value={refreshToken}
onChange={(e) => setRefreshToken(e.target.value)}
placeholder="Token will be auto-filled..."
className="font-mono text-sm"
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-900/20 p-3 rounded-lg border border-red-200 dark:border-red-800">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleImportToken}
fullWidth
disabled={importing || !refreshToken.trim()}
>
{importing ? "Importing..." : "Import Token"}
</Button>
<Button onClick={handleBack} variant="ghost" fullWidth>
Back
</Button>
</div>
</>
)}
</div>
)}
</div>
</Modal>
);
}
KiroAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onMethodSelect: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,98 @@
"use client";
import { useState, useCallback } from "react";
import PropTypes from "prop-types";
import OAuthModal from "./OAuthModal";
import KiroAuthModal from "./KiroAuthModal";
import KiroSocialOAuthModal from "./KiroSocialOAuthModal";
/**
* Kiro OAuth Wrapper
* Orchestrates between method selection, device code flow, and social login flow
*/
export default function KiroOAuthWrapper({ isOpen, providerInfo, onSuccess, onClose }) {
const [authMethod, setAuthMethod] = useState(null); // null | "builder-id" | "idc" | "social" | "import"
const [socialProvider, setSocialProvider] = useState(null); // "google" | "github"
const [idcConfig, setIdcConfig] = useState(null);
const handleMethodSelect = useCallback(
(method, config) => {
if (method === "builder-id") {
// Use device code flow (AWS Builder ID)
setAuthMethod("builder-id");
} else if (method === "idc") {
// Use device code flow with IDC config
setAuthMethod("idc");
setIdcConfig(config);
} else if (method === "social") {
// Use social login with manual callback
setAuthMethod("social");
setSocialProvider(config.provider);
} else if (method === "import") {
// Import handled in KiroAuthModal, just close
onSuccess?.();
}
},
[onSuccess]
);
const handleBack = () => {
setAuthMethod(null);
setSocialProvider(null);
setIdcConfig(null);
};
const handleSocialSuccess = () => {
setAuthMethod(null);
setSocialProvider(null);
onSuccess?.();
};
const handleDeviceSuccess = () => {
setAuthMethod(null);
setIdcConfig(null);
onSuccess?.();
};
// Show method selection first
if (!authMethod) {
return <KiroAuthModal isOpen={isOpen} onMethodSelect={handleMethodSelect} onClose={onClose} />;
}
// Show device code flow (Builder ID or IDC)
if (authMethod === "builder-id" || authMethod === "idc") {
return (
<OAuthModal
isOpen={isOpen}
provider="kiro"
providerInfo={providerInfo}
onSuccess={handleDeviceSuccess}
onClose={handleBack}
idcConfig={idcConfig}
/>
);
}
// Show social login flow (Google/GitHub with manual callback)
if (authMethod === "social" && socialProvider) {
return (
<KiroSocialOAuthModal
isOpen={isOpen}
provider={socialProvider}
onSuccess={handleSocialSuccess}
onClose={handleBack}
/>
);
}
return null;
}
KiroOAuthWrapper.propTypes = {
isOpen: PropTypes.bool.isRequired,
providerInfo: PropTypes.shape({
name: PropTypes.string,
}),
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,205 @@
"use client";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
/**
* Kiro Social OAuth Modal (Google/GitHub)
* Handles manual callback URL flow for social login
*/
export default function KiroSocialOAuthModal({ isOpen, provider, onSuccess, onClose }) {
const [step, setStep] = useState("loading"); // loading | input | success | error
const [authUrl, setAuthUrl] = useState("");
const [authData, setAuthData] = useState(null);
const [callbackUrl, setCallbackUrl] = useState("");
const [error, setError] = useState(null);
const { copied, copy } = useCopyToClipboard();
// Initialize auth flow
useEffect(() => {
if (!isOpen || !provider) return;
const initAuth = async () => {
try {
setError(null);
setStep("loading");
const res = await fetch(`/api/oauth/kiro/social-authorize?provider=${provider}`);
const data = await res.json();
if (!res.ok) {
throw new Error(data.error);
}
setAuthData(data);
setAuthUrl(data.authUrl);
setStep("input");
// Auto-open browser
window.open(data.authUrl, "kiro_social_auth");
} catch (err) {
setError(err.message);
setStep("error");
}
};
initAuth();
}, [isOpen, provider]);
const handleManualSubmit = async () => {
try {
setError(null);
// Parse callback URL - can be either kiro:// or http://localhost format
let url;
try {
url = new URL(callbackUrl);
} catch (e) {
// If URL parsing fails, might be malformed
throw new Error("Invalid callback URL format");
}
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const errorParam = url.searchParams.get("error");
if (errorParam) {
throw new Error(url.searchParams.get("error_description") || errorParam);
}
if (!code) {
throw new Error("No authorization code found in URL");
}
// Exchange code for tokens
const res = await fetch("/api/oauth/kiro/social-exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
codeVerifier: authData.codeVerifier,
provider,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
} catch (err) {
setError(err.message);
setStep("error");
}
};
const providerName = provider === "google" ? "Google" : "GitHub";
return (
<Modal isOpen={isOpen} title={`Connect Kiro via ${providerName}`} onClose={onClose} size="lg">
<div className="flex flex-col gap-4">
{/* Loading */}
{step === "loading" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Initializing...</h3>
<p className="text-sm text-text-muted">Setting up {providerName} authentication</p>
</div>
)}
{/* Manual Input Step */}
{step === "input" && (
<>
<div className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">Step 1: Open this URL in your browser</p>
<div className="flex gap-2">
<Input value={authUrl} readOnly className="flex-1 font-mono text-xs" />
<Button
variant="secondary"
icon={copied === "auth_url" ? "check" : "content_copy"}
onClick={() => copy(authUrl, "auth_url")}
>
Copy
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Step 2: Paste the callback URL here</p>
<p className="text-xs text-text-muted mb-2">
After authorization, copy the full URL from your browser address bar.
</p>
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder="kiro://kiro.kiroAgent/authenticate-success?code=..."
className="font-mono text-xs"
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>
Connect
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</>
)}
{/* Success */}
{step === "success" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-green-600">
check_circle
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Connected Successfully!</h3>
<p className="text-sm text-text-muted mb-4">
Your Kiro account via {providerName} has been connected.
</p>
<Button onClick={onClose} fullWidth>
Done
</Button>
</div>
)}
{/* Error */}
{step === "error" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
</div>
<h3 className="text-lg font-semibold mb-2">Connection Failed</h3>
<p className="text-sm text-red-600 mb-4">{error}</p>
<div className="flex gap-2">
<Button onClick={() => setStep("input")} variant="secondary" fullWidth>
Try Again
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
)}
</div>
</Modal>
);
}
KiroSocialOAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
provider: PropTypes.oneOf(["google", "github"]).isRequired,
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,72 @@
"use client";
import { cn } from "@/shared/utils/cn";
// Spinner loading
export function Spinner({ size = "md", className }) {
const sizes = {
sm: "size-4",
md: "size-6",
lg: "size-8",
xl: "size-12",
};
return (
<span
role="status"
aria-label="Loading"
className={cn("material-symbols-outlined animate-spin text-primary", sizes[size], className)}
>
progress_activity
</span>
);
}
// Full page loading
export function PageLoading({ message = "Loading..." }) {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-bg">
<Spinner size="xl" />
<p className="mt-4 text-text-muted">{message}</p>
</div>
);
}
// Skeleton loading
export function Skeleton({ className, ...props }) {
return (
<div
aria-hidden="true"
className={cn("animate-pulse rounded-lg bg-border", className)}
{...props}
/>
);
}
// Card skeleton
export function CardSkeleton() {
return (
<div className="p-6 rounded-xl border border-border bg-surface">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="size-10 rounded-lg" />
</div>
<Skeleton className="h-8 w-16 mb-2" />
<Skeleton className="h-3 w-20" />
</div>
);
}
// Default export
export default function Loading({ type = "spinner", ...props }) {
switch (type) {
case "page":
return <PageLoading {...props} />;
case "skeleton":
return <Skeleton {...props} />;
case "card":
return <CardSkeleton {...props} />;
default:
return <Spinner {...props} />;
}
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import Modal from "./Modal";
import Button from "./Button";
export default function ManualConfigModal({
isOpen,
onClose,
title = "Manual Configuration",
configs = [],
}) {
const [copiedIndex, setCopiedIndex] = useState(null);
const copyToClipboard = async (text, index) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch (err) {
console.log("Failed to copy:", err);
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} size="xl">
<div className="flex flex-col gap-4">
{configs.map((config, index) => (
<div key={index} className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-text-main">{config.filename}</span>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(config.content, index)}
>
<span className="material-symbols-outlined text-[14px] mr-1">
{copiedIndex === index ? "check" : "content_copy"}
</span>
{copiedIndex === index ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto border border-border">
{config.content}
</pre>
</div>
))}
</div>
</Modal>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useEffect, useRef, useId } from "react";
import { cn } from "@/shared/utils/cn";
import Button from "./Button";
export default function Modal({
isOpen,
onClose,
title,
children,
footer,
size = "md",
closeOnOverlay = true,
showCloseButton = true,
className,
}) {
const titleId = useId();
const dialogRef = useRef(null);
const sizes = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
full: "max-w-4xl",
};
// Lock body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
// Handle escape key
useEffect(() => {
const handleEscape = (e) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
// Focus trap
useEffect(() => {
if (!isOpen || !dialogRef.current) return;
const dialog = dialogRef.current;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
// Focus first focusable element
const firstFocusable = dialog.querySelector(focusableSelector);
if (firstFocusable) {
setTimeout(() => firstFocusable.focus(), 50);
}
const handleTab = (e) => {
if (e.key !== "Tab") return;
const focusable = [...dialog.querySelectorAll(focusableSelector)];
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
dialog.addEventListener("keydown", handleTab);
return () => dialog.removeEventListener("keydown", handleTab);
}, [isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
onClick={closeOnOverlay ? onClose : undefined}
aria-hidden="true"
/>
{/* Modal content */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
className={cn(
"relative w-full bg-surface",
"border border-black/10 dark:border-white/10",
"rounded-xl shadow-2xl",
"animate-in fade-in zoom-in-95 duration-200",
sizes[size],
className
)}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5">
<div className="flex items-center">
<div className="flex items-center gap-2 mr-4" aria-hidden="true">
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
</div>
{title && (
<h2 id={titleId} className="text-lg font-semibold text-text-main">
{title}
</h2>
)}
</div>
{showCloseButton && (
<button
onClick={onClose}
aria-label="Close"
className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
close
</span>
</button>
)}
</div>
)}
{/* Body */}
<div className="p-6 max-h-[calc(80vh-140px)] overflow-y-auto">{children}</div>
{/* Footer */}
{footer && (
<div className="flex items-center justify-end gap-3 p-6 border-t border-black/5 dark:border-white/5">
{footer}
</div>
)}
</div>
</div>
);
}
// Confirm Modal helper
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title = "Confirm",
message,
confirmText = "Confirm",
cancelText = "Cancel",
variant = "danger",
loading = false,
}) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
size="sm"
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={loading}>
{cancelText}
</Button>
<Button variant={variant} onClick={onConfirm} loading={loading}>
{confirmText}
</Button>
</>
}
>
<p className="text-text-muted">{message}</p>
</Modal>
);
}

View File

@@ -0,0 +1,377 @@
"use client";
import { useState, useMemo, useEffect } from "react";
import PropTypes from "prop-types";
import Modal from "./Modal";
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import {
OAUTH_PROVIDERS,
FREE_PROVIDERS,
APIKEY_PROVIDERS,
isOpenAICompatibleProvider,
isAnthropicCompatibleProvider,
} from "@/shared/constants/providers";
// Provider order: OAuth first, then Free, then API Key (matches dashboard/providers)
const PROVIDER_ORDER = [
...Object.keys(OAUTH_PROVIDERS),
...Object.keys(FREE_PROVIDERS),
...Object.keys(APIKEY_PROVIDERS),
];
export default function ModelSelectModal({
isOpen,
onClose,
onSelect,
selectedModel,
activeProviders = [],
title = "Select Model",
modelAliases = {},
}) {
const [searchQuery, setSearchQuery] = useState("");
const [combos, setCombos] = useState([]);
const [providerNodes, setProviderNodes] = useState([]);
const [customModels, setCustomModels] = useState({});
const fetchCombos = async () => {
try {
const res = await fetch("/api/combos");
if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`);
const data = await res.json();
setCombos(data.combos || []);
} catch (error) {
console.error("Error fetching combos:", error);
setCombos([]);
}
};
useEffect(() => {
if (isOpen) fetchCombos();
}, [isOpen]);
const fetchProviderNodes = async () => {
try {
const res = await fetch("/api/provider-nodes");
if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`);
const data = await res.json();
setProviderNodes(data.nodes || []);
} catch (error) {
console.error("Error fetching provider nodes:", error);
setProviderNodes([]);
}
};
useEffect(() => {
if (isOpen) fetchProviderNodes();
}, [isOpen]);
const fetchCustomModels = async () => {
try {
const res = await fetch("/api/provider-models");
if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`);
const data = await res.json();
setCustomModels(data.models || {});
} catch (error) {
console.error("Error fetching custom models:", error);
setCustomModels({});
}
};
useEffect(() => {
if (isOpen) fetchCustomModels();
}, [isOpen]);
const allProviders = useMemo(
() => ({ ...OAUTH_PROVIDERS, ...FREE_PROVIDERS, ...APIKEY_PROVIDERS }),
[]
);
// Group models by provider with priority order
const groupedModels = useMemo(() => {
const groups = {};
// Get all active provider IDs from connections
const activeConnectionIds = activeProviders.map((p) => p.provider);
// Only show connected providers (including both standard and custom)
const providerIdsToShow = new Set([
...activeConnectionIds, // Only connected providers
]);
// Sort by PROVIDER_ORDER
const sortedProviderIds = [...providerIdsToShow].sort((a, b) => {
const indexA = PROVIDER_ORDER.indexOf(a);
const indexB = PROVIDER_ORDER.indexOf(b);
return (indexA === -1 ? 999 : indexA) - (indexB === -1 ? 999 : indexB);
});
sortedProviderIds.forEach((providerId) => {
const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
const providerInfo = allProviders[providerId] || { name: providerId, color: "#666" };
const isCustomProvider =
isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId);
// Get user-added custom models for this provider (if any)
const providerCustomModels = customModels[providerId] || [];
if (providerInfo.passthroughModels) {
const aliasModels = Object.entries(modelAliases)
.filter(([, fullModel]) => fullModel.startsWith(`${alias}/`))
.map(([aliasName, fullModel]) => ({
id: fullModel.replace(`${alias}/`, ""),
name: aliasName,
value: fullModel,
}));
// Merge custom models for passthrough providers
const customEntries = providerCustomModels
.filter((cm) => !aliasModels.some((am) => am.id === cm.id))
.map((cm) => ({
id: cm.id,
name: cm.name || cm.id,
value: `${alias}/${cm.id}`,
isCustom: true,
}));
const allModels = [...aliasModels, ...customEntries];
if (allModels.length > 0) {
const matchedNode = providerNodes.find((node) => node.id === providerId);
const displayName = matchedNode?.name || providerInfo.name;
groups[providerId] = {
name: displayName,
alias: alias,
color: providerInfo.color,
models: allModels,
};
}
} else if (isCustomProvider) {
const matchedNode = providerNodes.find((node) => node.id === providerId);
const displayName = matchedNode?.name || providerInfo.name;
const nodeModels = Object.entries(modelAliases)
.filter(([, fullModel]) => fullModel.startsWith(`${providerId}/`))
.map(([aliasName, fullModel]) => ({
id: fullModel.replace(`${providerId}/`, ""),
name: aliasName,
value: fullModel,
}));
// Merge custom models for custom providers
const customEntries = providerCustomModels
.filter((cm) => !nodeModels.some((nm) => nm.id === cm.id))
.map((cm) => ({
id: cm.id,
name: cm.name || cm.id,
value: `${providerId}/${cm.id}`,
isCustom: true,
}));
const allModels = [...nodeModels, ...customEntries];
if (allModels.length > 0) {
groups[providerId] = {
name: displayName,
alias: matchedNode?.prefix || providerId,
color: providerInfo.color,
models: allModels,
isCustom: true,
hasModels: true,
};
}
} else {
const systemModels = getModelsByProviderId(providerId);
// Merge system models with user-added custom models
const systemEntries = systemModels.map((m) => ({
id: m.id,
name: m.name,
value: `${alias}/${m.id}`,
}));
const customEntries = providerCustomModels
.filter((cm) => !systemModels.some((sm) => sm.id === cm.id))
.map((cm) => ({
id: cm.id,
name: cm.name || cm.id,
value: `${alias}/${cm.id}`,
isCustom: true,
}));
const allModels = [...systemEntries, ...customEntries];
if (allModels.length > 0) {
groups[providerId] = {
name: providerInfo.name,
alias: alias,
color: providerInfo.color,
models: allModels,
};
}
}
});
return groups;
}, [activeProviders, modelAliases, allProviders, providerNodes, customModels]);
// Filter combos by search query
const filteredCombos = useMemo(() => {
if (!searchQuery.trim()) return combos;
const query = searchQuery.toLowerCase();
return combos.filter((c) => c.name.toLowerCase().includes(query));
}, [combos, searchQuery]);
// Filter models by search query
const filteredGroups = useMemo(() => {
if (!searchQuery.trim()) return groupedModels;
const query = searchQuery.toLowerCase();
const filtered = {};
Object.entries(groupedModels).forEach(([providerId, group]) => {
const matchedModels = group.models.filter(
(m) => m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query)
);
const providerNameMatches = group.name.toLowerCase().includes(query);
if (matchedModels.length > 0 || providerNameMatches) {
filtered[providerId] = {
...group,
models: matchedModels,
};
}
});
return filtered;
}, [groupedModels, searchQuery]);
const handleSelect = (model) => {
onSelect(model);
onClose();
setSearchQuery("");
};
return (
<Modal
isOpen={isOpen}
onClose={() => {
onClose();
setSearchQuery("");
}}
title={title}
size="md"
className="p-4!"
>
{/* Search - compact */}
<div className="mb-3">
<div className="relative">
<span className="material-symbols-outlined absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted text-[16px]">
search
</span>
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 bg-surface border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
</div>
</div>
{/* Models grouped by provider - compact */}
<div className="max-h-[300px] overflow-y-auto space-y-3">
{/* Combos section - always first */}
{filteredCombos.length > 0 && (
<div>
<div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5">
<span className="material-symbols-outlined text-primary text-[14px]">layers</span>
<span className="text-xs font-medium text-primary">Combos</span>
<span className="text-[10px] text-text-muted">({filteredCombos.length})</span>
</div>
<div className="flex flex-wrap gap-1.5">
{filteredCombos.map((combo) => {
const isSelected = selectedModel === combo.name;
return (
<button
key={combo.id}
onClick={() =>
handleSelect({ id: combo.name, name: combo.name, value: combo.name })
}
className={`
px-2 py-1 rounded-xl text-xs font-medium transition-all border hover:cursor-pointer
${
isSelected
? "bg-primary text-white border-primary"
: "bg-surface border-border text-text-main hover:border-primary/50 hover:bg-primary/5"
}
`}
>
{combo.name}
</button>
);
})}
</div>
</div>
)}
{/* Provider models */}
{Object.entries(filteredGroups).map(([providerId, group]) => (
<div key={providerId}>
{/* Provider header */}
<div className="flex items-center gap-1.5 mb-1.5 sticky top-0 bg-surface py-0.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: group.color }} />
<span className="text-xs font-medium text-primary">{group.name}</span>
<span className="text-[10px] text-text-muted">({group.models.length})</span>
</div>
<div className="flex flex-wrap gap-1.5">
{group.models.map((model) => {
const isSelected = selectedModel === model.value;
return (
<button
key={model.id}
onClick={() => handleSelect(model)}
className={`
px-2 py-1 rounded-xl text-xs font-medium transition-all border hover:cursor-pointer
${
isSelected
? "bg-primary text-white border-primary"
: "bg-surface border-border text-text-main hover:border-primary/50 hover:bg-primary/5"
}
`}
>
{model.name}
{model.isCustom ? " ★" : ""}
</button>
);
})}
</div>
</div>
))}
{Object.keys(filteredGroups).length === 0 && filteredCombos.length === 0 && (
<div className="text-center py-4 text-text-muted">
<span className="material-symbols-outlined text-2xl mb-1 block">search_off</span>
<p className="text-xs">No models found</p>
</div>
)}
</div>
</Modal>
);
}
ModelSelectModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onSelect: PropTypes.func.isRequired,
selectedModel: PropTypes.string,
activeProviders: PropTypes.arrayOf(
PropTypes.shape({
provider: PropTypes.string.isRequired,
})
),
title: PropTypes.string,
modelAliases: PropTypes.object,
};

View File

@@ -0,0 +1,527 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
/**
* OAuth Modal Component
* - Localhost: Auto callback via popup message
* - Remote: Manual paste callback URL
*/
export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose }) {
const [step, setStep] = useState("waiting"); // waiting | input | success | error
const [authData, setAuthData] = useState(null);
const [callbackUrl, setCallbackUrl] = useState("");
const [error, setError] = useState(null);
const [isDeviceCode, setIsDeviceCode] = useState(false);
const [deviceData, setDeviceData] = useState(null);
const [polling, setPolling] = useState(false);
const popupRef = useRef(null);
const { copied, copy } = useCopyToClipboard();
// State for client-only values to avoid hydration mismatch
const [isLocalhost, setIsLocalhost] = useState(false);
const [placeholderUrl, setPlaceholderUrl] = useState("/callback?code=...");
const callbackProcessedRef = useRef(false);
const flowStartedRef = useRef(false);
// Detect if running on localhost (client-side only)
useEffect(() => {
if (typeof window !== "undefined") {
setIsLocalhost(
window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"
);
setPlaceholderUrl(`${window.location.origin}/callback?code=...`);
}
}, []);
// Define all useCallback hooks BEFORE the useEffects that reference them
// Exchange tokens
const exchangeTokens = useCallback(
async (code, state) => {
if (!authData) return;
try {
const res = await fetch(`/api/oauth/${provider}/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code,
redirectUri: authData.redirectUri,
codeVerifier: authData.codeVerifier,
state,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setStep("success");
onSuccess?.();
} catch (err) {
setError(err.message);
setStep("error");
}
},
[authData, provider, onSuccess]
);
// Poll for device code token
const startPolling = useCallback(
async (deviceCode, codeVerifier, interval, extraData) => {
setPolling(true);
const maxAttempts = 60;
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, interval * 1000));
try {
const res = await fetch(`/api/oauth/${provider}/poll`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceCode, codeVerifier, extraData }),
});
const data = await res.json();
if (data.success) {
setStep("success");
setPolling(false);
onSuccess?.();
return;
}
if (data.error === "expired_token" || data.error === "access_denied") {
throw new Error(data.errorDescription || data.error);
}
if (data.error === "slow_down") {
interval = Math.min(interval + 5, 30);
}
} catch (err) {
setError(err.message);
setStep("error");
setPolling(false);
return;
}
}
setError("Authorization timeout");
setStep("error");
setPolling(false);
},
[provider, onSuccess]
);
// Start OAuth flow
const startOAuthFlow = useCallback(async () => {
if (!provider) return;
try {
setError(null);
// Device code flow (GitHub, Qwen, Kiro, Kimi Coding, KiloCode)
if (
provider === "github" ||
provider === "qwen" ||
provider === "kiro" ||
provider === "kimi-coding" ||
provider === "kilocode"
) {
setIsDeviceCode(true);
setStep("waiting");
const res = await fetch(`/api/oauth/${provider}/device-code`);
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setDeviceData(data);
// Open verification URL
const verifyUrl = data.verification_uri_complete || data.verification_uri;
if (verifyUrl) window.open(verifyUrl, "oauth_verify");
// Start polling - pass extraData for Kiro (contains _clientId, _clientSecret)
const extraData =
provider === "kiro"
? { _clientId: data._clientId, _clientSecret: data._clientSecret }
: null;
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
return;
}
// Codex: use callback server on port 1455 + polling (auto-complete)
if (provider === "codex") {
try {
// Start callback server on port 1455
const serverRes = await fetch(`/api/oauth/codex/start-callback-server`);
const serverData = await serverRes.json();
if (!serverRes.ok) throw new Error(serverData.error);
setAuthData({ ...serverData, redirectUri: serverData.redirectUri });
setStep("waiting");
window.open(serverData.authUrl, "oauth_auth");
// Poll for callback (like device code flow)
setPolling(true);
const maxAttempts = 150; // 5 min at 2s interval
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, 2000));
const pollRes = await fetch(`/api/oauth/codex/poll-callback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const pollData = await pollRes.json();
if (pollData.success) {
setStep("success");
setPolling(false);
onSuccess?.();
return;
}
if (pollData.error && !pollData.pending) {
throw new Error(pollData.errorDescription || pollData.error);
}
}
setPolling(false);
throw new Error("Authorization timeout");
} catch (codexErr) {
setPolling(false);
// Fallback to manual input mode
setStep("input");
setError(codexErr.message + " — You can paste the callback URL manually below.");
}
return;
}
// Authorization code flow (non-Codex providers)
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
const redirectUri = `http://localhost:${port}/callback`;
const res = await fetch(
`/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`
);
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setAuthData({ ...data, redirectUri });
// For non-localhost: use manual input mode (user pastes callback URL)
if (!isLocalhost) {
setStep("input");
window.open(data.authUrl, "oauth_auth");
} else {
// Localhost: Open popup and wait for message
setStep("waiting");
popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700");
// Check if popup was blocked
if (!popupRef.current) {
setStep("input");
}
}
} catch (err) {
setError(err.message);
setStep("error");
}
}, [provider, isLocalhost, startPolling, onSuccess]);
// Reset guard when modal closes
useEffect(() => {
if (!isOpen) {
flowStartedRef.current = false;
}
}, [isOpen]);
// Reset state and start OAuth when modal opens
useEffect(() => {
if (isOpen && provider) {
if (flowStartedRef.current) return; // Already started, prevent duplicate
flowStartedRef.current = true;
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
// Auto start OAuth
startOAuthFlow();
}
}, [isOpen, provider, startOAuthFlow]);
// Listen for OAuth callback via multiple methods
useEffect(() => {
if (!authData) return;
callbackProcessedRef.current = false; // Reset when authData changes
// Handler for callback data - only process once
const handleCallback = async (data) => {
if (callbackProcessedRef.current) return; // Already processed
const { code, state, error: callbackError, errorDescription } = data;
if (callbackError) {
callbackProcessedRef.current = true;
setError(errorDescription || callbackError);
setStep("error");
return;
}
if (code) {
callbackProcessedRef.current = true;
await exchangeTokens(code, state);
}
};
// Method 1: postMessage from popup
const handleMessage = (event) => {
// Accept same-origin OR localhost with same port (remote access scenario:
// dashboard at 192.168.x:port, callback redirects to localhost:port)
const currentPort = window.location.port;
const isLocalhostSamePort =
event.origin.match(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/) &&
new URL(event.origin).port === currentPort;
if (event.origin !== window.location.origin && !isLocalhostSamePort) return;
if (event.data?.type === "oauth_callback") {
handleCallback(event.data.data);
}
};
window.addEventListener("message", handleMessage);
// Method 2: BroadcastChannel
let channel;
try {
channel = new BroadcastChannel("oauth_callback");
channel.onmessage = (event) => handleCallback(event.data);
} catch (e) {
console.log("BroadcastChannel not supported");
}
// Method 3: localStorage event
const handleStorage = (event) => {
if (event.key === "oauth_callback" && event.newValue) {
try {
const data = JSON.parse(event.newValue);
handleCallback(data);
localStorage.removeItem("oauth_callback");
} catch (e) {
console.log("Failed to parse localStorage data");
}
}
};
window.addEventListener("storage", handleStorage);
// Also check localStorage on mount (in case callback already happened)
try {
const stored = localStorage.getItem("oauth_callback");
if (stored) {
const data = JSON.parse(stored);
// Only use if recent (within 30 seconds)
if (data.timestamp && Date.now() - data.timestamp < 30000) {
handleCallback(data);
localStorage.removeItem("oauth_callback");
}
}
} catch {
// localStorage may be unavailable or data may be malformed - ignore silently
}
return () => {
window.removeEventListener("message", handleMessage);
window.removeEventListener("storage", handleStorage);
if (channel) channel.close();
};
}, [authData, exchangeTokens]);
// Handle manual URL input
const handleManualSubmit = async () => {
try {
setError(null);
const url = new URL(callbackUrl);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const errorParam = url.searchParams.get("error");
if (errorParam) {
throw new Error(url.searchParams.get("error_description") || errorParam);
}
if (!code) {
throw new Error("No authorization code found in URL");
}
await exchangeTokens(code, state);
} catch (err) {
setError(err.message);
setStep("error");
}
};
if (!provider || !providerInfo) return null;
return (
<Modal isOpen={isOpen} title={`Connect ${providerInfo.name}`} onClose={onClose} size="lg">
<div className="flex flex-col gap-4">
{/* Waiting Step (Localhost - popup mode) */}
{step === "waiting" && !isDeviceCode && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Waiting for Authorization</h3>
<p className="text-sm text-text-muted mb-4">
Complete the authorization in the popup window.
</p>
<Button variant="ghost" onClick={() => setStep("input")}>
Popup blocked? Enter URL manually
</Button>
</div>
)}
{/* Device Code Flow - Waiting */}
{step === "waiting" && isDeviceCode && deviceData && (
<>
<div className="text-center py-4">
<p className="text-sm text-text-muted mb-4">
Visit the URL below and enter the code:
</p>
<div className="bg-sidebar p-4 rounded-lg mb-4">
<p className="text-xs text-text-muted mb-1">Verification URL</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm break-all">{deviceData.verification_uri}</code>
<Button
size="sm"
variant="ghost"
icon={copied === "verify_url" ? "check" : "content_copy"}
onClick={() => copy(deviceData.verification_uri, "verify_url")}
/>
</div>
</div>
<div className="bg-primary/10 p-4 rounded-lg">
<p className="text-xs text-text-muted mb-1">Your Code</p>
<div className="flex items-center justify-center gap-2">
<p className="text-2xl font-mono font-bold text-primary">
{deviceData.user_code}
</p>
<Button
size="sm"
variant="ghost"
icon={copied === "user_code" ? "check" : "content_copy"}
onClick={() => copy(deviceData.user_code, "user_code")}
/>
</div>
</div>
</div>
{polling && (
<div className="flex items-center justify-center gap-2 text-sm text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
Waiting for authorization...
</div>
)}
</>
)}
{/* Manual Input Step */}
{step === "input" && !isDeviceCode && (
<>
<div className="space-y-4">
<div>
<p className="text-sm font-medium mb-2">Step 1: Open this URL in your browser</p>
<div className="flex gap-2">
<Input
value={authData?.authUrl || ""}
readOnly
className="flex-1 font-mono text-xs"
/>
<Button
variant="secondary"
icon={copied === "auth_url" ? "check" : "content_copy"}
onClick={() => copy(authData?.authUrl, "auth_url")}
>
Copy
</Button>
</div>
</div>
<div>
<p className="text-sm font-medium mb-2">Step 2: Paste the callback URL here</p>
<p className="text-xs text-text-muted mb-2">
After authorization, copy the full URL from your browser.
</p>
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder={placeholderUrl}
className="font-mono text-xs"
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleManualSubmit} fullWidth disabled={!callbackUrl}>
Connect
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</>
)}
{/* Success Step */}
{step === "success" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-green-600">
check_circle
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Connected Successfully!</h3>
<p className="text-sm text-text-muted mb-4">
Your {providerInfo.name} account has been connected.
</p>
<Button onClick={onClose} fullWidth>
Done
</Button>
</div>
)}
{/* Error Step */}
{step === "error" && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
</div>
<h3 className="text-lg font-semibold mb-2">Connection Failed</h3>
<p className="text-sm text-red-600 mb-4">{error}</p>
<div className="flex gap-2">
<Button onClick={startOAuthFlow} variant="secondary" fullWidth>
Try Again
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
)}
</div>
</Modal>
);
}
OAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
provider: PropTypes.string,
providerInfo: PropTypes.shape({
name: PropTypes.string,
}),
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1,208 @@
"use client";
import { useState, useEffect } from "react";
import { getDefaultPricing, formatCost } from "@/shared/constants/pricing.js";
export default function PricingModal({ isOpen, onClose, onSave }) {
const [pricingData, setPricingData] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
loadPricing();
}
}, [isOpen]);
const loadPricing = async () => {
setLoading(true);
try {
const response = await fetch("/api/pricing");
if (response.ok) {
const data = await response.json();
setPricingData(data);
} else {
// Fallback to defaults
const defaults = getDefaultPricing();
setPricingData(defaults);
}
} catch (error) {
console.error("Failed to load pricing:", error);
const defaults = getDefaultPricing();
setPricingData(defaults);
} finally {
setLoading(false);
}
};
const handlePricingChange = (provider, model, field, value) => {
const numValue = parseFloat(value);
if (isNaN(numValue) || numValue < 0) return;
setPricingData((prev) => {
const newData = { ...prev };
if (!newData[provider]) newData[provider] = {};
if (!newData[provider][model]) newData[provider][model] = {};
newData[provider][model][field] = numValue;
return newData;
});
};
const handleSave = async () => {
setSaving(true);
try {
const response = await fetch("/api/pricing", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pricingData),
});
if (response.ok) {
onSave?.();
onClose();
} else {
const error = await response.json();
alert(`Failed to save pricing: ${error.error}`);
}
} catch (error) {
console.error("Failed to save pricing:", error);
alert("Failed to save pricing");
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!confirm("Reset all pricing to defaults? This cannot be undone.")) return;
try {
const response = await fetch("/api/pricing", { method: "DELETE" });
if (response.ok) {
const defaults = getDefaultPricing();
setPricingData(defaults);
}
} catch (error) {
console.error("Failed to reset pricing:", error);
alert("Failed to reset pricing");
}
};
if (!isOpen) return null;
// Get all unique providers and models for display
const allProviders = Object.keys(pricingData).sort();
const pricingFields = ["input", "output", "cached", "reasoning", "cache_creation"];
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-bg-base border border-border rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="p-4 border-b border-border flex items-center justify-between">
<h2 className="text-xl font-semibold">Pricing Configuration</h2>
<button
onClick={onClose}
className="text-text-muted hover:text-text text-2xl leading-none"
>
×
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-4">
{loading ? (
<div className="text-center py-8 text-text-muted">Loading pricing data...</div>
) : (
<div className="space-y-6">
{/* Instructions */}
<div className="bg-bg-subtle border border-border rounded-lg p-3 text-sm">
<p className="font-medium mb-1">Pricing Rates Format</p>
<p className="text-text-muted">
All rates are in <strong>dollars per million tokens</strong> ($/1M tokens).
Example: Input rate of 2.50 means $2.50 per 1,000,000 input tokens.
</p>
</div>
{/* Pricing Tables */}
{allProviders.map((provider) => {
const models = Object.keys(pricingData[provider]).sort();
return (
<div key={provider} className="border border-border rounded-lg overflow-hidden">
<div className="bg-bg-subtle px-4 py-2 font-semibold text-sm">
{provider.toUpperCase()}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-bg-hover text-text-muted uppercase text-xs">
<tr>
<th className="px-3 py-2 text-left">Model</th>
<th className="px-3 py-2 text-right">Input</th>
<th className="px-3 py-2 text-right">Output</th>
<th className="px-3 py-2 text-right">Cached</th>
<th className="px-3 py-2 text-right">Reasoning</th>
<th className="px-3 py-2 text-right">Cache Creation</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{models.map((model) => (
<tr key={model} className="hover:bg-bg-subtle/50">
<td className="px-3 py-2 font-medium">{model}</td>
{pricingFields.map((field) => (
<td key={field} className="px-3 py-2">
<input
type="number"
step="0.01"
min="0"
value={pricingData[provider][model][field] || 0}
onChange={(e) =>
handlePricingChange(provider, model, field, e.target.value)
}
className="w-20 px-2 py-1 text-right bg-bg-base border border-border rounded focus:outline-none focus:border-primary"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
{allProviders.length === 0 && (
<div className="text-center py-8 text-text-muted">No pricing data available</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-border flex items-center justify-between gap-2">
<button
onClick={handleReset}
className="px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 rounded border border-red-500/20 transition-colors"
disabled={saving}
>
Reset to Defaults
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-text-muted hover:text-text border border-border rounded transition-colors"
disabled={saving}
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm bg-primary text-white rounded hover:bg-primary/90 transition-colors disabled:opacity-50"
disabled={saving}
>
{saving ? "Saving..." : "Save Changes"}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,434 @@
"use client";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import Modal from "./Modal";
import Button from "./Button";
const ALL_PROXY_TYPES = [
{ value: "http", label: "HTTP" },
{ value: "https", label: "HTTPS" },
{ value: "socks5", label: "SOCKS5" },
];
const SOCKS5_UI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY === "true";
const PROXY_TYPES = SOCKS5_UI_ENABLED
? ALL_PROXY_TYPES
: ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
const LEVEL_LABELS = {
global: "Global",
provider: "Provider",
combo: "Combo",
key: "Key",
direct: "Direct (none)",
};
/**
* ProxyConfigModal — Reusable proxy configuration modal for all 4 levels
* @param {Object} props
* @param {boolean} props.isOpen
* @param {Function} props.onClose
* @param {"global"|"provider"|"combo"|"key"} props.level
* @param {string} [props.levelId] — providerId, comboId, or connectionId
* @param {string} [props.levelLabel] — display name for the level
* @param {Function} [props.onSaved] — callback after save
*/
export default function ProxyConfigModal({ isOpen, onClose, level, levelId, levelLabel, onSaved }) {
const [proxyType, setProxyType] = useState(PROXY_TYPES[0]?.value || "http");
const [host, setHost] = useState("");
const [port, setPort] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showAuth, setShowAuth] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [loading, setLoading] = useState(true);
const [inheritedFrom, setInheritedFrom] = useState(null);
const [hasOwnProxy, setHasOwnProxy] = useState(false);
const [formError, setFormError] = useState(null);
const getDefaultPort = (type) => (type === "socks5" ? "1080" : "8080");
// Load existing proxy config when modal opens
useEffect(() => {
if (!isOpen) return;
setTestResult(null);
setFormError(null);
setLoading(true);
const loadProxy = async () => {
try {
// Load own proxy
const params = new URLSearchParams({ level });
if (levelId) params.set("id", levelId);
const res = await fetch(`/api/settings/proxy?${params}`);
if (res.ok) {
const data = await res.json();
const proxy = data.proxy;
if (proxy && proxy.host) {
const normalizedType = String(proxy.type || "http").toLowerCase();
const hasTypeOption = PROXY_TYPES.some((entry) => entry.value === normalizedType);
setProxyType(hasTypeOption ? normalizedType : PROXY_TYPES[0]?.value || "http");
setHost(proxy.host || "");
setPort(proxy.port || "");
setUsername(proxy.username || "");
setPassword(proxy.password || "");
setShowAuth(!!(proxy.username || proxy.password));
setHasOwnProxy(true);
if (normalizedType === "socks5" && !SOCKS5_UI_ENABLED) {
setFormError(
"SOCKS5 is configured but hidden because NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=false."
);
}
} else {
resetFields();
setHasOwnProxy(false);
}
}
// Check inherited proxy (for non-global levels)
if (level !== "global" && levelId) {
// Try to resolve the effective proxy to show inheritance info
const fullConfig = await fetch("/api/settings/proxy");
if (fullConfig.ok) {
const config = await fullConfig.json();
// Determine inheritance source
if (level === "key") {
// Check combo, provider, global
if (config.global) setInheritedFrom({ level: "Global", proxy: config.global });
// Provider info requires more context, showing global as fallback
} else if (level === "combo") {
if (config.global) setInheritedFrom({ level: "Global", proxy: config.global });
} else if (level === "provider") {
if (config.global) setInheritedFrom({ level: "Global", proxy: config.global });
}
}
}
} catch (error) {
console.error("Error loading proxy config:", error);
} finally {
setLoading(false);
}
};
loadProxy();
}, [isOpen, level, levelId]);
const resetFields = () => {
setProxyType(PROXY_TYPES[0]?.value || "http");
setHost("");
setPort("");
setUsername("");
setPassword("");
setShowAuth(false);
setFormError(null);
};
const handleSave = async () => {
if (!host.trim()) return;
setFormError(null);
setSaving(true);
try {
const proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
const res = await fetch("/api/settings/proxy", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ level, id: levelId, proxy }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setFormError(payload?.error?.message || "Failed to save proxy configuration");
return;
}
setHasOwnProxy(true);
onSaved?.();
} catch (error) {
console.error("Error saving proxy:", error);
setFormError(error.message || "Failed to save proxy configuration");
} finally {
setSaving(false);
}
};
const handleClear = async () => {
setFormError(null);
setSaving(true);
try {
const params = new URLSearchParams({ level });
if (levelId) params.set("id", levelId);
const res = await fetch(`/api/settings/proxy?${params}`, { method: "DELETE" });
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setFormError(payload?.error?.message || "Failed to clear proxy configuration");
return;
}
resetFields();
setHasOwnProxy(false);
setTestResult(null);
onSaved?.();
} catch (error) {
console.error("Error clearing proxy:", error);
setFormError(error.message || "Failed to clear proxy configuration");
} finally {
setSaving(false);
}
};
const handleTest = async () => {
if (!host.trim()) return;
setFormError(null);
setTesting(true);
setTestResult(null);
try {
const proxy = {
type: proxyType,
host: host.trim(),
port: port.trim() || getDefaultPort(proxyType),
username: username.trim(),
password: password.trim(),
};
const res = await fetch("/api/settings/proxy/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proxy }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message = data?.error?.message || "Connection failed";
setTestResult({ success: false, error: message });
setFormError(message);
return;
}
setTestResult(data);
} catch (error) {
setTestResult({ success: false, error: error.message });
setFormError(error.message || "Connection failed");
} finally {
setTesting(false);
}
};
const title =
level === "global"
? "Global Proxy Configuration"
: `${LEVEL_LABELS[level]} Proxy — ${levelLabel || levelId || ""}`;
return (
<Modal isOpen={isOpen} onClose={onClose} title={title} maxWidth="lg">
{loading ? (
<div className="py-8 text-center text-text-muted animate-pulse">
Loading proxy configuration...
</div>
) : (
<div className="flex flex-col gap-5">
{/* Inheritance indicator */}
{level !== "global" && !hasOwnProxy && inheritedFrom && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-blue-500/10 border border-blue-500/20 text-sm">
<span className="material-symbols-outlined text-blue-400 text-base">
subdirectory_arrow_right
</span>
<span className="text-blue-300">
Inheriting from <strong>{inheritedFrom.level}</strong>: {inheritedFrom.proxy?.type}
://{inheritedFrom.proxy?.host}:{inheritedFrom.proxy?.port}
</span>
</div>
)}
{/* Proxy Type Selector */}
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Proxy Type
</label>
<div className="flex gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
{PROXY_TYPES.map((t) => (
<button
key={t.value}
onClick={() => setProxyType(t.value)}
className={`flex-1 px-4 py-2 rounded-md text-sm font-medium transition-all ${
proxyType === t.value
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* Host + Port */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Host
</label>
<input
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="1.2.3.4 or proxy.example.com"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Port
</label>
<input
type="text"
value={port}
onChange={(e) => setPort(e.target.value)}
placeholder={getDefaultPort(proxyType)}
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
</div>
{/* Auth Toggle */}
<div>
<button
onClick={() => setShowAuth(!showAuth)}
className="flex items-center gap-2 text-sm text-text-muted hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-base">
{showAuth ? "expand_less" : "expand_more"}
</span>
Authentication (optional)
</button>
{showAuth && (
<div className="grid grid-cols-2 gap-3 mt-3">
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Username
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
<div>
<label className="text-xs text-text-muted mb-1.5 block uppercase tracking-wider font-medium">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full px-3 py-2.5 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted/50 focus:outline-none focus:border-primary transition-colors"
/>
</div>
</div>
)}
</div>
{/* Test Result */}
{formError && (
<div className="px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-sm text-red-400">
{formError}
</div>
)}
{testResult && (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-lg border ${
testResult.success
? "bg-emerald-500/10 border-emerald-500/30"
: "bg-red-500/10 border-red-500/30"
}`}
>
<span
className={`material-symbols-outlined text-xl ${
testResult.success ? "text-emerald-400" : "text-red-400"
}`}
>
{testResult.success ? "check_circle" : "error"}
</span>
<div className="flex-1">
{testResult.success ? (
<div>
<span className="text-sm font-medium text-emerald-400">Connected</span>
<span className="text-text-muted text-xs ml-2">
IP: <span className="font-mono text-emerald-300">{testResult.publicIp}</span>
{testResult.latencyMs && ` · ${testResult.latencyMs}ms`}
</span>
</div>
) : (
<div className="text-sm text-red-400">
{testResult.error || "Connection failed"}
{testResult.latencyMs && (
<span className="text-text-muted text-xs ml-2">
({testResult.latencyMs}ms)
</span>
)}
</div>
)}
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-between pt-2 border-t border-border">
<div className="flex gap-2">
<Button
size="sm"
variant="secondary"
icon="speed"
onClick={handleTest}
loading={testing}
disabled={!host.trim()}
>
Test Connection
</Button>
{hasOwnProxy && (
<Button
size="sm"
variant="ghost"
icon="delete"
onClick={handleClear}
disabled={saving}
className="!text-red-400 hover:!bg-red-500/10"
>
Clear
</Button>
)}
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
size="sm"
icon="save"
onClick={handleSave}
loading={saving}
disabled={!host.trim()}
>
Save
</Button>
</div>
</div>
</div>
)}
</Modal>
);
}
ProxyConfigModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
level: PropTypes.oneOf(["global", "provider", "combo", "key"]).isRequired,
levelId: PropTypes.string,
levelLabel: PropTypes.string,
onSaved: PropTypes.func,
};

View File

@@ -0,0 +1,676 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import Card from "./Card";
import {
TYPE_COLORS,
LEVEL_COLORS,
PROVIDER_COLORS,
getProxyStatusStyle as getStatusStyle,
} from "@/shared/constants/colors";
import {
formatTime,
formatDuration as formatLatency,
truncateUrl,
} from "@/shared/utils/formatting";
const STATUS_FILTERS = [
{ key: "all", label: "All" },
{ key: "error", label: "Errors", icon: "error" },
{ key: "ok", label: "Success", icon: "check_circle" },
{ key: "timeout", label: "Timeout", icon: "timer_off" },
];
const COLUMNS = [
{ key: "status", label: "Status" },
{ key: "proxy", label: "Proxy" },
{ key: "type", label: "Type" },
{ key: "level", label: "Level" },
{ key: "provider", label: "Provider" },
{ key: "target", label: "Target" },
{ key: "latency", label: "Latency" },
{ key: "ip", label: "Public IP" },
{ key: "time", label: "Time" },
];
const DEFAULT_VISIBLE = Object.fromEntries(COLUMNS.map((c) => [c.key, true]));
export default function ProxyLogger() {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [recording, setRecording] = useState(true);
const [search, setSearch] = useState("");
const [activeFilter, setActiveFilter] = useState("all");
const [selectedType, setSelectedType] = useState("");
const [selectedProvider, setSelectedProvider] = useState("");
const [selectedLevel, setSelectedLevel] = useState("");
const [sortBy, setSortBy] = useState("newest");
const [selectedLog, setSelectedLog] = useState(null);
const intervalRef = useRef(null);
const hasLoadedRef = useRef(false);
const [visibleColumns, setVisibleColumns] = useState(() => {
if (typeof window === "undefined") return DEFAULT_VISIBLE;
try {
const saved = localStorage.getItem("proxyLoggerVisibleColumns");
return saved ? { ...DEFAULT_VISIBLE, ...JSON.parse(saved) } : DEFAULT_VISIBLE;
} catch {
return DEFAULT_VISIBLE;
}
});
const toggleColumn = useCallback((key) => {
setVisibleColumns((prev) => {
const next = { ...prev, [key]: !prev[key] };
try {
localStorage.setItem("proxyLoggerVisibleColumns", JSON.stringify(next));
} catch {}
return next;
});
}, []);
const fetchLogs = useCallback(
async (showLoading = false) => {
if (showLoading) setLoading(true);
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (activeFilter === "error") params.set("status", "error");
if (activeFilter === "ok") params.set("status", "ok");
if (activeFilter === "timeout") params.set("status", "timeout");
if (selectedType) params.set("type", selectedType);
if (selectedProvider) params.set("provider", selectedProvider);
if (selectedLevel) params.set("level", selectedLevel);
params.set("limit", "300");
const res = await fetch(`/api/usage/proxy-logs?${params}`);
if (res.ok) {
const data = await res.json();
setLogs(data);
}
} catch (error) {
console.error("Failed to fetch proxy logs:", error);
} finally {
if (showLoading) setLoading(false);
}
},
[search, activeFilter, selectedType, selectedProvider, selectedLevel]
);
useEffect(() => {
const showLoading = !hasLoadedRef.current;
hasLoadedRef.current = true;
fetchLogs(showLoading);
}, [fetchLogs]);
useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (recording) {
intervalRef.current = setInterval(() => fetchLogs(false), 3000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [recording, fetchLogs]);
const sortedLogs = useMemo(() => {
const arr = [...logs];
arr.sort((a, b) => {
switch (sortBy) {
case "oldest":
return new Date(a.timestamp) - new Date(b.timestamp);
case "latency_desc":
return (b.latencyMs || 0) - (a.latencyMs || 0);
case "latency_asc":
return (a.latencyMs || 0) - (b.latencyMs || 0);
case "newest":
default:
return new Date(b.timestamp) - new Date(a.timestamp);
}
});
return arr;
}, [logs, sortBy]);
const uniqueProviders = [...new Set(logs.map((l) => l.provider).filter(Boolean))].sort();
const uniqueTypes = [...new Set(logs.map((l) => l.proxy?.type).filter(Boolean))].sort();
const uniqueLevels = [...new Set(logs.map((l) => l.level).filter(Boolean))].sort();
const totalCount = logs.length;
const okCount = logs.filter((l) => l.status === "success").length;
const errorCount = logs.filter((l) => l.status === "error").length;
const timeoutCount = logs.filter((l) => l.status === "timeout").length;
const directCount = logs.filter((l) => l.level === "direct").length;
return (
<div className="flex flex-col gap-4">
{/* Header Bar */}
<div className="flex flex-wrap items-center gap-3">
{/* Recording Toggle */}
<button
onClick={() => setRecording(!recording)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
recording
? "bg-red-500/10 border-red-500/30 text-red-400"
: "bg-bg-subtle border-border text-text-muted"
}`}
>
<span
className={`w-2 h-2 rounded-full ${recording ? "bg-red-500 animate-pulse" : "bg-text-muted"}`}
/>
{recording ? "Recording" : "Paused"}
</button>
{/* Search */}
<div className="flex-1 min-w-[200px] relative">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-[18px]">
search
</span>
<input
type="text"
placeholder="Search host, provider, target, IP..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary"
/>
</div>
{/* Type Dropdown */}
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[120px]"
>
<option value="">All Types</option>
{uniqueTypes.map((t) => (
<option key={t} value={t}>
{(TYPE_COLORS[t]?.label || t).toUpperCase()}
</option>
))}
</select>
{/* Level Dropdown */}
<select
value={selectedLevel}
onChange={(e) => setSelectedLevel(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[120px]"
>
<option value="">All Levels</option>
{uniqueLevels.map((l) => (
<option key={l} value={l}>
{LEVEL_COLORS[l]?.label || l}
</option>
))}
</select>
{/* Provider Dropdown */}
<select
value={selectedProvider}
onChange={(e) => setSelectedProvider(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[140px]"
>
<option value="">All Providers</option>
{uniqueProviders.map((p) => {
const pc = PROVIDER_COLORS[p];
return (
<option key={p} value={p}>
{pc?.label || p.toUpperCase()}
</option>
);
})}
</select>
{/* Stats */}
<div className="flex items-center gap-2 text-xs text-text-muted">
<span className="px-2 py-1 rounded bg-bg-subtle border border-border font-mono">
{totalCount} total
</span>
<span className="px-2 py-1 rounded bg-emerald-500/10 text-emerald-400 font-mono">
{okCount} OK
</span>
{errorCount > 0 && (
<span className="px-2 py-1 rounded bg-red-500/10 text-red-400 font-mono">
{errorCount} ERR
</span>
)}
{timeoutCount > 0 && (
<span className="px-2 py-1 rounded bg-amber-500/10 text-amber-400 font-mono">
{timeoutCount} TMO
</span>
)}
{directCount > 0 && (
<span className="px-2 py-1 rounded bg-gray-500/10 text-gray-400 font-mono">
{directCount} direct
</span>
)}
</div>
{/* Sort */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[140px]"
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="latency_desc">Latency </option>
<option value="latency_asc">Latency </option>
</select>
{/* Refresh */}
<button
onClick={() => fetchLogs(false)}
className="p-2 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
title="Refresh"
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
</button>
</div>
{/* Quick Filters */}
<div className="flex flex-wrap items-center gap-2">
{STATUS_FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setActiveFilter(activeFilter === f.key ? "all" : f.key)}
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-all ${
activeFilter === f.key
? f.key === "error"
? "bg-red-500/20 text-red-400 border-red-500/40"
: f.key === "ok"
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/40"
: f.key === "timeout"
? "bg-amber-500/20 text-amber-400 border-amber-500/40"
: "bg-primary text-white border-primary"
: "bg-bg-subtle border-border text-text-muted hover:border-text-muted"
}`}
>
{f.icon && <span className="material-symbols-outlined text-[14px]">{f.icon}</span>}
{f.label}
</button>
))}
{uniqueProviders.length > 0 && <span className="w-px h-5 bg-border mx-1" />}
{uniqueProviders.map((p) => {
const pc = PROVIDER_COLORS[p] || { bg: "#374151", text: "#fff", label: p.toUpperCase() };
const isActive = selectedProvider === p;
return (
<button
key={p}
onClick={() => setSelectedProvider(isActive ? "" : p)}
className={`px-3 py-1 rounded-full text-xs font-bold uppercase border transition-all ${
isActive
? "border-white/40 ring-1 ring-white/20"
: "border-transparent opacity-70 hover:opacity-100"
}`}
style={{
backgroundColor: isActive ? pc.bg : `${pc.bg}33`,
color: isActive ? pc.text : pc.bg,
}}
>
{pc.label}
</button>
);
})}
</div>
{/* Column Visibility Toggles */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] text-text-muted uppercase tracking-wider mr-1">Columns</span>
{COLUMNS.map((col) => (
<button
key={col.key}
onClick={() => toggleColumn(col.key)}
className={`px-2 py-0.5 rounded text-[10px] font-medium border transition-all ${
visibleColumns[col.key]
? "bg-primary/15 text-primary border-primary/30"
: "bg-bg-subtle text-text-muted border-border opacity-50 hover:opacity-80"
}`}
>
{col.label}
</button>
))}
</div>
{/* Table */}
<Card className="overflow-hidden bg-black/5 dark:bg-black/20">
<div className="p-0 overflow-x-auto max-h-[calc(100vh-320px)] overflow-y-auto">
{loading && logs.length === 0 ? (
<div className="p-8 text-center text-text-muted">Loading proxy logs...</div>
) : logs.length === 0 ? (
<div className="p-8 text-center text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-2 block opacity-40">
vpn_lock
</span>
No proxy logs yet. Configure proxies and make API calls to see them here.
</div>
) : sortedLogs.length === 0 ? (
<div className="p-8 text-center text-text-muted">
No logs match the current filters.
</div>
) : (
<table className="w-full text-left border-collapse text-xs">
<thead
className="sticky top-0 z-10"
style={{ backgroundColor: "var(--bg-primary, #0f1117)" }}
>
<tr
className="border-b border-border"
style={{ backgroundColor: "var(--bg-primary, #0f1117)" }}
>
{visibleColumns.status && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Status
</th>
)}
{visibleColumns.proxy && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Proxy
</th>
)}
{visibleColumns.type && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Type
</th>
)}
{visibleColumns.level && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Level
</th>
)}
{visibleColumns.provider && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Provider
</th>
)}
{visibleColumns.target && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Target
</th>
)}
{visibleColumns.latency && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
Latency
</th>
)}
{visibleColumns.ip && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Public IP
</th>
)}
{visibleColumns.time && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
Time
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{sortedLogs.map((log) => {
const statusStyle = getStatusStyle(log.status);
const typeColor = TYPE_COLORS[log.proxy?.type] || {
bg: "#6B7280",
text: "#fff",
label: log.proxy?.type || "-",
};
const levelColor = LEVEL_COLORS[log.level] || LEVEL_COLORS.direct;
const providerColor = PROVIDER_COLORS[log.provider] || {
bg: "#374151",
text: "#fff",
label: (log.provider || "-").toUpperCase(),
};
const isError = log.status === "error" || log.status === "timeout";
return (
<tr
key={log.id}
onClick={() => setSelectedLog(selectedLog?.id === log.id ? null : log)}
className={`cursor-pointer hover:bg-primary/5 transition-colors ${isError ? "bg-red-500/5" : ""}`}
>
{visibleColumns.status && (
<td className="px-3 py-2">
<span
className="inline-block px-2 py-0.5 rounded text-[10px] font-bold min-w-[50px] text-center uppercase"
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
>
{log.status}
</span>
</td>
)}
{visibleColumns.proxy && (
<td className="px-3 py-2 font-mono text-[11px] text-primary">
{log.proxy ? `${log.proxy.host}:${log.proxy.port}` : "—"}
</td>
)}
{visibleColumns.type && (
<td className="px-3 py-2">
<span
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
style={{ backgroundColor: typeColor.bg, color: typeColor.text }}
>
{typeColor.label}
</span>
</td>
)}
{visibleColumns.level && (
<td className="px-3 py-2">
<span
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
style={{ backgroundColor: levelColor.bg, color: levelColor.text }}
>
{levelColor.label}
</span>
</td>
)}
{visibleColumns.provider && (
<td className="px-3 py-2">
{log.provider ? (
<span
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
style={{
backgroundColor: providerColor.bg,
color: providerColor.text,
}}
>
{providerColor.label}
</span>
) : (
<span className="text-text-muted text-[10px]"></span>
)}
</td>
)}
{visibleColumns.target && (
<td
className="px-3 py-2 text-text-muted truncate max-w-[200px] font-mono text-[10px]"
title={log.targetUrl}
>
{truncateUrl(log.targetUrl)}
</td>
)}
{visibleColumns.latency && (
<td className="px-3 py-2 text-right text-text-muted font-mono">
{formatLatency(log.latencyMs)}
</td>
)}
{visibleColumns.ip && (
<td className="px-3 py-2 font-mono text-[11px] text-emerald-400">
{log.publicIp || "—"}
</td>
)}
{visibleColumns.time && (
<td className="px-3 py-2 text-right text-text-muted">
{formatTime(log.timestamp)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
)}
</div>
</Card>
{/* Detail Panel */}
{selectedLog && <ProxyLogDetail log={selectedLog} onClose={() => setSelectedLog(null)} />}
</div>
);
}
// ─── Detail Modal ───────────────────────────────────────────────────────────
function ProxyLogDetail({ log, onClose }) {
useEffect(() => {
const handler = (e) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const statusStyle = getStatusStyle(log.status);
const typeColor = TYPE_COLORS[log.proxy?.type] || {
bg: "#6B7280",
text: "#fff",
label: log.proxy?.type || "-",
};
const levelColor = LEVEL_COLORS[log.level] || LEVEL_COLORS.direct;
const providerColor = PROVIDER_COLORS[log.provider] || {
bg: "#374151",
text: "#fff",
label: (log.provider || "-").toUpperCase(),
};
const formatDate = (iso) => {
try {
const d = new Date(iso);
return (
d.toLocaleDateString("pt-BR") + ", " + d.toLocaleTimeString("en-US", { hour12: false })
);
} catch {
return iso;
}
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[5vh]" onClick={onClose}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-bg-primary border border-border rounded-xl w-full max-w-[700px] max-h-[90vh] overflow-y-auto shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl">
<div className="flex items-center gap-3">
<span
className="inline-block px-2.5 py-1 rounded text-xs font-bold uppercase"
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
>
{log.status}
</span>
<span className="font-bold text-lg">Proxy Event</span>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="p-6 flex flex-col gap-6">
{/* Metadata Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 p-4 bg-bg-subtle rounded-xl border border-border">
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Time</div>
<div className="text-sm font-medium">{formatDate(log.timestamp)}</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Latency
</div>
<div className="text-sm font-medium">{formatLatency(log.latencyMs)}</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Public IP
</div>
<div className="text-sm font-medium font-mono text-emerald-400">
{log.publicIp || "—"}
</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Proxy</div>
<div className="text-sm font-medium font-mono text-primary">
{log.proxy ? `${log.proxy.type}://${log.proxy.host}:${log.proxy.port}` : "Direct"}
</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Type</div>
<span
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
style={{ backgroundColor: typeColor.bg, color: typeColor.text }}
>
{typeColor.label}
</span>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Level</div>
<span
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
style={{ backgroundColor: levelColor.bg, color: levelColor.text }}
>
{levelColor.label}
</span>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Provider
</div>
{log.provider ? (
<span
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
style={{ backgroundColor: providerColor.bg, color: providerColor.text }}
>
{providerColor.label}
</span>
) : (
<div className="text-sm text-text-muted"></div>
)}
</div>
<div className="col-span-2">
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Target URL
</div>
<div className="text-sm font-medium font-mono text-text-muted break-all">
{log.targetUrl || "—"}
</div>
</div>
</div>
{/* Error */}
{log.error && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30">
<div className="text-[10px] text-red-400 uppercase tracking-wider mb-1 font-bold">
Error
</div>
<div className="text-sm text-red-300 font-mono">{log.error}</div>
</div>
)}
{/* Proxy Config Details */}
{log.proxy && (
<div className="p-4 rounded-xl bg-bg-subtle border border-border">
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-2 font-bold">
Proxy Configuration
</div>
<pre className="text-xs font-mono text-text-primary bg-black/20 rounded-lg p-3 overflow-x-auto">
{JSON.stringify(log.proxy, null, 2)}
</pre>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,909 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import Card from "./Card";
import {
PROTOCOL_COLORS,
PROVIDER_COLORS,
getHttpStatusStyle as getStatusStyle,
} from "@/shared/constants/colors";
import {
formatTime,
formatDuration,
maskSegment,
maskAccount,
formatApiKeyLabel,
} from "@/shared/utils/formatting";
// Quick filter categories - status-based only (providers are dynamic from data)
const STATUS_FILTERS = [
{ key: "all", label: "All" },
{ key: "error", label: "Errors", icon: "error" },
{ key: "ok", label: "Success", icon: "check_circle" },
{ key: "combo", label: "Combo", icon: "hub" },
];
// Column definitions for visibility toggles
const COLUMNS = [
{ key: "status", label: "Status" },
{ key: "model", label: "Model" },
{ key: "provider", label: "Provider" },
{ key: "protocol", label: "Protocol" },
{ key: "account", label: "Account" },
{ key: "apiKey", label: "API Key" },
{ key: "combo", label: "Combo" },
{ key: "tokens", label: "Tokens" },
{ key: "duration", label: "Duration" },
{ key: "time", label: "Time" },
];
const DEFAULT_VISIBLE = Object.fromEntries(COLUMNS.map((c) => [c.key, true]));
function getLogTotalTokens(log) {
return (log?.tokens?.in || 0) + (log?.tokens?.out || 0);
}
export default function RequestLoggerV2() {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [recording, setRecording] = useState(true);
const [search, setSearch] = useState("");
const [activeFilter, setActiveFilter] = useState("all");
const [selectedModel, setSelectedModel] = useState("");
const [selectedAccount, setSelectedAccount] = useState("");
const [selectedProvider, setSelectedProvider] = useState("");
const [selectedApiKey, setSelectedApiKey] = useState("");
const [sortBy, setSortBy] = useState("newest");
const [selectedLog, setSelectedLog] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailData, setDetailData] = useState(null);
const intervalRef = useRef(null);
const hasLoadedRef = useRef(false);
// Column visibility with localStorage persistence
const [visibleColumns, setVisibleColumns] = useState(() => {
if (typeof window === "undefined") return DEFAULT_VISIBLE;
try {
const saved = localStorage.getItem("loggerVisibleColumns");
return saved ? { ...DEFAULT_VISIBLE, ...JSON.parse(saved) } : DEFAULT_VISIBLE;
} catch {
return DEFAULT_VISIBLE;
}
});
const toggleColumn = useCallback((key) => {
setVisibleColumns((prev) => {
const next = { ...prev, [key]: !prev[key] };
try {
localStorage.setItem("loggerVisibleColumns", JSON.stringify(next));
} catch {}
return next;
});
}, []);
const fetchLogs = useCallback(
async (showLoading = false) => {
if (showLoading) setLoading(true);
try {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (activeFilter === "error") params.set("status", "error");
if (activeFilter === "ok") params.set("status", "ok");
if (activeFilter === "combo") params.set("combo", "1");
if (selectedModel) params.set("model", selectedModel);
if (selectedProvider) params.set("provider", selectedProvider);
if (selectedAccount) params.set("account", selectedAccount);
if (selectedApiKey) params.set("apiKey", selectedApiKey);
params.set("limit", "300");
const res = await fetch(`/api/usage/call-logs?${params}`);
if (res.ok) {
const data = await res.json();
setLogs(data);
}
} catch (error) {
console.error("Failed to fetch call logs:", error);
} finally {
if (showLoading) setLoading(false);
}
},
[search, activeFilter, selectedModel, selectedAccount, selectedProvider, selectedApiKey]
);
useEffect(() => {
const showLoading = !hasLoadedRef.current;
hasLoadedRef.current = true;
fetchLogs(showLoading);
}, [fetchLogs]);
// Auto-refresh
useEffect(() => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (recording) {
intervalRef.current = setInterval(() => fetchLogs(false), 3000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [recording, fetchLogs]);
const filteredLogs = useMemo(() => {
if (activeFilter === "combo") return logs.filter((l) => l.comboName);
return logs;
}, [activeFilter, logs]);
const sortedLogs = useMemo(() => {
const arr = [...filteredLogs];
arr.sort((a, b) => {
switch (sortBy) {
case "oldest":
return new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime();
case "tokens_desc":
return getLogTotalTokens(b) - getLogTotalTokens(a);
case "tokens_asc":
return getLogTotalTokens(a) - getLogTotalTokens(b);
case "duration_desc":
return (b.duration || 0) - (a.duration || 0);
case "duration_asc":
return (a.duration || 0) - (b.duration || 0);
case "status_desc":
return (b.status || 0) - (a.status || 0);
case "status_asc":
return (a.status || 0) - (b.status || 0);
case "model_asc":
return (a.model || "").localeCompare(b.model || "");
case "model_desc":
return (b.model || "").localeCompare(a.model || "");
case "newest":
default:
return new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime();
}
});
return arr;
}, [filteredLogs, sortBy]);
// Fetch log detail
const openDetail = async (logEntry) => {
setSelectedLog(logEntry);
setDetailLoading(true);
setDetailData(null);
try {
const res = await fetch(`/api/usage/call-logs/${logEntry.id}`);
if (res.ok) {
const data = await res.json();
setDetailData(data);
}
} catch (error) {
console.error("Failed to fetch log detail:", error);
} finally {
setDetailLoading(false);
}
};
const closeDetail = () => {
setSelectedLog(null);
setDetailData(null);
};
// Copy to clipboard
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fallback for non-HTTPS or older browsers
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
return true;
} catch {
return false;
}
}
};
// Unique accounts and providers for dropdowns
const uniqueAccounts = [...new Set(logs.map((l) => l.account).filter((a) => a && a !== "-"))];
const uniqueModels = [...new Set(logs.map((l) => l.model).filter(Boolean))].sort();
const uniqueProviders = [
...new Set(logs.map((l) => l.provider).filter((p) => p && p !== "-")),
].sort();
const uniqueApiKeys = [
...new Set(logs.map((l) => l.apiKeyId || l.apiKeyName).filter(Boolean)),
].sort();
// Stats
const totalCount = filteredLogs.length;
const okCount = filteredLogs.filter((l) => l.status >= 200 && l.status < 300).length;
const errorCount = filteredLogs.filter((l) => l.status >= 400).length;
const comboCount = logs.filter((l) => l.comboName).length;
const apiKeyCount = uniqueApiKeys.length;
return (
<div className="flex flex-col gap-4">
{/* Header Bar */}
<div className="flex flex-wrap items-center gap-3">
{/* Recording Toggle */}
<button
onClick={() => setRecording(!recording)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
recording
? "bg-red-500/10 border-red-500/30 text-red-400"
: "bg-bg-subtle border-border text-text-muted"
}`}
>
<span
className={`w-2 h-2 rounded-full ${recording ? "bg-red-500 animate-pulse" : "bg-text-muted"}`}
/>
{recording ? "Recording" : "Paused"}
</button>
{/* Search */}
<div className="flex-1 min-w-[200px] relative">
<span className="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-[18px]">
search
</span>
<input
type="text"
placeholder="Search model, provider, account, API key, combo..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary"
/>
</div>
{/* Provider Dropdown */}
<select
value={selectedProvider}
onChange={(e) => setSelectedProvider(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[140px]"
>
<option value="">All Providers</option>
{uniqueProviders.map((p) => {
const pc = PROVIDER_COLORS[p];
return (
<option key={p} value={p}>
{pc?.label || p.toUpperCase()}
</option>
);
})}
</select>
{/* Model Dropdown */}
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[180px]"
>
<option value="">All Models</option>
{uniqueModels.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
{/* Account Dropdown */}
<select
value={selectedAccount}
onChange={(e) => setSelectedAccount(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[140px]"
>
<option value="">All Accounts</option>
{uniqueAccounts.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
{/* API Key Dropdown */}
<select
value={selectedApiKey}
onChange={(e) => setSelectedApiKey(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[160px]"
>
<option value="">All API Keys</option>
{uniqueApiKeys.map((value) => {
const matched = logs.find((l) => (l.apiKeyId || l.apiKeyName) === value);
const label = formatApiKeyLabel(matched?.apiKeyName, matched?.apiKeyId);
return (
<option key={value} value={value}>
{label}
</option>
);
})}
</select>
{/* Stats */}
<div className="flex items-center gap-2 text-xs text-text-muted">
<span className="px-2 py-1 rounded bg-bg-subtle border border-border font-mono">
{totalCount} total
</span>
<span className="px-2 py-1 rounded bg-emerald-500/10 text-emerald-400 font-mono">
{okCount} OK
</span>
{errorCount > 0 && (
<span className="px-2 py-1 rounded bg-red-500/10 text-red-400 font-mono">
{errorCount} ERR
</span>
)}
{comboCount > 0 && (
<span className="px-2 py-1 rounded bg-violet-500/10 text-violet-300 font-mono">
{comboCount} combo
</span>
)}
{apiKeyCount > 0 && (
<span className="px-2 py-1 rounded bg-primary/10 text-primary font-mono">
{apiKeyCount} keys
</span>
)}
<span className="px-2 py-1 rounded bg-bg-subtle border border-border font-mono">
{sortedLogs.length} shown
</span>
</div>
{/* Sort Dropdown */}
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-2 rounded-lg bg-bg-subtle border border-border text-sm text-text-primary focus:outline-none focus:border-primary appearance-none cursor-pointer min-w-[150px]"
title="Sort logs"
>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="tokens_desc">Tokens </option>
<option value="tokens_asc">Tokens </option>
<option value="duration_desc">Duration </option>
<option value="duration_asc">Duration </option>
<option value="status_desc">Status </option>
<option value="status_asc">Status </option>
<option value="model_asc">Model A-Z</option>
<option value="model_desc">Model Z-A</option>
</select>
{/* Refresh */}
<button
onClick={() => fetchLogs(false)}
className="p-2 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
title="Refresh"
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
</button>
</div>
{/* Quick Filters */}
<div className="flex flex-wrap items-center gap-2">
{/* Status Filters */}
{STATUS_FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setActiveFilter(activeFilter === f.key ? "all" : f.key)}
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border transition-all ${
activeFilter === f.key
? f.key === "error"
? "bg-red-500/20 text-red-400 border-red-500/40"
: f.key === "ok"
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/40"
: f.key === "combo"
? "bg-violet-500/20 text-violet-300 border-violet-500/40"
: "bg-primary text-white border-primary"
: "bg-bg-subtle border-border text-text-muted hover:border-text-muted"
}`}
>
{f.icon && <span className="material-symbols-outlined text-[14px]">{f.icon}</span>}
{f.label}
</button>
))}
{/* Divider */}
{uniqueProviders.length > 0 && <span className="w-px h-5 bg-border mx-1" />}
{/* Dynamic Provider Quick Filters (from data) */}
{uniqueProviders.map((p) => {
const pc = PROVIDER_COLORS[p] || { bg: "#374151", text: "#fff", label: p.toUpperCase() };
const isActive = selectedProvider === p;
return (
<button
key={p}
onClick={() => setSelectedProvider(isActive ? "" : p)}
className={`px-3 py-1 rounded-full text-xs font-bold uppercase border transition-all ${
isActive
? "border-white/40 ring-1 ring-white/20"
: "border-transparent opacity-70 hover:opacity-100"
}`}
style={{
backgroundColor: isActive ? pc.bg : `${pc.bg}33`,
color: isActive ? pc.text : pc.bg,
}}
>
{pc.label}
</button>
);
})}
</div>
{/* Column Visibility Toggles */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[10px] text-text-muted uppercase tracking-wider mr-1">Columns</span>
{COLUMNS.map((col) => (
<button
key={col.key}
onClick={() => toggleColumn(col.key)}
className={`px-2 py-0.5 rounded text-[10px] font-medium border transition-all ${
visibleColumns[col.key]
? "bg-primary/15 text-primary border-primary/30"
: "bg-bg-subtle text-text-muted border-border opacity-50 hover:opacity-80"
}`}
>
{col.label}
</button>
))}
</div>
{/* Table */}
<Card className="overflow-hidden bg-black/5 dark:bg-black/20">
<div className="p-0 overflow-x-auto max-h-[calc(100vh-320px)] overflow-y-auto">
{loading && logs.length === 0 ? (
<div className="p-8 text-center text-text-muted">Loading logs...</div>
) : logs.length === 0 ? (
<div className="p-8 text-center text-text-muted">
<span className="material-symbols-outlined text-[48px] mb-2 block opacity-40">
receipt_long
</span>
No logs recorded yet. Make some API calls to see them here.
</div>
) : sortedLogs.length === 0 ? (
<div className="p-8 text-center text-text-muted">
No logs match the current filters.
</div>
) : (
<table className="w-full text-left border-collapse text-xs">
<thead
className="sticky top-0 z-10"
style={{ backgroundColor: "var(--bg-primary, #0f1117)" }}
>
<tr
className="border-b border-border"
style={{ backgroundColor: "var(--bg-primary, #0f1117)" }}
>
{visibleColumns.status && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Status
</th>
)}
{visibleColumns.model && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Model
</th>
)}
{visibleColumns.provider && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Provider
</th>
)}
{visibleColumns.protocol && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Protocol
</th>
)}
{visibleColumns.account && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Account
</th>
)}
{visibleColumns.apiKey && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
API Key
</th>
)}
{visibleColumns.combo && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Combo
</th>
)}
{visibleColumns.tokens && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
Tokens
</th>
)}
{visibleColumns.duration && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
Duration
</th>
)}
{visibleColumns.time && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px] text-right">
Time
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-border/30">
{sortedLogs.map((log) => {
const statusStyle = getStatusStyle(log.status);
const protocolKey = log.sourceFormat || log.provider;
const protocol = PROTOCOL_COLORS[protocolKey] ||
PROTOCOL_COLORS[log.provider] || {
bg: "#6B7280",
text: "#fff",
label: (protocolKey || log.provider || "-").toUpperCase(),
};
const providerColor = PROVIDER_COLORS[log.provider] || {
bg: "#374151",
text: "#fff",
label: (log.provider || "-").toUpperCase(),
};
const isError = log.status >= 400;
return (
<tr
key={log.id}
onClick={() => openDetail(log)}
className={`cursor-pointer hover:bg-primary/5 transition-colors ${isError ? "bg-red-500/5" : ""}`}
>
{visibleColumns.status && (
<td className="px-3 py-2">
<span
className="inline-block px-2 py-0.5 rounded text-[10px] font-bold min-w-[36px] text-center"
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
>
{log.status || "..."}
</span>
</td>
)}
{visibleColumns.model && (
<td className="px-3 py-2 font-medium text-primary font-mono text-[11px]">
{log.model}
</td>
)}
{visibleColumns.provider && (
<td className="px-3 py-2">
<span
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
style={{ backgroundColor: providerColor.bg, color: providerColor.text }}
>
{providerColor.label}
</span>
</td>
)}
{visibleColumns.protocol && (
<td className="px-3 py-2">
<span
className="inline-block px-2 py-0.5 rounded text-[9px] font-bold uppercase"
style={{ backgroundColor: protocol.bg, color: protocol.text }}
>
{protocol.label}
</span>
</td>
)}
{visibleColumns.account && (
<td
className="px-3 py-2 text-text-muted truncate max-w-[120px]"
title={log.account}
>
{maskAccount(log.account)}
</td>
)}
{visibleColumns.apiKey && (
<td
className="px-3 py-2 text-text-muted truncate max-w-[140px]"
title={log.apiKeyName || log.apiKeyId || "No API key"}
>
{formatApiKeyLabel(log.apiKeyName, log.apiKeyId)}
</td>
)}
{visibleColumns.combo && (
<td className="px-3 py-2">
{log.comboName ? (
<span className="inline-block px-2 py-0.5 rounded-full text-[9px] font-bold bg-violet-500/20 text-violet-300 border border-violet-500/30">
{log.comboName}
</span>
) : (
<span className="text-text-muted text-[10px]"></span>
)}
</td>
)}
{visibleColumns.tokens && (
<td className="px-3 py-2 text-right whitespace-nowrap">
<span className="text-text-muted">I:</span>{" "}
<span className="text-primary">
{log.tokens?.in?.toLocaleString() || 0}
</span>
<span className="mx-1 text-border">|</span>
<span className="text-text-muted">O:</span>{" "}
<span className="text-emerald-400">
{log.tokens?.out?.toLocaleString() || 0}
</span>
</td>
)}
{visibleColumns.duration && (
<td className="px-3 py-2 text-right text-text-muted font-mono">
{formatDuration(log.duration)}
</td>
)}
{visibleColumns.time && (
<td className="px-3 py-2 text-right text-text-muted">
{formatTime(log.timestamp)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
)}
</div>
</Card>
<div className="text-[10px] text-text-muted italic">
Call logs are also saved as JSON files to <code>{`{DATA_DIR}/call_logs/`}</code> with 7-day
rotation.
</div>
{/* Detail Modal */}
{selectedLog && (
<DetailModal
log={selectedLog}
detail={detailData}
loading={detailLoading}
onClose={closeDetail}
onCopy={copyToClipboard}
/>
)}
</div>
);
}
// ─── Detail Modal ───────────────────────────────────────────────────────────
function DetailModal({ log, detail, loading, onClose, onCopy }) {
// Close on Escape key
useEffect(() => {
const handler = (e) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onClose]);
const statusStyle = getStatusStyle(log.status);
const protocolKey = log.sourceFormat || log.provider;
const protocol = PROTOCOL_COLORS[protocolKey] ||
PROTOCOL_COLORS[log.provider] || {
bg: "#6B7280",
text: "#fff",
label: (protocolKey || log.provider || "-").toUpperCase(),
};
const providerColor = PROVIDER_COLORS[log.provider] || {
bg: "#374151",
text: "#fff",
label: (log.provider || "-").toUpperCase(),
};
const formatDate = (iso) => {
try {
const d = new Date(iso);
return (
d.toLocaleDateString("pt-BR") + ", " + d.toLocaleTimeString("en-US", { hour12: false })
);
} catch {
return iso;
}
};
const requestJson = detail?.requestBody ? JSON.stringify(detail.requestBody, null, 2) : null;
const responseJson = detail?.responseBody ? JSON.stringify(detail.responseBody, null, 2) : null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[5vh]" onClick={onClose}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
className="relative bg-bg-primary border border-border rounded-xl w-full max-w-[900px] max-h-[90vh] overflow-y-auto shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 border-b border-border bg-bg-primary/95 backdrop-blur-sm rounded-t-xl">
<div className="flex items-center gap-3">
<span
className="inline-block px-2.5 py-1 rounded text-xs font-bold"
style={{ backgroundColor: statusStyle.bg, color: statusStyle.text }}
>
{log.status}
</span>
<span className="font-bold text-lg">{log.method}</span>
<span className="text-text-muted font-mono text-sm">{log.path}</span>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<div className="p-6 flex flex-col gap-6">
{/* Metadata Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-bg-subtle rounded-xl border border-border">
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Time</div>
<div className="text-sm font-medium">{formatDate(log.timestamp)}</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Duration
</div>
<div className="text-sm font-medium">{formatDuration(log.duration)}</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Tokens (I/O)
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded bg-primary/20 text-primary text-xs font-bold">
In: {(detail?.tokens?.in || log.tokens?.in || 0).toLocaleString()}
</span>
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400 text-xs font-bold">
Out: {(detail?.tokens?.out || log.tokens?.out || 0).toLocaleString()}
</span>
</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Model</div>
<div className="text-sm font-medium text-primary font-mono">{log.model}</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Provider
</div>
<span
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
style={{ backgroundColor: providerColor.bg, color: providerColor.text }}
>
{providerColor.label}
</span>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Protocol
</div>
<span
className="inline-block px-2.5 py-1 rounded text-[10px] font-bold uppercase"
style={{ backgroundColor: protocol.bg, color: protocol.text }}
>
{protocol.label}
</span>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Account
</div>
<div className="text-sm font-medium">{detail?.account || log.account || "-"}</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
API Key
</div>
<div
className="text-sm font-medium"
title={
detail?.apiKeyName ||
detail?.apiKeyId ||
log.apiKeyName ||
log.apiKeyId ||
"No API key"
}
>
{formatApiKeyLabel(
detail?.apiKeyName || log.apiKeyName,
detail?.apiKeyId || log.apiKeyId
)}
</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Combo</div>
{detail?.comboName || log.comboName ? (
<span className="inline-block px-2.5 py-1 rounded-full text-[10px] font-bold bg-violet-500/20 text-violet-300 border border-violet-500/30">
{detail?.comboName || log.comboName}
</span>
) : (
<div className="text-sm text-text-muted"></div>
)}
</div>
</div>
{/* Error Message */}
{(detail?.error || log.error) && (
<div className="p-4 rounded-xl bg-red-500/10 border border-red-500/30">
<div className="text-[10px] text-red-400 uppercase tracking-wider mb-1 font-bold">
Error
</div>
<div className="text-sm text-red-300 font-mono">{detail?.error || log.error}</div>
</div>
)}
{loading ? (
<div className="p-8 text-center text-text-muted animate-pulse">
Loading request details...
</div>
) : (
<>
{/* Request Payload */}
{requestJson && (
<PayloadSection
title="Request Payload"
json={requestJson}
onCopy={() => onCopy(requestJson)}
/>
)}
{/* Response Payload */}
{responseJson && (
<PayloadSection
title="Response Payload"
json={responseJson}
onCopy={() => onCopy(responseJson)}
/>
)}
{!requestJson && !responseJson && !loading && (
<div className="p-6 text-center text-text-muted">
<span className="material-symbols-outlined text-[32px] mb-2 block opacity-40">
info
</span>
<p className="text-sm">No payload data available for this log entry.</p>
<p className="text-xs mt-1">
Request/response bodies are only captured for non-streaming calls or when
streaming completes normally.
</p>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
// ─── Payload Code Block ─────────────────────────────────────────────────────
function PayloadSection({ title, json, onCopy }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
const success = await onCopy();
if (success !== false) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-[11px] text-text-muted uppercase tracking-wider font-bold">{title}</h3>
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<pre className="p-4 rounded-xl bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-primary max-h-[600px] overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
{json}
</pre>
</div>
);
}

View File

@@ -0,0 +1,54 @@
"use client";
import { cn } from "@/shared/utils/cn";
export default function SegmentedControl({
options = [],
value,
onChange,
size = "md",
className,
"aria-label": ariaLabel,
}) {
const sizes = {
sm: "h-7 text-xs",
md: "h-9 text-sm",
lg: "h-11 text-base",
};
return (
<div
role="tablist"
aria-label={ariaLabel}
className={cn(
"inline-flex items-center p-1 rounded-lg",
"bg-black/5 dark:bg-white/5",
className
)}
>
{options.map((option) => (
<button
key={option.value}
role="tab"
aria-selected={value === option.value}
tabIndex={value === option.value ? 0 : -1}
onClick={() => onChange(option.value)}
className={cn(
"px-4 rounded-md font-medium transition-all",
sizes[size],
value === option.value
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
)}
>
{option.icon && (
<span className="material-symbols-outlined text-[16px] mr-1.5" aria-hidden="true">
{option.icon}
</span>
)}
{option.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useId } from "react";
import { cn } from "@/shared/utils/cn";
export default function Select({
label,
options = [],
value,
onChange,
placeholder = "Select an option",
error,
hint,
disabled = false,
required = false,
className,
selectClassName,
id: externalId,
...props
}) {
const generatedId = useId();
const selectId = externalId || generatedId;
const errorId = error ? `${selectId}-error` : undefined;
const hintId = hint && !error ? `${selectId}-hint` : undefined;
const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined;
return (
<div className={cn("flex flex-col gap-1.5", className)}>
{label && (
<label htmlFor={selectId} className="text-sm font-medium text-text-main">
{label}
{required && (
<span className="text-red-500 ml-1" aria-hidden="true">
*
</span>
)}
</label>
)}
<div className="relative">
<select
id={selectId}
value={value}
onChange={onChange}
disabled={disabled}
required={required}
aria-required={required || undefined}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy}
className={cn(
"w-full py-2 px-3 pr-10 text-sm text-text-main",
"bg-white dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-md appearance-none",
"focus:ring-1 focus:ring-primary/30 focus:border-primary/50 focus:outline-none",
"transition-all disabled:opacity-50 disabled:cursor-not-allowed",
"text-[16px] sm:text-sm",
error ? "border-red-500 focus:border-red-500 focus:ring-red-500/20" : "",
selectClassName
)}
{...props}
>
<option value="" disabled>
{placeholder}
</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div
className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-text-muted"
aria-hidden="true"
>
<span className="material-symbols-outlined text-[20px]">expand_more</span>
</div>
</div>
{error && (
<p id={errorId} className="text-xs text-red-500 flex items-center gap-1" role="alert">
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
error
</span>
{error}
</p>
)}
{hint && !error && (
<p id={hintId} className="text-xs text-text-muted">
{hint}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,331 @@
"use client";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/shared/utils/cn";
import { APP_CONFIG } from "@/shared/constants/config";
import Button from "./Button";
import { ConfirmModal } from "./Modal";
const navItems = [
{ 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/cli-tools", label: "CLI Tools", icon: "terminal" },
];
// Debug items (only show when ENABLE_REQUEST_LOGS=true)
const debugItems = [{ href: "/dashboard/translator", label: "Translator", icon: "translate" }];
const systemItems = [{ href: "/dashboard/settings", label: "Settings", icon: "settings" }];
const helpItems = [
{ href: "/docs", label: "Docs", icon: "menu_book" },
{
href: "https://github.com/decolua/omniroute/issues",
label: "Issues",
icon: "bug_report",
external: true,
},
];
export default function Sidebar({ onClose, collapsed = false, onToggleCollapse }) {
const pathname = usePathname();
const [showShutdownModal, setShowShutdownModal] = useState(false);
const [showRestartModal, setShowRestartModal] = useState(false);
const [isShuttingDown, setIsShuttingDown] = useState(false);
const [isRestarting, setIsRestarting] = useState(false);
const [isDisconnected, setIsDisconnected] = useState(false);
const [showDebug, setShowDebug] = useState(false);
// Check if debug mode is enabled
useEffect(() => {
fetch("/api/settings")
.then((res) => res.json())
.then((data) => setShowDebug(data?.enableRequestLogs === true))
.catch(() => {});
}, []);
const isActive = (href) => {
if (href === "/dashboard/endpoint") {
return pathname === "/dashboard" || pathname.startsWith("/dashboard/endpoint");
}
return pathname.startsWith(href);
};
const handleShutdown = async () => {
setIsShuttingDown(true);
try {
await fetch("/api/shutdown", { method: "POST" });
} catch (e) {
// Expected to fail as server shuts down; ignore error
}
setIsShuttingDown(false);
setShowShutdownModal(false);
setIsDisconnected(true);
};
const handleRestart = async () => {
setIsRestarting(true);
try {
await fetch("/api/restart", { method: "POST" });
} catch (e) {
// Expected to fail as server restarts
}
setIsRestarting(false);
setShowRestartModal(false);
// Show reconnecting state, then try to reload after a delay
setIsDisconnected(true);
setTimeout(() => {
globalThis.location.reload();
}, 3000);
};
const renderNavLink = (item) => {
const active = !item.external && isActive(item.href);
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",
active
? "bg-primary/10 text-primary"
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
);
const iconClassName = cn(
"material-symbols-outlined text-[18px]",
active ? "fill-1" : "group-hover:text-primary transition-colors"
);
const content = (
<>
<span className={iconClassName}>{item.icon}</span>
{!collapsed && <span className="text-sm font-medium">{item.label}</span>}
</>
);
if (item.external) {
return (
<a
key={item.href}
href={item.href}
target="_blank"
rel="noopener noreferrer"
onClick={onClose}
title={collapsed ? item.label : undefined}
className={className}
>
{content}
</a>
);
}
return (
<Link
key={item.href}
href={item.href}
onClick={onClose}
title={collapsed ? item.label : undefined}
className={className}
>
{content}
</Link>
);
};
return (
<>
<aside
className={cn(
"flex flex-col border-r border-black/5 dark:border-white/5 bg-vibrancy backdrop-blur-xl transition-all duration-300 ease-in-out",
collapsed ? "w-16" : "w-72"
)}
>
{/* Skip to content link */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-3 focus:bg-primary focus:text-white focus:rounded-md focus:m-2"
>
Skip to content
</a>
{/* Traffic lights + collapse toggle */}
<div
className={cn(
"flex items-center gap-2 pt-5 pb-2",
collapsed ? "px-3 justify-center" : "px-6"
)}
aria-hidden="true"
>
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
<div className="w-3 h-3 rounded-full bg-[#FFBD2E]" />
<div className="w-3 h-3 rounded-full bg-[#27C93F]" />
{!collapsed && <div className="flex-1" />}
{onToggleCollapse && (
<button
onClick={onToggleCollapse}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
aria-expanded={!collapsed}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className={cn(
"p-1 rounded-md text-text-muted/50 hover:text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors",
collapsed && "mt-2"
)}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{collapsed ? "chevron_right" : "chevron_left"}
</span>
</button>
)}
</div>
{/* Logo */}
<div className={cn("py-4", collapsed ? "px-2" : "px-6")}>
<Link
href="/dashboard"
className={cn("flex items-center", collapsed ? "justify-center" : "gap-3")}
>
<div className="flex items-center justify-center size-9 rounded bg-linear-to-br from-[#f97815] to-[#c2590a] shrink-0">
<span className="material-symbols-outlined text-white text-[20px]">hub</span>
</div>
{!collapsed && (
<div className="flex flex-col">
<h1 className="text-lg font-semibold tracking-tight text-text-main">
{APP_CONFIG.name}
</h1>
<span className="text-xs text-text-muted">v{APP_CONFIG.version}</span>
</div>
)}
</Link>
</div>
{/* Navigation */}
<nav
aria-label="Main navigation"
className={cn(
"flex-1 py-2 space-y-1 overflow-y-auto custom-scrollbar",
collapsed ? "px-2" : "px-4"
)}
>
{navItems.map(renderNavLink)}
{/* Debug section */}
{showDebug && (
<div className="pt-4 mt-2">
{!collapsed && (
<p className="px-4 text-xs font-semibold text-text-muted/60 uppercase tracking-wider mb-2">
Debug
</p>
)}
{collapsed && <div className="border-t border-black/5 dark:border-white/5 mb-2" />}
{debugItems.map(renderNavLink)}
</div>
)}
{/* System section */}
<div className="pt-4 mt-2">
{!collapsed && (
<p className="px-4 text-xs font-semibold text-text-muted/60 uppercase tracking-wider mb-2">
System
</p>
)}
{collapsed && <div className="border-t border-black/5 dark:border-white/5 mb-2" />}
{systemItems.map(renderNavLink)}
</div>
<div className="pt-4 mt-2">
{!collapsed && (
<p className="px-4 text-xs font-semibold text-text-muted/60 uppercase tracking-wider mb-2">
Help
</p>
)}
{collapsed && <div className="border-t border-black/5 dark:border-white/5 mb-2" />}
{helpItems.map(renderNavLink)}
</div>
</nav>
{/* Footer — Shutdown + Restart */}
<div
className={cn(
"border-t border-black/5 dark:border-white/5",
collapsed ? "p-2 flex flex-col gap-1" : "p-3 flex gap-2"
)}
>
<button
onClick={() => setShowRestartModal(true)}
title="Restart server"
className={cn(
"flex items-center justify-center gap-2 rounded-lg font-medium transition-all",
"text-amber-500 hover:bg-amber-500/10 border border-amber-500/20 hover:border-amber-500/40",
collapsed ? "p-2" : "flex-1 px-3 py-2 text-sm"
)}
>
<span className="material-symbols-outlined text-[18px]">restart_alt</span>
{!collapsed && "Restart"}
</button>
<button
onClick={() => setShowShutdownModal(true)}
title="Shutdown server"
className={cn(
"flex items-center justify-center gap-2 rounded-lg font-medium transition-all",
"text-red-500 hover:bg-red-500/10 border border-red-500/20 hover:border-red-500/40",
collapsed ? "p-2" : "flex-1 px-3 py-2 text-sm"
)}
>
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
{!collapsed && "Shutdown"}
</button>
</div>
</aside>
{/* Shutdown Confirmation Modal */}
<ConfirmModal
isOpen={showShutdownModal}
onClose={() => setShowShutdownModal(false)}
onConfirm={handleShutdown}
title="Close Proxy"
message="Are you sure you want to close the proxy server?"
confirmText="Close"
cancelText="Cancel"
variant="danger"
loading={isShuttingDown}
/>
{/* Restart Confirmation Modal */}
<ConfirmModal
isOpen={showRestartModal}
onClose={() => setShowRestartModal(false)}
onConfirm={handleRestart}
title="Restart Proxy"
message="Are you sure you want to restart the proxy server? It will be back online in a few seconds."
confirmText="Restart"
cancelText="Cancel"
variant="warning"
loading={isRestarting}
/>
{/* Disconnected Overlay */}
{isDisconnected && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="text-center p-8">
<div className="flex items-center justify-center size-16 rounded-full bg-red-500/20 text-red-500 mx-auto mb-4">
<span className="material-symbols-outlined text-[32px]">power_off</span>
</div>
<h2 className="text-xl font-semibold text-white mb-2">Server Disconnected</h2>
<p className="text-text-muted mb-6">
The proxy server has been stopped or is restarting.
</p>
<Button variant="secondary" onClick={() => globalThis.location.reload()}>
Reload Page
</Button>
</div>
</div>
)}
</>
);
}
Sidebar.propTypes = {
onClose: PropTypes.func,
collapsed: PropTypes.bool,
onToggleCollapse: PropTypes.func,
};

View File

@@ -0,0 +1,14 @@
"use client";
import { useEffect } from "react";
import useThemeStore from "@/store/themeStore";
export function ThemeProvider({ children }) {
const { initTheme } = useThemeStore();
useEffect(() => {
initTheme();
}, [initTheme]);
return <>{children}</>;
}

View File

@@ -0,0 +1,46 @@
"use client";
import { useTheme } from "@/shared/hooks/useTheme";
import { cn } from "@/shared/utils/cn";
export default function ThemeToggle({ className, variant = "default" }) {
const { theme, toggleTheme, isDark } = useTheme();
const variants = {
default: cn(
"flex items-center justify-center size-10 rounded-full",
"text-text-muted",
"hover:bg-black/5",
"hover:text-text-main",
"transition-colors"
),
card: cn(
"flex items-center justify-center size-11 rounded-full",
"bg-surface/60",
"hover:bg-surface",
"border border-border",
"backdrop-blur-md shadow-sm hover:shadow-md",
"text-text-muted-light hover:text-primary",
"hover:text-primary",
"transition-all group"
),
};
return (
<button
onClick={toggleTheme}
className={cn(variants[variant], className)}
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
title={`Switch to ${isDark ? "light" : "dark"} mode`}
>
<span
className={cn(
"material-symbols-outlined text-[22px]",
variant === "card" && "transition-transform duration-300 group-hover:rotate-12"
)}
>
{isDark ? "light_mode" : "dark_mode"}
</span>
</button>
);
}

View File

@@ -0,0 +1,81 @@
"use client";
import { cn } from "@/shared/utils/cn";
export default function Toggle({
checked = false,
onChange,
label,
description,
disabled = false,
size = "md",
className,
}) {
const sizes = {
sm: {
track: "w-8 h-4",
thumb: "size-3",
translate: "translate-x-4",
},
md: {
track: "w-11 h-6",
thumb: "size-5",
translate: "translate-x-5",
},
lg: {
track: "w-14 h-7",
thumb: "size-6",
translate: "translate-x-7",
},
};
const handleClick = () => {
if (!disabled && onChange) {
onChange(!checked);
}
};
return (
<div
className={cn(
"flex items-center gap-3",
disabled && "opacity-50 cursor-not-allowed",
className
)}
>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={!label ? description || "Toggle" : undefined}
disabled={disabled}
onClick={handleClick}
className={cn(
"relative inline-flex shrink-0 cursor-pointer rounded-full",
"transition-colors duration-200 ease-in-out",
"focus:outline-none focus:ring-1 focus:ring-primary/30",
checked ? "bg-primary" : "bg-black/10 dark:bg-white/20",
sizes[size].track,
disabled && "cursor-not-allowed"
)}
>
<span
aria-hidden="true"
className={cn(
"pointer-events-none inline-block rounded-full bg-white shadow-sm",
"transform transition duration-200 ease-in-out",
checked ? sizes[size].translate : "translate-x-0.5",
sizes[size].thumb,
"mt-0.5"
)}
/>
</button>
{(label || description) && (
<div className="flex flex-col">
{label && <span className="text-sm font-medium text-text-main">{label}</span>}
{description && <span className="text-xs text-text-muted">{description}</span>}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Card from "./Card";
import { CardSkeleton } from "./Loading";
import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting";
import {
StatCard,
ActivityHeatmap,
DailyTrendChart,
AccountDonut,
ApiKeyDonut,
ApiKeyTable,
MostActiveDay7d,
WeeklySquares7d,
ModelTable,
} from "./analytics";
// ============================================================================
// Main Component
// ============================================================================
export default function UsageAnalytics() {
const [range, setRange] = useState("30d");
const [analytics, setAnalytics] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchAnalytics = useCallback(async () => {
try {
setLoading(true);
const res = await fetch(`/api/usage/analytics?range=${range}`);
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
setAnalytics(data);
setError(null);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [range]);
useEffect(() => {
fetchAnalytics();
}, [fetchAnalytics]);
const ranges = [
{ value: "7d", label: "7D" },
{ value: "30d", label: "30D" },
{ value: "90d", label: "90D" },
{ value: "ytd", label: "YTD" },
{ value: "all", label: "All" },
];
if (loading && !analytics) return <CardSkeleton />;
if (error) return <Card className="p-6 text-center text-red-500">Error: {error}</Card>;
const s = analytics?.summary || {};
return (
<div className="flex flex-col gap-5">
{/* Header + Time Range */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[22px]">analytics</span>
Usage Analytics
</h2>
<div className="flex items-center gap-1 bg-black/[0.03] dark:bg-white/[0.03] rounded-lg p-1 border border-black/5 dark:border-white/5">
{ranges.map((r) => (
<button
key={r.value}
onClick={() => setRange(r.value)}
className={`px-3 py-1 rounded-md text-xs font-semibold transition-all ${
range === r.value
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
{r.label}
</button>
))}
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-3">
<StatCard
icon="generating_tokens"
label="Total Tokens"
value={fmt(s.totalTokens)}
subValue={`${fmtFull(s.totalRequests)} requests`}
/>
<StatCard
icon="input"
label="Input Tokens"
value={fmt(s.promptTokens)}
color="text-primary"
/>
<StatCard
icon="output"
label="Output Tokens"
value={fmt(s.completionTokens)}
color="text-emerald-500"
/>
<StatCard icon="group" label="Accounts" value={s.uniqueAccounts || 0} />
<StatCard icon="vpn_key" label="API Keys" value={s.uniqueApiKeys || 0} />
<StatCard icon="model_training" label="Models" value={s.uniqueModels || 0} />
</div>
{/* Activity Heatmap + Weekly Widgets */}
<div
style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16, alignItems: "stretch" }}
>
<ActivityHeatmap activityMap={analytics?.activityMap} />
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<MostActiveDay7d activityMap={analytics?.activityMap} />
<WeeklySquares7d activityMap={analytics?.activityMap} />
</div>
</div>
{/* Token Trend + Account Donut */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<DailyTrendChart dailyTrend={analytics?.dailyTrend} />
<AccountDonut byAccount={analytics?.byAccount} />
</div>
{/* API Key Graph + Table */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ApiKeyDonut byApiKey={analytics?.byApiKey} />
<ApiKeyTable byApiKey={analytics?.byApiKey} />
</div>
{/* Model Breakdown Table */}
<ModelTable byModel={analytics?.byModel} summary={s} />
{/* Bottom Stats */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-3">
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold">Requests</span>
<div className="text-lg font-bold mt-1">{fmtFull(s.totalRequests)}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold">Models</span>
<div className="text-lg font-bold mt-1">{s.uniqueModels}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold">Accounts</span>
<div className="text-lg font-bold mt-1">{s.uniqueAccounts}</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold">Streak</span>
<div className="text-lg font-bold mt-1">{s.streak || 0}d</div>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold">Total Tokens</span>
<div className="text-lg font-bold mt-1">{fmt(s.totalTokens)}</div>
<span className="text-[10px] text-text-muted">Est. {fmtCost(s.totalCost)}</span>
</Card>
<Card className="px-4 py-3 text-center">
<span className="text-xs text-text-muted uppercase font-semibold">Usage Cost</span>
<div className="text-lg font-bold text-amber-500 mt-1">{fmtCost(s.totalCost)}</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,684 @@
"use client";
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
import PropTypes from "prop-types";
import { useSearchParams, useRouter } from "next/navigation";
import Card from "./Card";
import Badge from "./Badge";
import { CardSkeleton } from "./Loading";
import { fmtFull, fmtCost } from "@/shared/utils/formatting";
function SortIcon({ field, currentSort, currentOrder }) {
if (currentSort !== field) return <span className="ml-1 opacity-20"></span>;
return <span className="ml-1">{currentOrder === "asc" ? "↑" : "↓"}</span>;
}
SortIcon.propTypes = {
field: PropTypes.string.isRequired,
currentSort: PropTypes.string.isRequired,
currentOrder: PropTypes.string.isRequired,
};
function MiniBarGraph({ data, colorClass = "bg-primary" }) {
const max = Math.max(...data, 1);
return (
<div className="flex items-end gap-1 h-8 w-24">
{data.slice(-9).map((val, idx) => (
<div
key={`bar-${idx}-${val}`}
className={`flex-1 rounded-t-sm transition-all duration-500 ${colorClass}`}
style={{ height: `${Math.max((val / max) * 100, 5)}%` }}
title={String(val)}
/>
))}
</div>
);
}
MiniBarGraph.propTypes = {
data: PropTypes.arrayOf(PropTypes.number).isRequired,
colorClass: PropTypes.string,
};
export default function UsageStats() {
const router = useRouter();
const searchParams = useSearchParams();
const sortBy = searchParams.get("sortBy") || "rawModel";
const sortOrder = searchParams.get("sortOrder") || "asc";
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [viewMode, setViewMode] = useState("tokens"); // 'tokens' or 'costs'
const [refreshInterval, setRefreshInterval] = useState(5000); // Start with 5s
const prevTotalRequestsRef = useRef(0);
const toggleSort = (field) => {
const params = new URLSearchParams(searchParams.toString());
if (sortBy === field) {
params.set("sortOrder", sortOrder === "asc" ? "desc" : "asc");
} else {
params.set("sortBy", field);
params.set("sortOrder", "asc");
}
router.replace(`?${params.toString()}`, { scroll: false });
};
const sortData = useCallback(
(dataMap, pendingMap = {}) => {
return Object.entries(dataMap || {})
.map(([key, data]) => {
const totalTokens = (data.promptTokens || 0) + (data.completionTokens || 0);
const totalCost = data.cost || 0;
// Calculate cost breakdown (estimated based on token ratio)
const inputCost =
totalTokens > 0 ? (data.promptTokens || 0) * (totalCost / totalTokens) : 0;
const outputCost =
totalTokens > 0 ? (data.completionTokens || 0) * (totalCost / totalTokens) : 0;
return {
...data,
key,
totalTokens,
totalCost,
inputCost,
outputCost,
pending: pendingMap[key] || 0,
};
})
.sort((a, b) => {
let valA = a[sortBy];
let valB = b[sortBy];
// Handle case-insensitive sorting for strings
if (typeof valA === "string") valA = valA.toLowerCase();
if (typeof valB === "string") valB = valB.toLowerCase();
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
return 0;
});
},
[sortBy, sortOrder]
);
const sortedModels = useMemo(
() => sortData(stats?.byModel, stats?.pending?.byModel),
[stats?.byModel, stats?.pending?.byModel, sortData]
);
const sortedAccounts = useMemo(() => {
// For accounts, pendingMap is by connectionId, but dataMap is by accountKey
// We need to map connectionId pending counts to accountKeys
const accountPendingMap = {};
if (stats?.pending?.byAccount) {
Object.entries(stats.byAccount || {}).forEach(([accountKey, data]) => {
const connPending = stats.pending.byAccount[data.connectionId];
if (connPending) {
// Get modelKey (rawModel (provider))
const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel;
accountPendingMap[accountKey] = connPending[modelKey] || 0;
}
});
}
return sortData(stats?.byAccount, accountPendingMap);
}, [stats?.byAccount, stats?.pending?.byAccount, sortData]);
const fetchStats = useCallback(async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const res = await fetch("/api/usage/history");
if (res.ok) {
const data = await res.json();
setStats(data);
// Smart polling: adjust interval based on activity
const currentTotal = data.totalRequests || 0;
if (currentTotal > prevTotalRequestsRef.current) {
// New requests detected - reset to fast polling
setRefreshInterval(5000);
} else {
// No change - increase interval (exponential backoff)
setRefreshInterval((prev) => Math.min(prev * 2, 60000)); // Max 60s
}
prevTotalRequestsRef.current = currentTotal;
}
} catch (error) {
console.error("Failed to fetch usage stats:", error);
} finally {
if (showLoading) setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
useEffect(() => {
let intervalId;
let isPageVisible = true;
// Page Visibility API - pause when tab is hidden
const handleVisibilityChange = () => {
isPageVisible = !document.hidden;
if (isPageVisible && autoRefresh) {
fetchStats(false); // Fetch immediately when tab becomes visible
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
if (autoRefresh) {
// Clear any existing interval first
if (intervalId) clearInterval(intervalId);
intervalId = setInterval(() => {
if (isPageVisible) {
fetchStats(false); // fetch without loading skeleton
}
}, refreshInterval);
}
return () => {
if (intervalId) clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [autoRefresh, refreshInterval, fetchStats]);
if (loading) return <CardSkeleton />;
if (!stats) return <div className="text-text-muted">Failed to load usage statistics.</div>;
// Format number with commas — delegated to shared module
const fmt = (n) => fmtFull(n);
// Format cost with dollar sign and 2 decimals — delegated to shared module
// Time format for "Last Used"
const fmtTime = (iso) => {
if (!iso) return "Never";
const date = new Date(iso);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return "Just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
return date.toLocaleDateString();
};
return (
<div className="flex flex-col gap-6">
{/* Header with Auto Refresh Toggle and View Toggle */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Usage Overview</h2>
<div className="flex items-center gap-2">
{/* View Toggle */}
<div className="flex items-center gap-1 bg-bg-subtle rounded-lg p-1 border border-border">
<button
onClick={() => setViewMode("tokens")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
viewMode === "tokens"
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
>
Tokens
</button>
<button
onClick={() => setViewMode("costs")}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
viewMode === "costs"
? "bg-primary text-white shadow-sm"
: "text-text-muted hover:text-text hover:bg-bg-hover"
}`}
>
Costs
</button>
</div>
{/* Auto Refresh Toggle */}
<div className="text-sm font-medium text-text-muted flex items-center gap-2">
<span>Auto Refresh ({refreshInterval / 1000}s)</span>
<button
type="button"
onClick={() => setAutoRefresh(!autoRefresh)}
role="switch"
aria-checked={autoRefresh}
aria-label="Toggle auto refresh"
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 ${
autoRefresh ? "bg-primary" : "bg-bg-subtle border border-border"
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
autoRefresh ? "translate-x-5" : "translate-x-1"
}`}
/>
</button>
</div>
</div>
</div>
{/* Active Requests Summary */}
{(stats.activeRequests || []).length > 0 && (
<Card className="p-3 border-primary/20 bg-primary/5">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-primary font-semibold text-sm uppercase tracking-wider">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
Active Requests
</div>
<div className="flex flex-wrap gap-3">
{stats.activeRequests.map((req) => (
<div
key={`${req.model}-${req.provider}-${req.account}`}
className="px-3 py-1.5 rounded-md bg-bg-subtle border border-primary/20 text-xs font-mono shadow-sm"
>
<span className="text-primary font-bold">{req.model}</span>
<span className="mx-1 text-text-muted">|</span>
<span className="text-text">{req.provider}</span>
<span className="mx-1 text-text-muted">|</span>
<span className="text-text font-medium">{req.account}</span>
{req.count > 1 && (
<span className="ml-2 px-1.5 py-0.5 rounded bg-primary text-white font-bold">
x{req.count}
</span>
)}
</div>
))}
</div>
</div>
</Card>
)}
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="px-4 py-3 flex flex-col gap-1">
<div className="flex justify-between items-start">
<div className="flex flex-col gap-1">
<span className="text-text-muted text-sm uppercase font-semibold">
Total Requests
</span>
<span className="text-2xl font-bold">{fmt(stats.totalRequests)}</span>
</div>
<MiniBarGraph
data={(stats.last10Minutes || []).map((m) => m.requests)}
colorClass="bg-text-muted/30"
/>
</div>
</Card>
<Card className="px-4 py-3 flex flex-col gap-1">
<div className="flex justify-between items-start">
<div className="flex flex-col gap-1">
<span className="text-text-muted text-sm uppercase font-semibold">
Total Input Tokens
</span>
<span className="text-2xl font-bold text-primary">
{fmt(stats.totalPromptTokens)}
</span>
</div>
<MiniBarGraph
data={(stats.last10Minutes || []).map((m) => m.promptTokens)}
colorClass="bg-primary/50"
/>
</div>
</Card>
<Card className="px-4 py-2 flex flex-col gap-1">
<div className="flex justify-between items-start gap-4">
<div className="flex flex-col gap-1 flex-1">
<span className="text-text-muted text-sm uppercase font-semibold">Output Tokens</span>
<span className="text-2xl font-bold text-success">
{fmt(stats.totalCompletionTokens)}
</span>
</div>
<div className="w-px bg-border self-stretch mx-2" />
<div className="flex flex-col gap-1 flex-1">
<span className="text-text-muted text-sm uppercase font-semibold">Total Cost</span>
<span className="text-2xl font-bold text-warning">{fmtCost(stats.totalCost)}</span>
</div>
</div>
</Card>
</div>
{/* Usage by Model Table */}
<Card className="overflow-hidden">
<div className="p-4 border-b border-border bg-bg-subtle/50">
<h3 className="font-semibold">Usage by Model</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-bg-subtle/30 text-text-muted uppercase text-xs">
<tr>
<th
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("rawModel")}
>
Model <SortIcon field="rawModel" currentSort={sortBy} currentOrder={sortOrder} />
</th>
<th
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("provider")}
>
Provider{" "}
<SortIcon field="provider" currentSort={sortBy} currentOrder={sortOrder} />
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("requests")}
>
Requests{" "}
<SortIcon field="requests" currentSort={sortBy} currentOrder={sortOrder} />
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("lastUsed")}
>
Last Used{" "}
<SortIcon field="lastUsed" currentSort={sortBy} currentOrder={sortOrder} />
</th>
{viewMode === "tokens" ? (
<>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("promptTokens")}
>
Input Tokens{" "}
<SortIcon
field="promptTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("completionTokens")}
>
Output Tokens{" "}
<SortIcon
field="completionTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("totalTokens")}
>
Total Tokens{" "}
<SortIcon field="totalTokens" currentSort={sortBy} currentOrder={sortOrder} />
</th>
</>
) : (
<>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("promptTokens")}
>
Input Cost{" "}
<SortIcon
field="promptTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("completionTokens")}
>
Output Cost{" "}
<SortIcon
field="completionTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("cost")}
>
Total Cost{" "}
<SortIcon field="cost" currentSort={sortBy} currentOrder={sortOrder} />
</th>
</>
)}
</tr>
</thead>
<tbody className="divide-y divide-border">
{sortedModels.map((data) => (
<tr key={data.key} className="hover:bg-bg-subtle/20">
<td
className={`px-6 py-3 font-medium transition-colors ${
data.pending > 0 ? "text-primary" : ""
}`}
>
{data.rawModel}
</td>
<td className="px-6 py-3">
<Badge variant={data.pending > 0 ? "primary" : "neutral"} size="sm">
{data.provider}
</Badge>
</td>
<td className="px-6 py-3 text-right">{fmt(data.requests)}</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(data.lastUsed)}
</td>
{viewMode === "tokens" ? (
<>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.promptTokens)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.completionTokens)}
</td>
<td className="px-6 py-3 text-right font-medium">{fmt(data.totalTokens)}</td>
</>
) : (
<>
<td className="px-6 py-3 text-right text-text-muted">
{fmtCost(data.inputCost)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmtCost(data.outputCost)}
</td>
<td className="px-6 py-3 text-right font-medium text-warning">
{fmtCost(data.totalCost)}
</td>
</>
)}
</tr>
))}
{sortedModels.length === 0 && (
<tr>
<td colSpan={8} className="px-6 py-8 text-center text-text-muted">
No usage recorded yet. Make some requests to see data here.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
{/* Usage by Account Table */}
<Card className="overflow-hidden">
<div className="p-4 border-b border-border bg-bg-subtle/50">
<h3 className="font-semibold">Usage by Account</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-bg-subtle/30 text-text-muted uppercase text-xs">
<tr>
<th
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("rawModel")}
>
Model <SortIcon field="rawModel" currentSort={sortBy} currentOrder={sortOrder} />
</th>
<th
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("provider")}
>
Provider{" "}
<SortIcon field="provider" currentSort={sortBy} currentOrder={sortOrder} />
</th>
<th
className="px-6 py-3 cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("accountName")}
>
Account{" "}
<SortIcon field="accountName" currentSort={sortBy} currentOrder={sortOrder} />
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("requests")}
>
Requests{" "}
<SortIcon field="requests" currentSort={sortBy} currentOrder={sortOrder} />
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("lastUsed")}
>
Last Used{" "}
<SortIcon field="lastUsed" currentSort={sortBy} currentOrder={sortOrder} />
</th>
{viewMode === "tokens" ? (
<>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("promptTokens")}
>
Input Tokens{" "}
<SortIcon
field="promptTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("completionTokens")}
>
Output Tokens{" "}
<SortIcon
field="completionTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("totalTokens")}
>
Total Tokens{" "}
<SortIcon field="totalTokens" currentSort={sortBy} currentOrder={sortOrder} />
</th>
</>
) : (
<>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("promptTokens")}
>
Input Cost{" "}
<SortIcon
field="promptTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("completionTokens")}
>
Output Cost{" "}
<SortIcon
field="completionTokens"
currentSort={sortBy}
currentOrder={sortOrder}
/>
</th>
<th
className="px-6 py-3 text-right cursor-pointer hover:bg-bg-subtle/50"
onClick={() => toggleSort("cost")}
>
Total Cost{" "}
<SortIcon field="cost" currentSort={sortBy} currentOrder={sortOrder} />
</th>
</>
)}
</tr>
</thead>
<tbody className="divide-y divide-border">
{sortedAccounts.map((data) => (
<tr key={data.key} className="hover:bg-bg-subtle/20">
<td
className={`px-6 py-3 font-medium transition-colors ${
data.pending > 0 ? "text-primary" : ""
}`}
>
{data.rawModel}
</td>
<td className="px-6 py-3">
<Badge variant={data.pending > 0 ? "primary" : "neutral"} size="sm">
{data.provider}
</Badge>
</td>
<td className="px-6 py-3">
<span
className={`font-medium transition-colors ${
data.pending > 0 ? "text-primary" : ""
}`}
>
{data.accountName || `Account ${data.connectionId?.slice(0, 8)}...`}
</span>
</td>
<td className="px-6 py-3 text-right">{fmt(data.requests)}</td>
<td className="px-6 py-3 text-right text-text-muted whitespace-nowrap">
{fmtTime(data.lastUsed)}
</td>
{viewMode === "tokens" ? (
<>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.promptTokens)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmt(data.completionTokens)}
</td>
<td className="px-6 py-3 text-right font-medium">{fmt(data.totalTokens)}</td>
</>
) : (
<>
<td className="px-6 py-3 text-right text-text-muted">
{fmtCost(data.inputCost)}
</td>
<td className="px-6 py-3 text-right text-text-muted">
{fmtCost(data.outputCost)}
</td>
<td className="px-6 py-3 text-right font-medium text-warning">
{fmtCost(data.totalCost)}
</td>
</>
)}
</tr>
))}
{sortedAccounts.length === 0 && (
<tr>
<td colSpan={9} className="px-6 py-8 text-center text-text-muted">
No account-specific usage recorded yet. Make requests using OAuth accounts to
see data here.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,913 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import Card from "../Card";
import { getModelColor } from "@/shared/constants/colors";
import {
fmtCompact as fmt,
fmtFull,
fmtCost,
formatApiKeyLabel as maskApiKeyLabel,
} from "@/shared/utils/formatting";
import { BarChart, Bar, XAxis, Tooltip, ResponsiveContainer, Cell, PieChart, Pie } from "recharts";
// ── Custom Tooltip for dark theme ──────────────────────────────────────────
function DarkTooltip({ active, payload, label, formatter }) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border border-white/10 bg-surface px-3 py-2 text-xs shadow-lg">
{label && <div className="font-semibold text-text-main mb-1">{label}</div>}
{payload.map((entry, i) => (
<div key={i} className="flex items-center gap-1.5 text-text-muted">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: entry.color }}
/>
<span>{entry.name}:</span>
<span className="font-mono font-medium text-text-main">
{formatter ? formatter(entry.value) : entry.value}
</span>
</div>
))}
</div>
);
}
// ── Sort Indicator (shared by tables) ──────────────────────────────────────
export function SortIndicator({ active, sortOrder }) {
if (!active) {
return (
<span className="material-symbols-outlined text-[12px] opacity-0 group-hover:opacity-30">
unfold_more
</span>
);
}
return (
<span className="material-symbols-outlined text-[12px] text-primary">
{sortOrder === "asc" ? "expand_less" : "expand_more"}
</span>
);
}
// ── StatCard ───────────────────────────────────────────────────────────────
export function StatCard({ icon, label, value, subValue, color = "text-text-main" }) {
return (
<Card className="px-4 py-3 flex flex-col gap-1">
<div className="flex items-center gap-2 text-text-muted text-xs uppercase font-semibold tracking-wider">
<span className="material-symbols-outlined text-[16px]">{icon}</span>
{label}
</div>
<span className={`text-2xl font-bold ${color}`}>{value}</span>
{subValue && <span className="text-xs text-text-muted">{subValue}</span>}
</Card>
);
}
// ── ActivityHeatmap ────────────────────────────────────────────────────────
export function ActivityHeatmap({ activityMap }) {
const cells = useMemo(() => {
const today = new Date();
const days = [];
let maxVal = 0;
for (let i = 364; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const val = activityMap?.[key] || 0;
if (val > maxVal) maxVal = val;
days.push({ date: key, value: val, dayOfWeek: d.getDay() });
}
return { days, maxVal };
}, [activityMap]);
const weeks = useMemo(() => {
const w = [];
let current = [];
const firstDay = cells.days[0]?.dayOfWeek || 0;
for (let i = 0; i < firstDay; i++) {
current.push(null);
}
for (const day of cells.days) {
current.push(day);
if (current.length === 7) {
w.push(current);
current = [];
}
}
if (current.length > 0) w.push(current);
return w;
}, [cells]);
const monthLabels = useMemo(() => {
const labels = [];
let lastMonth = -1;
weeks.forEach((week, weekIdx) => {
const firstDay = week.find((d) => d !== null);
if (firstDay) {
const m = new Date(firstDay.date).getMonth();
if (m !== lastMonth) {
const monthNames = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
labels.push({ weekIdx, label: monthNames[m] });
lastMonth = m;
}
}
});
return labels;
}, [weeks]);
function getCellColor(value) {
if (!value || value === 0) return "bg-white/[0.04]";
const intensity = Math.min(value / (cells.maxVal || 1), 1);
if (intensity < 0.25) return "bg-primary/20";
if (intensity < 0.5) return "bg-primary/40";
if (intensity < 0.75) return "bg-primary/60";
return "bg-primary/90";
}
return (
<Card className="p-4 h-full">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">Activity</h3>
<span className="text-xs text-text-muted">
{Object.keys(activityMap || {}).length} active days ·{" "}
{fmt(Object.values(activityMap || {}).reduce((a, b) => a + b, 0))} tokens · 365 days
</span>
</div>
<div className="flex gap-[3px] mb-1 ml-6" style={{ fontSize: "10px" }}>
{monthLabels.map((m, i) => (
<span
key={i}
className="text-text-muted"
style={{
position: "relative",
left: `${m.weekIdx * 13}px`,
marginLeft: i === 0 ? 0 : "-20px",
}}
>
{m.label}
</span>
))}
</div>
<div className="flex gap-[3px] overflow-x-auto">
<div className="flex flex-col gap-[3px] shrink-0 text-[10px] text-text-muted pr-1">
<span className="h-[10px]"></span>
<span className="h-[10px] leading-[10px]">Mon</span>
<span className="h-[10px]"></span>
<span className="h-[10px] leading-[10px]">Wed</span>
<span className="h-[10px]"></span>
<span className="h-[10px] leading-[10px]">Fri</span>
<span className="h-[10px]"></span>
</div>
{weeks.map((week, wi) => (
<div key={wi} className="flex flex-col gap-[3px]">
{week.map((day, di) => (
<div
key={di}
title={day ? `${day.date}: ${fmtFull(day.value)} tokens` : ""}
className={`w-[10px] h-[10px] rounded-[2px] ${day ? getCellColor(day.value) : "bg-transparent"}`}
/>
))}
</div>
))}
</div>
<div className="flex items-center gap-1 mt-2 ml-6 text-[10px] text-text-muted">
<span>Less</span>
<div className="w-[10px] h-[10px] rounded-[2px] bg-white/[0.04]" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/20" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/40" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/60" />
<div className="w-[10px] h-[10px] rounded-[2px] bg-primary/90" />
<span>More</span>
</div>
</Card>
);
}
// ── DailyTrendChart (Recharts) ─────────────────────────────────────────────
export function DailyTrendChart({ dailyTrend }) {
const chartData = useMemo(() => {
return (dailyTrend || []).map((d) => ({
date: d.date.slice(5),
Input: d.promptTokens,
Output: d.completionTokens,
}));
}, [dailyTrend]);
if (!chartData.length) {
return (
<Card className="p-4">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Token Trend
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
</Card>
);
}
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Token Trend
</h3>
<ResponsiveContainer width="100%" height={128}>
<BarChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<XAxis
dataKey="date"
tick={{ fontSize: 9, fill: "var(--text-muted)" }}
axisLine={false}
tickLine={false}
interval={Math.max(Math.floor(chartData.length / 6), 0)}
/>
<Tooltip
content={<DarkTooltip formatter={fmt} />}
cursor={{ fill: "rgba(255,255,255,0.04)" }}
/>
<Bar
dataKey="Input"
stackId="a"
fill="var(--primary)"
opacity={0.7}
radius={[0, 0, 0, 0]}
animationDuration={600}
/>
<Bar
dataKey="Output"
stackId="a"
fill="#10b981"
opacity={0.7}
radius={[3, 3, 0, 0]}
animationDuration={600}
/>
</BarChart>
</ResponsiveContainer>
<div className="flex items-center gap-4 mt-2 text-[10px] text-text-muted">
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-primary/70" /> Input
</span>
<span className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-emerald-500/70" /> Output
</span>
</div>
</Card>
);
}
// ── AccountDonut (Recharts) ────────────────────────────────────────────────
export function AccountDonut({ byAccount }) {
const data = useMemo(() => byAccount || [], [byAccount]);
const hasData = data.length > 0;
const pieData = useMemo(() => {
return data.slice(0, 8).map((item, i) => ({
name: item.account,
value: item.totalTokens,
fill: getModelColor(i),
}));
}, [data]);
if (!hasData) {
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
By Account
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
</Card>
);
}
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
By Account
</h3>
<div className="flex items-center gap-4">
<ResponsiveContainer width={120} height={120}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={28}
outerRadius={55}
paddingAngle={1}
animationDuration={600}
>
{pieData.map((entry, i) => (
<Cell key={i} fill={entry.fill} stroke="none" />
))}
</Pie>
<Tooltip content={<DarkTooltip formatter={fmt} />} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-col gap-1 min-w-0 flex-1">
{pieData.map((seg, i) => (
<div key={i} className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-1.5 min-w-0">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: seg.fill }}
/>
<span className="truncate text-text-main">{seg.name}</span>
</div>
<span className="font-mono font-medium text-text-muted shrink-0">
{fmt(seg.value)}
</span>
</div>
))}
</div>
</div>
</Card>
);
}
// ── ApiKeyDonut (Recharts) ─────────────────────────────────────────────────
export function ApiKeyDonut({ byApiKey }) {
const data = useMemo(() => byApiKey || [], [byApiKey]);
const hasData = data.length > 0;
const pieData = useMemo(() => {
return data.slice(0, 8).map((item, i) => ({
name: maskApiKeyLabel(item.apiKeyName, item.apiKeyId),
fullName: item.apiKeyName || item.apiKeyId || "unknown",
value: item.totalTokens,
fill: getModelColor(i),
}));
}, [data]);
if (!hasData) {
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
By API Key
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
</Card>
);
}
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
By API Key
</h3>
<div className="flex items-center gap-4">
<ResponsiveContainer width={120} height={120}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={28}
outerRadius={55}
paddingAngle={1}
animationDuration={600}
>
{pieData.map((entry, i) => (
<Cell key={i} fill={entry.fill} stroke="none" />
))}
</Pie>
<Tooltip content={<DarkTooltip formatter={fmt} />} />
</PieChart>
</ResponsiveContainer>
<div className="flex flex-col gap-1 min-w-0 flex-1">
{pieData.map((seg, i) => (
<div
key={`${seg.fullName}-${i}`}
className="flex items-center justify-between gap-2 text-xs"
>
<div className="flex items-center gap-1.5 min-w-0">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: seg.fill }}
/>
<span className="truncate text-text-main" title={seg.fullName}>
{seg.name}
</span>
</div>
<span className="font-mono font-medium text-text-muted shrink-0">
{fmt(seg.value)}
</span>
</div>
))}
</div>
</div>
</Card>
);
}
// ── ApiKeyTable ────────────────────────────────────────────────────────────
export function ApiKeyTable({ byApiKey }) {
const [query, setQuery] = useState("");
const [sortBy, setSortBy] = useState("totalTokens");
const [sortOrder, setSortOrder] = useState("desc");
const data = useMemo(() => byApiKey || [], [byApiKey]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return data;
return data.filter(
(row) =>
(row.apiKeyName || "").toLowerCase().includes(q) ||
(row.apiKeyId || "").toLowerCase().includes(q)
);
}, [data, query]);
const sorted = useMemo(() => {
const arr = [...filtered];
arr.sort((a, b) => {
const va = a[sortBy] ?? 0;
const vb = b[sortBy] ?? 0;
if (typeof va === "string") {
return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
}
return sortOrder === "asc" ? va - vb : vb - va;
});
return arr;
}, [filtered, sortBy, sortOrder]);
const toggleSort = useCallback(
(field) => {
if (sortBy === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
return;
}
setSortBy(field);
setSortOrder("desc");
},
[sortBy]
);
const hasData = data.length > 0;
if (!hasData) {
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
API Key Breakdown
</h3>
<div className="text-center text-text-muted text-sm py-8">No data</div>
</Card>
);
}
return (
<Card className="overflow-hidden">
<div className="p-4 border-b border-border flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
API Key Breakdown
</h3>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter API key..."
className="w-full max-w-[220px] px-3 py-1.5 rounded-lg bg-bg-subtle border border-border text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary"
/>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]">
<tr>
<th
className="px-4 py-2.5 text-left cursor-pointer group"
onClick={() => toggleSort("apiKeyName")}
>
API Key <SortIndicator active={sortBy === "apiKeyName"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("requests")}
>
Requests <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("promptTokens")}
>
Input <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("completionTokens")}
>
Output{" "}
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("totalTokens")}
>
Total Tokens{" "}
<SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("cost")}
>
Cost <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sorted.map((row, i) => (
<tr
key={`${row.apiKeyId || row.apiKeyName || "key"}-${i}`}
className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
>
<td className="px-4 py-2.5">
<span className="font-medium" title={row.apiKeyName || row.apiKeyId || "unknown"}>
{maskApiKeyLabel(row.apiKeyName, row.apiKeyId)}
</span>
</td>
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
{fmtFull(row.requests)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-primary">
{fmt(row.promptTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-emerald-500">
{fmt(row.completionTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono font-semibold">
{fmt(row.totalTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-amber-500">
{fmtCost(row.cost)}
</td>
</tr>
))}
{sorted.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-text-muted">
No API key matches this filter.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
);
}
// ── WeeklyPattern (Recharts) ───────────────────────────────────────────────
export function WeeklyPattern({ weeklyPattern }) {
const chartData = useMemo(() => {
return (weeklyPattern || []).map((w) => ({
day: w.day.slice(0, 3),
Tokens: w.totalTokens,
}));
}, [weeklyPattern]);
return (
<Card className="px-4 py-3">
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
Weekly
</h3>
<ResponsiveContainer width="100%" height={48}>
<BarChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
<XAxis
dataKey="day"
tick={{ fontSize: 9, fill: "var(--text-muted)" }}
axisLine={false}
tickLine={false}
/>
<Tooltip
content={<DarkTooltip formatter={fmt} />}
cursor={{ fill: "rgba(255,255,255,0.04)" }}
/>
<Bar
dataKey="Tokens"
fill="var(--text-muted)"
opacity={0.3}
radius={[3, 3, 0, 0]}
animationDuration={400}
/>
</BarChart>
</ResponsiveContainer>
</Card>
);
}
// ── MostActiveDay7d ────────────────────────────────────────────────────────
export function MostActiveDay7d({ activityMap }) {
const data = useMemo(() => {
if (!activityMap) return null;
const today = new Date();
let peakKey = null;
let peakVal = 0;
for (let i = 0; i < 7; i++) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const val = activityMap[key] || 0;
if (val > peakVal) {
peakVal = val;
peakKey = key;
}
}
if (!peakKey || peakVal === 0) return null;
const peakDate = new Date(peakKey + "T12:00:00");
const weekdays = ["domingo", "segunda", "terça", "quarta", "quinta", "sexta", "sábado"];
const months = [
"jan",
"fev",
"mar",
"abr",
"mai",
"jun",
"jul",
"ago",
"set",
"out",
"nov",
"dez",
];
return {
weekday: weekdays[peakDate.getDay()],
label: `${peakDate.getDate()} de ${months[peakDate.getMonth()]}`,
tokens: peakVal,
};
}, [activityMap]);
return (
<Card className="p-4 flex flex-col justify-center" style={{ flex: 1, minHeight: 0 }}>
<h3
className="text-xs font-semibold uppercase tracking-wider mb-2"
style={{ color: "var(--text-muted)" }}
>
Most Active Day
</h3>
{data ? (
<>
<span className="text-xl font-bold capitalize" style={{ lineHeight: 1.2 }}>
{data.weekday}
</span>
<span className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{data.label} · {fmt(data.tokens)} tokens
</span>
</>
) : (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Sem dados nos últimos 7 dias
</span>
)}
</Card>
);
}
// ── WeeklySquares7d ────────────────────────────────────────────────────────
export function WeeklySquares7d({ activityMap }) {
const days = useMemo(() => {
if (!activityMap) return [];
const today = new Date();
const result = [];
let maxVal = 0;
for (let i = 6; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
const val = activityMap[key] || 0;
if (val > maxVal) maxVal = val;
const shortDays = ["DOM", "SEG", "TER", "QUA", "QUI", "SEX", "SÁB"];
result.push({ key, val, label: shortDays[d.getDay()] });
}
return result.map((d) => ({ ...d, intensity: maxVal > 0 ? d.val / maxVal : 0 }));
}, [activityMap]);
function getSquareStyle(intensity) {
if (intensity === 0) return { background: "rgba(255,255,255,0.04)" };
const opacity = 0.15 + intensity * 0.75;
return { background: `rgba(217, 119, 87, ${opacity.toFixed(2)})` };
}
return (
<Card className="p-4 flex flex-col justify-center" style={{ flex: 1, minHeight: 0 }}>
<h3
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: "var(--text-muted)" }}
>
Weekly
</h3>
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, justifyContent: "center" }}>
{days.map((d, i) => (
<div
key={i}
style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}
>
<div
title={`${d.key}: ${fmtFull(d.val)} tokens`}
style={{
width: 36,
height: 36,
borderRadius: 8,
...getSquareStyle(d.intensity),
transition: "all 0.2s",
cursor: "default",
}}
/>
<span
style={{
fontSize: 9,
fontWeight: 600,
color: "var(--text-muted)",
letterSpacing: "0.03em",
}}
>
{d.label}
</span>
</div>
))}
</div>
</Card>
);
}
// ── ModelTable ──────────────────────────────────────────────────────────────
export function ModelTable({ byModel, summary }) {
const [sortBy, setSortBy] = useState("totalTokens");
const [sortOrder, setSortOrder] = useState("desc");
const toggleSort = useCallback(
(field) => {
if (sortBy === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortBy(field);
setSortOrder("desc");
}
},
[sortBy]
);
const sorted = useMemo(() => {
const arr = [...(byModel || [])];
arr.sort((a, b) => {
const va = a[sortBy] ?? 0;
const vb = b[sortBy] ?? 0;
if (typeof va === "string")
return sortOrder === "asc" ? va.localeCompare(vb) : vb.localeCompare(va);
return sortOrder === "asc" ? va - vb : vb - va;
});
return arr;
}, [byModel, sortBy, sortOrder]);
return (
<Card className="overflow-hidden">
<div className="p-4 border-b border-border">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
Model Breakdown
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-xs text-text-muted uppercase bg-black/[0.02] dark:bg-white/[0.02]">
<tr>
<th
className="px-4 py-2.5 text-left cursor-pointer group"
onClick={() => toggleSort("model")}
>
Model <SortIndicator active={sortBy === "model"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("requests")}
>
Requests <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("promptTokens")}
>
Input <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("completionTokens")}
>
Output{" "}
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
</th>
<th
className="px-4 py-2.5 text-right cursor-pointer group"
onClick={() => toggleSort("totalTokens")}
>
Total <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
</th>
<th className="px-4 py-2.5 text-right w-36">Share</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sorted.map((m, i) => (
<tr
key={m.model}
className="hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: getModelColor(i) }}
/>
<span className="font-medium">{m.model}</span>
</div>
</td>
<td className="px-4 py-2.5 text-right font-mono text-text-muted">
{fmtFull(m.requests)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-primary">
{fmt(m.promptTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono text-emerald-500">
{fmt(m.completionTokens)}
</td>
<td className="px-4 py-2.5 text-right font-mono font-semibold">
{fmt(m.totalTokens)}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center gap-2 justify-end">
<div className="w-16 h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div
className="h-full rounded-full transition-all"
style={{ width: `${m.pct}%`, backgroundColor: getModelColor(i) }}
/>
</div>
<span className="text-xs font-mono text-text-muted w-10 text-right">
{m.pct}%
</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
}
// ── UsageDetail ────────────────────────────────────────────────────────────
export function UsageDetail({ summary }) {
const items = [
{ label: "Input", value: summary?.promptTokens, color: "text-primary" },
{ label: "Cache read", value: 0, color: "text-text-muted" },
{ label: "Output", value: summary?.completionTokens, color: "text-emerald-500" },
];
return (
<Card className="p-4 flex-1">
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
Usage Detail
</h3>
<div className="flex flex-col gap-2">
{items.map((item, i) => (
<div key={i} className="flex items-center justify-between">
<span className={`text-sm ${item.color}`}>{item.label}</span>
<span className="font-mono font-medium text-sm">{fmtFull(item.value)}</span>
</div>
))}
</div>
</Card>
);
}

View File

@@ -0,0 +1,14 @@
export {
SortIndicator,
StatCard,
ActivityHeatmap,
DailyTrendChart,
AccountDonut,
ApiKeyDonut,
ApiKeyTable,
WeeklyPattern,
MostActiveDay7d,
WeeklySquares7d,
ModelTable,
UsageDetail,
} from "./charts";

View File

@@ -0,0 +1,31 @@
// Shared Components - Export all
export { default as Button } from "./Button";
export { default as Input } from "./Input";
export { default as Select } from "./Select";
export { default as Card } from "./Card";
export { default as Modal, ConfirmModal } from "./Modal";
export { default as Loading, Spinner, PageLoading, Skeleton, CardSkeleton } from "./Loading";
export { default as Avatar } from "./Avatar";
export { default as Badge } from "./Badge";
export { default as Toggle } from "./Toggle";
export { default as ThemeToggle } from "./ThemeToggle";
export { ThemeProvider } from "./ThemeProvider";
export { default as Sidebar } from "./Sidebar";
export { default as Header } from "./Header";
export { default as Footer } from "./Footer";
export { default as OAuthModal } from "./OAuthModal";
export { default as ModelSelectModal } from "./ModelSelectModal";
export { default as ManualConfigModal } from "./ManualConfigModal";
export { default as UsageStats } from "./UsageStats";
export { default as UsageAnalytics } from "./UsageAnalytics";
export { default as RequestLoggerV2 } from "./RequestLoggerV2";
export { default as ProxyConfigModal } from "./ProxyConfigModal";
export { default as ProxyLogger } from "./ProxyLogger";
export { default as KiroAuthModal } from "./KiroAuthModal";
export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as SegmentedControl } from "./SegmentedControl";
// Layouts
export * from "./layouts";

View File

@@ -0,0 +1,28 @@
"use client";
import PropTypes from "prop-types";
import ThemeToggle from "../ThemeToggle";
export default function AuthLayout({ children }) {
return (
<div className="min-h-screen flex flex-col relative bg-bg transition-colors duration-500 overflow-x-hidden selection:bg-primary/20 selection:text-primary">
{/* Background effects */}
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-primary/5 dark:bg-primary/5 rounded-full blur-[100px] pointer-events-none z-0" />
<div className="fixed bottom-0 right-0 w-[600px] h-[600px] bg-orange-200/20 dark:bg-orange-900/10 rounded-full blur-[120px] pointer-events-none z-0 translate-y-1/3 translate-x-1/3" />
{/* Theme toggle */}
<div className="absolute top-6 right-6 z-20">
<ThemeToggle variant="card" />
</div>
{/* Content */}
<main className="flex-1 flex flex-col items-center justify-center p-4 sm:p-6 z-10 w-full h-full">
{children}
</main>
</div>
);
}
AuthLayout.propTypes = {
children: PropTypes.node.isRequired,
};

View File

@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import Sidebar from "../Sidebar";
import Header from "../Header";
const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed";
export default function DashboardLayout({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [collapsed, setCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
try {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
} catch {
return false;
}
});
const handleToggleCollapse = () => {
const next = !collapsed;
setCollapsed(next);
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
};
return (
<div className="flex h-screen w-full overflow-hidden bg-bg">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/20 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar - Desktop */}
<div className="hidden lg:flex">
<Sidebar collapsed={collapsed} onToggleCollapse={handleToggleCollapse} />
</div>
{/* Sidebar - Mobile */}
<div
className={`fixed inset-y-0 left-0 z-50 transform lg:hidden transition-transform duration-300 ease-in-out ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<Sidebar onClose={() => setSidebarOpen(false)} />
</div>
{/* Main content */}
<main
id="main-content"
className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300"
>
<Header onMenuClick={() => setSidebarOpen(true)} />
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 lg:p-10">
<div className="max-w-7xl mx-auto">{children}</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,3 @@
// Layout Components - Export all
export { default as DashboardLayout } from "./DashboardLayout";
export { default as AuthLayout } from "./AuthLayout";

View File

@@ -0,0 +1,197 @@
// CLI Tools configuration
export const CLI_TOOLS = {
claude: {
id: "claude",
name: "Claude Code",
icon: "terminal",
color: "#D97757",
description: "Anthropic Claude Code CLI",
configType: "env",
envVars: {
baseUrl: "ANTHROPIC_BASE_URL",
model: "ANTHROPIC_MODEL",
opusModel: "ANTHROPIC_DEFAULT_OPUS_MODEL",
sonnetModel: "ANTHROPIC_DEFAULT_SONNET_MODEL",
haikuModel: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
},
modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"],
settingsFile: "~/.claude/settings.json",
defaultModels: [
{
id: "opus",
name: "Claude Opus",
alias: "opus",
envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL",
defaultValue: "cc/claude-opus-4-5-20251101",
},
{
id: "sonnet",
name: "Claude Sonnet",
alias: "sonnet",
envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL",
defaultValue: "cc/claude-sonnet-4-5-20250929",
},
{
id: "haiku",
name: "Claude Haiku",
alias: "haiku",
envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL",
defaultValue: "cc/claude-haiku-4-5-20251001",
},
],
},
codex: {
id: "codex",
name: "OpenAI Codex CLI",
image: "/providers/codex.png",
color: "#10A37F",
description: "OpenAI Codex CLI",
configType: "custom",
},
droid: {
id: "droid",
name: "Factory Droid",
image: "/providers/droid.png",
color: "#00D4FF",
description: "Factory Droid AI Assistant",
configType: "custom",
},
openclaw: {
id: "openclaw",
name: "Open Claw",
image: "/providers/openclaw.png",
color: "#FF6B35",
description: "Open Claw AI Assistant",
configType: "custom",
},
cursor: {
id: "cursor",
name: "Cursor",
image: "/providers/cursor.png",
color: "#000000",
description: "Cursor AI Code Editor",
configType: "guide",
requiresCloud: true,
notes: [
{ type: "warning", text: "Requires Cursor Pro account to use this feature." },
{
type: "cloudCheck",
text: "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Cloud Endpoint in Settings.",
},
],
guideSteps: [
{ step: 1, title: "Open Settings", desc: "Go to Settings → Models" },
{ step: 2, title: "Enable OpenAI API", desc: 'Enable "OpenAI API key" option' },
{ step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true },
{ step: 4, title: "API Key", type: "apiKeySelector" },
{ step: 5, title: "Add Custom Model", desc: 'Click "View All Model" → "Add Custom Model"' },
{ step: 6, title: "Select Model", type: "modelSelector" },
],
},
cline: {
id: "cline",
name: "Cline",
image: "/providers/cline.png",
color: "#00D1B2",
description: "Cline AI Coding Assistant CLI",
configType: "custom",
},
kilo: {
id: "kilo",
name: "Kilo Code",
image: "/providers/kilocode.png",
color: "#FF6B6B",
description: "Kilo Code AI Assistant CLI",
configType: "custom",
},
continue: {
id: "continue",
name: "Continue",
image: "/providers/continue.png",
color: "#7C3AED",
description: "Continue AI Assistant",
configType: "guide",
guideSteps: [
{ step: 1, title: "Open Config", desc: "Open Continue configuration file" },
{ step: 2, title: "API Key", type: "apiKeySelector" },
{ step: 3, title: "Select Model", type: "modelSelector" },
{
step: 4,
title: "Add Model Config",
desc: "Add the following configuration to your models array:",
},
],
codeBlock: {
language: "json",
code: `{
"apiBase": "{{baseUrl}}",
"title": "{{model}}",
"model": "{{model}}",
"provider": "openai",
"apiKey": "{{apiKey}}"
}`,
},
},
antigravity: {
id: "antigravity",
name: "Antigravity",
image: "/providers/antigravity.png",
color: "#4285F4",
description: "Google Antigravity IDE with MITM",
configType: "mitm",
modelAliases: [
"claude-opus-4-5-thinking",
"claude-sonnet-4-5-thinking",
"claude-sonnet-4-5",
"gemini-3-pro-high",
],
defaultModels: [
{
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
alias: "claude-opus-4-5-thinking",
},
{
id: "claude-sonnet-4-5-thinking",
name: "Claude Sonnet 4.5 Thinking",
alias: "claude-sonnet-4-5-thinking",
},
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4-5" },
{ id: "gemini-3-pro-high", name: "Gemini 3 Pro High", alias: "gemini-3-pro-high" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash" },
],
},
// HIDDEN: gemini-cli
// "gemini-cli": {
// id: "gemini-cli",
// name: "Gemini CLI",
// icon: "terminal",
// color: "#4285F4",
// description: "Google Gemini CLI",
// configType: "env",
// envVars: {
// baseUrl: "GEMINI_API_BASE_URL",
// model: "GEMINI_MODEL",
// },
// defaultModels: [
// { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", alias: "pro" },
// { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", alias: "flash" },
// ],
// },
};
// Get all provider models for mapping dropdown
export const getProviderModelsForMapping = (providers) => {
const result = [];
providers.forEach((conn) => {
if (conn.isActive && (conn.testStatus === "active" || conn.testStatus === "success")) {
result.push({
connectionId: conn.id,
provider: conn.provider,
name: conn.name,
models: conn.models || [],
});
}
});
return result;
};

View File

@@ -0,0 +1,135 @@
/**
* Shared UI constants — Single source of truth for provider, protocol, and status colors.
*
* Previously duplicated in:
* - RequestLoggerV2.js (PROTOCOL_COLORS, PROVIDER_COLORS, getStatusStyle)
* - ProxyLogger.js (PROVIDER_COLORS, TYPE_COLORS, LEVEL_COLORS, getStatusStyle)
* - UsageAnalytics.js (MODEL_COLORS, getModelColor)
*/
// ═══════════════════════════════════════════
// Provider Colors (used across all logger components)
// ═══════════════════════════════════════════
export const PROVIDER_COLORS = {
github: { bg: "#6e40c9", text: "#fff", label: "GitHub" },
kiro: { bg: "#FF9900", text: "#000", label: "Kiro" },
antigravity: { bg: "#4285F4", text: "#fff", label: "AG" },
claude: { bg: "#D97757", text: "#fff", label: "Claude" },
codex: { bg: "#10A37F", text: "#fff", label: "Codex" },
gemini: { bg: "#34A853", text: "#fff", label: "Gemini" },
qwen: { bg: "#6366F1", text: "#fff", label: "Qwen" },
iflow: { bg: "#EC4899", text: "#fff", label: "iFlow" },
fireworks: { bg: "#F97316", text: "#fff", label: "Fireworks" },
kimi: { bg: "#06B6D4", text: "#fff", label: "Kimi" },
"gemini-cli": { bg: "#34A853", text: "#fff", label: "Gemini CLI" },
};
// ═══════════════════════════════════════════
// Protocol Colors (RequestLoggerV2)
// ═══════════════════════════════════════════
export const PROTOCOL_COLORS = {
openai: { bg: "#10A37F", text: "#fff", label: "OpenAI" },
claude: { bg: "#D97757", text: "#fff", label: "Claude" },
gemini: { bg: "#4285F4", text: "#fff", label: "Gemini" },
warmup: { bg: "#F59E0B", text: "#000", label: "Warmup" },
bypass: { bg: "#6B7280", text: "#fff", label: "Bypass" },
};
// ═══════════════════════════════════════════
// Proxy Type Colors (ProxyLogger)
// ═══════════════════════════════════════════
export const TYPE_COLORS = {
http: { bg: "#3B82F6", text: "#fff", label: "HTTP" },
https: { bg: "#10B981", text: "#fff", label: "HTTPS" },
socks5: { bg: "#8B5CF6", text: "#fff", label: "SOCKS5" },
};
// ═══════════════════════════════════════════
// Level Colors (ProxyLogger)
// ═══════════════════════════════════════════
export const LEVEL_COLORS = {
key: { bg: "#F59E0B", text: "#000", label: "Key" },
combo: { bg: "#8B5CF6", text: "#fff", label: "Combo" },
provider: { bg: "#3B82F6", text: "#fff", label: "Provider" },
global: { bg: "#6B7280", text: "#fff", label: "Global" },
direct: { bg: "#374151", text: "#9CA3AF", label: "Direct" },
};
// ═══════════════════════════════════════════
// Model Colors (charts/analytics)
// ═══════════════════════════════════════════
export const MODEL_COLORS = [
"#D97757",
"#60A5FA",
"#34D399",
"#A78BFA",
"#F472B6",
"#FBBF24",
"#38BDF8",
"#FB923C",
"#818CF8",
"#2DD4BF",
"#E879F9",
"#F87171",
"#4ADE80",
"#C084FC",
"#FB7185",
];
/**
* Get a model color by index (cycles through palette).
* @param {number} index
* @returns {string} Hex color
*/
export function getModelColor(index) {
return MODEL_COLORS[index % MODEL_COLORS.length];
}
// ═══════════════════════════════════════════
// Status Styling Helpers
// ═══════════════════════════════════════════
/**
* Get badge style for HTTP status codes (RequestLoggerV2).
* @param {number} status - HTTP status code
* @returns {{ bg: string, text: string }}
*/
export function getHttpStatusStyle(status) {
if (status >= 200 && status < 300) return { bg: "#059669", text: "#fff" };
if (status >= 400 && status < 500) return { bg: "#D97706", text: "#fff" };
if (status >= 500) return { bg: "#DC2626", text: "#fff" };
if (status === 0) return { bg: "#6366F1", text: "#fff" }; // pending
return { bg: "#6B7280", text: "#fff" };
}
/**
* Get badge style for string-based status (ProxyLogger).
* @param {string} status - Status string ("success", "error", "timeout")
* @returns {{ bg: string, text: string }}
*/
export function getProxyStatusStyle(status) {
if (status === "success") return { bg: "#059669", text: "#fff" };
if (status === "error") return { bg: "#DC2626", text: "#fff" };
if (status === "timeout") return { bg: "#D97706", text: "#fff" };
return { bg: "#6B7280", text: "#fff" };
}
/**
* Get default fallback for a provider color lookup.
* @param {string} provider - Provider key
* @returns {{ bg: string, text: string, label: string }}
*/
export function getProviderColor(provider) {
return (
PROVIDER_COLORS[provider] || {
bg: "#374151",
text: "#fff",
label: (provider || "-").toUpperCase(),
}
);
}

View File

@@ -0,0 +1,55 @@
import pkg from "../../../package.json" with { type: "json" };
// App configuration
export const APP_CONFIG = {
name: "Endpoint Proxy",
description: "AI Infrastructure Management",
version: pkg.version,
};
// Theme configuration
export const THEME_CONFIG = {
storageKey: "theme",
defaultTheme: "system", // "light" | "dark" | "system"
};
// Subscription
export const SUBSCRIPTION_CONFIG = {
price: 1.0,
currency: "USD",
interval: "month",
planName: "Pro Plan",
};
// API endpoints
export const API_ENDPOINTS = {
users: "/api/users",
providers: "/api/providers",
payments: "/api/payments",
auth: "/api/auth",
};
// Provider API endpoints (for display only)
export const PROVIDER_ENDPOINTS = {
openrouter: "https://openrouter.ai/api/v1/chat/completions",
glm: "https://api.z.ai/api/anthropic/v1/messages",
kimi: "https://api.moonshot.ai/v1/chat/completions",
"kimi-coding": "https://api.kimi.com/coding/v1/messages",
minimax: "https://api.minimax.io/anthropic/v1/messages",
"minimax-cn": "https://api.minimaxi.com/anthropic/v1/messages",
openai: "https://api.openai.com/v1/chat/completions",
anthropic: "https://api.anthropic.com/v1/messages",
gemini: "https://generativelanguage.googleapis.com/v1beta/models",
};
// Re-export from providers.js for backward compatibility
export {
FREE_PROVIDERS,
OAUTH_PROVIDERS,
APIKEY_PROVIDERS,
AI_PROVIDERS,
AUTH_METHODS,
} from "./providers.js";
// Re-export from models.js for backward compatibility
export { PROVIDER_MODELS, AI_MODELS } from "./models.js";

View File

@@ -0,0 +1,3 @@
// Shared Constants - Export all
export * from "./colors";
export * from "./config";

View File

@@ -0,0 +1,35 @@
// Import directly from file to avoid pulling in server-side dependencies via index.js
export {
PROVIDER_MODELS,
getProviderModels,
getDefaultModel,
isValidModel as isValidModelCore,
findModelName,
getModelTargetFormat,
PROVIDER_ID_TO_ALIAS,
getModelsByProviderId,
} from "@omniroute/open-sse/config/providerModels.js";
import { AI_PROVIDERS, isOpenAICompatibleProvider } from "./providers.js";
import { PROVIDER_MODELS as MODELS } from "@omniroute/open-sse/config/providerModels.js";
// Providers that accept any model (passthrough)
const PASSTHROUGH_PROVIDERS = new Set(
Object.entries(AI_PROVIDERS)
.filter(([, p]) => p.passthroughModels)
.map(([key]) => key)
);
// Wrap isValidModel with passthrough providers
export function isValidModel(aliasOrId, modelId) {
if (isOpenAICompatibleProvider(aliasOrId)) return true;
if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true;
const models = MODELS[aliasOrId];
if (!models) return false;
return models.some((m) => m.id === modelId);
}
// Legacy AI_MODELS for backward compatibility
export const AI_MODELS = Object.entries(MODELS).flatMap(([alias, models]) =>
models.map((m) => ({ provider: alias, model: m.id, name: m.name }))
);

View File

@@ -0,0 +1,598 @@
// Default pricing rates for AI models
// All rates are in dollars per million tokens ($/1M tokens)
// Based on user-provided pricing for Antigravity models and industry standards for others
export const DEFAULT_PRICING = {
// OAuth Providers (using aliases)
// Claude Code (cc)
cc: {
"claude-opus-4-5-20251101": {
input: 15.0,
output: 75.0,
cached: 7.5,
reasoning: 75.0,
cache_creation: 15.0,
},
"claude-sonnet-4-5-20250929": {
input: 3.0,
output: 15.0,
cached: 1.5,
reasoning: 15.0,
cache_creation: 3.0,
},
"claude-haiku-4-5-20251001": {
input: 0.5,
output: 2.5,
cached: 0.25,
reasoning: 2.5,
cache_creation: 0.5,
},
},
// OpenAI Codex (cx)
cx: {
"gpt-5.2-codex": {
input: 5.0,
output: 20.0,
cached: 2.5,
reasoning: 30.0,
cache_creation: 5.0,
},
"gpt-5.2": {
input: 5.0,
output: 20.0,
cached: 2.5,
reasoning: 30.0,
cache_creation: 5.0,
},
"gpt-5.1-codex-max": {
input: 8.0,
output: 32.0,
cached: 4.0,
reasoning: 48.0,
cache_creation: 8.0,
},
"gpt-5.1-codex": {
input: 4.0,
output: 16.0,
cached: 2.0,
reasoning: 24.0,
cache_creation: 4.0,
},
"gpt-5.1-codex-mini": {
input: 1.5,
output: 6.0,
cached: 0.75,
reasoning: 9.0,
cache_creation: 1.5,
},
"gpt-5.1": {
input: 4.0,
output: 16.0,
cached: 2.0,
reasoning: 24.0,
cache_creation: 4.0,
},
"gpt-5-codex": {
input: 3.0,
output: 12.0,
cached: 1.5,
reasoning: 18.0,
cache_creation: 3.0,
},
"gpt-5-codex-mini": {
input: 1.0,
output: 4.0,
cached: 0.5,
reasoning: 6.0,
cache_creation: 1.0,
},
},
// Gemini CLI (gc)
gc: {
"gemini-3-flash-preview": {
input: 0.5,
output: 3.0,
cached: 0.03,
reasoning: 4.5,
cache_creation: 0.5,
},
"gemini-3-pro-preview": {
input: 2.0,
output: 12.0,
cached: 0.25,
reasoning: 18.0,
cache_creation: 2.0,
},
"gemini-2.5-pro": {
input: 2.0,
output: 12.0,
cached: 0.25,
reasoning: 18.0,
cache_creation: 2.0,
},
"gemini-2.5-flash": {
input: 0.3,
output: 2.5,
cached: 0.03,
reasoning: 3.75,
cache_creation: 0.3,
},
"gemini-2.5-flash-lite": {
input: 0.15,
output: 1.25,
cached: 0.015,
reasoning: 1.875,
cache_creation: 0.15,
},
},
// Qwen Code (qw)
qw: {
"qwen3-coder-plus": {
input: 1.0,
output: 4.0,
cached: 0.5,
reasoning: 6.0,
cache_creation: 1.0,
},
"qwen3-coder-flash": {
input: 0.5,
output: 2.0,
cached: 0.25,
reasoning: 3.0,
cache_creation: 0.5,
},
"vision-model": {
input: 1.5,
output: 6.0,
cached: 0.75,
reasoning: 9.0,
cache_creation: 1.5,
},
},
// iFlow AI (if)
if: {
"qwen3-coder-plus": {
input: 1.0,
output: 4.0,
cached: 0.5,
reasoning: 6.0,
cache_creation: 1.0,
},
"kimi-k2": {
input: 1.0,
output: 4.0,
cached: 0.5,
reasoning: 6.0,
cache_creation: 1.0,
},
"kimi-k2-thinking": {
input: 1.5,
output: 6.0,
cached: 0.75,
reasoning: 9.0,
cache_creation: 1.5,
},
"deepseek-r1": {
input: 0.75,
output: 3.0,
cached: 0.375,
reasoning: 4.5,
cache_creation: 0.75,
},
"deepseek-v3.2-chat": {
input: 0.5,
output: 2.0,
cached: 0.25,
reasoning: 3.0,
cache_creation: 0.5,
},
"deepseek-v3.2-reasoner": {
input: 0.75,
output: 3.0,
cached: 0.375,
reasoning: 4.5,
cache_creation: 0.75,
},
"minimax-m2": {
input: 0.5,
output: 2.0,
cached: 0.25,
reasoning: 3.0,
cache_creation: 0.5,
},
"glm-4.6": {
input: 0.5,
output: 2.0,
cached: 0.25,
reasoning: 3.0,
cache_creation: 0.5,
},
"glm-4.7": {
input: 0.75,
output: 3.0,
cached: 0.375,
reasoning: 4.5,
cache_creation: 0.75,
},
},
// Antigravity (ag) - User-provided pricing
ag: {
"gemini-3-pro-low": {
input: 2.0,
output: 12.0,
cached: 0.25,
reasoning: 18.0,
cache_creation: 2.0,
},
"gemini-3-pro-high": {
input: 4.0,
output: 18.0,
cached: 0.5,
reasoning: 27.0,
cache_creation: 4.0,
},
"gemini-3-flash": {
input: 0.5,
output: 3.0,
cached: 0.03,
reasoning: 4.5,
cache_creation: 0.5,
},
"gemini-2.5-flash": {
input: 0.3,
output: 2.5,
cached: 0.03,
reasoning: 3.75,
cache_creation: 0.3,
},
"claude-sonnet-4-5": {
input: 3.0,
output: 15.0,
cached: 0.3,
reasoning: 22.5,
cache_creation: 3.0,
},
"claude-sonnet-4-5-thinking": {
input: 3.0,
output: 15.0,
cached: 0.3,
reasoning: 22.5,
cache_creation: 3.0,
},
"claude-opus-4-5-thinking": {
input: 5.0,
output: 25.0,
cached: 0.5,
reasoning: 37.5,
cache_creation: 5.0,
},
},
// GitHub Copilot (gh)
gh: {
"gpt-5": {
input: 3.0,
output: 12.0,
cached: 1.5,
reasoning: 18.0,
cache_creation: 3.0,
},
"gpt-5-mini": {
input: 0.75,
output: 3.0,
cached: 0.375,
reasoning: 4.5,
cache_creation: 0.75,
},
"gpt-5.1-codex": {
input: 4.0,
output: 16.0,
cached: 2.0,
reasoning: 24.0,
cache_creation: 4.0,
},
"gpt-5.1-codex-max": {
input: 8.0,
output: 32.0,
cached: 4.0,
reasoning: 48.0,
cache_creation: 8.0,
},
"gpt-4.1": {
input: 2.5,
output: 10.0,
cached: 1.25,
reasoning: 15.0,
cache_creation: 2.5,
},
"claude-4.5-sonnet": {
input: 3.0,
output: 15.0,
cached: 0.3,
reasoning: 22.5,
cache_creation: 3.0,
},
"claude-4.5-opus": {
input: 5.0,
output: 25.0,
cached: 0.5,
reasoning: 37.5,
cache_creation: 5.0,
},
"claude-4.5-haiku": {
input: 0.5,
output: 2.5,
cached: 0.05,
reasoning: 3.75,
cache_creation: 0.5,
},
"gemini-3-pro": {
input: 2.0,
output: 12.0,
cached: 0.25,
reasoning: 18.0,
cache_creation: 2.0,
},
"gemini-3-flash": {
input: 0.5,
output: 3.0,
cached: 0.03,
reasoning: 4.5,
cache_creation: 0.5,
},
"gemini-2.5-pro": {
input: 2.0,
output: 12.0,
cached: 0.25,
reasoning: 18.0,
cache_creation: 2.0,
},
"grok-code-fast-1": {
input: 0.5,
output: 2.0,
cached: 0.25,
reasoning: 3.0,
cache_creation: 0.5,
},
},
// API Key Providers (alias = id)
// OpenAI
openai: {
"gpt-4o": {
input: 2.5,
output: 10.0,
cached: 1.25,
reasoning: 15.0,
cache_creation: 2.5,
},
"gpt-4o-mini": {
input: 0.15,
output: 0.6,
cached: 0.075,
reasoning: 0.9,
cache_creation: 0.15,
},
"gpt-4-turbo": {
input: 10.0,
output: 30.0,
cached: 5.0,
reasoning: 45.0,
cache_creation: 10.0,
},
o1: {
input: 15.0,
output: 60.0,
cached: 7.5,
reasoning: 90.0,
cache_creation: 15.0,
},
"o1-mini": {
input: 3.0,
output: 12.0,
cached: 1.5,
reasoning: 18.0,
cache_creation: 3.0,
},
},
// Anthropic
anthropic: {
"claude-sonnet-4-20250514": {
input: 3.0,
output: 15.0,
cached: 1.5,
reasoning: 15.0,
cache_creation: 3.0,
},
"claude-opus-4-20250514": {
input: 15.0,
output: 75.0,
cached: 7.5,
reasoning: 112.5,
cache_creation: 15.0,
},
"claude-3-5-sonnet-20241022": {
input: 3.0,
output: 15.0,
cached: 1.5,
reasoning: 15.0,
cache_creation: 3.0,
},
},
// Gemini
gemini: {
"gemini-3-pro-preview": {
input: 2.0,
output: 12.0,
cached: 0.25,
reasoning: 18.0,
cache_creation: 2.0,
},
"gemini-2.5-pro": {
input: 2.0,
output: 12.0,
cached: 0.25,
reasoning: 18.0,
cache_creation: 2.0,
},
"gemini-2.5-flash": {
input: 0.3,
output: 2.5,
cached: 0.03,
reasoning: 3.75,
cache_creation: 0.3,
},
"gemini-2.5-flash-lite": {
input: 0.15,
output: 1.25,
cached: 0.015,
reasoning: 1.875,
cache_creation: 0.15,
},
},
// OpenRouter
openrouter: {
auto: {
input: 2.0,
output: 8.0,
cached: 1.0,
reasoning: 12.0,
cache_creation: 2.0,
},
},
// GLM
glm: {
"glm-4.7": {
input: 0.75,
output: 3.0,
cached: 0.375,
reasoning: 4.5,
cache_creation: 0.75,
},
"glm-4.6": {
input: 0.5,
output: 2.0,
cached: 0.25,
reasoning: 3.0,
cache_creation: 0.5,
},
"glm-4.6v": {
input: 0.75,
output: 3.0,
cached: 0.375,
reasoning: 4.5,
cache_creation: 0.75,
},
},
// Kimi
kimi: {
"kimi-latest": {
input: 1.0,
output: 4.0,
cached: 0.5,
reasoning: 6.0,
cache_creation: 1.0,
},
},
// MiniMax
minimax: {
"MiniMax-M2.1": {
input: 0.5,
output: 2.0,
cached: 0.25,
reasoning: 3.0,
cache_creation: 0.5,
},
},
};
/**
* Get pricing for a specific provider and model
* @param {string} provider - Provider ID (e.g., "openai", "cc", "gc")
* @param {string} model - Model ID
* @returns {object|null} Pricing object or null if not found
*/
export function getPricingForModel(provider, model) {
if (!provider || !model) return null;
const providerPricing = DEFAULT_PRICING[provider];
if (!providerPricing) return null;
return providerPricing[model] || null;
}
/**
* Get all pricing data
* @returns {object} All default pricing
*/
export function getDefaultPricing() {
return DEFAULT_PRICING;
}
/**
* Format cost for display
* @param {number} cost - Cost in dollars
* @returns {string} Formatted cost string
*/
export function formatCost(cost) {
if (cost === null || cost === undefined || isNaN(cost)) return "$0.00";
return `$${cost.toFixed(2)}`;
}
/**
* Calculate cost from tokens and pricing
* @param {object} tokens - Token counts
* @param {object} pricing - Pricing object
* @returns {number} Cost in dollars
*/
export function calculateCostFromTokens(tokens, pricing) {
if (!tokens || !pricing) return 0;
let cost = 0;
// Input tokens (non-cached)
const inputTokens = tokens.prompt_tokens || tokens.input_tokens || 0;
const cachedTokens = tokens.cached_tokens || tokens.cache_read_input_tokens || 0;
const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
cost += nonCachedInput * (pricing.input / 1000000);
// Cached tokens
if (cachedTokens > 0) {
const cachedRate = pricing.cached || pricing.input; // Fallback to input rate
cost += cachedTokens * (cachedRate / 1000000);
}
// Output tokens
const outputTokens = tokens.completion_tokens || tokens.output_tokens || 0;
cost += outputTokens * (pricing.output / 1000000);
// Reasoning tokens
const reasoningTokens = tokens.reasoning_tokens || 0;
if (reasoningTokens > 0) {
const reasoningRate = pricing.reasoning || pricing.output; // Fallback to output rate
cost += reasoningTokens * (reasoningRate / 1000000);
}
// Cache creation tokens
const cacheCreationTokens = tokens.cache_creation_input_tokens || 0;
if (cacheCreationTokens > 0) {
const cacheCreationRate = pricing.cache_creation || pricing.input; // Fallback to input rate
cost += cacheCreationTokens * (cacheCreationRate / 1000000);
}
return cost;
}

View File

@@ -0,0 +1,295 @@
// Provider definitions
// Free Providers
export const FREE_PROVIDERS = {
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" },
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
"gemini-cli": {
id: "gemini-cli",
alias: "gc",
name: "Gemini CLI",
icon: "terminal",
color: "#4285F4",
},
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
};
// OAuth Providers
export const OAUTH_PROVIDERS = {
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" },
antigravity: {
id: "antigravity",
alias: "ag",
name: "Antigravity",
icon: "rocket_launch",
color: "#F59E0B",
},
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6" },
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" },
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
"kimi-coding": {
id: "kimi-coding",
alias: "kmc",
name: "Kimi Coding",
icon: "psychology",
color: "#1E40AF",
textIcon: "KC",
},
kilocode: {
id: "kilocode",
alias: "kc",
name: "Kilo Code",
icon: "code",
color: "#FF6B35",
textIcon: "KC",
},
cline: {
id: "cline",
alias: "cl",
name: "Cline",
icon: "smart_toy",
color: "#5B9BD5",
textIcon: "CL",
},
};
export const APIKEY_PROVIDERS = {
openrouter: {
id: "openrouter",
alias: "openrouter",
name: "OpenRouter",
icon: "router",
color: "#F97316",
textIcon: "OR",
passthroughModels: true,
website: "https://openrouter.ai",
},
glm: {
id: "glm",
alias: "glm",
name: "GLM Coding",
icon: "code",
color: "#2563EB",
textIcon: "GL",
website: "https://open.bigmodel.cn",
},
kimi: {
id: "kimi",
alias: "kimi",
name: "Kimi",
icon: "psychology",
color: "#1E3A8A",
textIcon: "KM",
website: "https://kimi.moonshot.cn",
},
minimax: {
id: "minimax",
alias: "minimax",
name: "Minimax Coding",
icon: "memory",
color: "#7C3AED",
textIcon: "MM",
website: "https://www.minimaxi.com",
},
"minimax-cn": {
id: "minimax-cn",
alias: "minimax-cn",
name: "Minimax (China)",
icon: "memory",
color: "#DC2626",
textIcon: "MC",
website: "https://www.minimaxi.com",
},
openai: {
id: "openai",
alias: "openai",
name: "OpenAI",
icon: "auto_awesome",
color: "#10A37F",
textIcon: "OA",
website: "https://platform.openai.com",
},
anthropic: {
id: "anthropic",
alias: "anthropic",
name: "Anthropic",
icon: "smart_toy",
color: "#D97757",
textIcon: "AN",
website: "https://console.anthropic.com",
},
gemini: {
id: "gemini",
alias: "gemini",
name: "Gemini",
icon: "diamond",
color: "#4285F4",
textIcon: "GE",
website: "https://ai.google.dev",
},
deepseek: {
id: "deepseek",
alias: "ds",
name: "DeepSeek",
icon: "bolt",
color: "#4D6BFE",
textIcon: "DS",
website: "https://deepseek.com",
},
groq: {
id: "groq",
alias: "groq",
name: "Groq",
icon: "speed",
color: "#F55036",
textIcon: "GQ",
website: "https://groq.com",
},
xai: {
id: "xai",
alias: "xai",
name: "xAI (Grok)",
icon: "auto_awesome",
color: "#1DA1F2",
textIcon: "XA",
website: "https://x.ai",
},
mistral: {
id: "mistral",
alias: "mistral",
name: "Mistral",
icon: "air",
color: "#FF7000",
textIcon: "MI",
website: "https://mistral.ai",
},
perplexity: {
id: "perplexity",
alias: "pplx",
name: "Perplexity",
icon: "search",
color: "#20808D",
textIcon: "PP",
website: "https://www.perplexity.ai",
},
together: {
id: "together",
alias: "together",
name: "Together AI",
icon: "group_work",
color: "#0F6FFF",
textIcon: "TG",
website: "https://www.together.ai",
},
fireworks: {
id: "fireworks",
alias: "fireworks",
name: "Fireworks AI",
icon: "local_fire_department",
color: "#7B2EF2",
textIcon: "FW",
website: "https://fireworks.ai",
},
cerebras: {
id: "cerebras",
alias: "cerebras",
name: "Cerebras",
icon: "memory",
color: "#FF4F00",
textIcon: "CB",
website: "https://www.cerebras.ai",
},
cohere: {
id: "cohere",
alias: "cohere",
name: "Cohere",
icon: "hub",
color: "#39594D",
textIcon: "CO",
website: "https://cohere.com",
},
nvidia: {
id: "nvidia",
alias: "nvidia",
name: "NVIDIA NIM",
icon: "developer_board",
color: "#76B900",
textIcon: "NV",
website: "https://developer.nvidia.com/nim",
},
nebius: {
id: "nebius",
alias: "nebius",
name: "Nebius AI",
icon: "cloud",
color: "#6C5CE7",
textIcon: "NB",
website: "https://nebius.com",
},
siliconflow: {
id: "siliconflow",
alias: "siliconflow",
name: "SiliconFlow",
icon: "cloud_queue",
color: "#5B6EF5",
textIcon: "SF",
website: "https://cloud.siliconflow.com",
},
};
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
export const ANTHROPIC_COMPATIBLE_PREFIX = "anthropic-compatible-";
export function isOpenAICompatibleProvider(providerId) {
return typeof providerId === "string" && providerId.startsWith(OPENAI_COMPATIBLE_PREFIX);
}
export function isAnthropicCompatibleProvider(providerId) {
return typeof providerId === "string" && providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX);
}
// All providers (combined)
export const AI_PROVIDERS = { ...FREE_PROVIDERS, ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS };
// Auth methods
export const AUTH_METHODS = {
oauth: { id: "oauth", name: "OAuth", icon: "lock" },
apikey: { id: "apikey", name: "API Key", icon: "key" },
};
// Helper: Get provider by alias
export function getProviderByAlias(alias) {
for (const provider of Object.values(AI_PROVIDERS)) {
if (provider.alias === alias || provider.id === alias) {
return provider;
}
}
return null;
}
// Helper: Get provider ID from alias
export function resolveProviderId(aliasOrId) {
const provider = getProviderByAlias(aliasOrId);
return provider?.id || aliasOrId;
}
// Helper: Get alias from provider ID
export function getProviderAlias(providerId) {
const provider = AI_PROVIDERS[providerId];
return provider?.alias || providerId;
}
// Alias to ID mapping (for quick lookup)
export const ALIAS_TO_ID = Object.values(AI_PROVIDERS).reduce((acc, p) => {
acc[p.alias] = p.id;
return acc;
}, {});
// ID to Alias mapping
export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
acc[p.id] = p.alias;
return acc;
}, {});
// Providers that support usage/quota API
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "codex", "claude"];

View File

@@ -0,0 +1,2 @@
// Shared Hooks - Export all
export { useTheme } from "./useTheme";

View File

@@ -0,0 +1,61 @@
"use client";
import { useState, useCallback, useRef } from "react";
/**
* Fallback copy using legacy execCommand (works on HTTP)
*/
function fallbackCopy(text) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.left = "-9999px";
textarea.style.top = "-9999px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
document.execCommand("copy");
} catch {
// ignore
}
document.body.removeChild(textarea);
}
/**
* Hook for copy to clipboard with feedback
* @param {number} resetDelay - Time in ms before resetting copied state (default: 2000)
* @returns {{ copied: string|null, copy: (text: string, id?: string) => void }}
*/
export function useCopyToClipboard(resetDelay = 2000) {
const [copied, setCopied] = useState(null);
const timeoutRef = useRef(null);
const copy = useCallback(
async (text, id = "default") => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
fallbackCopy(text);
}
} catch {
fallbackCopy(text);
}
setCopied(id);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setCopied(null);
}, resetDelay);
},
[resetDelay]
);
return { copied, copy };
}

View File

@@ -0,0 +1,59 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import useThemeStore from "@/store/themeStore";
// Subscribe to system theme changes
function subscribeToSystemTheme(callback) {
if (typeof window === "undefined") return () => {};
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", callback);
return () => mediaQuery.removeEventListener("change", callback);
}
// Get current system theme preference
function getSystemThemeSnapshot() {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
// Server snapshot always returns false
function getServerSnapshot() {
return false;
}
export function useTheme() {
const { theme, setTheme, toggleTheme, initTheme } = useThemeStore();
// Use useSyncExternalStore to safely subscribe to system theme
const systemPrefersDark = useSyncExternalStore(
subscribeToSystemTheme,
getSystemThemeSnapshot,
getServerSnapshot
);
useEffect(() => {
initTheme();
}, [initTheme]);
// Listen for system theme changes when theme is "system"
useEffect(() => {
if (theme !== "system") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => initTheme();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [theme, initTheme]);
// Compute isDark from current state (no effect needed)
const isDark = theme === "dark" || (theme === "system" && systemPrefersDark);
return {
theme,
setTheme,
toggleTheme,
isDark,
};
}

View File

@@ -0,0 +1,219 @@
import fs from "fs/promises";
import path from "path";
import os from "os";
const BACKUP_DIR = path.join(os.homedir(), ".omniroute", "backups");
const MAX_BACKUPS_PER_TOOL = 5;
/**
* Get backup directory for a specific tool
*/
function getToolBackupDir(toolId) {
return path.join(BACKUP_DIR, toolId);
}
/**
* Ensure backup directory exists for a tool
*/
async function ensureBackupDir(toolId) {
const dir = getToolBackupDir(toolId);
await fs.mkdir(dir, { recursive: true });
return dir;
}
/**
* Generate a backup filename with timestamp
*/
function makeBackupName(originalPath) {
const ext = path.extname(originalPath);
const base = path.basename(originalPath, ext);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
return `${base}_${ts}${ext}`;
}
/**
* Create a backup of a file before modifying it.
* Returns the backup path, or null if the source doesn't exist.
*/
export async function createBackup(toolId, filePath) {
try {
await fs.access(filePath);
} catch {
// Source file doesn't exist — nothing to back up
return null;
}
const dir = await ensureBackupDir(toolId);
const backupName = makeBackupName(filePath);
const backupPath = path.join(dir, backupName);
await fs.copyFile(filePath, backupPath);
// Save metadata alongside the backup
const metaPath = backupPath + ".meta.json";
await fs.writeFile(
metaPath,
JSON.stringify({
originalPath: filePath,
backupName,
toolId,
createdAt: new Date().toISOString(),
})
);
// Enforce rotation (max backups per tool)
await rotateBackups(toolId);
return backupPath;
}
/**
* Create backups for multiple files in one operation (e.g. Codex config.toml + auth.json).
* Returns an array of backup paths.
*/
export async function createMultiBackup(toolId, filePaths) {
const results = [];
for (const filePath of filePaths) {
const result = await createBackup(toolId, filePath);
results.push(result);
}
return results;
}
/**
* List all backups for a tool (sorted newest first).
*/
export async function listBackups(toolId) {
const dir = getToolBackupDir(toolId);
let entries;
try {
entries = await fs.readdir(dir);
} catch {
return [];
}
const metaFiles = entries.filter((e) => e.endsWith(".meta.json"));
const backups = [];
for (const metaFile of metaFiles) {
try {
const metaPath = path.join(dir, metaFile);
const raw = await fs.readFile(metaPath, "utf-8");
const meta = JSON.parse(raw);
const backupFile = metaFile.replace(".meta.json", "");
const backupPath = path.join(dir, backupFile);
let size = 0;
try {
const stat = await fs.stat(backupPath);
size = stat.size;
} catch {
// Backup file missing — skip
continue;
}
backups.push({
id: backupFile,
toolId: meta.toolId,
originalPath: meta.originalPath,
createdAt: meta.createdAt,
size,
});
} catch {
// Corrupt meta — skip
}
}
// Sort newest first
backups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return backups;
}
/**
* Restore a backup by its id (filename).
*/
export async function restoreBackup(toolId, backupId) {
const dir = getToolBackupDir(toolId);
const backupPath = path.join(dir, backupId);
const metaPath = backupPath + ".meta.json";
// Read metadata to find original path
let meta;
try {
const raw = await fs.readFile(metaPath, "utf-8");
meta = JSON.parse(raw);
} catch {
throw new Error(`Backup metadata not found: ${backupId}`);
}
// Verify actual backup file exists
try {
await fs.access(backupPath);
} catch {
throw new Error(`Backup file not found: ${backupId}`);
}
// Before restoring, back up the current file (so restore is reversible)
await createBackup(toolId, meta.originalPath);
// Copy backup over the original
const targetDir = path.dirname(meta.originalPath);
await fs.mkdir(targetDir, { recursive: true });
await fs.copyFile(backupPath, meta.originalPath);
return {
restored: true,
backupId,
originalPath: meta.originalPath,
};
}
/**
* Delete a specific backup by its id.
*/
export async function deleteBackup(toolId, backupId) {
const dir = getToolBackupDir(toolId);
const backupPath = path.join(dir, backupId);
const metaPath = backupPath + ".meta.json";
try {
await fs.unlink(backupPath);
} catch {
// Already gone
}
try {
await fs.unlink(metaPath);
} catch {
// Already gone
}
return { deleted: true, backupId };
}
/**
* Enforce max backups per tool — removes oldest when limit exceeded.
* Groups by original file basename so each config file gets its own rotation.
*/
async function rotateBackups(toolId) {
const all = await listBackups(toolId);
// Group by original file basename
const groups = {};
for (const b of all) {
const key = path.basename(b.originalPath);
if (!groups[key]) groups[key] = [];
groups[key].push(b);
}
for (const [, group] of Object.entries(groups)) {
// Already sorted newest first
if (group.length > MAX_BACKUPS_PER_TOOL) {
const toDelete = group.slice(MAX_BACKUPS_PER_TOOL);
for (const old of toDelete) {
await deleteBackup(toolId, old.id);
}
}
}
}

View File

@@ -0,0 +1,359 @@
import fs from "fs/promises";
import os from "os";
import path from "path";
import { spawn } from "child_process";
const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]);
const FALSE_VALUES = new Set(["0", "false", "no", "off"]);
const CLI_TOOLS = {
claude: {
defaultCommand: "claude",
envBinKey: "CLI_CLAUDE_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 4000,
paths: {
settings: ".claude/settings.json",
},
},
codex: {
defaultCommand: "codex",
envBinKey: "CLI_CODEX_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 4000,
paths: {
config: ".codex/config.toml",
auth: ".codex/auth.json",
},
},
droid: {
defaultCommand: "droid",
envBinKey: "CLI_DROID_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 4000,
paths: {
settings: ".factory/settings.json",
},
},
openclaw: {
defaultCommand: "openclaw",
envBinKey: "CLI_OPENCLAW_BIN",
requiresBinary: true,
// openclaw CLI may take >4s on cold start in containers.
healthcheckTimeoutMs: 12000,
paths: {
settings: ".openclaw/openclaw.json",
},
},
cursor: {
defaultCommands: ["agent", "cursor"],
envBinKey: "CLI_CURSOR_BIN",
requiresBinary: true,
// Cursor startup can be slower on first run in containerized host-mount mode.
healthcheckTimeoutMs: 12000,
paths: {
config: ".cursor/cli-config.json",
auth: ".config/cursor/auth.json",
state: ".cursor/agent-cli-state.json",
},
},
cline: {
defaultCommand: "cline",
envBinKey: "CLI_CLINE_BIN",
requiresBinary: true,
// Cline startup/version check can take >4s on some environments.
healthcheckTimeoutMs: 12000,
paths: {
globalState: ".cline/data/globalState.json",
secrets: ".cline/data/secrets.json",
},
},
kilo: {
defaultCommand: "kilocode",
envBinKey: "CLI_KILO_BIN",
requiresBinary: true,
healthcheckTimeoutMs: 4000,
paths: {
auth: ".local/share/kilo/auth.json",
},
},
continue: {
defaultCommand: null,
envBinKey: "CLI_CONTINUE_BIN",
requiresBinary: false,
paths: {
settings: ".continue/config.json",
},
},
};
const isWindows = () => process.platform === "win32";
const parseBoolean = (value, defaultValue = true) => {
if (value == null || value === "") return defaultValue;
return !FALSE_VALUES.has(String(value).trim().toLowerCase());
};
const runProcess = (command, args, { env, timeoutMs = 3000 } = {}) =>
new Promise((resolve) => {
let stdout = "";
let stderr = "";
let timedOut = false;
let settled = false;
const child = spawn(command, args, { env, stdio: ["ignore", "pipe", "pipe"] });
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, timeoutMs);
const done = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};
child.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
child.on("error", (error) => {
done({
ok: false,
code: null,
stdout: stdout.trim(),
stderr: stderr.trim(),
timedOut,
error: error?.message || "spawn_error",
});
});
child.on("close", (code) => {
done({
ok: !timedOut && code === 0,
code,
stdout: stdout.trim(),
stderr: stderr.trim(),
timedOut,
error: timedOut ? "timeout" : null,
});
});
});
const getRuntimeMode = () => {
const mode = String(process.env.CLI_MODE || "auto")
.trim()
.toLowerCase();
return VALID_RUNTIME_MODES.has(mode) ? mode : "auto";
};
const getExtraPaths = () =>
String(process.env.CLI_EXTRA_PATHS || "")
.split(path.delimiter)
.map((segment) => segment.trim())
.filter(Boolean);
const getLookupEnv = () => {
const env = { ...process.env };
const extraPaths = getExtraPaths();
if (extraPaths.length > 0) {
env.PATH = [...extraPaths, env.PATH || ""].filter(Boolean).join(path.delimiter);
}
return env;
};
const resolveToolCommands = (toolId) => {
const tool = CLI_TOOLS[toolId];
if (!tool) return [];
const envCommand = String(process.env[tool.envBinKey] || "").trim();
if (envCommand) return [envCommand];
if (Array.isArray(tool.defaultCommands) && tool.defaultCommands.length > 0) {
return tool.defaultCommands.filter(Boolean);
}
return tool.defaultCommand ? [tool.defaultCommand] : [];
};
const checkExplicitPath = async (commandPath) => {
try {
await fs.access(commandPath, fs.constants.F_OK);
} catch {
return { installed: false, commandPath: null, reason: "not_found" };
}
try {
await fs.access(commandPath, fs.constants.X_OK);
return { installed: true, commandPath, reason: null };
} catch {
return { installed: true, commandPath, reason: "not_executable" };
}
};
const locateCommand = async (command, env) => {
if (!command) {
return { installed: false, commandPath: null, reason: "missing_command" };
}
if (command.includes("/") || command.includes("\\")) {
return checkExplicitPath(command);
}
if (isWindows()) {
const located = await runProcess("where", [command], { env, timeoutMs: 3000 });
if (!located.ok || !located.stdout) {
return { installed: false, commandPath: null, reason: "not_found" };
}
const first =
located.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) || null;
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
}
const located = await runProcess("sh", ["-c", 'command -v -- "$1"', "sh", command], {
env,
timeoutMs: 3000,
});
if (!located.ok || !located.stdout) {
return { installed: false, commandPath: null, reason: "not_found" };
}
const first =
located.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) || null;
return { installed: !!first, commandPath: first, reason: first ? null : "not_found" };
};
const locateCommandCandidate = async (commands, env) => {
if (!Array.isArray(commands) || commands.length === 0) {
return { command: null, installed: false, commandPath: null, reason: "missing_command" };
}
for (const command of commands) {
const located = await locateCommand(command, env);
if (located.installed || located.reason !== "not_found") {
return { command, ...located };
}
}
return { command: commands[0], installed: false, commandPath: null, reason: "not_found" };
};
const checkRunnable = async (commandPath, env, timeoutMs = 4000) => {
for (const args of [["--version"], ["-v"]]) {
const result = await runProcess(commandPath, args, { env, timeoutMs });
if (result.ok) {
return { runnable: true, reason: null };
}
}
return { runnable: false, reason: "healthcheck_failed" };
};
export const isCliConfigWriteAllowed = () =>
parseBoolean(process.env.CLI_ALLOW_CONFIG_WRITES, true);
export const ensureCliConfigWriteAllowed = () => {
if (isCliConfigWriteAllowed()) return null;
return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)";
};
export const getCliConfigHome = () =>
String(process.env.CLI_CONFIG_HOME || "").trim() || os.homedir();
export const getCliConfigPaths = (toolId) => {
const tool = CLI_TOOLS[toolId];
if (!tool) return null;
const home = getCliConfigHome();
return Object.fromEntries(
Object.entries(tool.paths).map(([key, relativePath]) => [key, path.join(home, relativePath)])
);
};
export const getCliPrimaryConfigPath = (toolId) => {
const paths = getCliConfigPaths(toolId);
if (!paths) return null;
const firstKey = Object.keys(paths)[0];
return firstKey ? paths[firstKey] : null;
};
export const getCliRuntimeStatus = async (toolId) => {
const tool = CLI_TOOLS[toolId];
const runtimeMode = getRuntimeMode();
if (!tool) {
return {
installed: false,
runnable: false,
command: null,
commandPath: null,
reason: "unknown_tool",
runtimeMode,
requiresBinary: false,
};
}
const env = getLookupEnv();
const commands = resolveToolCommands(toolId);
const requiresBinary = tool.requiresBinary !== false;
if (!requiresBinary && commands.length === 0) {
return {
installed: true,
runnable: true,
command: null,
commandPath: null,
reason: "not_required",
runtimeMode,
requiresBinary,
};
}
const located = await locateCommandCandidate(commands, env);
const command = located.command;
if (!located.installed) {
return {
installed: false,
runnable: false,
command,
commandPath: null,
reason: located.reason || "not_found",
runtimeMode,
requiresBinary,
};
}
if (located.reason === "not_executable") {
return {
installed: true,
runnable: false,
command,
commandPath: located.commandPath,
reason: "not_executable",
runtimeMode,
requiresBinary,
};
}
const healthcheck = await checkRunnable(
located.commandPath,
env,
Number(tool.healthcheckTimeoutMs || 4000)
);
return {
installed: true,
runnable: healthcheck.runnable,
command,
commandPath: located.commandPath,
reason: healthcheck.reason,
runtimeMode,
requiresBinary,
};
};
export const CLI_TOOL_IDS = Object.keys(CLI_TOOLS);

View File

@@ -0,0 +1,123 @@
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isCloudEnabled } from "@/lib/localDb";
const INTERNAL_BASE_URL =
process.env.BASE_URL || process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:20128";
/**
* Cloud sync scheduler
*/
export class CloudSyncScheduler {
constructor(machineId = null, intervalMinutes = 15) {
this.machineId = machineId;
this.intervalMinutes = intervalMinutes;
this.intervalId = null;
}
/**
* Initialize machine ID if not provided
*/
async initializeMachineId() {
if (!this.machineId) {
this.machineId = await getConsistentMachineId();
}
}
/**
* Start periodic sync (delays first sync to allow server to be ready)
*/
async start() {
if (this.intervalId) {
return;
}
await this.initializeMachineId();
// Delay first sync by 30 seconds to ensure server is ready
setTimeout(() => {
this.syncWithRetry().catch(() => {});
}, 30000);
// Then sync periodically
this.intervalId = setInterval(
() => {
this.syncWithRetry().catch(() => {});
},
this.intervalMinutes * 60 * 1000
);
}
/**
* Stop periodic sync
*/
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
/**
* Sync with retry logic (exponential backoff)
*/
async syncWithRetry(maxRetries = 1) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await this.sync();
return result;
} catch (error) {
if (attempt === maxRetries) {
return null;
}
const delay = Math.min(1000 * Math.pow(2, attempt), 10000); // Max 10s
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
/**
* Perform sync via internal API route (handles token update to db.json)
*/
async sync() {
// Check if cloud is enabled
const enabled = await isCloudEnabled();
if (!enabled) {
return null;
}
await this.initializeMachineId();
// Call internal API route which handles both sync and token update
const response = await fetch(`${INTERNAL_BASE_URL}/api/sync/cloud`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ machineId: this.machineId, action: "sync" }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Sync failed");
}
const result = await response.json();
return result;
}
/**
* Check if scheduler is running
*/
isRunning() {
return this.intervalId !== null;
}
}
// Export a singleton instance if needed
let cloudSyncScheduler = null;
export async function getCloudSyncScheduler(machineId = null, intervalMinutes = 15) {
if (!cloudSyncScheduler) {
cloudSyncScheduler = new CloudSyncScheduler(machineId, intervalMinutes);
}
return cloudSyncScheduler;
}

View File

@@ -0,0 +1,31 @@
import { getCloudSyncScheduler } from "@/shared/services/cloudSyncScheduler";
import { isCloudEnabled, cleanupProviderConnections } from "@/lib/localDb";
/**
* Initialize cloud sync scheduler
* This should be called when the application starts
*/
export async function initializeCloudSync() {
try {
// Cleanup null fields from existing data
await cleanupProviderConnections();
// Create scheduler instance with default 15-minute interval
const scheduler = await getCloudSyncScheduler(null, 15);
// Start the scheduler
await scheduler.start();
return scheduler;
} catch (error) {
console.error("[CloudSync] Error initializing scheduler:", error);
throw error;
}
}
// For development/testing purposes
if (typeof require !== "undefined" && require.main === module) {
initializeCloudSync().catch(console.log);
}
export default initializeCloudSync;

92
src/shared/utils/api.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* API utility functions for making HTTP requests
*/
const DEFAULT_HEADERS = {
"Content-Type": "application/json",
};
/**
* Make a GET request
* @param {string} url - API endpoint
* @param {object} options - Fetch options
* @returns {Promise<object>}
*/
export async function get(url, options = {}) {
const response = await fetch(url, {
method: "GET",
headers: { ...DEFAULT_HEADERS, ...options.headers },
...options,
});
return handleResponse(response);
}
/**
* Make a POST request
* @param {string} url - API endpoint
* @param {object} data - Request body
* @param {object} options - Fetch options
* @returns {Promise<object>}
*/
export async function post(url, data, options = {}) {
const response = await fetch(url, {
method: "POST",
headers: { ...DEFAULT_HEADERS, ...options.headers },
body: JSON.stringify(data),
...options,
});
return handleResponse(response);
}
/**
* Make a PUT request
* @param {string} url - API endpoint
* @param {object} data - Request body
* @param {object} options - Fetch options
* @returns {Promise<object>}
*/
export async function put(url, data, options = {}) {
const response = await fetch(url, {
method: "PUT",
headers: { ...DEFAULT_HEADERS, ...options.headers },
body: JSON.stringify(data),
...options,
});
return handleResponse(response);
}
/**
* Make a DELETE request
* @param {string} url - API endpoint
* @param {object} options - Fetch options
* @returns {Promise<object>}
*/
export async function del(url, options = {}) {
const response = await fetch(url, {
method: "DELETE",
headers: { ...DEFAULT_HEADERS, ...options.headers },
...options,
});
return handleResponse(response);
}
/**
* Handle API response
* @param {Response} response - Fetch response
* @returns {Promise<object>}
*/
async function handleResponse(response) {
const data = await response.json();
if (!response.ok) {
const error = new Error(data.error || "An error occurred");
error.status = response.status;
error.data = data;
throw error;
}
return data;
}
const api = { get, post, put, del };
export default api;

View File

@@ -0,0 +1,97 @@
import crypto from "crypto";
const API_KEY_SECRET = process.env.API_KEY_SECRET || "endpoint-proxy-api-key-secret";
/**
* Generate 6-char random keyId
*/
function generateKeyId() {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let result = "";
for (let i = 0; i < 6; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* Generate CRC (8-char HMAC)
*/
function generateCrc(machineId, keyId) {
return crypto
.createHmac("sha256", API_KEY_SECRET)
.update(machineId + keyId)
.digest("hex")
.slice(0, 8);
}
/**
* Generate API key with machineId embedded
* Format: sk-{machineId}-{keyId}-{crc8}
* @param {string} machineId - 16-char machine ID
* @returns {{ key: string, keyId: string }}
*/
export function generateApiKeyWithMachine(machineId) {
const keyId = generateKeyId();
const crc = generateCrc(machineId, keyId);
const key = `sk-${machineId}-${keyId}-${crc}`;
return { key, keyId };
}
/**
* Parse API key and extract machineId + keyId
* Supports both formats:
* - New: sk-{machineId}-{keyId}-{crc8}
* - Old: sk-{random8}
* @param {string} apiKey
* @returns {{ machineId: string, keyId: string, isNewFormat: boolean } | null}
*/
export function parseApiKey(apiKey) {
if (!apiKey || !apiKey.startsWith("sk-")) return null;
const parts = apiKey.split("-");
// New format: sk-{machineId}-{keyId}-{crc8} = 4 parts
if (parts.length === 4) {
const [, machineId, keyId, crc] = parts;
// Validate CRC
const expectedCrc = generateCrc(machineId, keyId);
if (crc !== expectedCrc) return null;
return { machineId, keyId, isNewFormat: true };
}
// Old format: sk-{random8} = 2 parts
if (parts.length === 2) {
return { machineId: null, keyId: parts[1], isNewFormat: false };
}
return null;
}
/**
* Verify API key CRC (only for new format)
* @param {string} apiKey
* @returns {boolean}
*/
export function verifyApiKeyCrc(apiKey) {
const parsed = parseApiKey(apiKey);
if (!parsed) return false;
// Old format doesn't have CRC, always valid if parsed
if (!parsed.isNewFormat) return true;
// New format already verified in parseApiKey
return true;
}
/**
* Check if API key is new format (contains machineId)
* @param {string} apiKey
* @returns {boolean}
*/
export function isNewFormatKey(apiKey) {
const parsed = parseApiKey(apiKey);
return parsed?.isNewFormat === true;
}

41
src/shared/utils/cloud.js Normal file
View File

@@ -0,0 +1,41 @@
import { getMachineId } from "@/shared/utils/machine";
// Function to get cloud URL with machine ID
export function getCloudUrl(machineId) {
// Get from environment or default to localhost:8787
const cloudUrl = process.env.NEXT_PUBLIC_CLOUD_URL || "http://localhost:8787";
return `${cloudUrl}/${machineId}/v1/chat/completions`;
}
// Function to call cloud with machine ID
export async function callCloudWithMachineId(request) {
const machineId = await getMachineId();
if (!machineId) {
throw new Error("Could not get machine ID");
}
const cloudUrl = getCloudUrl(machineId);
// Get the original request body and headers
const body = await request.json();
const headers = new Headers(request.headers);
// Remove authorization header since cloud won't need it (uses machineId instead)
headers.delete("authorization");
// Call the cloud with machine ID
const response = await fetch(cloudUrl, {
method: "POST",
headers: headers,
body: JSON.stringify(body),
});
return response;
}
// Function to periodically sync provider data to cloud (now a no-op)
export function startProviderSync(cloudUrl, intervalMs = 900000) {
// Default 15 minutes
console.log("Frontend sync is disabled. Use backend sync instead.");
return null;
}

6
src/shared/utils/cn.js Normal file
View File

@@ -0,0 +1,6 @@
// Utility function to merge class names
// Handles conditional classes and removes duplicates
export function cn(...classes) {
return classes.filter(Boolean).join(" ").replace(/\s+/g, " ").trim();
}

36
src/shared/utils/cors.js Normal file
View File

@@ -0,0 +1,36 @@
/**
* Shared CORS configuration for all API routes.
*
* Centralizes the Access-Control-Allow-Origin header so it can be
* configured via the CORS_ORIGIN environment variable instead of
* being hardcoded as "*" in every route handler.
*
* Usage:
* import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
*
* // In route responses:
* return new Response(body, { headers: { ...CORS_HEADERS, "Content-Type": "application/json" } });
*
* // For OPTIONS preflight:
* export function OPTIONS() { return handleCorsOptions(); }
*/
const CORS_ORIGIN = process.env.CORS_ORIGIN || "*";
/**
* Standard CORS headers to spread into any Response.
* @type {Record<string, string>}
*/
export const CORS_HEADERS = {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, x-api-key, anthropic-version",
};
/**
* Handle CORS preflight (OPTIONS) request.
* @returns {Response} 204 No Content with CORS headers
*/
export function handleCorsOptions() {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}

View File

@@ -0,0 +1,150 @@
/**
* Shared formatting utilities — DRY extraction from duplicated functions
* across RequestLoggerV2.js, UsageAnalytics.js, ProxyLogger.js
*
* Prevents copy-paste duplication and provides a single source of truth.
*/
/**
* Format an ISO date string to a localized time string (HH:MM:SS).
* @param {string} isoString - ISO 8601 date string
* @returns {string}
*/
export function formatTime(isoString) {
try {
const d = new Date(isoString);
return d.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return "-";
}
}
/**
* Format a duration in milliseconds to a human-readable string.
* @param {number} ms - Duration in milliseconds
* @returns {string} e.g., "42ms", "1.2s", "-"
*/
export function formatDuration(ms) {
if (!ms) return "-";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
/**
* Format an ISO date to a full date+time string (pt-BR locale).
* @param {string} iso - ISO 8601 date string
* @returns {string}
*/
export function formatDateTime(iso) {
try {
const d = new Date(iso);
return d.toLocaleDateString("pt-BR") + ", " + d.toLocaleTimeString("en-US", { hour12: false });
} catch {
return iso;
}
}
/**
* Mask a string by showing only start and end characters.
* @param {string} value - Value to mask
* @param {number} start - Number of characters to show at start (default: 2)
* @param {number} end - Number of characters to show at end (default: 2)
* @returns {string}
*/
export function maskSegment(value, start = 2, end = 2) {
if (!value) return "";
if (value.length <= start + end) return `${value.slice(0, 1)}***`;
return `${value.slice(0, start)}***${value.slice(-end)}`;
}
/**
* Mask an email or account string for display.
* @param {string} account - Account identifier (email or username)
* @returns {string}
*/
export function maskAccount(account) {
if (!account || account === "-") return "-";
const atIdx = account.indexOf("@");
if (atIdx > 3) {
return account.slice(0, 3) + "***" + account.slice(atIdx);
}
if (account.length > 8) {
return account.slice(0, 5) + "***";
}
return account;
}
/**
* Format an API key label, masking both name and ID.
* @param {string} apiKeyName - Human-readable name of the key
* @param {string} apiKeyId - Unique ID of the key
* @returns {string}
*/
export function formatApiKeyLabel(apiKeyName, apiKeyId) {
if (!apiKeyName && !apiKeyId) return "—";
const maskedName = apiKeyName ? maskSegment(apiKeyName, 2, 1) : "key";
if (!apiKeyId) return maskedName;
return `${maskedName} (${maskSegment(apiKeyId, 4, 4)})`;
}
/**
* Mask a sensitive key for log output.
* @param {string} key - API key or token to mask
* @returns {string}
*/
export function maskKey(key) {
if (!key || key.length < 8) return "***";
return `${key.slice(0, 4)}...${key.slice(-4)}`;
}
/**
* Format large numbers with K/M/B suffixes.
* @param {number} n - Number to format
* @returns {string}
*/
export function fmtCompact(n) {
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return new Intl.NumberFormat().format(n || 0);
}
/**
* Format a number with full locale formatting.
* @param {number} n - Number to format
* @returns {string}
*/
export function fmtFull(n) {
return new Intl.NumberFormat().format(n || 0);
}
/**
* Format a cost value with dollar sign.
* @param {number} n - Cost value
* @returns {string}
*/
export function fmtCost(n) {
return `$${(n || 0).toFixed(2)}`;
}
/**
* Truncate a URL for compact display.
* @param {string} url - Full URL
* @param {number} max - Maximum characters (default: 50)
* @returns {string}
*/
export function truncateUrl(url, max = 50) {
if (!url) return "-";
try {
const parsed = new URL(url);
const display = parsed.hostname + parsed.pathname;
return display.length > max ? display.slice(0, max) + "…" : display;
} catch {
return url.length > max ? url.slice(0, max) + "…" : url;
}
}

39
src/shared/utils/index.js Normal file
View File

@@ -0,0 +1,39 @@
// Shared Utils - Export all
export { cn } from "./cn";
export * as api from "./api";
import { v4 as uuidv4 } from "uuid";
/**
* Generate unique ID (UUID v4)
* @returns {string} UUID v4 string
*/
export const generateId = uuidv4;
/**
* Extract error code from error message (401, 429, 503...)
* @param {string} lastError - Error message
* @returns {string|null} Error code or null
*/
export function getErrorCode(lastError) {
if (!lastError) return null;
const match = lastError.match(/\b([45]\d{2})\b/);
return match ? match[1] : "ERR";
}
/**
* Get relative time string (e.g. "5 min ago")
* @param {string} isoDate - ISO date string
* @returns {string} Relative time
*/
export function getRelativeTime(isoDate) {
if (!isoDate) return "";
const diff = Date.now() - new Date(isoDate).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}

View File

@@ -0,0 +1,54 @@
/**
* Structured Logger — Pino-based logger for OmniRoute
*
* Usage:
* import { logger } from "@/shared/utils/logger";
* const log = logger.child({ module: "proxy" });
* log.info({ model: "gpt-4o" }, "Request received");
* log.error({ err }, "Connection failed");
*
* In development, output is pretty-printed via pino-pretty.
* In production, output is structured JSON for log aggregation.
*/
import pino from "pino";
const isDev = process.env.NODE_ENV !== "production";
const baseConfig = {
level: process.env.LOG_LEVEL || (isDev ? "debug" : "info"),
base: { service: "omniroute" },
timestamp: pino.stdTimeFunctions.isoTime,
formatters: {
level(label) {
return { level: label };
},
},
};
// In development, use pino-pretty for human-readable output
const devTransport = isDev
? {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss.l",
ignore: "pid,hostname,service",
messageFormat: "[{module}] {msg}",
},
},
}
: {};
export const logger = pino({ ...baseConfig, ...devTransport });
/**
* Create a child logger with a module tag.
* @param {string} module - Module name for log context (e.g., "proxy", "db", "sse")
* @returns {pino.Logger}
*/
export function createLogger(module) {
return logger.child({ module });
}
export default logger;

View File

@@ -0,0 +1,18 @@
import { getConsistentMachineId } from "./machineId";
// Get machine ID using node-machine-id with salt
export async function getMachineId() {
return await getConsistentMachineId();
}
// Keep sync functions for backward compatibility but make them no-ops
// (Frontend sync is disabled - use backend sync instead)
export async function syncProviderDataToCloud(cloudUrl) {
console.log("Frontend sync is disabled. Use backend sync instead.");
return Promise.resolve(true);
}
export async function getProvidersNeedingRefresh() {
console.log("Frontend sync is disabled. Use backend sync instead.");
return Promise.resolve([]);
}

View File

@@ -0,0 +1,63 @@
import { machineIdSync } from "node-machine-id";
/**
* Get consistent machine ID using node-machine-id with salt
* This ensures the same physical machine gets the same ID across runs
*
* @param {string} salt - Optional salt to use (defaults to environment variable)
* @returns {Promise<string>} Machine ID (16-character base32)
*/
export async function getConsistentMachineId(salt = null) {
// For server-side, use node-machine-id with salt
const saltValue = salt || process.env.MACHINE_ID_SALT || "endpoint-proxy-salt";
try {
const rawMachineId = machineIdSync();
// Create consistent ID using salt
const crypto = await import("crypto");
const hashedMachineId = crypto
.createHash("sha256")
.update(rawMachineId + saltValue)
.digest("hex");
// Return only first 16 characters for brevity
return hashedMachineId.substring(0, 16);
} catch (error) {
console.log("Error getting machine ID:", error);
// Fallback to random ID if node-machine-id fails
return crypto.randomUUID
? crypto.randomUUID()
: "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
/**
* Get raw machine ID without hashing (for debugging purposes)
* @returns {Promise<string>} Raw machine ID
*/
export async function getRawMachineId() {
// For server-side, use raw node-machine-id
try {
return machineIdSync();
} catch (error) {
console.log("Error getting raw machine ID:", error);
// Fallback to random ID if node-machine-id fails
return crypto.randomUUID
? crypto.randomUUID()
: "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
}
/**
* Check if we're running in browser or server environment
* @returns {boolean} True if in browser, false if in server
*/
export function isBrowser() {
return typeof window !== "undefined";
}

View File

@@ -0,0 +1,52 @@
/**
* Normalize upstream error bodies to a JSON-safe payload.
* Accepts unknown/object/string inputs and guarantees an { error: { ... } } shape.
*/
export function toJsonErrorPayload(rawError, fallbackMessage = "Upstream provider error") {
const fallback = {
error: {
message: fallbackMessage,
type: "upstream_error",
code: "upstream_error",
},
};
if (rawError && typeof rawError === "object") {
const errorObj = rawError.error;
if (typeof errorObj === "string") {
return {
error: {
message: errorObj,
type: "upstream_error",
code: "upstream_error",
},
};
}
if (errorObj && typeof errorObj === "object") {
return rawError;
}
return { error: rawError };
}
if (typeof rawError === "string") {
const trimmed = rawError.trim();
if (!trimmed) {
return fallback;
}
try {
const parsed = JSON.parse(trimmed);
return toJsonErrorPayload(parsed, fallbackMessage);
} catch {
return {
error: {
message: trimmed,
type: "upstream_error",
code: "upstream_error",
},
};
}
}
return fallback;
}

View File

@@ -0,0 +1,93 @@
import { z } from "zod";
// ──── Provider Schemas ────
export const createProviderSchema = z.object({
provider: z.string().min(1).max(100),
apiKey: z.string().min(1).max(10000),
name: z.string().min(1).max(200),
priority: z.number().int().min(1).max(100).optional(),
globalPriority: z.number().int().min(1).max(100).nullable().optional(),
defaultModel: z.string().max(200).nullable().optional(),
testStatus: z.string().max(50).optional(),
});
// ──── API Key Schemas ────
export const createKeySchema = z.object({
name: z.string().min(1, "Name is required").max(200),
});
// ──── Combo Schemas ────
// A model entry can be a plain string (legacy) or an object with weight
const comboModelEntry = z.union([
z.string(),
z.object({
model: z.string().min(1),
weight: z.number().min(0).max(100).default(0),
}),
]);
// Per-combo config overrides
const comboConfigSchema = z
.object({
maxRetries: z.number().int().min(0).max(10).optional(),
retryDelayMs: z.number().int().min(0).max(60000).optional(),
timeoutMs: z.number().int().min(1000).max(600000).optional(),
healthCheckEnabled: z.boolean().optional(),
})
.optional();
export const createComboSchema = z.object({
name: z
.string()
.min(1, "Name is required")
.max(100)
.regex(/^[a-zA-Z0-9_/.-]+$/, "Name can only contain letters, numbers, -, _, / and ."),
models: z.array(comboModelEntry).optional().default([]),
strategy: z.enum(["priority", "weighted"]).optional().default("priority"),
config: comboConfigSchema,
});
// ──── Settings Schemas ────
export const updateSettingsSchema = z
.object({
newPassword: z.string().min(1).max(200).optional(),
currentPassword: z.string().max(200).optional(),
theme: z.string().max(50).optional(),
language: z.string().max(10).optional(),
requireLogin: z.boolean().optional(),
})
.passthrough(); // Allow extra fields for flexibility
// ──── Auth Schemas ────
export const loginSchema = z.object({
password: z.string().min(1, "Password is required").max(200),
});
// ──── Helper ────
/**
* Parse and validate request body with a Zod schema.
* Returns { success: true, data } or { success: false, error }.
*/
export function validateBody(schema, body) {
const result = schema.safeParse(body);
if (result.success) {
return { success: true, data: result.data };
}
const issues = Array.isArray(result.error?.issues) ? result.error.issues : [];
return {
success: false,
error: {
message: "Invalid request",
details: issues.map((e) => ({
field: e.path.join("."),
message: e.message,
})),
},
};
}