Files
MikrotikManager/components/app-sidebar.tsx
T
Denozordec 40e2040fb0
Docker images / backend-image (push) Successful in 2m35s
Docker images / frontend-image (push) Successful in 2m9s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
feat: refactor theme management and remove next-themes dependency
- Replaced the next-themes package with a custom ThemeProvider component for enhanced theme management.
- Implemented local storage handling for theme persistence and system theme detection.
- Updated the Toaster component to utilize the new theme context.
- Removed unused next-themes references from package.json and package-lock.json.
2026-05-12 16:07:40 +07:00

268 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import * as React from "react"
import { useTheme } from "@/components/theme-provider"
import { NavMain, type NavGroup } from "@/components/nav-main"
import { SearchIcon } from "lucide-react"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarRail,
} from "@/components/ui/sidebar"
import {
RouterIcon,
LayoutDashboardIcon,
ActivityIcon,
MapIcon,
HeartPulseIcon,
GlobeIcon,
NetworkIcon,
LayersIcon,
TagIcon,
ServerIcon,
FilterIcon,
ShieldIcon,
HardDriveIcon,
RouteIcon,
GitForkIcon,
GitMergeIcon,
TerminalIcon,
SettingsIcon,
DatabaseIcon,
CableIcon,
ScanLineIcon,
BellIcon,
SunIcon,
MoonIcon,
ShieldCheckIcon,
BoxIcon,
BadgeCheckIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import {
formatSidebarBadgeCount,
mockSidebarBadgesByUrl,
type SidebarCountsDto,
} from "@/lib/sidebar-badges"
// ─── Command palette trigger button ──────────────────────────────────────────
function CommandPaletteButton() {
function trigger() {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true }))
}
return (
<button
onClick={trigger}
className="group-data-[collapsible=icon]:hidden mx-2 mb-1 flex items-center gap-2 px-2.5 py-1.5 rounded-md border border-sidebar-border/60 bg-sidebar-accent/30 hover:bg-sidebar-accent text-sidebar-foreground/50 hover:text-sidebar-foreground text-xs transition-colors w-[calc(100%-1rem)]"
>
<SearchIcon className="size-3 shrink-0" />
<span className="flex-1 text-left">Поиск…</span>
<span className="font-mono text-[10px] border border-sidebar-border/60 rounded px-1 py-0.5">K</span>
</button>
)
}
type NavItemBase = { title: string; url: string; icon: React.ReactNode }
const navStructure: { label: string; items: NavItemBase[] }[] = [
{
label: "Обзор",
items: [
{ title: "Дашборд", url: "/dashboard", icon: <LayoutDashboardIcon /> },
{ title: "Трафик", url: "/traffic", icon: <ActivityIcon /> },
{ title: "Карта сети", url: "/network-map", icon: <MapIcon /> },
{ title: "Мониторинг", url: "/uptime", icon: <HeartPulseIcon /> },
],
},
{
label: "Данные",
items: [
{ title: "Домены", url: "/domains", icon: <GlobeIcon /> },
{ title: "IP-диапазоны", url: "/ip-ranges", icon: <NetworkIcon /> },
{ title: "ASN", url: "/asns", icon: <LayersIcon /> },
{ title: "Communities", url: "/communities", icon: <TagIcon /> },
],
},
{
label: "Управление",
items: [
{ title: "Серверы", url: "/servers", icon: <ServerIcon /> },
{ title: "Фильтры", url: "/filters", icon: <FilterIcon /> },
{ title: "Рекурсивные маршруты", url: "/recursive-routes", icon: <RouteIcon /> },
{ title: "Firewall", url: "/firewall", icon: <ShieldIcon /> },
{ title: "WireGuard", url: "/wireguard", icon: <ShieldCheckIcon /> },
{ title: "GRE-туннели", url: "/gre", icon: <CableIcon /> },
{ title: "VXLAN", url: "/vxlan", icon: <NetworkIcon /> },
{ title: "Контейнеры", url: "/containers", icon: <BoxIcon /> },
{ title: "Сертификаты", url: "/certificates", icon: <BadgeCheckIcon /> },
{ title: "Бэкапы", url: "/backups", icon: <HardDriveIcon /> },
],
},
{
label: "Инструменты",
items: [
{ title: "Оптимизатор маршрутов", url: "/route-optimizer", icon: <RouteIcon /> },
{ title: "OSPF", url: "/ospf", icon: <GitForkIcon /> },
{ title: "BGP", url: "/bgp", icon: <GitMergeIcon /> },
{ title: "Диагностика", url: "/probes", icon: <ScanLineIcon /> },
{ title: "Терминал", url: "/terminal", icon: <TerminalIcon /> },
],
},
{
label: "Система",
items: [
{ title: "Оповещения", url: "/alerts", icon: <BellIcon /> },
{ title: "Сбор данных", url: "/data-collection", icon: <DatabaseIcon /> },
{ title: "Настройки", url: "/settings", icon: <SettingsIcon /> },
],
},
]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { resolvedTheme, setTheme } = useTheme()
const { mode, backendUrl, backendStatus } = useDataSource()
const evo = useEvoBGP()
const [mounted, setMounted] = React.useState(false)
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
const mockBadges = React.useMemo(() => mockSidebarBadgesByUrl(), [])
React.useEffect(() => {
queueMicrotask(() => {
setMounted(true)
})
}, [])
React.useEffect(() => {
if (mode !== "live" || backendStatus !== true) {
setLiveCounts(null)
return
}
let cancelled = false
const load = async () => {
try {
const base = backendUrl.replace(/\/$/, "")
const [cRes, gRes] = await Promise.all([
fetch(`${base}/api/sidebar-counts`),
fetch(`${base}/api/filters/gre-tunnels`),
])
if (cancelled) return
if (!cRes.ok) {
setLiveCounts(null)
return
}
const cJson = (await cRes.json()) as SidebarCountsDto
let greN = 0
if (gRes.ok) {
const gJson = (await gRes.json()) as { tunnels?: unknown[] }
greN = (gJson.tunnels ?? []).length
}
setLiveCounts({ ...cJson, greTunnels: greN })
} catch {
if (!cancelled) setLiveCounts(null)
}
}
void load()
const id = window.setInterval(load, 120_000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [mode, backendStatus, backendUrl])
const navGroups = React.useMemo((): NavGroup[] => {
function badgeFor(url: string): string | undefined {
if (mode !== "live") return mockBadges[url]
if (backendStatus !== true) return undefined
if (url === "/domains" || url === "/ip-ranges" || url === "/asns") {
if (!evo.enabled) return mockBadges[url]
if (!evo.snapshot) return undefined
if (url === "/domains") return formatSidebarBadgeCount(evo.snapshot.domains.length)
if (url === "/ip-ranges") return formatSidebarBadgeCount(evo.snapshot.ipRanges.length)
return formatSidebarBadgeCount(evo.snapshot.asns.length)
}
if (!liveCounts) return undefined
if (url === "/servers") return formatSidebarBadgeCount(liveCounts.servers)
if (url === "/filters") return formatSidebarBadgeCount(liveCounts.filterRules)
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/wireguard" || url === "/containers" || url === "/certificates" || url === "/bgp") {
return undefined
}
return undefined
}
return navStructure.map((g) => ({
label: g.label,
items: g.items.map((it) => ({
title: it.title,
url: it.url,
icon: it.icon,
badge: mounted ? badgeFor(it.url) : undefined,
})),
}))
}, [mounted, mode, backendStatus, liveCounts, mockBadges, evo.enabled, evo.snapshot])
const isDark = (resolvedTheme ?? "dark") === "dark"
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<div className="flex items-center gap-2.5 px-2 py-1.5 overflow-hidden">
<div className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<RouterIcon className="size-4" />
</div>
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">RouterLists</span>
<span className="text-[10px] font-mono text-sidebar-foreground/40">v3.4.1</span>
</div>
</div>
<CommandPaletteButton />
</SidebarHeader>
<SidebarContent>
<NavMain groups={navGroups} />
</SidebarContent>
<SidebarFooter>
<div className="flex items-center gap-2 px-2 py-2 group-data-[collapsible=icon]:justify-center">
<div className="size-7 rounded-full bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
АК
</div>
<div className="flex flex-col leading-tight min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
<span className="text-[13px] font-medium text-sidebar-foreground truncate">а.коротаев</span>
<span className="text-[11px] text-sidebar-foreground/50 truncate">admin@routerlists.io</span>
</div>
{mounted ? (
<button
type="button"
onClick={() => setTheme(isDark ? "light" : "dark")}
title={isDark ? "Светлая тема" : "Тёмная тема"}
className="shrink-0 size-6 flex items-center justify-center rounded-md text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors group-data-[collapsible=icon]:hidden"
>
{isDark
? <SunIcon className="size-3.5" />
: <MoonIcon className="size-3.5" />}
</button>
) : (
<span
className="shrink-0 size-6 group-data-[collapsible=icon]:hidden"
aria-hidden
/>
)}
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
}