mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
Search Playground (Phase 1): - Web Search as 10th endpoint in Playground with isolated SearchPlayground component - Endpoint selector moved first; Provider/Model/Send hidden when search selected - Provider dropdown via GET /api/search/providers, formatted results with cache indicator Search Tools page (Phase 2) at /dashboard/search-tools: - Split panel: SearchForm (left) with query, provider, filters + ResultsPanel (right) - Compare Providers: parallel queries with latency, cost, response size, URL overlap - Rerank Pipeline: model selector from /v1/models, results with position delta - Search History: last 10 searches from call_logs with replay - Sidebar entry under Debug section Backend: - GET /api/search/providers — list providers with auth guard + SEARCH_CREDENTIAL_FALLBACKS - GET /api/search/stats — cache stats, provider aggregates, recent searches (auth guard) - Add local provider_nodes routing for /v1/rerank (oMLX, vLLM support) Bug fixes (from F-27 PR #432): - Fix Brave news normalizer: data.results directly, not data.news.results - Enforce max_results truncation after normalization for all providers - Fix EndpointPageClient: use /api/search/providers instead of /api/v1/search - Add isAuthenticated() guards on /api/search/providers and /api/search/stats Response size metric in results meta bar and compare table. i18n: 30+ keys in search namespace (en.json)
383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
"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 OmniRouteLogo from "./OmniRouteLogo";
|
|
import Button from "./Button";
|
|
import { ConfirmModal } from "./Modal";
|
|
import CloudSyncStatus from "./CloudSyncStatus";
|
|
import { useTranslations } from "next-intl";
|
|
// Nav items use i18n keys resolved inside the component
|
|
const navItemDefs = [
|
|
{ href: "/dashboard", i18nKey: "home", icon: "home", exact: true },
|
|
{ href: "/dashboard/endpoint", i18nKey: "endpoints", icon: "api" },
|
|
{ href: "/dashboard/api-manager", i18nKey: "apiManager", icon: "vpn_key" },
|
|
{ href: "/dashboard/providers", i18nKey: "providers", icon: "dns" },
|
|
{ href: "/dashboard/combos", i18nKey: "combos", icon: "layers" },
|
|
{ href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" },
|
|
{ href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" },
|
|
{ href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
|
|
];
|
|
|
|
const cliItemDefs = [
|
|
{ href: "/dashboard/cli-tools", i18nKey: "cliToolsShort", icon: "terminal" },
|
|
{ href: "/dashboard/agents", i18nKey: "agents", icon: "smart_toy" },
|
|
];
|
|
|
|
const debugItemDefs = [
|
|
{ href: "/dashboard/translator", i18nKey: "translator", icon: "translate" },
|
|
{ href: "/dashboard/playground", i18nKey: "playground", icon: "science" },
|
|
{ href: "/dashboard/media", i18nKey: "media", icon: "auto_awesome" },
|
|
{ href: "/dashboard/search-tools", i18nKey: "searchTools", icon: "manage_search" },
|
|
];
|
|
|
|
const systemItemDefs = [
|
|
{ href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
|
|
{ href: "/dashboard/logs", i18nKey: "logs", icon: "description" },
|
|
{ href: "/dashboard/settings", i18nKey: "settings", icon: "settings" },
|
|
];
|
|
|
|
const helpItemDefs = [
|
|
{ href: "/docs", i18nKey: "docs", icon: "menu_book" },
|
|
{
|
|
href: "https://github.com/diegosouzapw/OmniRoute/issues",
|
|
i18nKey: "issues",
|
|
icon: "bug_report",
|
|
external: true,
|
|
},
|
|
];
|
|
|
|
export default function Sidebar({
|
|
onClose,
|
|
collapsed = false,
|
|
onToggleCollapse,
|
|
}: {
|
|
onClose?: any;
|
|
collapsed?: boolean;
|
|
onToggleCollapse?: any;
|
|
}) {
|
|
const pathname = usePathname();
|
|
const t = useTranslations("sidebar");
|
|
const tc = useTranslations("common");
|
|
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, exact) => {
|
|
if (exact) {
|
|
return pathname === href;
|
|
}
|
|
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);
|
|
};
|
|
|
|
// Resolve i18n keys → labels
|
|
const resolveItems = (defs) => defs.map((d) => ({ ...d, label: t(d.i18nKey) }));
|
|
const navItems = resolveItems(navItemDefs);
|
|
const cliItems = resolveItems(cliItemDefs);
|
|
const debugItems = resolveItems(debugItemDefs);
|
|
const systemItems = resolveItems(systemItemDefs);
|
|
const helpItems = resolveItems(helpItemDefs);
|
|
|
|
const renderNavLink = (item) => {
|
|
const active = !item.external && isActive(item.href, item.exact);
|
|
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 h-full 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-[#E54D5E] to-[#C93D4E] shrink-0">
|
|
<OmniRouteLogo size={20} className="text-white" />
|
|
</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)}
|
|
|
|
{/* CLI 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">
|
|
CLI
|
|
</p>
|
|
)}
|
|
{collapsed && <div className="border-t border-black/5 dark:border-white/5 mb-2" />}
|
|
{cliItems.map(renderNavLink)}
|
|
</div>
|
|
|
|
{/* 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>
|
|
|
|
{/* Cloud sync status indicator */}
|
|
<CloudSyncStatus collapsed={collapsed} />
|
|
|
|
{/* 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={t("restart")}
|
|
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 && t("restart")}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowShutdownModal(true)}
|
|
title={t("shutdown")}
|
|
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 && t("shutdown")}
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Shutdown Confirmation Modal */}
|
|
<ConfirmModal
|
|
isOpen={showShutdownModal}
|
|
onClose={() => setShowShutdownModal(false)}
|
|
onConfirm={handleShutdown}
|
|
title={t("shutdown")}
|
|
message={t("shutdownConfirm")}
|
|
confirmText={t("shutdown")}
|
|
cancelText={tc("cancel")}
|
|
variant="danger"
|
|
loading={isShuttingDown}
|
|
/>
|
|
|
|
{/* Restart Confirmation Modal */}
|
|
<ConfirmModal
|
|
isOpen={showRestartModal}
|
|
onClose={() => setShowRestartModal(false)}
|
|
onConfirm={handleRestart}
|
|
title={t("restart")}
|
|
message={t("restartConfirm")}
|
|
confirmText={t("restart")}
|
|
cancelText={tc("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,
|
|
};
|