refactor: update dashboard quick links and app shell for improved layout and functionality
CI / changes (push) Successful in 7s
CI / openapi (push) Skipped
CI / commitlint (push) Skipped
CI / web (push) Successful in 1m4s
CI / go (push) Failing after 17s
CI / bird2 (push) Skipped
CI / release (push) Skipped

Refactored the DashboardQuickLinks component to simplify icon classes for better semantic clarity. Updated the AppShell component to integrate the AppSwitcher and AppsMenu, enhancing navigation and user experience. Adjusted the layout of the app shell header and main content for improved consistency and alignment. Updated UI design documentation to reflect these changes and ensure adherence to shared design standards.
This commit is contained in:
Denozordec
2026-07-17 23:15:00 +07:00
parent 54a0b5b966
commit 039d2f3dd9
10 changed files with 401 additions and 49 deletions
+97
View File
@@ -0,0 +1,97 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@evobgp/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@evobgp/ui/components/sidebar'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
getCurrentApp,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Sidebar app switcher — same chrome as CFDM / vps-tracker. @see https://reui.io/preview/base/app-shell-12 */
export function AppSwitcher() {
const { isMobile } = useSidebar()
const { config, isLoading } = useAppSwitcherConfig()
const current = getCurrentApp(config)
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
}
>
<div
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
aria-hidden
>
<CurrentIcon className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{current.name}</span>
{current.subtitle ? (
<span className="truncate text-xs text-muted-foreground">
{current.subtitle}
</span>
) : null}
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="start"
sideOffset={4}
>
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{isLoading ? 'Загрузка…' : config.menuLabel}
</div>
{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>
<Icon />
{app.name}
<CheckIcon className="ml-auto size-4" />
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
>
<Icon />
{app.name}
{app.shortcut ? (
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
) : null}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -2,6 +2,7 @@ import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react'
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
/** iconClassName: semantic text only (shared chrome with CFDM). @see https://reui.io/preview/base/stats-12 */
const ACTIONS: QuickActionItem[] = [
{
id: 'lookup',
@@ -9,7 +10,7 @@ const ACTIONS: QuickActionItem[] = [
description: 'Membership в списках и community (entry + snapshot).',
to: '/lookup',
icon: <Search aria-hidden />,
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
iconClassName: 'text-primary',
},
{
id: 'new-module',
@@ -17,7 +18,7 @@ const ACTIONS: QuickActionItem[] = [
description: 'Новый модуль маршрутизации и источники префиксов.',
to: '/modules/new',
icon: <Plus aria-hidden />,
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
iconClassName: 'text-primary',
},
{
id: 'communities',
@@ -25,7 +26,7 @@ const ACTIONS: QuickActionItem[] = [
description: 'Справочник communities для политик экспорта.',
to: '/directories',
icon: <Tags aria-hidden />,
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
iconClassName: 'text-info',
},
{
id: 'network',
@@ -34,7 +35,7 @@ const ACTIONS: QuickActionItem[] = [
to: '/network',
search: { tab: 'overview' },
icon: <Network aria-hidden />,
iconClassName: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
iconClassName: 'text-success',
},
{
id: 'add-peer',
@@ -43,7 +44,7 @@ const ACTIONS: QuickActionItem[] = [
to: '/network',
search: { tab: 'peers' },
icon: <Share2 aria-hidden />,
iconClassName: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
iconClassName: 'text-warning',
},
{
id: 'deploy',
@@ -52,7 +53,7 @@ const ACTIONS: QuickActionItem[] = [
to: '/operations',
search: { tab: 'revisions' },
icon: <Play aria-hidden />,
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
iconClassName: 'text-muted-foreground',
},
{
id: 'monitoring',
@@ -61,7 +62,7 @@ const ACTIONS: QuickActionItem[] = [
to: '/monitoring',
search: { tab: 'system' },
icon: <Gauge aria-hidden />,
iconClassName: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
iconClassName: 'text-destructive',
},
]
+24 -19
View File
@@ -38,10 +38,13 @@ import {
} from '@evobgp/ui/components/breadcrumb'
import { Separator } from '@evobgp/ui/components/separator'
import { TooltipProvider } from '@evobgp/ui/components/tooltip'
import { cn } from '@evobgp/ui/lib/utils'
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 { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { ModeToggle } from '@/components/mode-toggle'
@@ -118,6 +121,11 @@ const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
keywords: [item.to.replace(/^\//, '')],
}))
/**
* Shared ops chrome — tokens match 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 activeItem =
@@ -134,28 +142,21 @@ export function AppShell({ children }: { children: ReactNode }) {
return (
<TooltipProvider delay={0}>
<SidebarProvider
className={cn(
'[--sidebar:color-mix(in_oklab,var(--color-sidebar)_60%,transparent)]',
'[--sidebar-border:transparent]',
'[--sidebar-accent:color-mix(in_oklab,var(--color-primary)_14%,transparent)]',
'[--sidebar-accent-foreground:var(--color-primary)]',
)}
style={
{
'--sidebar-width': '260px',
'--sidebar-width-icon': '62px',
'--header-height': '56px',
'--sidebar-width': '240px',
} as CSSProperties
}
>
<Sidebar collapsible="icon">
<SidebarHeader className="gap-2">
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
B
</div>
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">EvoBGP</span>
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
</div>
</div>
<div className="px-2 group-data-[collapsible=icon]:px-0">
<CommandPalette items={COMMAND_ITEMS} />
</div>
<SidebarHeader>
<AppSwitcher />
</SidebarHeader>
<SidebarContent>
{NAV_GROUPS.map((group) => (
@@ -187,8 +188,8 @@ export function AppShell({ children }: { children: ReactNode }) {
<SidebarFooter />
</Sidebar>
<SidebarInset>
<header className="sticky top-0 z-10 flex h-(--header-height) shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<SidebarTrigger />
<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>
@@ -206,12 +207,16 @@ export function AppShell({ children }: { children: ReactNode }) {
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
<ModeToggle />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</SidebarInset>
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
</SidebarProvider>
</TooltipProvider>
)
@@ -0,0 +1,93 @@
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'
/** 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 />
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings" search={{ tab: 'connection' }} />}
className="justify-center text-sm font-medium"
>
Настройки
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -29,9 +29,11 @@ export type CommandPaletteItem = {
interface CommandPaletteProps {
items: CommandPaletteItem[]
className?: string
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
hotkeyOnly?: boolean
}
export function CommandPalette({ items }: CommandPaletteProps) {
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const searchInputId = useId()
@@ -68,25 +70,27 @@ export function CommandPalette({ items }: CommandPaletteProps) {
return (
<>
<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>
{!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}
+22
View File
@@ -0,0 +1,22 @@
import {
DEFAULT_APP_SWITCHER_CONFIG,
getAppSwitcherConfig,
getAppUrl as getAppUrlFromConfig,
type AppSwitcherConfig,
} from '@/lib/app-switcher-config'
/** Env-backed app switcher (no DB API in EvoBGP v1). */
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
isLoading: boolean
} {
return {
config: getAppSwitcherConfig(),
isLoading: false,
}
}
export function useAppUrl(appId: string): string | undefined {
const { config } = useAppSwitcherConfig()
return getAppUrlFromConfig(appId, config ?? DEFAULT_APP_SWITCHER_CONFIG)
}
+102
View File
@@ -0,0 +1,102 @@
import {
ChartBarIcon,
CloudIcon,
GlobeIcon,
LayoutDashboardIcon,
ServerIcon,
type LucideIcon,
} from 'lucide-react'
import { z } from 'zod'
export const CURRENT_APP_ID = 'evobgp'
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema>
export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = {
server: ServerIcon,
cloud: CloudIcon,
globe: GlobeIcon,
dashboard: LayoutDashboardIcon,
chart: ChartBarIcon,
}
const appSwitcherEntrySchema = z.object({
id: z.string(),
name: z.string(),
subtitle: z.string().optional(),
url: z.string(),
icon: appSwitcherIconSchema.default('server'),
shortcut: z.string().optional(),
})
const appSwitcherConfigSchema = z.object({
menuLabel: z.string().default('Приложения'),
apps: z.array(appSwitcherEntrySchema).min(1),
})
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
/** Shared defaults across ops apps — chrome app switcher. */
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
menuLabel: 'Приложения',
apps: [
{
id: 'vps-tracker',
name: 'VPS Tracker',
subtitle: 'Учёт виртуальных серверов',
url: 'http://192.168.100.67:3001',
icon: 'server',
shortcut: '⌘1',
},
{
id: 'cfdm',
name: 'CF Domain Manager',
subtitle: 'Управление доменами',
url: 'http://192.168.100.67:6363',
icon: 'cloud',
shortcut: '⌘2',
},
{
id: 'evobgp',
name: 'EvoBGP',
subtitle: 'BGP маршрутизация',
url: 'http://192.168.100.67:3000',
icon: 'globe',
shortcut: '⌘3',
},
],
}
export function parseAppSwitcherConfig(raw?: string): AppSwitcherConfig {
if (!raw?.trim()) {
return DEFAULT_APP_SWITCHER_CONFIG
}
try {
const parsed = JSON.parse(raw) as unknown
return appSwitcherConfigSchema.parse(parsed)
} catch (error) {
console.warn('Invalid VITE_APP_SWITCHER, using defaults:', error)
return DEFAULT_APP_SWITCHER_CONFIG
}
}
export function getAppSwitcherConfig(): AppSwitcherConfig {
return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER)
}
export function getAppUrl(
appId: string,
config: AppSwitcherConfig = getAppSwitcherConfig(),
): string | undefined {
return config.apps.find((app) => app.id === appId)?.url
}
export function getCurrentApp(
config: AppSwitcherConfig = getAppSwitcherConfig(),
): AppSwitcherEntry {
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_SWITCHER?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
File diff suppressed because one or more lines are too long
+21 -2
View File
@@ -48,9 +48,28 @@ Ops / list / dashboard / detail / settings — только **Frame**, не shad
Gating: KV `ui_show_quick_actions` in `global_settings` via `PATCH /v1/settings` (default `true`).
## Shared App Shell chrome
Эталон: CFDM + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12) · monitor/switchers [app-shell-7](https://reui.io/preview/base/app-shell-7).
При переключении между vps-tracker / CFDM / EvoBGP меняются **только** sidebar nav и `main` content. ClassNames chrome идентичны.
| Токен / зона | Значение |
|--------------|----------|
| `--sidebar-width` | `240px` |
| Sidebar accent | primary **14%** mix + transparent border |
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` (без blur / без `h-16`) |
| Header right | **AppsMenu****SystemMonitorPopover****ModeToggle** |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
| Sidebar header | `AppSwitcher` (не static brand) |
App Switcher ids: `vps-tracker` · `cfdm` · `evobgp`. Override: `VITE_APP_SWITCHER` JSON.
QuickActionGrid icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid `bg-primary` fills. Preview: [stats-12](https://reui.io/preview/base/stats-12).
## System monitor
`SystemMonitorPopover` in app-shell header next to `ModeToggle`. Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
`SystemMonitorPopover` in app-shell header next to `ModeToggle` (после AppsMenu). Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
## MCP workflow
@@ -64,7 +83,7 @@ Primitives: MCP `plugin-shadcn-shadcn` + `@evobgp/ui`.
## Spacing
- Main: `gap-4 md:gap-6`, `px-4 md:px-6`
- AppShell main: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` (shared chrome)
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
- Max 1 primary CTA per screen
- Semantic tokens only (`variant="success"|"info"|"warning"`) — no raw `bg-emerald-*`