quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 22s
quality / web (push) Successful in 1m0s
CD / quality (push) Successful in 1m35s
CD / publish (push) Successful in 2m59s
Секции страниц переведены на segmented Tabs (c-tabs-9), статус грида — ToggleGroup в toolbar. Убраны календарь задач, пустые вкладки мониторинга и demo-деревья ReUI. KPI overflow как в CFDM, DetailPanel на карточке модуля, один FrameSection. Co-authored-by: Cursor <[email protected]>
234 lines
7.8 KiB
TypeScript
234 lines
7.8 KiB
TypeScript
import {
|
||
LayoutDashboard,
|
||
Boxes,
|
||
Network,
|
||
Cog,
|
||
ListChecks,
|
||
Activity,
|
||
Settings,
|
||
BookText,
|
||
KeyRound,
|
||
Search,
|
||
} from 'lucide-react'
|
||
|
||
import {
|
||
Sidebar,
|
||
SidebarContent,
|
||
SidebarFooter,
|
||
SidebarGroup,
|
||
SidebarGroupContent,
|
||
SidebarGroupLabel,
|
||
SidebarHeader,
|
||
SidebarInset,
|
||
SidebarMenu,
|
||
SidebarMenuButton,
|
||
SidebarMenuItem,
|
||
SidebarProvider,
|
||
SidebarTrigger,
|
||
} from '@evobgp/ui/components/sidebar'
|
||
import {
|
||
Breadcrumb,
|
||
BreadcrumbItem,
|
||
BreadcrumbLink,
|
||
BreadcrumbList,
|
||
BreadcrumbPage,
|
||
BreadcrumbSeparator,
|
||
} from '@evobgp/ui/components/breadcrumb'
|
||
import { Separator } from '@evobgp/ui/components/separator'
|
||
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
|
||
|
||
import { Link, useRouterState } from '@tanstack/react-router'
|
||
import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
||
|
||
import { AppSwitcher } from '@/components/app-switcher'
|
||
import { AppsMenu } from '@/components/layout/apps-menu'
|
||
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
||
import { NavUser } from '@/components/layout/nav-user'
|
||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||
|
||
interface NavItem {
|
||
to: string
|
||
label: string
|
||
icon: ComponentType<{ className?: string }>
|
||
description?: string
|
||
search?: Record<string, string>
|
||
/** Explicit permission override; when omitted derived from `to`. */
|
||
permission?: string
|
||
}
|
||
|
||
interface NavGroup {
|
||
label: string
|
||
items: NavItem[]
|
||
}
|
||
|
||
const NAV_GROUPS: NavGroup[] = [
|
||
{
|
||
label: 'Обзор',
|
||
items: [
|
||
{
|
||
to: '/dashboard',
|
||
label: 'Панель',
|
||
icon: LayoutDashboard,
|
||
description: 'Метрики, модули и активность',
|
||
},
|
||
],
|
||
},
|
||
{
|
||
label: 'Маршрутизация',
|
||
items: [
|
||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'BGP community и DoH' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Операции',
|
||
items: [
|
||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и применение', search: { tab: 'revisions' } },
|
||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание обновления' },
|
||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Состояние системы и BIRD' },
|
||
],
|
||
},
|
||
{
|
||
label: 'Система',
|
||
items: [
|
||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||
{ to: '/settings', label: 'Настройки', icon: Settings, description: 'UI и BIRD', search: { tab: 'ui' } },
|
||
],
|
||
},
|
||
]
|
||
|
||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
||
|
||
/** Nav visible for the current claim (or all items when portal auth is off). */
|
||
function useVisibleNavGroups(): NavGroup[] {
|
||
if (!isAuthEnabled()) return NAV_GROUPS
|
||
return NAV_GROUPS.map((group) => ({
|
||
...group,
|
||
items: group.items.filter((item) => {
|
||
const perm = item.permission ?? permissionForPath(item.to)
|
||
return !perm || can(perm)
|
||
}),
|
||
})).filter((group) => group.items.length > 0)
|
||
}
|
||
|
||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||
)
|
||
|
||
const PARENT_ROUTE: Record<string, string> = {
|
||
'/modules/new': '/modules',
|
||
}
|
||
|
||
const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
|
||
id: item.to,
|
||
label: item.label,
|
||
description: item.description,
|
||
to: item.to,
|
||
search: item.search,
|
||
keywords: [item.to.replace(/^\//, '')],
|
||
}))
|
||
|
||
/**
|
||
* Shared ops chrome etalon for CFDM / vps-tracker.
|
||
* @see https://reui.io/preview/base/app-shell-12
|
||
* @see docs/ui-design-contract.md — Shared App Shell chrome
|
||
*/
|
||
export function AppShell({ children }: { children: ReactNode }) {
|
||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||
const visibleGroups = useVisibleNavGroups()
|
||
const activeItem =
|
||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||
ALL_NAV_ITEMS[0]
|
||
const parentTo = PARENT_ROUTE[pathname] ?? PARENT_ROUTE[activeItem.to]
|
||
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
|
||
|
||
const pageTitle =
|
||
pathname.startsWith('/modules/') && pathname !== '/modules/new'
|
||
? 'Модуль'
|
||
: (ROUTE_LABELS[activeItem.to] ?? activeItem.label)
|
||
|
||
return (
|
||
<TooltipProvider delay={0}>
|
||
<SidebarProvider
|
||
style={
|
||
{
|
||
'--sidebar-width': '240px',
|
||
} as CSSProperties
|
||
}
|
||
>
|
||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||
К содержимому
|
||
</a>
|
||
<Sidebar collapsible="icon">
|
||
<SidebarHeader>
|
||
<AppSwitcher />
|
||
</SidebarHeader>
|
||
<SidebarContent>
|
||
{visibleGroups.map((group) => (
|
||
<SidebarGroup key={group.label}>
|
||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||
<SidebarGroupContent>
|
||
<SidebarMenu>
|
||
{group.items.map((item) => {
|
||
const Icon = item.icon
|
||
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
|
||
return (
|
||
<SidebarMenuItem key={item.to}>
|
||
<SidebarMenuButton
|
||
render={<Link to={item.to} search={item.search} />}
|
||
isActive={isActive}
|
||
tooltip={item.label}
|
||
>
|
||
<Icon className="size-4" />
|
||
<span>{item.label}</span>
|
||
</SidebarMenuButton>
|
||
</SidebarMenuItem>
|
||
)
|
||
})}
|
||
</SidebarMenu>
|
||
</SidebarGroupContent>
|
||
</SidebarGroup>
|
||
))}
|
||
</SidebarContent>
|
||
<SidebarFooter>
|
||
<NavUser />
|
||
</SidebarFooter>
|
||
</Sidebar>
|
||
<SidebarInset id="main-content">
|
||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||
<SidebarTrigger className="-ml-1" />
|
||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||
<Breadcrumb>
|
||
<BreadcrumbList>
|
||
{parentLabel && parentTo ? (
|
||
<>
|
||
<BreadcrumbItem className="hidden md:block">
|
||
<BreadcrumbLink render={<Link to={parentTo} />}>{parentLabel}</BreadcrumbLink>
|
||
</BreadcrumbItem>
|
||
<BreadcrumbSeparator className="hidden md:block" />
|
||
</>
|
||
) : null}
|
||
<BreadcrumbItem>
|
||
<BreadcrumbPage>{pageTitle}</BreadcrumbPage>
|
||
</BreadcrumbItem>
|
||
</BreadcrumbList>
|
||
</Breadcrumb>
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<AppsMenu />
|
||
<SystemMonitorPopover />
|
||
</div>
|
||
</header>
|
||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||
{children}
|
||
</div>
|
||
</SidebarInset>
|
||
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||
</SidebarProvider>
|
||
</TooltipProvider>
|
||
)
|
||
}
|