Files
OmniRoute/src/shared/components/Header.tsx
Hernan Javier Ardila Sanchez fd26e601a2 fix(dashboard): use lightweight ping endpoint for MaintenanceBanner (fixes #3040) (#3043)
Integrated into release/v3.8.8. Applied review fixes: moved the SELECT 1 into a pingDb() db helper (no raw SQL in route, Hard Rule #5) + the 503 catch no longer leaks err.message (Hard Rule #12). Thanks @herjarsa!
2026-06-01 14:30:17 -03:00

285 lines
10 KiB
TypeScript

"use client";
import { useSyncExternalStore } from "react";
import { usePathname, useRouter } from "next/navigation";
const subscribePlatform = () => () => {};
const getPlatformIsMac = () => {
if (typeof navigator === "undefined") return false;
const platform = navigator.platform || navigator.userAgent;
return /Mac|iPhone|iPad|iPod/.test(platform);
};
const getPlatformIsMacServer = () => false;
import ThemeToggle from "./ThemeToggle";
import TokenHealthBadge from "./TokenHealthBadge";
import DegradationBadge from "./DegradationBadge";
import LanguageSelector from "./LanguageSelector";
import ProviderIcon from "./ProviderIcon";
import { useTranslations } from "next-intl";
import {
OAUTH_PROVIDERS,
APIKEY_PROVIDERS,
NOAUTH_PROVIDERS,
CLAUDE_CODE_COMPATIBLE_PREFIX,
OPENAI_COMPATIBLE_PREFIX,
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
import {
SIDEBAR_SECTIONS,
getSectionItems,
type SidebarItemDefinition,
type HideableSidebarItemId,
} from "@/shared/constants/sidebarVisibility";
import { useIsElectron } from "@/shared/hooks/useElectron";
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
// Map sidebar item id → header description i18n key
// "omni-skills" is an extended key for the /dashboard/omni-skills route (graceful fallback during deploy)
const HEADER_DESCRIPTIONS: Partial<Record<HideableSidebarItemId | "omni-skills", string>> = {
home: "homeDescription",
endpoints: "endpointDescription",
"api-manager": "apiManagerDescription",
providers: "providerDescription",
combos: "comboDescription",
batch: "batchDescription",
costs: "costsDescription",
analytics: "analyticsDescription",
cache: "cacheDescription",
quota: "limitsDescription",
runtime: "runtimeDescription",
media: "mediaDescription",
"cli-code": "cliToolsDescription",
"cli-agents": "agentsDescription",
"acp-agents": "agentsDescription",
"cloud-agents": "cloudAgentsDescription",
memory: "memoryDescription",
skills: "skillsDescription",
"agent-skills": "agentSkillsDescription",
"omni-skills": "omniSkillsDescription",
settings: "settingsDescription",
"context-caveman": "contextCavemanDescription",
"context-rtk": "contextRtkDescription",
"context-combos": "contextCombosDescription",
translator: "translatorDescription",
playground: "playgroundDescription",
"search-tools": "searchToolsDescription",
logs: "logsDescription",
audit: "auditDescription",
webhooks: "webhooksDescription",
health: "healthDescription",
proxy: "proxyDescription",
changelog: "changelogDescription",
// Protocols
mcp: "mcpDescription",
a2a: "a2aDescription",
"api-endpoints": "apiEndpointsDescription",
// Agents & AI sub-pages
"batch-files": "batchFilesDescription",
// Analytics sub-pages
"analytics-evals": "analyticsEvalsDescription",
"analytics-search": "analyticsSearchDescription",
"analytics-utilization": "analyticsUtilizationDescription",
"analytics-combo-health": "analyticsComboHealthDescription",
"analytics-compression": "analyticsCompressionDescription",
// Costs sub-pages
"costs-budget": "costsBudgetDescription",
"costs-pricing": "costsPricingDescription",
// Logs sub-pages
"logs-proxy": "logsProxyDescription",
"logs-console": "logsConsoleDescription",
"logs-activity": "logsActivityDescription",
// Audit sub-pages
"audit-mcp": "auditMcpDescription",
// Settings sub-pages
"settings-general": "settingsGeneralDescription",
"settings-appearance": "settingsAppearanceDescription",
"settings-ai": "settingsAiDescription",
"settings-security": "settingsSecurityDescription",
"settings-routing": "settingsRoutingDescription",
"settings-resilience": "settingsResilienceDescription",
"settings-advanced": "settingsAdvancedDescription",
// Proxy sub-pages
"mitm-proxy": "mitmProxyDescription",
"1proxy": "oneProxyDescription",
};
// Build href → sidebar item lookup (non-external items only)
const sidebarByHref = new Map<string, SidebarItemDefinition>();
for (const section of SIDEBAR_SECTIONS) {
for (const item of getSectionItems(section)) {
if (!item.external) sidebarByHref.set(item.href, item);
}
}
function getSidebarItem(pathname: string): SidebarItemDefinition | undefined {
const exact = sidebarByHref.get(pathname);
if (exact) return exact;
// Longest prefix match
let best: SidebarItemDefinition | undefined;
let bestLen = 0;
for (const [href, item] of sidebarByHref) {
if (pathname.startsWith(href) && href.length > bestLen) {
best = item;
bestLen = href.length;
}
}
return best;
}
type HeaderProps = {
onMenuClick?: () => void;
onOpenCommandPalette?: () => void;
showMenuButton?: boolean;
};
type PageInfo = {
title: string;
description: string;
icon?: string;
providerId?: string;
};
function usePageInfo(pathname: string | null): PageInfo {
const ts = useTranslations("sidebar");
const th = useTranslations("header");
if (!pathname) return { title: "", description: "" };
// Special: provider detail page /dashboard/providers/[id]
const providerMatch = pathname.match(/\/providers\/([^/]+)$/);
if (providerMatch) {
const pid = providerMatch[1];
const info = OAUTH_PROVIDERS[pid] || NOAUTH_PROVIDERS[pid] || APIKEY_PROVIDERS[pid];
if (info) return { title: info.name, description: "", providerId: info.id };
if (pid.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX))
return { title: "CC Compatible", description: "", providerId: "claude" };
if (pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
return { title: th("openaiCompatible"), description: "", providerId: "oai-cc" };
if (pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
return { title: th("anthropicCompatible"), description: "", providerId: "anthropic-m" };
}
// Derive from sidebar
const item = getSidebarItem(pathname);
if (item) {
const descKey = HEADER_DESCRIPTIONS[item.id];
return {
title: ts(item.i18nKey),
description: descKey ? th(descKey) : "",
icon: item.icon,
};
}
return { title: "", description: "" };
}
export default function Header({
onMenuClick,
onOpenCommandPalette,
showMenuButton = true,
}: HeaderProps) {
const isMac = useSyncExternalStore(subscribePlatform, getPlatformIsMac, getPlatformIsMacServer);
const pathname = usePathname();
const router = useRouter();
const isElectron = useIsElectron();
const t = useTranslations("header");
const { title, description, icon, providerId } = usePageInfo(pathname);
const isMacElectron =
isElectron &&
typeof window !== "undefined" &&
(window as any).electronAPI?.platform === "darwin";
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="sticky top-0 z-10 flex items-center justify-between border-b border-black/5 bg-bg px-8 py-4 dark:border-white/5"
style={{
paddingTop: isMacElectron ? "calc(1rem + var(--desktop-safe-top))" : undefined,
}}
>
{/* 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 icon - desktop */}
<div className="hidden lg:flex items-center gap-3">
{(icon || providerId) && (
<div className="flex items-center justify-center size-9 rounded-lg bg-primary/10 shrink-0">
{icon ? (
<span className="material-symbols-outlined text-primary text-[20px]">{icon}</span>
) : (
providerId && <ProviderIcon providerId={providerId} size={22} type="color" />
)}
</div>
)}
{title && (
<div>
<h1 className="text-xl font-semibold text-text-main tracking-tight">{title}</h1>
{description && <p className="text-xs text-text-muted mt-0.5">{description}</p>}
</div>
)}
</div>
{/* Right actions */}
<div className="flex items-center gap-3 ml-auto">
{onOpenCommandPalette && (
<>
<button
type="button"
onClick={onOpenCommandPalette}
className="hidden md:inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg border border-black/10 dark:border-white/10 bg-bg-subtle text-text-muted hover:text-text-main hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-colors"
title="Quick navigation (⌘K / Ctrl+K)"
aria-label="Open quick navigation"
>
<span className="material-symbols-outlined text-[16px]">search</span>
<span className="text-xs">Quick nav</span>
<kbd className="hidden lg:inline-flex font-mono text-[10px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10">
{isMac ? "⌘K" : "Ctrl+K"}
</kbd>
</button>
<button
type="button"
onClick={onOpenCommandPalette}
className="md:hidden p-2 rounded-lg text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="Open quick navigation"
>
<span className="material-symbols-outlined">search</span>
</button>
</>
)}
<LanguageSelector />
<ThemeToggle />
{!isE2EMode && <DegradationBadge />}
{!isE2EMode && <TokenHealthBadge />}
<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={t("logout")}
>
<span className="material-symbols-outlined">logout</span>
</button>
</div>
</header>
);
}