mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
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:
81
src/shared/components/Avatar.js
Normal file
81
src/shared/components/Avatar.js
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/shared/components/Badge.js
Normal file
59
src/shared/components/Badge.js
Normal 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>
|
||||
);
|
||||
}
|
||||
73
src/shared/components/Button.js
Normal file
73
src/shared/components/Button.js
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/shared/components/Card.js
Normal file
112
src/shared/components/Card.js
Normal 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>
|
||||
);
|
||||
};
|
||||
194
src/shared/components/CursorAuthModal.js
Normal file
194
src/shared/components/CursorAuthModal.js
Normal 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,
|
||||
};
|
||||
162
src/shared/components/Footer.js
Normal file
162
src/shared/components/Footer.js
Normal 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>
|
||||
);
|
||||
}
|
||||
189
src/shared/components/Header.js
Normal file
189
src/shared/components/Header.js
Normal 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,
|
||||
};
|
||||
89
src/shared/components/Input.js
Normal file
89
src/shared/components/Input.js
Normal 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>
|
||||
);
|
||||
}
|
||||
389
src/shared/components/KiroAuthModal.js
Normal file
389
src/shared/components/KiroAuthModal.js
Normal 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'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'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'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,
|
||||
};
|
||||
98
src/shared/components/KiroOAuthWrapper.js
Normal file
98
src/shared/components/KiroOAuthWrapper.js
Normal 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,
|
||||
};
|
||||
205
src/shared/components/KiroSocialOAuthModal.js
Normal file
205
src/shared/components/KiroSocialOAuthModal.js
Normal 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,
|
||||
};
|
||||
72
src/shared/components/Loading.js
Normal file
72
src/shared/components/Loading.js
Normal 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} />;
|
||||
}
|
||||
}
|
||||
65
src/shared/components/ManualConfigModal.js
Normal file
65
src/shared/components/ManualConfigModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
189
src/shared/components/Modal.js
Normal file
189
src/shared/components/Modal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
377
src/shared/components/ModelSelectModal.js
Normal file
377
src/shared/components/ModelSelectModal.js
Normal 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,
|
||||
};
|
||||
527
src/shared/components/OAuthModal.js
Normal file
527
src/shared/components/OAuthModal.js
Normal 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,
|
||||
};
|
||||
208
src/shared/components/PricingModal.js
Normal file
208
src/shared/components/PricingModal.js
Normal 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>
|
||||
);
|
||||
}
|
||||
434
src/shared/components/ProxyConfigModal.js
Normal file
434
src/shared/components/ProxyConfigModal.js
Normal 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,
|
||||
};
|
||||
676
src/shared/components/ProxyLogger.js
Normal file
676
src/shared/components/ProxyLogger.js
Normal 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>
|
||||
);
|
||||
}
|
||||
909
src/shared/components/RequestLoggerV2.js
Normal file
909
src/shared/components/RequestLoggerV2.js
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/shared/components/SegmentedControl.js
Normal file
54
src/shared/components/SegmentedControl.js
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/shared/components/Select.js
Normal file
91
src/shared/components/Select.js
Normal 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>
|
||||
);
|
||||
}
|
||||
331
src/shared/components/Sidebar.js
Normal file
331
src/shared/components/Sidebar.js
Normal 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,
|
||||
};
|
||||
14
src/shared/components/ThemeProvider.js
Normal file
14
src/shared/components/ThemeProvider.js
Normal 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}</>;
|
||||
}
|
||||
46
src/shared/components/ThemeToggle.js
Normal file
46
src/shared/components/ThemeToggle.js
Normal 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>
|
||||
);
|
||||
}
|
||||
81
src/shared/components/Toggle.js
Normal file
81
src/shared/components/Toggle.js
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/shared/components/UsageAnalytics.js
Normal file
167
src/shared/components/UsageAnalytics.js
Normal 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>
|
||||
);
|
||||
}
|
||||
684
src/shared/components/UsageStats.js
Normal file
684
src/shared/components/UsageStats.js
Normal 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>
|
||||
);
|
||||
}
|
||||
913
src/shared/components/analytics/charts.js
Normal file
913
src/shared/components/analytics/charts.js
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/shared/components/analytics/index.js
Normal file
14
src/shared/components/analytics/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
SortIndicator,
|
||||
StatCard,
|
||||
ActivityHeatmap,
|
||||
DailyTrendChart,
|
||||
AccountDonut,
|
||||
ApiKeyDonut,
|
||||
ApiKeyTable,
|
||||
WeeklyPattern,
|
||||
MostActiveDay7d,
|
||||
WeeklySquares7d,
|
||||
ModelTable,
|
||||
UsageDetail,
|
||||
} from "./charts";
|
||||
31
src/shared/components/index.js
Normal file
31
src/shared/components/index.js
Normal 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";
|
||||
28
src/shared/components/layouts/AuthLayout.js
Normal file
28
src/shared/components/layouts/AuthLayout.js
Normal 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,
|
||||
};
|
||||
62
src/shared/components/layouts/DashboardLayout.js
Normal file
62
src/shared/components/layouts/DashboardLayout.js
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/shared/components/layouts/index.js
Normal file
3
src/shared/components/layouts/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Layout Components - Export all
|
||||
export { default as DashboardLayout } from "./DashboardLayout";
|
||||
export { default as AuthLayout } from "./AuthLayout";
|
||||
197
src/shared/constants/cliTools.js
Normal file
197
src/shared/constants/cliTools.js
Normal 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;
|
||||
};
|
||||
135
src/shared/constants/colors.js
Normal file
135
src/shared/constants/colors.js
Normal 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(),
|
||||
}
|
||||
);
|
||||
}
|
||||
55
src/shared/constants/config.js
Normal file
55
src/shared/constants/config.js
Normal 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";
|
||||
3
src/shared/constants/index.js
Normal file
3
src/shared/constants/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Shared Constants - Export all
|
||||
export * from "./colors";
|
||||
export * from "./config";
|
||||
35
src/shared/constants/models.js
Normal file
35
src/shared/constants/models.js
Normal 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 }))
|
||||
);
|
||||
598
src/shared/constants/pricing.js
Normal file
598
src/shared/constants/pricing.js
Normal 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;
|
||||
}
|
||||
295
src/shared/constants/providers.js
Normal file
295
src/shared/constants/providers.js
Normal 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"];
|
||||
2
src/shared/hooks/index.js
Normal file
2
src/shared/hooks/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// Shared Hooks - Export all
|
||||
export { useTheme } from "./useTheme";
|
||||
61
src/shared/hooks/useCopyToClipboard.js
Normal file
61
src/shared/hooks/useCopyToClipboard.js
Normal 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 };
|
||||
}
|
||||
59
src/shared/hooks/useTheme.js
Normal file
59
src/shared/hooks/useTheme.js
Normal 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,
|
||||
};
|
||||
}
|
||||
219
src/shared/services/backupService.js
Normal file
219
src/shared/services/backupService.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
359
src/shared/services/cliRuntime.js
Normal file
359
src/shared/services/cliRuntime.js
Normal 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);
|
||||
123
src/shared/services/cloudSyncScheduler.js
Normal file
123
src/shared/services/cloudSyncScheduler.js
Normal 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;
|
||||
}
|
||||
31
src/shared/services/initializeCloudSync.js
Normal file
31
src/shared/services/initializeCloudSync.js
Normal 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
92
src/shared/utils/api.js
Normal 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;
|
||||
97
src/shared/utils/apiKey.js
Normal file
97
src/shared/utils/apiKey.js
Normal 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
41
src/shared/utils/cloud.js
Normal 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
6
src/shared/utils/cn.js
Normal 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
36
src/shared/utils/cors.js
Normal 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 });
|
||||
}
|
||||
150
src/shared/utils/formatting.js
Normal file
150
src/shared/utils/formatting.js
Normal 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
39
src/shared/utils/index.js
Normal 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`;
|
||||
}
|
||||
54
src/shared/utils/logger.js
Normal file
54
src/shared/utils/logger.js
Normal 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;
|
||||
18
src/shared/utils/machine.js
Normal file
18
src/shared/utils/machine.js
Normal 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([]);
|
||||
}
|
||||
63
src/shared/utils/machineId.js
Normal file
63
src/shared/utils/machineId.js
Normal 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";
|
||||
}
|
||||
52
src/shared/utils/upstreamError.js
Normal file
52
src/shared/utils/upstreamError.js
Normal 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;
|
||||
}
|
||||
93
src/shared/validation/schemas.js
Normal file
93
src/shared/validation/schemas.js
Normal 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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user