feat(web): align tables, KPI strip and tabs with shared mockup
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 36s
quality / web (push) Successful in 1m4s
CD / quality (push) Successful in 1m49s
CD / publish (push) Successful in 9m35s
quality / changes (push) Successful in 7s
quality / go (push) Skipped
quality / bird2 (push) Skipped
quality / commitlint (push) Skipped
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 36s
quality / web (push) Successful in 1m4s
CD / quality (push) Successful in 1m49s
CD / publish (push) Successful in 9m35s
Таблицы ResourcePage и FrameDataGrid переведены на раскладку MikrotikManager: сегменты, фильтры и поиск в тулбаре, подложка шапки, ячейки py-3. KPI стали полосой в одном Frame, переключатели разделов — линейными вкладками. Старая оболочка (app-shell, nav-user, монитор, палитра) удалена: её заменил PortalShell. Тесты kit-раскладки обновлены под новый контракт. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
bf43f2846e
commit
b20caef1f1
12 files changed
+144
-1054
No files matched your search
@@ -1,233 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import { LayoutGridIcon } from 'lucide-react'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@evobgp/ui/components/dropdown-menu'
|
|
||||||
import {
|
|
||||||
APP_SWITCHER_ICONS,
|
|
||||||
CURRENT_APP_ID,
|
|
||||||
} from '@/lib/app-switcher-config'
|
|
||||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
|
||||||
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
|
||||||
|
|
||||||
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
|
|
||||||
export function AppsMenu() {
|
|
||||||
const { config, isLoading } = useAppSwitcherConfig()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LayoutGridIcon
|
|
||||||
className="size-4.5 transition-colors"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent
|
|
||||||
side="bottom"
|
|
||||||
align="end"
|
|
||||||
sideOffset={8}
|
|
||||||
className="w-72"
|
|
||||||
>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel>
|
|
||||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
<div className="grid grid-cols-3 gap-1 p-1">
|
|
||||||
{config.apps.map((app) => {
|
|
||||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
|
||||||
const isCurrent = app.id === CURRENT_APP_ID
|
|
||||||
|
|
||||||
if (isCurrent) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={app.id}
|
|
||||||
disabled
|
|
||||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
|
||||||
>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<Icon aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium">{app.name}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={app.id}
|
|
||||||
nativeButton={false}
|
|
||||||
render={<a href={app.url} />}
|
|
||||||
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
|
|
||||||
>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<Icon aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
<span className="text-xs font-medium">{app.name}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{isAuthEnabled() ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
nativeButton={false}
|
|
||||||
render={<a href={authPortalUrl()} />}
|
|
||||||
className="justify-center text-sm font-medium"
|
|
||||||
>
|
|
||||||
Настроить на портале
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : (
|
|
||||||
<DropdownMenuItem
|
|
||||||
nativeButton={false}
|
|
||||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
|
||||||
className="justify-center text-sm font-medium"
|
|
||||||
>
|
|
||||||
Настройки
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { useEffect, useId, useMemo, useState } from 'react'
|
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
import { Search } from 'lucide-react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@evobgp/ui/components/dialog'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { Kbd } from '@evobgp/ui/components/kbd'
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
} from '@evobgp/ui/components/sidebar'
|
|
||||||
|
|
||||||
export type CommandPaletteItem = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
description?: string
|
|
||||||
to: string
|
|
||||||
search?: Record<string, string>
|
|
||||||
keywords?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
|
||||||
items: CommandPaletteItem[]
|
|
||||||
className?: string
|
|
||||||
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
|
|
||||||
hotkeyOnly?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const [query, setQuery] = useState('')
|
|
||||||
const searchInputId = useId()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
|
||||||
event.preventDefault()
|
|
||||||
setOpen(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => window.removeEventListener('keydown', onKeyDown)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase()
|
|
||||||
if (!q) return items
|
|
||||||
return items.filter((item) => {
|
|
||||||
const haystack = [item.label, item.description, ...(item.keywords ?? [])]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
return haystack.includes(q)
|
|
||||||
})
|
|
||||||
}, [items, query])
|
|
||||||
|
|
||||||
function go(item: CommandPaletteItem) {
|
|
||||||
setOpen(false)
|
|
||||||
setQuery('')
|
|
||||||
void navigate({ to: item.to, search: item.search })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!hotkeyOnly ? (
|
|
||||||
<SidebarGroup className="p-0">
|
|
||||||
<SidebarGroupContent className="relative">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
Поиск…
|
|
||||||
</Button>
|
|
||||||
<Search
|
|
||||||
aria-hidden
|
|
||||||
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
|
|
||||||
/>
|
|
||||||
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
|
||||||
⌘K
|
|
||||||
</Kbd>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
setOpen(next)
|
|
||||||
if (!next) setQuery('')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogHeader className="sr-only">
|
|
||||||
<DialogTitle>Быстрый переход</DialogTitle>
|
|
||||||
<DialogDescription>Навигация по разделам EvoBGP</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogContent className="max-w-md gap-0 px-0 py-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3">
|
|
||||||
<div className="flex items-center gap-3 border-b px-4 py-3">
|
|
||||||
<Search aria-hidden className="size-4 shrink-0 opacity-60" />
|
|
||||||
<Input
|
|
||||||
id={searchInputId}
|
|
||||||
className="h-9 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
|
||||||
autoFocus
|
|
||||||
placeholder="Раздел или действие…"
|
|
||||||
aria-label="Поиск разделов"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && filtered[0]) go(filtered[0])
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ul className="max-h-72 overflow-y-auto p-2" role="listbox">
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<li className="text-muted-foreground px-2 py-6 text-center text-sm">Ничего не найдено</li>
|
|
||||||
) : (
|
|
||||||
filtered.map((item) => (
|
|
||||||
<li key={item.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="option"
|
|
||||||
className="hover:bg-accent flex w-full flex-col items-start gap-0.5 rounded-md px-3 py-2 text-left text-sm transition-colors"
|
|
||||||
onClick={() => go(item)}
|
|
||||||
>
|
|
||||||
<span className="font-medium">{item.label}</span>
|
|
||||||
{item.description ? (
|
|
||||||
<span className="text-muted-foreground text-xs">{item.description}</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useTheme } from 'next-themes'
|
|
||||||
import {
|
|
||||||
ChevronsUpDownIcon,
|
|
||||||
ExternalLinkIcon,
|
|
||||||
LogOutIcon,
|
|
||||||
MonitorIcon,
|
|
||||||
MoonIcon,
|
|
||||||
PaletteIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
SunIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@evobgp/ui/components/dropdown-menu'
|
|
||||||
import {
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
useSidebar,
|
|
||||||
} from '@evobgp/ui/components/sidebar'
|
|
||||||
|
|
||||||
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
|
||||||
import {
|
|
||||||
authPortalUrl,
|
|
||||||
clearPortalToken,
|
|
||||||
getClaims,
|
|
||||||
isAuthEnabled,
|
|
||||||
redirectToPortalLogout,
|
|
||||||
resetPortalHandoff,
|
|
||||||
} from '@/lib/auth'
|
|
||||||
|
|
||||||
/** Sidebar footer account menu — ReUI app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
|
||||||
|
|
||||||
const THEMES = [
|
|
||||||
{
|
|
||||||
value: 'light',
|
|
||||||
label: 'Светлая',
|
|
||||||
icon: <SunIcon className="size-3.5" aria-hidden />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'dark',
|
|
||||||
label: 'Тёмная',
|
|
||||||
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'system',
|
|
||||||
label: 'Системная',
|
|
||||||
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function ThemeSegmentedToggle() {
|
|
||||||
const { theme, setTheme } = useTheme()
|
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const currentTheme = mounted ? (theme ?? 'system') : 'system'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label="Тема"
|
|
||||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{THEMES.map(({ value, label, icon }) => {
|
|
||||||
const isActive = currentTheme === value
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
aria-checked={isActive}
|
|
||||||
aria-label={label}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
onClick={() => setTheme(value)}
|
|
||||||
className={cn(
|
|
||||||
'rounded-full',
|
|
||||||
isActive
|
|
||||||
? 'bg-background text-foreground shadow-sm'
|
|
||||||
: 'text-muted-foreground hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function initials(source: string): string {
|
|
||||||
const base = source.trim()
|
|
||||||
if (!base) return '?'
|
|
||||||
const parts = base.split(/\s+/).filter(Boolean)
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
|
||||||
}
|
|
||||||
return base.slice(0, 2).toUpperCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NavUser() {
|
|
||||||
const { isMobile } = useSidebar()
|
|
||||||
const authOn = isAuthEnabled()
|
|
||||||
const claims = getClaims()
|
|
||||||
|
|
||||||
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
|
||||||
const email =
|
|
||||||
claims?.email?.trim() || (authOn ? '' : 'локальный API-токен')
|
|
||||||
const fallback = initials(name || email)
|
|
||||||
|
|
||||||
function handleSignOut() {
|
|
||||||
if (authOn) {
|
|
||||||
clearPortalToken()
|
|
||||||
resetPortalHandoff()
|
|
||||||
redirectToPortalLogout()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setApiToken(null)
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
|
||||||
window.location.assign('/settings?tab=ui&reason=token-required')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
render={
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="lg"
|
|
||||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Avatar className="size-8 rounded-lg">
|
|
||||||
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
||||||
<span className="truncate font-semibold">{name}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
|
||||||
{email || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-(--anchor-width) min-w-56 rounded-lg"
|
|
||||||
side={isMobile ? 'bottom' : 'right'}
|
|
||||||
align="end"
|
|
||||||
sideOffset={4}
|
|
||||||
>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
|
|
||||||
<Avatar className="size-8 rounded-lg">
|
|
||||||
<AvatarFallback className="rounded-lg text-xs">
|
|
||||||
{fallback}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
|
||||||
<span className="truncate font-semibold">{name}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
|
||||||
{email || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuLabel>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem
|
|
||||||
nativeButton={false}
|
|
||||||
render={<Link to="/settings" search={{ tab: 'ui' }} />}
|
|
||||||
>
|
|
||||||
<SettingsIcon aria-hidden />
|
|
||||||
Настройки
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{authOn ? (
|
|
||||||
<DropdownMenuItem
|
|
||||||
nativeButton={false}
|
|
||||||
render={<a href={authPortalUrl()} />}
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon aria-hidden />
|
|
||||||
Открыть Auth Portal
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
|
||||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
|
||||||
<PaletteIcon aria-hidden />
|
|
||||||
Тема
|
|
||||||
<div className="ml-auto">
|
|
||||||
<ThemeSegmentedToggle />
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem onClick={handleSignOut}>
|
|
||||||
<LogOutIcon aria-hidden />
|
|
||||||
{authOn ? 'Выйти' : 'Сбросить токен'}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { useMemo, type CSSProperties, type ReactNode } from 'react'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { Activity, Bird, HeartPulse, ListChecks, Network } from 'lucide-react'
|
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@evobgp/ui/components/popover'
|
|
||||||
import { Progress } from '@evobgp/ui/components/progress'
|
|
||||||
|
|
||||||
import { monitoringHealthQueryOptions } from '@/queries/monitoring'
|
|
||||||
import { networkBirdQueryOptions } from '@/queries/network'
|
|
||||||
import { aggregateNetworkMetrics, overviewJobsQueryOptions, overviewPeersQueryOptions } from '@/queries/overview'
|
|
||||||
|
|
||||||
type MonitorMetric = {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
unit: string
|
|
||||||
percent: number
|
|
||||||
icon: ReactNode
|
|
||||||
tone: 'success' | 'warning' | 'destructive' | 'info'
|
|
||||||
alert: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function toneColor(tone: MonitorMetric['tone']) {
|
|
||||||
switch (tone) {
|
|
||||||
case 'success':
|
|
||||||
return 'var(--color-success)'
|
|
||||||
case 'warning':
|
|
||||||
return 'var(--color-warning)'
|
|
||||||
case 'destructive':
|
|
||||||
return 'var(--color-destructive)'
|
|
||||||
default:
|
|
||||||
return 'var(--color-info)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
|
||||||
const color = toneColor(metric.tone)
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 p-3">
|
|
||||||
<div className="flex items-center justify-between gap-1">
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<Item
|
|
||||||
className="flex size-5 shrink-0 items-center justify-center p-0"
|
|
||||||
style={{ backgroundColor: `${color}18` }}
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
|
|
||||||
{metric.icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
|
||||||
{metric.value}
|
|
||||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress
|
|
||||||
value={metric.percent}
|
|
||||||
className="**:data-[slot=progress-indicator]:bg-(--bar-color) **:data-[slot=progress-track]:h-1"
|
|
||||||
style={{ '--bar-color': color } as CSSProperties}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Live system monitor popover (app-shell-7 pattern, EvoBGP API data). */
|
|
||||||
export function SystemMonitorPopover() {
|
|
||||||
const healthQ = useQuery({ ...monitoringHealthQueryOptions(), refetchInterval: 30_000 })
|
|
||||||
const peersQ = useQuery({ ...overviewPeersQueryOptions(), refetchInterval: 30_000 })
|
|
||||||
const jobsQ = useQuery({ ...overviewJobsQueryOptions(), refetchInterval: 30_000 })
|
|
||||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
|
||||||
|
|
||||||
const peers = peersQ.data?.items ?? []
|
|
||||||
const jobs = jobsQ.data?.items ?? []
|
|
||||||
const network = aggregateNetworkMetrics(peers, [])
|
|
||||||
const bgpPct =
|
|
||||||
network.peersEnabled > 0
|
|
||||||
? Math.round((network.peersEstablished / network.peersEnabled) * 100)
|
|
||||||
: 0
|
|
||||||
const failedJobs = jobs.filter((j) =>
|
|
||||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
|
||||||
).length
|
|
||||||
const runningJobs = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
|
||||||
const healthOk = healthQ.data?.ok ?? false
|
|
||||||
const birdRatio =
|
|
||||||
birdQ.data && birdQ.data.bgp_sessions_total > 0
|
|
||||||
? Math.round((birdQ.data.bgp_established / birdQ.data.bgp_sessions_total) * 100)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const metrics = useMemo<MonitorMetric[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
id: 'api',
|
|
||||||
label: 'Состояние API',
|
|
||||||
value: healthOk ? 'норма' : '—',
|
|
||||||
unit: '',
|
|
||||||
percent: healthOk ? 100 : 0,
|
|
||||||
icon: <HeartPulse aria-hidden />,
|
|
||||||
tone: healthOk ? 'success' : 'destructive',
|
|
||||||
alert: !healthOk,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bgp',
|
|
||||||
label: 'BGP пиры',
|
|
||||||
value: String(bgpPct),
|
|
||||||
unit: '%',
|
|
||||||
percent: bgpPct,
|
|
||||||
icon: <Network aria-hidden />,
|
|
||||||
tone: bgpPct >= 90 ? 'success' : bgpPct >= 50 ? 'warning' : 'destructive',
|
|
||||||
alert: bgpPct < 70 && network.peersEnabled > 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bird',
|
|
||||||
label: 'BIRD сессии',
|
|
||||||
value: birdRatio !== null ? String(birdRatio) : '—',
|
|
||||||
unit: birdRatio !== null ? '%' : '',
|
|
||||||
percent: birdRatio ?? 0,
|
|
||||||
icon: <Bird aria-hidden />,
|
|
||||||
tone:
|
|
||||||
birdRatio === null
|
|
||||||
? 'info'
|
|
||||||
: birdRatio >= 100
|
|
||||||
? 'success'
|
|
||||||
: birdRatio >= 50
|
|
||||||
? 'warning'
|
|
||||||
: 'destructive',
|
|
||||||
alert: birdRatio !== null && birdRatio < 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'jobs',
|
|
||||||
label: 'Ошибки задач',
|
|
||||||
value: String(failedJobs),
|
|
||||||
unit: 'шт.',
|
|
||||||
percent: Math.min(100, failedJobs * 10),
|
|
||||||
icon: <ListChecks aria-hidden />,
|
|
||||||
tone: failedJobs > 0 ? 'warning' : 'success',
|
|
||||||
alert: failedJobs > 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[bgpPct, birdRatio, failedJobs, healthOk, network.peersEnabled],
|
|
||||||
)
|
|
||||||
|
|
||||||
const spiking = metrics.some((m) => m.alert) || runningJobs > 5
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Монитор системы"
|
|
||||||
className={cn(
|
|
||||||
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
|
|
||||||
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="relative flex size-3.5 items-center justify-center">
|
|
||||||
<Activity
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
'size-3.5 transition-colors',
|
|
||||||
spiking ? 'text-destructive' : 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{spiking ? (
|
|
||||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
|
||||||
) : null}
|
|
||||||
</span>
|
|
||||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
|
||||||
<Badge
|
|
||||||
variant={spiking ? 'destructive-light' : 'success-light'}
|
|
||||||
size="xs"
|
|
||||||
className="h-4 px-1.5 text-[10px]"
|
|
||||||
>
|
|
||||||
{spiking ? 'Внимание' : 'Норма'}
|
|
||||||
</Badge>
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
|
||||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
|
||||||
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
|
|
||||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
|
||||||
{new Date().toLocaleTimeString('ru-RU')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2">
|
|
||||||
{metrics.map((metric, i) => (
|
|
||||||
<div
|
|
||||||
key={metric.id}
|
|
||||||
className={cn(
|
|
||||||
i % 2 === 1 && 'border-border border-l',
|
|
||||||
i >= 2 && 'border-border border-t',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<MetricCell metric={metric} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
|
||||||
Активных задач: <span className="text-foreground font-medium tabular-nums">{runningJobs}</span>
|
|
||||||
{' · '}
|
|
||||||
Пиров в каталоге:{' '}
|
|
||||||
<span className="text-foreground font-medium tabular-nums">{network.peersTotal}</span>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -432,7 +432,7 @@ export function PageTabsList({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
|
||||||
return (
|
return (
|
||||||
<TabsTrigger value={value} className="gap-1.5 px-4 text-sm">
|
<TabsTrigger value={value} className="flex-none gap-1.5 px-4 text-sm">
|
||||||
{label}
|
{label}
|
||||||
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
|
{count !== undefined ? <span className="text-muted-foreground font-mono text-xs font-normal">{count}</span> : null}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ import {
|
|||||||
} from './frame-data-grid'
|
} from './frame-data-grid'
|
||||||
|
|
||||||
describe('kitDataGridTableLayout', () => {
|
describe('kitDataGridTableLayout', () => {
|
||||||
it('filtering-2 defaults: dense, headerBackground false, width fixed', () => {
|
it('defaults макета: не dense, headerBackground true, width fixed', () => {
|
||||||
const layout = kitDataGridTableLayout()
|
const layout = kitDataGridTableLayout()
|
||||||
expect(layout.dense).toBe(true)
|
expect(layout.dense).toBe(false)
|
||||||
expect(layout.headerBackground).toBe(false)
|
expect(layout.headerBackground).toBe(true)
|
||||||
expect(layout.width).toBe('fixed')
|
expect(layout.width).toBe('fixed')
|
||||||
expect(layout.headerSticky).toBe(false)
|
expect(layout.headerSticky).toBe(false)
|
||||||
expect('stripped' in layout ? layout.stripped : undefined).toBeUndefined()
|
expect('stripped' in layout ? layout.stripped : undefined).toBeUndefined()
|
||||||
expect(layout.columnsPinnable).toBe(false)
|
expect(layout.columnsPinnable).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('kit tableClassNames совпадает с filtering-2 edgeCell', () => {
|
it('kit tableClassNames: поля краёв как в MikrotikManager', () => {
|
||||||
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-3 last:pe-3')
|
expect(kitDataGridTableClassNames.edgeCell).toBe('first:ps-5 last:pe-4')
|
||||||
expect(kitDataGridTableClassNames.base).toBe(
|
expect(kitDataGridTableClassNames.base).toBe(
|
||||||
'[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
'[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
||||||
)
|
)
|
||||||
@@ -33,7 +33,7 @@ describe('kitDataGridTableLayout', () => {
|
|||||||
})
|
})
|
||||||
expect(layout.width).toBe('auto')
|
expect(layout.width).toBe('auto')
|
||||||
expect(layout.columnsPinnable).toBe(true)
|
expect(layout.columnsPinnable).toBe(true)
|
||||||
expect(layout.headerBackground).toBe(false)
|
expect(layout.headerBackground).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
import { Columns3Icon } from 'lucide-react'
|
import { Columns3Icon } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import {
|
import {
|
||||||
DataGrid,
|
DataGrid,
|
||||||
@@ -41,7 +40,6 @@ import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-tabl
|
|||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
FrameFooter,
|
|
||||||
FrameHeader,
|
FrameHeader,
|
||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
@@ -66,10 +64,10 @@ export function kitDataGridTableLayout(
|
|||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
dense: opts.dense ?? true,
|
dense: opts.dense ?? false,
|
||||||
rowBorder: true,
|
rowBorder: true,
|
||||||
headerSticky: false,
|
headerSticky: false,
|
||||||
headerBackground: false,
|
headerBackground: true,
|
||||||
headerBorder: true,
|
headerBorder: true,
|
||||||
width: opts.width ?? ('fixed' as const),
|
width: opts.width ?? ('fixed' as const),
|
||||||
columnsVisibility: opts.columnsVisibility ?? false,
|
columnsVisibility: opts.columnsVisibility ?? false,
|
||||||
@@ -84,7 +82,8 @@ export function kitDataGridTableLayout(
|
|||||||
/** filtering-2 edge inset + Frame-surface pinned cells (not page --background). */
|
/** filtering-2 edge inset + Frame-surface pinned cells (not page --background). */
|
||||||
export const kitDataGridTableClassNames = {
|
export const kitDataGridTableClassNames = {
|
||||||
base: '[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
base: '[&_[data-pinned]]:bg-(--frame-panel-bg)',
|
||||||
edgeCell: 'first:ps-3 last:pe-3',
|
edgeCell: 'first:ps-5 last:pe-4',
|
||||||
|
headerRow: 'border-b border-border',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -316,10 +315,7 @@ function FrameDataGridBody<TData extends object>({
|
|||||||
)}
|
)}
|
||||||
{showPagination ? (
|
{showPagination ? (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<DataGridPagination className="px-5 py-3" sizes={[10, 25, 50]} />
|
||||||
<FrameFooter>
|
|
||||||
<DataGridPagination sizes={[10, 25, 50]} />
|
|
||||||
</FrameFooter>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
|
|||||||
@@ -260,39 +260,61 @@ interface KpiStatGridProps {
|
|||||||
'aria-label'?: string
|
'aria-label'?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
/** Колонки полосы по ширине контейнера: 4 → 1/2/4, 3 → 1/3. */
|
||||||
const onActivate = resolveActivate(item)
|
function stripCols(count: number): string {
|
||||||
const panelClass = panelClassName(item)
|
if (count <= 1) return 'grid-cols-1'
|
||||||
|
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||||
|
if (count === 3) return 'grid-cols-1 @2xl:grid-cols-3'
|
||||||
|
if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-4'
|
||||||
|
return 'grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ячейка полосы показателей как на досках макета: плитка-иконка, подпись, значение и плашка рядом. */
|
||||||
|
function KpiStripCell({ item }: { item: KpiStatItem }) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const footer = resolveFooter(item)
|
||||||
|
const valueVariant = item.variant ?? 'default'
|
||||||
|
const clickable = Boolean(item.to || onActivate)
|
||||||
|
const body = (
|
||||||
|
<div className="flex min-w-0 items-center gap-3 px-[18px] py-3.5">
|
||||||
|
{item.icon ? (
|
||||||
|
<IconTile variant="outline" aria-hidden="true" className={cn('shrink-0', item.iconClassName ?? DEFAULT_ICON_CLASS)}>
|
||||||
|
{item.icon}
|
||||||
|
</IconTile>
|
||||||
|
) : null}
|
||||||
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
|
<div className="text-muted-foreground truncate text-[13px]">{item.label}</div>
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
|
<span className={cn('text-2xl font-semibold tracking-tight tabular-nums', VALUE_VARIANT_CLASS[valueVariant])}>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
{footer ? <span className="min-w-0 max-w-full">{footer}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
const cellClass = cn(
|
||||||
|
'bg-card relative min-w-0',
|
||||||
|
clickable && 'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||||
|
isSelected(item) && 'bg-muted/30',
|
||||||
|
)
|
||||||
if (item.to) {
|
if (item.to) {
|
||||||
return (
|
return (
|
||||||
<FramePanel className={panelClass}>
|
<div className={cellClass}>
|
||||||
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
<Link to={item.to} search={item.search} className="block focus-visible:outline-none">
|
||||||
<KpiStatCardBody item={item} />
|
{body}
|
||||||
</Link>
|
</Link>
|
||||||
</FramePanel>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onActivate) {
|
if (onActivate) {
|
||||||
return (
|
return (
|
||||||
<FramePanel
|
<div className={cellClass} onClick={onActivate} role="button" tabIndex={0} onKeyDown={(e) => handleCardKeyDown(onActivate, e)}>
|
||||||
className={panelClass}
|
{body}
|
||||||
onClick={onActivate}
|
</div>
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
|
||||||
>
|
|
||||||
<KpiStatCardBody item={item} />
|
|
||||||
</FramePanel>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return <div className={cellClass}>{body}</div>
|
||||||
return (
|
|
||||||
<FramePanel className={panelClass}>
|
|
||||||
<KpiStatCardBody item={item} />
|
|
||||||
</FramePanel>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -342,12 +364,14 @@ export function KpiStatGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Frame className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
<Frame spacing="sm" className={cn('@container w-full min-w-0', className)} aria-label={ariaLabel}>
|
||||||
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
<FramePanel className="overflow-hidden p-0">
|
||||||
{list.map((item, index) => (
|
<div className={cn('bg-border grid gap-px', stripCols(list.length || 1))}>
|
||||||
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
{list.map((item, index) => (
|
||||||
))}
|
<KpiStripCell key={kpiStatItemKey(item, index)} item={item} />
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-rea
|
|||||||
|
|
||||||
import { EmptyState } from '@/components/empty-state'
|
import { EmptyState } from '@/components/empty-state'
|
||||||
import {
|
import {
|
||||||
StatusToggleGroup,
|
|
||||||
type StatusToggleTone,
|
type StatusToggleTone,
|
||||||
} from '@/components/status-toggle-group'
|
} from '@/components/status-toggle-group'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
@@ -24,12 +23,12 @@ import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll
|
|||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table'
|
import { DataGridTableRowExpand } from '@/components/reui/data-grid/data-grid-table'
|
||||||
import { Filters } from '@/components/reui/filters/filters'
|
import { Filters } from '@/components/reui/filters/filters'
|
||||||
|
import { SegmentedControl } from '@/components/portal/data-page'
|
||||||
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
|
import { flattenFilterConditions } from '@/components/reui/filters/filters-query'
|
||||||
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
FrameFooter,
|
|
||||||
FrameHeader,
|
FrameHeader,
|
||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
@@ -41,7 +40,6 @@ import {
|
|||||||
InputGroupAddon,
|
InputGroupAddon,
|
||||||
InputGroupInput,
|
InputGroupInput,
|
||||||
} from '@evobgp/ui/components/input-group'
|
} from '@evobgp/ui/components/input-group'
|
||||||
import { Separator } from '@evobgp/ui/components/separator'
|
|
||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
|
import { DATA_GRID_I18N_RU } from '@/lib/reui-i18n-ru'
|
||||||
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
import { FILTERS_LABELS_RU, FILTERS_OPERATOR_LABELS_RU } from '@/lib/filters-i18n'
|
||||||
@@ -567,74 +565,66 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<FramePanel className="p-0 shadow-none!">
|
<FramePanel className="p-0 shadow-none!">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-2.5">
|
<div className="flex flex-wrap items-center gap-3 border-b px-5 py-3">
|
||||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
{countedTabs.length > 0 ? (
|
||||||
{showSearch && primaryTextField ? (
|
<SegmentedControl
|
||||||
<InputGroup className="h-8 w-full min-w-[12rem] max-w-sm">
|
value={activeTab}
|
||||||
<InputGroupAddon>
|
onChange={handleTabChange}
|
||||||
<SearchIcon aria-hidden="true" />
|
label="Фильтр по статусу"
|
||||||
</InputGroupAddon>
|
options={countedTabs.map((t) => ({ value: t.id, label: t.label, count: t.count }))}
|
||||||
<InputGroupInput
|
/>
|
||||||
value={searchText}
|
) : null}
|
||||||
onChange={(event) => handleSearchChange(event.target.value)}
|
{showFiltersPopover ? (
|
||||||
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
|
<Filters
|
||||||
aria-label={primaryTextField.label || 'Поиск'}
|
query={extraQuery}
|
||||||
/>
|
fields={extraFilterFields}
|
||||||
</InputGroup>
|
onQueryChange={handleFiltersChange}
|
||||||
) : null}
|
variant="advanced"
|
||||||
{showFiltersPopover ? (
|
advancedMode="popover"
|
||||||
<Filters
|
size="default"
|
||||||
query={extraQuery}
|
labels={FILTERS_LABELS_RU}
|
||||||
fields={extraFilterFields}
|
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
||||||
onQueryChange={handleFiltersChange}
|
trigger={
|
||||||
variant="advanced"
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
advancedMode="popover"
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
size="default"
|
Фильтры
|
||||||
labels={FILTERS_LABELS_RU}
|
{extraActiveCount > 0 ? (
|
||||||
operatorLabels={FILTERS_OPERATOR_LABELS_RU}
|
<Badge size="xs" variant="secondary" radius="full">
|
||||||
trigger={
|
{extraActiveCount}
|
||||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
</Badge>
|
||||||
<FilterIcon className="size-4" aria-hidden="true" />
|
) : null}
|
||||||
Фильтры
|
</Button>
|
||||||
{extraActiveCount > 0 ? (
|
}
|
||||||
<Badge size="xs" variant="secondary" radius="full">
|
/>
|
||||||
{extraActiveCount}
|
) : null}
|
||||||
</Badge>
|
{showSearch && primaryTextField ? (
|
||||||
) : null}
|
<InputGroup className="max-w-sm min-w-[220px]">
|
||||||
</Button>
|
<InputGroupAddon>
|
||||||
}
|
<SearchIcon className="size-3.5" aria-hidden="true" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
value={searchText}
|
||||||
|
onChange={(event) => handleSearchChange(event.target.value)}
|
||||||
|
placeholder={primaryTextField.placeholder ?? 'Поиск…'}
|
||||||
|
aria-label={primaryTextField.label || 'Поиск'}
|
||||||
/>
|
/>
|
||||||
) : null}
|
</InputGroup>
|
||||||
{!showSearch && !showFiltersPopover && countedTabs.length === 0 ? (
|
) : null}
|
||||||
<div />
|
<span className="text-muted-foreground ml-auto text-sm tabular-nums">{filteredData.length} записей</span>
|
||||||
) : null}
|
{toolbarExtra}
|
||||||
</div>
|
{selectedCount > 0 ? (
|
||||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
<Badge size="sm" variant="secondary">
|
||||||
{countedTabs.length > 0 ? (
|
{selectedCount} выбрано
|
||||||
<StatusToggleGroup
|
</Badge>
|
||||||
items={countedTabs}
|
) : null}
|
||||||
value={activeTab}
|
{hasDirtyFilters ? (
|
||||||
onValueChange={handleTabChange}
|
<Button type="button" variant="outline" onClick={handleClear}>
|
||||||
aria-label="Фильтр по статусу"
|
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||||
/>
|
Сбросить
|
||||||
) : null}
|
</Button>
|
||||||
{toolbarExtra}
|
) : null}
|
||||||
{selectedCount > 0 ? (
|
|
||||||
<Badge size="sm" variant="secondary">
|
|
||||||
{selectedCount} выбрано
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
{hasDirtyFilters ? (
|
|
||||||
<Button type="button" variant="outline" onClick={handleClear}>
|
|
||||||
<FilterXIcon className="size-4" aria-hidden="true" />
|
|
||||||
Сбросить
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{virtualization ? (
|
{virtualization ? (
|
||||||
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
<DataGridScrollArea orientation="vertical" style={{ height }}>
|
||||||
{tableNode}
|
{tableNode}
|
||||||
@@ -643,11 +633,7 @@ function ResourcePageFiltered<T extends object>({
|
|||||||
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
|
<DataGridScrollArea>{tableNode}</DataGridScrollArea>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Separator />
|
<DataGridPagination className="px-5 py-3" sizes={[5, 10, 20, 50]} />
|
||||||
|
|
||||||
<FrameFooter>
|
|
||||||
<DataGridPagination sizes={[5, 10, 20, 50]} />
|
|
||||||
</FrameFooter>
|
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { ComponentProps, ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
|
|
||||||
export type SegmentedTabItem = {
|
export type SegmentedTabItem = {
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
count?: number
|
count?: number
|
||||||
badgeVariant?: ComponentProps<typeof Badge>['variant']
|
/** @deprecated Счётчик теперь без плашки (линейные вкладки макета). */
|
||||||
|
badgeVariant?: string
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +24,7 @@ interface SegmentedTabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page-level section switcher — native shadcn Tabs, default (segmented) list.
|
* Page-level section switcher — line tabs as on the shared mockup boards.
|
||||||
* @see https://reui.io/preview/base/components/c-tabs-9
|
* @see https://reui.io/preview/base/components/c-tabs-9
|
||||||
* @see https://reui.io/components/tabs
|
* @see https://reui.io/components/tabs
|
||||||
*/
|
*/
|
||||||
@@ -47,15 +46,13 @@ export function SegmentedTabs({
|
|||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
className={cn('w-full min-w-0 gap-4', className)}
|
className={cn('w-full min-w-0 gap-4', className)}
|
||||||
>
|
>
|
||||||
<TabsList className={cn('h-auto w-full min-w-0 justify-start', listClassName)}>
|
<TabsList variant="line" className={cn('h-10! w-full min-w-0 justify-start gap-0.5 overflow-x-auto', listClassName)}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<TabsTrigger key={item.value} value={item.value} className="shrink-0 gap-2">
|
<TabsTrigger key={item.value} value={item.value} className="flex-none shrink-0 gap-1.5 px-4 text-sm">
|
||||||
{item.icon}
|
{item.icon}
|
||||||
{item.label}
|
{item.label}
|
||||||
{item.count !== undefined ? (
|
{item.count !== undefined ? (
|
||||||
<Badge variant={item.badgeVariant ?? 'primary-light'} size="sm">
|
<span className="text-muted-foreground font-mono text-xs font-normal">{item.count}</span>
|
||||||
{item.count}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
) : null}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */
|
/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */
|
||||||
|
/* Ячейки таблиц как в MikrotikManager: py-3, поля первой и последней колонки pl-5 / pr-4. */
|
||||||
|
html [data-slot='data-grid-table'] :is(th, td) {
|
||||||
|
padding-block: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html [data-slot='data-grid-table'] :is(th, td):first-child {
|
||||||
|
padding-inline-start: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html [data-slot='data-grid-table'] :is(th, td):last-child {
|
||||||
|
padding-inline-end: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
|
html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
|
||||||
padding-block: 0.25rem;
|
padding-block: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user