mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +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";
|
||||
Reference in New Issue
Block a user