mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
Refactor global styles and enhance MITM functionality
- Updated global CSS to implement a new brand color palette and improve light/dark theme consistency. - Enhanced the MitmServerCard component to provide clearer user feedback regarding admin privileges. - Filtered LLM combos in the CombosPage to ensure only relevant data is displayed. - Improved APIPageClient layout for better usability and visual consistency. - Added functionality to save and load DNS tool states in the MITM manager. - Updated OAuth configuration URLs for Qwen to reflect the new endpoint structure. - Refined tunnel management logic to improve reliability and user experience.
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
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",
|
||||
default: "bg-surface-2 text-text-muted",
|
||||
primary: "bg-brand-500/10 text-brand-600 dark:text-brand-300",
|
||||
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",
|
||||
@@ -42,7 +42,7 @@ export default function Badge({
|
||||
variant === "warning" && "bg-yellow-500",
|
||||
variant === "error" && "bg-red-500",
|
||||
variant === "info" && "bg-blue-500",
|
||||
variant === "primary" && "bg-primary",
|
||||
variant === "primary" && "bg-brand-500",
|
||||
variant === "default" && "bg-gray-500"
|
||||
)}
|
||||
/>
|
||||
@@ -52,4 +52,3 @@ export default function Badge({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
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",
|
||||
primary: "bg-brand-500 hover:bg-brand-600 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted",
|
||||
secondary: "bg-surface-2 hover:bg-surface-3 text-text-main border border-border disabled:opacity-50",
|
||||
outline: "border border-border text-text-main hover:bg-surface-2 hover:border-brand-500/40",
|
||||
ghost: "text-text-muted hover:bg-surface-2 hover:text-text-main",
|
||||
danger: "bg-red-500 hover:bg-red-600 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted",
|
||||
success: "bg-green-600 hover:bg-green-700 text-white shadow-sm disabled:bg-surface-3 disabled:text-text-muted",
|
||||
};
|
||||
|
||||
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",
|
||||
sm: "h-7 px-3 text-xs rounded-[8px]",
|
||||
md: "h-9 px-4 text-sm rounded-[10px]",
|
||||
lg: "h-11 px-6 text-sm rounded-[10px]",
|
||||
};
|
||||
|
||||
export default function Button({
|
||||
@@ -31,8 +32,8 @@ export default function Button({
|
||||
return (
|
||||
<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",
|
||||
"inline-flex items-center justify-center gap-2 font-semibold transition-all duration-150 ease-out cursor-pointer",
|
||||
"active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100",
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
fullWidth && "w-full",
|
||||
@@ -53,4 +54,3 @@ export default function Button({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function Card({
|
||||
action,
|
||||
padding = "md",
|
||||
hover = false,
|
||||
elev = false,
|
||||
className,
|
||||
...props
|
||||
}) {
|
||||
@@ -24,10 +25,9 @@ export default function Card({
|
||||
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",
|
||||
"bg-surface border border-border-subtle",
|
||||
elev ? "rounded-[14px] shadow-[var(--shadow-elev)]" : "rounded-[14px] shadow-[var(--shadow-soft)]",
|
||||
hover && "hover:shadow-[var(--shadow-warm)] hover:border-brand-500/30 transition-all cursor-pointer",
|
||||
paddings[padding],
|
||||
className
|
||||
)}
|
||||
@@ -37,7 +37,7 @@ export default function Card({
|
||||
<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">
|
||||
<div className="p-2 rounded-[10px] bg-bg text-text-muted">
|
||||
<span className="material-symbols-outlined text-[20px]">{icon}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -58,14 +58,12 @@ export default function Card({
|
||||
);
|
||||
}
|
||||
|
||||
// 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",
|
||||
"p-4 rounded-[10px]",
|
||||
"bg-bg border border-border-subtle",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -75,14 +73,13 @@ Card.Section = function CardSection({ children, className, ...props }) {
|
||||
);
|
||||
};
|
||||
|
||||
// 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]",
|
||||
"border-b border-border-subtle last:border-b-0",
|
||||
"hover:bg-surface-2/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -92,20 +89,18 @@ Card.Row = function CardRow({ children, className, ...props }) {
|
||||
);
|
||||
};
|
||||
|
||||
// Sub-component: List item with hover actions (macOS style)
|
||||
Card.ListItem = function CardListItem({
|
||||
children,
|
||||
Card.ListItem = function CardListItem({
|
||||
children,
|
||||
actions,
|
||||
className,
|
||||
...props
|
||||
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",
|
||||
"border-b border-border-subtle last:border-b-0",
|
||||
"hover:bg-surface-2/50 transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -119,4 +114,3 @@ Card.ListItem = function CardListItem({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
export default function Drawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
export default function Drawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
width = "md",
|
||||
className
|
||||
className
|
||||
}) {
|
||||
const widths = {
|
||||
sm: "w-[400px]",
|
||||
@@ -19,24 +19,18 @@ export default function Drawer({
|
||||
full: "w-full",
|
||||
};
|
||||
|
||||
// Lock body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
if (e.key === "Escape" && isOpen) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
@@ -47,40 +41,39 @@ export default function Drawer({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity cursor-pointer"
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[2px] fade-in cursor-pointer"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
|
||||
{/* Drawer panel */}
|
||||
<div className={cn(
|
||||
"absolute right-0 top-0 h-full bg-surface shadow-2xl flex flex-col",
|
||||
"animate-in slide-in-from-right duration-200",
|
||||
"border-l border-black/10 dark:border-white/10",
|
||||
"absolute right-0 top-0 h-full bg-surface flex flex-col",
|
||||
"shadow-[var(--shadow-elev)]",
|
||||
"slide-in-right",
|
||||
"border-l border-border-subtle",
|
||||
widths[width] || widths.md,
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-black/5 dark:border-white/5 flex-shrink-0">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border-subtle flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{title && (
|
||||
<h2 className="text-lg font-semibold text-text-main">
|
||||
{title}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main">{title}</h2>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
className="p-1.5 rounded-[10px] text-text-muted hover:bg-surface-2 hover:text-text-main transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from "next/link";
|
||||
import PropTypes from "prop-types";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import HeaderMenu from "@/shared/components/HeaderMenu";
|
||||
import ThemeToggle from "@/shared/components/ThemeToggle";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
@@ -181,9 +182,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
};
|
||||
|
||||
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">
|
||||
<header className="shrink-0 flex items-center justify-between gap-3 px-4 lg:px-8 pt-3 pb-2 border-b border-border-subtle bg-surface/60 backdrop-blur-xl lg:bg-transparent lg:backdrop-blur-none z-20">
|
||||
{/* Mobile menu button */}
|
||||
<div className="flex items-center gap-3 lg:hidden">
|
||||
<div className="flex items-center gap-3 lg:hidden shrink-0">
|
||||
{showMenuButton && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
@@ -194,8 +195,8 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Page title with breadcrumbs - desktop */}
|
||||
<div className="hidden lg:flex flex-col">
|
||||
{/* Page title with breadcrumbs */}
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
{breadcrumbs.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
@@ -226,7 +227,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
fallbackText={crumb.label.slice(0, 2).toUpperCase()}
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold text-text-main tracking-tight">
|
||||
<h1 className="text-base lg:text-2xl font-semibold text-text-main tracking-tight truncate">
|
||||
{translate(crumb.label)}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -238,16 +239,16 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && (
|
||||
<span className="material-symbols-outlined text-primary text-2xl">
|
||||
<span className="material-symbols-outlined text-primary text-xl lg:text-2xl">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
<h1 className="text-base lg:text-2xl font-semibold tracking-tight truncate">
|
||||
{translate(title)}
|
||||
</h1>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-text-muted">
|
||||
<p className="hidden lg:block text-sm text-text-muted truncate">
|
||||
{translate(description)}
|
||||
</p>
|
||||
)}
|
||||
@@ -255,8 +256,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Right actions - consolidated into dropdown menu */}
|
||||
<div className="flex items-center ml-auto">
|
||||
{/* Right actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<ThemeToggle />
|
||||
<HeaderMenu onLogout={handleLogout} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -38,17 +38,14 @@ export default function Input({
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
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",
|
||||
"w-full py-2.5 px-3 text-sm text-text-main bg-surface-2 rounded-[10px]",
|
||||
"border border-transparent placeholder-text-muted/70",
|
||||
"focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/40",
|
||||
"transition-all duration-150 ease-out 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"
|
||||
: "",
|
||||
error && "ring-1 ring-red-500 focus:ring-2 focus:ring-red-500/40 border-red-500/40",
|
||||
inputClassName
|
||||
)}
|
||||
{...props}
|
||||
@@ -66,4 +63,3 @@ export default function Input({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function Spinner({ size = "md", className }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"material-symbols-outlined animate-spin text-primary",
|
||||
"material-symbols-outlined animate-spin text-brand-500",
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
@@ -39,7 +39,7 @@ export function Skeleton({ className, ...props }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-pulse rounded-lg bg-border",
|
||||
"animate-pulse rounded-[10px] bg-surface-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -50,10 +50,10 @@ export function Skeleton({ className, ...props }) {
|
||||
// Card skeleton
|
||||
export function CardSkeleton() {
|
||||
return (
|
||||
<div className="p-6 rounded-xl border border-border bg-surface">
|
||||
<div className="p-6 rounded-[14px] border border-border-subtle bg-surface shadow-[var(--shadow-soft)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="size-10 rounded-lg" />
|
||||
<Skeleton className="size-10 rounded-[10px]" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16 mb-2" />
|
||||
<Skeleton className="h-3 w-20" />
|
||||
@@ -61,7 +61,6 @@ export function CardSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
// Default export
|
||||
export default function Loading({ type = "spinner", ...props }) {
|
||||
switch (type) {
|
||||
case "page":
|
||||
@@ -74,4 +73,3 @@ export default function Loading({ type = "spinner", ...props }) {
|
||||
return <Spinner {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export default function Modal({
|
||||
size = "md",
|
||||
closeOnOverlay = true,
|
||||
showCloseButton = true,
|
||||
showTrafficLights = true,
|
||||
className,
|
||||
}) {
|
||||
const sizes = {
|
||||
@@ -23,24 +24,18 @@ export default function Modal({
|
||||
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 = "";
|
||||
};
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
if (e.key === "Escape" && isOpen) onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
@@ -52,7 +47,7 @@ export default function Modal({
|
||||
<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"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-[2px] fade-in"
|
||||
onClick={closeOnOverlay ? onClose : undefined}
|
||||
/>
|
||||
|
||||
@@ -60,32 +55,32 @@ export default function Modal({
|
||||
<div
|
||||
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",
|
||||
"border border-border-subtle",
|
||||
"rounded-[14px] shadow-[var(--shadow-elev)]",
|
||||
"fade-in",
|
||||
sizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
{(title || showCloseButton) && (
|
||||
<div className="flex items-center justify-between p-2 border-b border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center justify-between p-2 border-b border-border-subtle">
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-2 mr-4">
|
||||
<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>
|
||||
{showTrafficLights && (
|
||||
<div className="flex items-center gap-2 mr-4 ml-2">
|
||||
<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 className="text-lg font-semibold text-text-main">
|
||||
{title}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-text-main">{title}</h2>
|
||||
)}
|
||||
</div>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
className="p-1.5 rounded-[10px] text-text-muted hover:bg-surface-2 hover:text-text-main transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
@@ -94,11 +89,11 @@ export default function Modal({
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 max-h-[calc(85vh-100px)] overflow-y-auto">{children}</div>
|
||||
<div className="p-6 max-h-[calc(85vh-100px)] overflow-y-auto custom-scrollbar">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
{footer && (
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-border-subtle">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
@@ -107,7 +102,6 @@ export default function Modal({
|
||||
);
|
||||
}
|
||||
|
||||
// Confirm Modal helper
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -140,4 +134,3 @@ export function ConfirmModal({
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,20 +30,20 @@ export default function NineRemotePromoModal({ isOpen, onClose }) {
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-[2px] fade-in" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-sm rounded-xl overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200 flex flex-col bg-surface border border-black/10 dark:border-white/10">
|
||||
<div className="relative w-full max-w-sm rounded-[14px] overflow-hidden shadow-[var(--shadow-elev)] fade-in flex flex-col bg-surface border border-border-subtle">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-black/5 dark:border-white/5">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-border-subtle">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: "#FF570A" }}>
|
||||
<div className="w-7 h-7 rounded-[8px] flex items-center justify-center bg-primary">
|
||||
<span className="material-symbols-outlined text-white text-base">terminal</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold uppercase tracking-wider" style={{ fontFamily: "monospace", color: "#FF570A" }}>9Remote</span>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-primary font-mono">9Remote</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-lg bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 text-text-muted hover:text-text-main transition-colors"
|
||||
className="p-1.5 rounded-[10px] text-text-muted hover:bg-surface-2 hover:text-text-main transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">close</span>
|
||||
</button>
|
||||
@@ -53,11 +53,8 @@ export default function NineRemotePromoModal({ isOpen, onClose }) {
|
||||
<div className="px-7 py-7 pb-9 flex flex-col gap-6">
|
||||
{/* Hero */}
|
||||
<div className="flex flex-col items-center gap-2 text-center mt-2">
|
||||
<div
|
||||
className="w-14 h-14 rounded-2xl flex items-center justify-center mb-1"
|
||||
style={{ background: "#FF570A", boxShadow: "rgba(255,87,10,0.35) 0px 8px 32px" }}
|
||||
>
|
||||
<span className="material-symbols-outlined text-white" style={{ fontSize: 30 }}>terminal</span>
|
||||
<div className="w-14 h-14 rounded-[14px] flex items-center justify-center mb-1 bg-primary shadow-[var(--shadow-warm)]">
|
||||
<span className="material-symbols-outlined text-white text-[30px]">terminal</span>
|
||||
</div>
|
||||
<h1 className="text-lg font-bold text-text-main tracking-tight">9Remote</h1>
|
||||
<p className="text-xs text-text-muted leading-5 max-w-[220px]">
|
||||
@@ -68,8 +65,8 @@ export default function NineRemotePromoModal({ isOpen, onClose }) {
|
||||
{/* Feature cards */}
|
||||
<div className="flex gap-2 w-full">
|
||||
{FEATURES.map(({ icon, label, desc }) => (
|
||||
<div key={label} className="flex-1 flex flex-col items-center gap-1.5 py-4 px-1 rounded-xl border border-black/10 dark:border-white/10 bg-bg-alt">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 22, color: "#ff6e33" }}>{icon}</span>
|
||||
<div key={label} className="flex-1 flex flex-col items-center gap-1.5 py-4 px-1 rounded-[10px] border border-border-subtle bg-surface-2">
|
||||
<span className="material-symbols-outlined text-primary text-[22px]">{icon}</span>
|
||||
<p className="text-xs font-semibold text-text-main">{label}</p>
|
||||
<p className="text-[10px] text-text-muted text-center leading-4">{desc}</p>
|
||||
</div>
|
||||
@@ -80,7 +77,7 @@ export default function NineRemotePromoModal({ isOpen, onClose }) {
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
{BULLETS.map(({ icon, text }) => (
|
||||
<div key={icon} className="flex items-center gap-2.5">
|
||||
<span className="material-symbols-outlined flex-shrink-0" style={{ fontSize: 16, color: "#ff6e33" }}>{icon}</span>
|
||||
<span className="material-symbols-outlined flex-shrink-0 text-primary text-[16px]">{icon}</span>
|
||||
<span className="text-xs text-text-muted">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -89,8 +86,7 @@ export default function NineRemotePromoModal({ isOpen, onClose }) {
|
||||
{/* CTA */}
|
||||
<button
|
||||
onClick={() => window.open(NINE_REMOTE_URL, "_blank")}
|
||||
className="w-full py-3.5 flex items-center justify-center gap-2 text-sm font-semibold text-white rounded-xl hover:opacity-90 active:scale-[0.98] transition-all"
|
||||
style={{ background: "#FF570A", boxShadow: "0 4px 16px rgba(255,87,10,0.35)" }}
|
||||
className="w-full py-3 flex items-center justify-center gap-2 text-sm font-semibold text-white rounded-[10px] bg-primary hover:bg-primary-hover shadow-[var(--shadow-warm)] active:scale-[0.98] transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-base">open_in_new</span>
|
||||
Get 9Remote
|
||||
|
||||
@@ -152,6 +152,10 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
|
||||
|
||||
setDeviceData(data);
|
||||
|
||||
// Auto-open verification URL in new tab
|
||||
const verifyUrl = data.verification_uri_complete || data.verification_uri;
|
||||
if (verifyUrl) window.open(verifyUrl, "_blank", "noopener,noreferrer");
|
||||
|
||||
// Pass extraData for Kiro (contains _clientId, _clientSecret)
|
||||
const extraData = provider === "kiro"
|
||||
? {
|
||||
|
||||
@@ -18,8 +18,8 @@ export default function SegmentedControl({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center p-1 rounded-lg overflow-x-auto",
|
||||
"bg-black/5 dark:bg-white/5",
|
||||
"inline-flex items-center p-1 rounded-[10px] overflow-x-auto",
|
||||
"bg-surface-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -28,10 +28,10 @@ export default function SegmentedControl({
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"shrink-0 px-4 rounded-md font-medium transition-all",
|
||||
"shrink-0 px-4 rounded-[8px] font-medium transition-all",
|
||||
sizes[size],
|
||||
value === option.value
|
||||
? "bg-white dark:bg-white/10 text-text-main shadow-sm"
|
||||
? "bg-surface text-text-main shadow-sm"
|
||||
: "text-text-muted hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -30,14 +30,12 @@ export default function Select({
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
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",
|
||||
"w-full py-2.5 px-3 pr-10 text-sm text-text-main",
|
||||
"bg-surface-2 border border-transparent rounded-[10px] appearance-none",
|
||||
"focus:outline-none focus:ring-2 focus:ring-brand-500/30 focus:border-brand-500/40",
|
||||
"transition-all duration-150 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"
|
||||
: "",
|
||||
error && "ring-1 ring-red-500 focus:ring-2 focus:ring-red-500/40 border-red-500/40",
|
||||
selectClassName
|
||||
)}
|
||||
{...props}
|
||||
@@ -67,4 +65,3 @@ export default function Select({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function Sidebar({ onClose }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="flex w-72 flex-col border-r border-black/5 dark:border-white/5 bg-vibrancy backdrop-blur-xl transition-colors duration-300 min-h-full">
|
||||
<aside className="flex w-72 flex-col border-r border-border-subtle bg-vibrancy backdrop-blur-xl transition-colors duration-300 min-h-full">
|
||||
{/* Traffic lights */}
|
||||
<div className="flex items-center gap-2 px-6 pt-5 pb-2">
|
||||
<div className="w-3 h-3 rounded-full bg-[#FF5F56]" />
|
||||
@@ -134,7 +134,7 @@ export default function Sidebar({ onClose }) {
|
||||
{/* Logo */}
|
||||
<div className="px-6 py-4 flex flex-col gap-2">
|
||||
<Link href="/dashboard" className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center size-9 rounded bg-linear-to-br from-[#f97815] to-[#c2590a]">
|
||||
<div className="flex items-center justify-center size-9 rounded-[10px] bg-gradient-to-br from-brand-500 to-brand-700 shadow-[var(--shadow-warm)]">
|
||||
<span className="material-symbols-outlined text-white text-[20px]">hub</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -178,10 +178,10 @@ export default function Sidebar({ onClose }) {
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -192,12 +192,12 @@ export default function Sidebar({ onClose }) {
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<span className="text-[13px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* System section */}
|
||||
<div className="pt-4 mt-2">
|
||||
<div className="pt-4 mt-2 space-y-1">
|
||||
<p className="px-4 text-xs font-semibold text-text-muted/60 uppercase tracking-wider mb-2">
|
||||
System
|
||||
</p>
|
||||
@@ -206,14 +206,14 @@ export default function Sidebar({ onClose }) {
|
||||
<button
|
||||
onClick={() => setMediaOpen((v) => !v)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
"w-full flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
pathname.startsWith("/dashboard/media-providers")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">perm_media</span>
|
||||
<span className="text-sm font-medium flex-1 text-left">Media Providers</span>
|
||||
<span className="text-[13px] font-medium flex-1 text-left">Media Providers</span>
|
||||
<span className="material-symbols-outlined text-[14px] transition-transform" style={{ transform: mediaOpen ? "rotate(180deg)" : "rotate(0deg)" }}>
|
||||
expand_more
|
||||
</span>
|
||||
@@ -229,7 +229,7 @@ export default function Sidebar({ onClose }) {
|
||||
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all group",
|
||||
pathname.startsWith(`/dashboard/media-providers/${kind.id}`)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">{kind.icon}</span>
|
||||
@@ -244,7 +244,7 @@ export default function Sidebar({ onClose }) {
|
||||
"flex items-center gap-3 px-4 py-1.5 rounded-lg transition-all group",
|
||||
pathname.startsWith(COMBINED_WEB_ITEM.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">{COMBINED_WEB_ITEM.icon}</span>
|
||||
@@ -259,10 +259,10 @@ export default function Sidebar({ onClose }) {
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -273,7 +273,7 @@ export default function Sidebar({ onClose }) {
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<span className="text-[13px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -286,10 +286,10 @@ export default function Sidebar({ onClose }) {
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
isActive(item.href)
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -300,7 +300,7 @@ export default function Sidebar({ onClose }) {
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
<span className="text-[13px] font-medium">{item.label}</span>
|
||||
</Link>
|
||||
) : null;
|
||||
})}
|
||||
@@ -310,10 +310,10 @@ export default function Sidebar({ onClose }) {
|
||||
href="/dashboard/profile"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2 rounded-lg transition-all group",
|
||||
"flex items-center gap-3 px-3 py-1.5 rounded-lg transition-all group",
|
||||
isActive("/dashboard/profile")
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-text-muted hover:bg-surface/50 hover:text-text-main"
|
||||
: "text-text-muted hover:bg-surface-2 hover:text-text-main"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -324,13 +324,13 @@ export default function Sidebar({ onClose }) {
|
||||
>
|
||||
settings
|
||||
</span>
|
||||
<span className="text-sm font-medium">Settings</span>
|
||||
<span className="text-[13px] font-medium">Settings</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Footer section */}
|
||||
<div className="p-3 border-t border-black/5 dark:border-white/5">
|
||||
<div className="p-3 border-t border-border-subtle">
|
||||
{/* Shutdown button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -4,24 +4,20 @@ 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 { isDark, toggleTheme } = 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"
|
||||
"text-text-muted hover:text-text-main",
|
||||
"hover:bg-surface-2 transition-colors"
|
||||
),
|
||||
card: cn(
|
||||
"flex items-center justify-center size-11 rounded-full",
|
||||
"bg-surface/60",
|
||||
"hover:bg-surface",
|
||||
"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",
|
||||
"backdrop-blur-md shadow-sm hover:shadow-[var(--shadow-warm)]",
|
||||
"text-text-muted hover:text-brand-500",
|
||||
"transition-all group"
|
||||
),
|
||||
};
|
||||
@@ -44,4 +40,3 @@ export default function ThemeToggle({ className, variant = "default" }) {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,27 +12,13 @@ export default function Toggle({
|
||||
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",
|
||||
},
|
||||
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);
|
||||
}
|
||||
if (!disabled && onChange) onChange(!checked);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -52,10 +38,8 @@ export default function Toggle({
|
||||
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",
|
||||
"focus:outline-none focus:ring-2 focus:ring-brand-500/30",
|
||||
checked ? "bg-brand-500" : "bg-surface-3",
|
||||
sizes[size].track,
|
||||
disabled && "cursor-not-allowed"
|
||||
)}
|
||||
@@ -73,18 +57,13 @@ export default function Toggle({
|
||||
{(label || description) && (
|
||||
<div className="flex flex-col">
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-text-main">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-main">{label}</span>
|
||||
)}
|
||||
{description && (
|
||||
<span className="text-xs text-text-muted">
|
||||
{description}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ function RecentRequests({ requests = [] }) {
|
||||
<div className="flex-1 flex items-center justify-center text-text-muted text-sm">No requests yet.</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<table className="w-full min-w-[420px] border-collapse text-xs">
|
||||
<table className="w-full min-w-[300px] border-collapse text-xs">
|
||||
<thead className="sticky top-0 bg-bg z-10">
|
||||
<tr className="border-b border-border">
|
||||
<th className="py-1.5 text-left font-semibold text-text-muted w-2"></th>
|
||||
@@ -181,7 +181,7 @@ const PERIODS = [
|
||||
{ value: "60d", label: "60D" },
|
||||
];
|
||||
|
||||
export default function UsageStats() {
|
||||
export default function UsageStats({ period: periodProp, setPeriod: setPeriodProp, hidePeriodSelector = false } = {}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -194,7 +194,9 @@ export default function UsageStats() {
|
||||
const [tableView, setTableView] = useState("model");
|
||||
const [viewMode, setViewMode] = useState("costs");
|
||||
const [providers, setProviders] = useState([]);
|
||||
const [period, setPeriod] = useState("7d");
|
||||
const [periodLocal, setPeriodLocal] = useState("7d");
|
||||
const period = periodProp ?? periodLocal;
|
||||
const setPeriod = setPeriodProp ?? setPeriodLocal;
|
||||
|
||||
// Fetch connected providers once, deduplicate by provider type
|
||||
// Always include noAuth free providers (e.g. opencode) regardless of connections
|
||||
@@ -398,24 +400,26 @@ export default function UsageStats() {
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{/* Period selector */}
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:self-end">
|
||||
<div className="grid flex-1 grid-cols-4 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:flex sm:flex-none">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setPeriod(p.value)}
|
||||
disabled={fetching}
|
||||
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${period === p.value ? "bg-primary text-white shadow-sm" : "text-text-muted hover:bg-bg-hover hover:text-text"}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Period selector (hidden when controlled by parent) */}
|
||||
{!hidePeriodSelector && (
|
||||
<div className="flex w-full items-center gap-2 sm:w-auto sm:self-end">
|
||||
<div className="grid flex-1 grid-cols-4 items-center gap-1 rounded-lg border border-border bg-bg-subtle p-1 sm:flex sm:flex-none">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
onClick={() => setPeriod(p.value)}
|
||||
disabled={fetching}
|
||||
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${period === p.value ? "bg-primary text-white shadow-sm" : "text-text-muted hover:bg-bg-hover hover:text-text"}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{fetching && (
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted animate-spin">progress_activity</span>
|
||||
)}
|
||||
</div>
|
||||
{fetching && (
|
||||
<span className="material-symbols-outlined text-[16px] text-text-muted animate-spin">progress_activity</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overview cards */}
|
||||
{loading ? spinner : <OverviewCards stats={stats} />}
|
||||
|
||||
@@ -91,7 +91,9 @@ export default function DashboardLayout({ children }) {
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300">
|
||||
<main className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300 isolate">
|
||||
{/* Faint grid background */}
|
||||
<div className="landing-grid absolute inset-0 pointer-events-none -z-10" aria-hidden="true" />
|
||||
<Header key={pathname} onMenuClick={() => setSidebarOpen(true)} />
|
||||
<div className={`flex-1 overflow-y-auto custom-scrollbar ${pathname === "/dashboard/basic-chat" ? "" : "p-6 lg:p-10"} ${pathname === "/dashboard/basic-chat" ? "flex flex-col overflow-hidden" : ""}`}>
|
||||
<div className={`${pathname === "/dashboard/basic-chat" ? "flex-1 w-full h-full flex flex-col" : "max-w-7xl mx-auto"}`}>{children}</div>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
// Free Providers (kiro first, iflow last)
|
||||
export const FREE_PROVIDERS = {
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", serviceKinds: ["llm", "tts", "stt"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Gemini CLI is designed exclusively for Gemini CLI. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans." },
|
||||
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35", website: "https://kiro.dev", notice: { signupUrl: "https://kiro.dev" } },
|
||||
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", website: "https://chat.qwen.ai", notice: { signupUrl: "https://chat.qwen.ai" }, serviceKinds: ["llm", "tts", "stt"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } },
|
||||
"gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Gemini CLI is designed exclusively for Gemini CLI. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://github.com/google-gemini/gemini-cli", notice: { signupUrl: "https://github.com/google-gemini/gemini-cli" } },
|
||||
// gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" },
|
||||
// codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" },
|
||||
// qoder: { id: "qoder", alias: "qd", name: "Qoder AI", icon: "water_drop", color: "#EC4899" },
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" },
|
||||
iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1", website: "https://iflow.cn", notice: { signupUrl: "https://iflow.cn" } },
|
||||
opencode: { id: "opencode", alias: "oc", name: "OpenCode Free", icon: "terminal", color: "#E87040", textIcon: "OC", noAuth: true, passthroughModels: true, modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" } },
|
||||
};
|
||||
|
||||
@@ -42,14 +42,14 @@ export const THINKING_CONFIG = {
|
||||
|
||||
// OAuth Providers
|
||||
export const OAUTH_PROVIDERS = {
|
||||
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" },
|
||||
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans." },
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." } },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] } },
|
||||
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA" },
|
||||
claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757", website: "https://claude.ai", notice: { signupUrl: "https://claude.ai" } },
|
||||
antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://antigravity.google", notice: { signupUrl: "https://antigravity.google" } },
|
||||
codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." }, website: "https://chatgpt.com/codex", notice: { signupUrl: "https://chatgpt.com/codex" } },
|
||||
github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] }, website: "https://github.com/features/copilot", notice: { signupUrl: "https://github.com/features/copilot" } },
|
||||
cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA", website: "https://cursor.com", notice: { signupUrl: "https://cursor.com" } },
|
||||
// "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" },
|
||||
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC" },
|
||||
cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL" },
|
||||
kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC", website: "https://kilocode.ai", notice: { signupUrl: "https://kilocode.ai" } },
|
||||
cline: { id: "cline", alias: "cl", name: "Cline", icon: "smart_toy", color: "#5B9BD5", textIcon: "CL", website: "https://cline.bot", notice: { signupUrl: "https://cline.bot" } },
|
||||
// opencode: { id: "opencode", alias: "oc", name: "OpenCode", icon: "terminal", color: "#E87040", textIcon: "OC" },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,112 +1,93 @@
|
||||
import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
|
||||
import { enableTunnel, isTunnelManuallyDisabled, isTunnelReconnecting } from "@/lib/tunnel/tunnelManager";
|
||||
import { killCloudflared, isCloudflaredRunning, ensureCloudflared } from "@/lib/tunnel/cloudflared";
|
||||
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
|
||||
import os from "os";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
|
||||
import {
|
||||
enableTunnel, enableTailscale,
|
||||
isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting,
|
||||
getTunnelService, getTailscaleService,
|
||||
} from "@/lib/tunnel/tunnelManager";
|
||||
import { killCloudflared, isCloudflaredRunning, ensureCloudflared } from "@/lib/tunnel/cloudflared";
|
||||
import { isTailscaleRunning } from "@/lib/tunnel/tailscale";
|
||||
import { loadState } from "@/lib/tunnel/state";
|
||||
import { checkInternet, probeUrlAlive } from "@/lib/tunnel/networkProbe";
|
||||
import {
|
||||
RESTART_COOLDOWN_MS, NETWORK_SETTLE_MS,
|
||||
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS,
|
||||
} from "@/lib/tunnel/tunnelConfig";
|
||||
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
|
||||
|
||||
import os from "os";
|
||||
|
||||
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context.
|
||||
// Must run before any MITM function is called.
|
||||
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
|
||||
(function bootstrapMitm() {
|
||||
// 1. Resolve server.js path from real ESM __filename (not bundled path)
|
||||
if (!process.env.MITM_SERVER_PATH) {
|
||||
try {
|
||||
const thisFile = fileURLToPath(import.meta.url);
|
||||
const appSrc = dirname(dirname(thisFile)); // src/
|
||||
const appSrc = dirname(dirname(thisFile));
|
||||
const candidate = join(appSrc, "mitm", "server.js");
|
||||
if (existsSync(candidate)) {
|
||||
process.env.MITM_SERVER_PATH = candidate;
|
||||
}
|
||||
if (existsSync(candidate)) process.env.MITM_SERVER_PATH = candidate;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 2. Inject DB functions so manager.js (CJS) can save/load settings
|
||||
// without dynamic import issues inside webpack bundles
|
||||
try {
|
||||
initDbHooks(getSettings, updateSettings);
|
||||
} catch { /* ignore */ }
|
||||
try { initDbHooks(getSettings, updateSettings); } catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
// Multiple modules register SIGINT/SIGTERM handlers legitimately
|
||||
process.setMaxListeners(20);
|
||||
|
||||
// Use global to survive Next.js hot reload — prevents duplicate intervals
|
||||
// Survive Next.js hot reload
|
||||
const g = global.__appSingleton ??= {
|
||||
signalHandlersRegistered: false,
|
||||
watchdogInterval: null,
|
||||
networkMonitorInterval: null,
|
||||
lastNetworkFingerprint: null,
|
||||
lastWatchdogTick: Date.now(),
|
||||
lastTunnelRestartAt: 0,
|
||||
tunnelRestartInProgress: false,
|
||||
mitmStartInProgress: false,
|
||||
};
|
||||
|
||||
const WATCHDOG_INTERVAL_MS = 60000;
|
||||
const NETWORK_CHECK_INTERVAL_MS = 5000;
|
||||
const NETWORK_RESTART_COOLDOWN_MS = 30000;
|
||||
|
||||
/**
|
||||
* Initialize app on startup
|
||||
* - Cleanup stale data
|
||||
* - Auto-reconnect tunnel if previously enabled
|
||||
* - Register shutdown handler to kill cloudflared
|
||||
* - Start watchdog to recover tunnel after sleep/wake
|
||||
*/
|
||||
export async function initializeApp() {
|
||||
try {
|
||||
await cleanupProviderConnections();
|
||||
|
||||
// Auto-reconnect tunnel if it was enabled before restart
|
||||
const settings = await getSettings();
|
||||
if (settings.tunnelEnabled && !isCloudflaredRunning()) {
|
||||
console.log("[InitApp] Tunnel was enabled, auto-reconnecting...");
|
||||
try {
|
||||
await enableTunnel();
|
||||
console.log("[InitApp] Tunnel reconnected");
|
||||
} catch (error) {
|
||||
console.log("[InitApp] Tunnel reconnect failed:", error.message);
|
||||
}
|
||||
|
||||
// Auto-resume tunnel
|
||||
if (settings.tunnelEnabled) {
|
||||
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
|
||||
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
|
||||
}
|
||||
|
||||
// Auto-resume tailscale
|
||||
if (settings.tailscaleEnabled) {
|
||||
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
|
||||
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
|
||||
}
|
||||
|
||||
// Kill cloudflared on process exit (register once only)
|
||||
if (!g.signalHandlersRegistered) {
|
||||
const cleanup = () => {
|
||||
try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
|
||||
killCloudflared();
|
||||
process.exit();
|
||||
};
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
process.on("exit", () => { try { removeAllDNSEntriesSync(); } catch { /* ignore */ } });
|
||||
g.signalHandlersRegistered = true;
|
||||
}
|
||||
|
||||
// Pre-download cloudflared binary in background
|
||||
ensureCloudflared().catch(() => {});
|
||||
|
||||
// Watchdog: recover tunnel after process crash
|
||||
startWatchdog();
|
||||
|
||||
// Network monitor: detect sleep/wake + network changes → restart tunnel
|
||||
startNetworkMonitor();
|
||||
|
||||
// Auto-start MITM if it was enabled before restart
|
||||
autoStartMitm();
|
||||
} catch (error) {
|
||||
console.error("[InitApp] Error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-start MITM if it was enabled before restart */
|
||||
async function autoStartMitm() {
|
||||
if (g.mitmStartInProgress) return;
|
||||
g.mitmStartInProgress = true;
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (!settings.mitmEnabled) return;
|
||||
|
||||
const mitmStatus = await getMitmStatus();
|
||||
if (mitmStatus.running) return;
|
||||
|
||||
@@ -116,13 +97,18 @@ async function autoStartMitm() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Need an active API key
|
||||
const keys = await getApiKeys();
|
||||
const activeKey = keys.find(k => k.isActive !== false);
|
||||
|
||||
console.log("[InitApp] MITM was enabled, auto-starting...");
|
||||
await startMitm(activeKey?.key || "sk_9router", password);
|
||||
console.log("[InitApp] MITM auto-started");
|
||||
try {
|
||||
await restoreToolDNS(password);
|
||||
console.log("[InitApp] DNS restored from saved state");
|
||||
} catch (e) {
|
||||
console.log("[InitApp] DNS restore failed:", e.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[InitApp] MITM auto-start failed:", err.message);
|
||||
} finally {
|
||||
@@ -130,34 +116,72 @@ async function autoStartMitm() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Periodically check tunnel process health and reconnect if crashed */
|
||||
// ─── Safe restart (4 guards: spawn / cooldown / alive / internet) ────────────
|
||||
|
||||
async function safeRestartTunnel(reason) {
|
||||
const svc = getTunnelService();
|
||||
const settings = await getSettings();
|
||||
if (!settings.tunnelEnabled) return;
|
||||
if (svc.cancelToken.cancelled) return;
|
||||
if (svc.spawnInProgress) return;
|
||||
if (Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) return;
|
||||
|
||||
// Alive check: process up + URL responds → skip
|
||||
if (isCloudflaredRunning()) {
|
||||
const state = loadState();
|
||||
const publicUrl = state?.shortId ? `https://r${state.shortId}.9router.com` : null;
|
||||
if (publicUrl && await probeUrlAlive(publicUrl)) return;
|
||||
}
|
||||
|
||||
if (!await checkInternet()) return;
|
||||
|
||||
console.log(`[Tunnel] safeRestart (${reason})`);
|
||||
try {
|
||||
await enableTunnel();
|
||||
svc.lastRestartAt = Date.now();
|
||||
console.log("[Tunnel] restart success");
|
||||
} catch (err) {
|
||||
console.log("[Tunnel] restart failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function safeRestartTailscale(reason) {
|
||||
const svc = getTailscaleService();
|
||||
const settings = await getSettings();
|
||||
if (!settings.tailscaleEnabled) return;
|
||||
if (svc.cancelToken.cancelled) return;
|
||||
if (svc.spawnInProgress) return;
|
||||
if (Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) return;
|
||||
|
||||
if (isTailscaleRunning() && settings.tailscaleUrl) {
|
||||
if (await probeUrlAlive(settings.tailscaleUrl)) return;
|
||||
}
|
||||
|
||||
if (!await checkInternet()) return;
|
||||
|
||||
console.log(`[Tailscale] safeRestart (${reason})`);
|
||||
try {
|
||||
await enableTailscale();
|
||||
svc.lastRestartAt = Date.now();
|
||||
console.log("[Tailscale] restart success");
|
||||
} catch (err) {
|
||||
console.log("[Tailscale] restart failed:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Watchdog: 60s tick check both services ──────────────────────────────────
|
||||
|
||||
function startWatchdog() {
|
||||
if (g.watchdogInterval) return;
|
||||
g.watchdogInterval = setInterval(async () => {
|
||||
try {
|
||||
if (isTunnelManuallyDisabled()) return;
|
||||
if (isTunnelReconnecting()) return;
|
||||
if (g.tunnelRestartInProgress) return;
|
||||
const settings = await getSettings();
|
||||
if (!settings.tunnelEnabled) return;
|
||||
if (isCloudflaredRunning()) return;
|
||||
console.log("[Watchdog] Tunnel process is down, attempting recovery...");
|
||||
g.tunnelRestartInProgress = true;
|
||||
try {
|
||||
await enableTunnel();
|
||||
console.log("[Watchdog] Tunnel recovered");
|
||||
} finally {
|
||||
g.tunnelRestartInProgress = false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("[Watchdog] Recovery failed:", err.message);
|
||||
}
|
||||
g.watchdogInterval = setInterval(() => {
|
||||
safeRestartTunnel("watchdog").catch(() => {});
|
||||
safeRestartTailscale("watchdog").catch(() => {});
|
||||
}, WATCHDOG_INTERVAL_MS);
|
||||
|
||||
if (g.watchdogInterval.unref) g.watchdogInterval.unref();
|
||||
}
|
||||
|
||||
/** Get network fingerprint from active interfaces (IPv4 only) */
|
||||
// ─── Network monitor: detect IPv4 fingerprint change + sleep/wake ────────────
|
||||
|
||||
function getNetworkFingerprint() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const active = [];
|
||||
@@ -172,7 +196,6 @@ function getNetworkFingerprint() {
|
||||
return active.sort().join("|");
|
||||
}
|
||||
|
||||
/** Monitor network changes + sleep/wake → kill and reconnect tunnel */
|
||||
function startNetworkMonitor() {
|
||||
if (g.networkMonitorInterval) return;
|
||||
|
||||
@@ -181,10 +204,6 @@ function startNetworkMonitor() {
|
||||
|
||||
g.networkMonitorInterval = setInterval(async () => {
|
||||
try {
|
||||
if (isTunnelManuallyDisabled()) return;
|
||||
const settings = await getSettings();
|
||||
if (!settings.tunnelEnabled) return;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = now - g.lastWatchdogTick;
|
||||
g.lastWatchdogTick = now;
|
||||
@@ -194,31 +213,17 @@ function startNetworkMonitor() {
|
||||
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 3;
|
||||
|
||||
if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
|
||||
|
||||
if (!networkChanged && !wasSleep) return;
|
||||
|
||||
// Skip if restart already in progress or restarted recently
|
||||
if (g.tunnelRestartInProgress) return;
|
||||
if (isTunnelReconnecting()) return;
|
||||
if (now - g.lastTunnelRestartAt < NETWORK_RESTART_COOLDOWN_MS) return;
|
||||
// Wait for DHCP/DNS to settle before probing
|
||||
await new Promise((r) => setTimeout(r, NETWORK_SETTLE_MS));
|
||||
|
||||
const reason = wasSleep && networkChanged ? "sleep/wake + network change"
|
||||
: wasSleep ? "sleep/wake" : "network change";
|
||||
console.log(`[NetworkMonitor] ${reason} detected, restarting tunnel...`);
|
||||
|
||||
g.tunnelRestartInProgress = true;
|
||||
g.lastTunnelRestartAt = now;
|
||||
try {
|
||||
killCloudflared();
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
await enableTunnel();
|
||||
console.log("[NetworkMonitor] Tunnel restarted");
|
||||
g.lastNetworkFingerprint = getNetworkFingerprint();
|
||||
} finally {
|
||||
g.tunnelRestartInProgress = false;
|
||||
}
|
||||
const reason = wasSleep && networkChanged ? "sleep+netchange"
|
||||
: wasSleep ? "sleep" : "netchange";
|
||||
safeRestartTunnel(reason).catch(() => {});
|
||||
safeRestartTailscale(reason).catch(() => {});
|
||||
} catch (err) {
|
||||
console.log("[NetworkMonitor] Tunnel restart failed:", err.message);
|
||||
console.log("[NetworkMonitor] error:", err.message);
|
||||
}
|
||||
}, NETWORK_CHECK_INTERVAL_MS);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user