diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx
deleted file mode 100644
index ebd3eb0..0000000
--- a/apps/web/src/components/app-sidebar.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Link, useRouterState } from '@tanstack/react-router'
-import { AppSwitcher } from '@/components/app-switcher'
-import { NavUser } from '@/components/layout/nav-user'
-import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
-import { useCan } from '@/lib/permissions'
-import {
- Sidebar,
- SidebarContent,
- SidebarFooter,
- SidebarGroup,
- SidebarGroupContent,
- SidebarGroupLabel,
- SidebarHeader,
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
-} from '@evofw/ui/components/sidebar'
-
-function isNavActive(pathname: string, to: string, exact?: boolean) {
- if (exact) return pathname === to
- return pathname === to || pathname.startsWith(`${to}/`)
-}
-
-function NavSection({
- label,
- items,
- pathname,
- can,
-}: {
- label: string
- items: readonly NavItem[]
- pathname: string
- can: (permission: string) => boolean
-}) {
- const visible = items.filter(
- (item) => !item.permission || can(item.permission),
- )
- if (visible.length === 0) return null
- return (
-
- {label}
-
-
- {visible.map((item) => (
-
-
- }
- >
-
- {item.label}
-
-
- ))}
-
-
-
- )
-}
-
-export function AppSidebar() {
- const pathname = useRouterState({ select: (s) => s.location.pathname })
- const can = useCan()
-
- return (
-
-
-
-
-
- {NAV_SECTIONS.map((section) => (
-
- ))}
-
-
-
-
-
- )
-}
diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx
deleted file mode 100644
index ede102d..0000000
--- a/apps/web/src/components/layout/app-shell.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { CSSProperties, ReactNode } from 'react'
-import { AppSidebar } from '@/components/app-sidebar'
-import { SiteHeader } from '@/components/layout/site-header'
-import { SearchMenu } from '@/components/layout/search-menu'
-import { TooltipProvider } from '@evofw/ui/components/tooltip'
-import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar'
-import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
-
-/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
-export function AppShell({ children }: { children: ReactNode }) {
- return (
-
-
-
- К содержимому
-
-
-
-
-
- {children}
-
-
-
-
-
- )
-}
diff --git a/apps/web/src/components/layout/apps-menu.tsx b/apps/web/src/components/layout/apps-menu.tsx
deleted file mode 100644
index 66d5e74..0000000
--- a/apps/web/src/components/layout/apps-menu.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { Link } from '@tanstack/react-router'
-import { LayoutGridIcon } from 'lucide-react'
-import { Button } from '@evofw/ui/components/button'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@evofw/ui/components/dropdown-menu'
-import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
-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, wired to auth-portal App Switcher. */
-export function AppsMenu() {
- const { config, isLoading } = useAppSwitcherConfig()
- const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
-
- return (
-
-
- }
- >
-
-
-
-
-
-
- {isLoading ? 'Загрузка…' : config.menuLabel}
-
-
- {config.apps.map((app) => {
- const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
- const isCurrent = app.id === CURRENT_APP_ID
-
- if (isCurrent) {
- return (
-
-
-
-
- {app.name}
-
- )
- }
-
- return (
- }
- className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
- >
-
-
-
- {app.name}
-
- )
- })}
-
-
- {isAuthEnabled() ? (
- }
- className="justify-center text-sm font-medium"
- >
- Настроить на портале
-
- ) : (
- }
- className="justify-center text-sm font-medium"
- >
- Настройки
-
- )}
-
-
-
- )
-}
diff --git a/apps/web/src/components/layout/nav-user.tsx b/apps/web/src/components/layout/nav-user.tsx
deleted file mode 100644
index 120f175..0000000
--- a/apps/web/src/components/layout/nav-user.tsx
+++ /dev/null
@@ -1,212 +0,0 @@
-import { Link } from '@tanstack/react-router'
-import { useEffect, useState } from 'react'
-import { useTheme } from 'next-themes'
-import {
- ChevronsUpDownIcon,
- LogOutIcon,
- MonitorIcon,
- MoonIcon,
- PaletteIcon,
- SettingsIcon,
- SunIcon,
-} from 'lucide-react'
-
-import { cn } from '@evofw/ui/lib/utils'
-import { Avatar, AvatarFallback } from '@evofw/ui/components/avatar'
-import { Button } from '@evofw/ui/components/button'
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuGroup,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from '@evofw/ui/components/dropdown-menu'
-import {
- SidebarMenu,
- SidebarMenuButton,
- SidebarMenuItem,
- useSidebar,
-} from '@evofw/ui/components/sidebar'
-
-import {
- authPortalUrl,
- getClaims,
- isAuthEnabled,
- logout,
-} from '@/lib/auth'
-
-/** Sidebar footer NavUser — theme in footer, not header. @see https://reui.io/preview/base/app-shell-1 */
-
-const THEMES = [
- {
- value: 'light',
- label: 'Светлая',
- icon: ,
- },
- {
- value: 'dark',
- label: 'Тёмная',
- icon: ,
- },
- {
- value: 'system',
- label: 'Системная',
- icon: ,
- },
-] as const
-
-function ThemeSegmentedToggle() {
- const { theme, setTheme } = useTheme()
- const [mounted, setMounted] = useState(false)
-
- useEffect(() => {
- setMounted(true)
- }, [])
-
- const currentTheme = mounted ? (theme ?? 'system') : 'system'
-
- return (
-
e.stopPropagation()}
- onPointerDown={(e) => e.stopPropagation()}
- >
- {THEMES.map(({ value, label, icon }) => {
- const isActive = currentTheme === value
- return (
-
- )
- })}
-
- )
-}
-
-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 ? '' : 'auth выключен')
- const fallback = initials(name || email)
-
- return (
-
-
-
-
- }
- >
-
- {fallback}
-
-
- {name}
-
- {email || '—'}
-
-
-
-
-
-
-
-
-
- {fallback}
-
-
-
- {name}
-
- {email || '—'}
-
-
-
-
-
-
-
-
- }
- >
-
- Настройки
-
- {authOn ? (
- }
- >
- Auth Portal
-
- ) : null}
-
-
- Тема
-
-
-
-
-
-
- {authOn ? (
- <>
-
-
- logout()}>
-
- Выйти
-
-
- >
- ) : null}
-
-
-
-
- )
-}
diff --git a/apps/web/src/components/layout/portal-shell.tsx b/apps/web/src/components/layout/portal-shell.tsx
new file mode 100644
index 0000000..13a8ace
--- /dev/null
+++ b/apps/web/src/components/layout/portal-shell.tsx
@@ -0,0 +1,174 @@
+import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { PlusIcon, ServerIcon, ShieldPlusIcon } from 'lucide-react'
+import { AppSwitcher } from '@/components/app-switcher'
+import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
+import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
+import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
+import { getClaims, isAuthEnabled, logout } from '@/lib/auth'
+import { NAV_ITEMS, NAV_SECTIONS } from '@/lib/nav'
+import { useCan } from '@/lib/permissions'
+import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
+import { agentsQueryOptions, dashboardQueryOptions, listsQueryOptions, policySetsQueryOptions } from '@/queries'
+import { Skeleton } from '@evofw/ui/components/skeleton'
+import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar'
+import { TooltipProvider } from '@evofw/ui/components/tooltip'
+
+const FRESH: Record = {
+ '/agents': { badge: 'Live', text: 'GET /api/v1/agents · статус и последняя связь агентов обновляются раз в минуту' },
+ '/lists': { badge: 'Списки', text: 'GET /api/v1/lists · статические, JSON-URL и community EvoBGP' },
+ '/rules': { badge: 'Политики', text: 'GET /api/v1/policy-sets · порядок правил задаёт приоритет' },
+ '/stats': { badge: 'Live', text: 'Счётчики пакетов с агентов · накопительно до сброса' },
+ '/settings': { badge: 'Настройки', text: 'Действие по умолчанию, интеграция с EvoBGP и ссылки установки' },
+ '/': { badge: 'Live', text: 'GET /api/v1/dashboard · агенты, списки и пакеты' },
+}
+
+function freshFor(pathname: string) {
+ const key = Object.keys(FRESH)
+ .filter((k) => k !== '/')
+ .sort((a, b) => b.length - a.length)
+ .find((k) => pathname === k || pathname.startsWith(`${k}/`))
+ return FRESH[key ?? '/']!
+}
+
+function initialsOf(name: string) {
+ const parts = name.trim().split(/[\s@._-]+/).filter(Boolean)
+ return ((parts[0]?.[0] ?? '?') + (parts[1]?.[0] ?? '')).toUpperCase()
+}
+
+/** Меню и данные оболочки EvoFirewall: пункты по правам, точки проблем и монитор из живых запросов. */
+function useChromeData(): ChromeConfig {
+ const can = useCan()
+ const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 })
+ const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 })
+ const listsQ = useQuery({ ...listsQueryOptions(), refetchInterval: 60_000 })
+ const setsQ = useQuery({ ...policySetsQueryOptions(), refetchInterval: 60_000 })
+
+ return useMemo(() => {
+ const d = dashQ.data
+ const agents = agentsQ.data?.items ?? []
+ const lists = listsQ.data?.items ?? []
+ const sets = setsQ.data?.items ?? []
+ const approved = agents.filter((a) => a.status === 'approved').length
+ const online = d?.agents_online ?? agents.filter((a) => a.last_seen_at).length
+ const pending = d?.agents_pending ?? agents.filter((a) => a.status === 'pending').length
+ const offline = Math.max(0, approved - online)
+ const onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0
+ const apiOk = !dashQ.isError
+ const failedLists = lists.filter((l) => l.last_error).length
+ const rulesCount = sets.reduce((n, s) => n + (s.rules_count ?? 0), 0)
+
+ const problems: Record = {
+ '/agents': pending > 0 ? { tone: 'wn', label: `${pending} агентов ждут одобрения` } : offline > 0 ? { tone: 'er', label: `${offline} агентов офлайн` } : undefined,
+ '/lists': failedLists > 0 ? { tone: 'er', label: `Ошибок обновления списков: ${failedLists}` } : undefined,
+ }
+ const groupLabel = Object.fromEntries(NAV_SECTIONS.map((s) => [s.id, s.label]))
+ const visible = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
+ const navItems: NavItem[] = visible.map((item) => ({
+ id: item.to,
+ label: item.label,
+ to: item.to,
+ icon: item.icon,
+ group: groupLabel[item.section]!,
+ problem: problems[item.to],
+ }))
+ const groups = [...new Set(navItems.map((i) => i.group))]
+
+ const monitorItems: MonitorItem[] = []
+ if (!apiOk) monitorItems.push({ id: 'api', tone: 'er', title: 'API недоступен', text: 'GET /api/v1/dashboard вернул ошибку', age: 'сейчас', to: '/' })
+ if (pending > 0) monitorItems.push({ id: 'pending', tone: 'wn', title: `Ждут одобрения: ${pending}`, text: 'Новые агенты не получают политику, пока их не одобрят', age: 'сейчас', to: '/agents' })
+ if (offline > 0) monitorItems.push({ id: 'offline', tone: 'er', title: `Агентов офлайн: ${offline}`, text: `Онлайн ${online} из ${approved} одобренных`, age: 'сейчас', to: '/agents' })
+ if (failedLists > 0) monitorItems.push({ id: 'lists', tone: 'wn', title: `Списков с ошибкой: ${failedLists}`, text: 'Проверьте источник списка и повторите обновление', age: 'недавно', to: '/lists' })
+ if (monitorItems.length === 0) monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, агенты и списки без проблем', age: 'сейчас', to: '/' })
+
+ const metrics: MonitorMetric[] = [
+ { l: 'API', v: apiOk ? 'OK' : 'ERR', p: apiOk ? 100 : 0, tone: apiOk ? 'success' : 'warning' },
+ { l: 'Агенты online', v: `${onlinePct}%`, p: onlinePct, tone: onlinePct >= 80 ? 'success' : 'warning' },
+ { l: 'Списки IP', v: String(d?.lists_total ?? lists.length), p: lists.length ? Math.round(((lists.length - failedLists) / lists.length) * 100) : 100, tone: failedLists > 0 ? 'warning' : 'success' },
+ { l: 'Правила', v: String(rulesCount), p: Math.min(100, rulesCount), tone: 'success' },
+ ]
+
+ const claims = getClaims()
+ const name = claims?.name?.trim() || (isAuthEnabled() ? 'Пользователь' : 'Гость')
+ const email = claims?.email?.trim() || (isAuthEnabled() ? '' : 'auth выключен')
+
+ const objects: PaletteObject[] = [
+ ...agents.slice(0, 50).map((a) => ({
+ id: `agent-${a.id}`,
+ group: 'Агенты',
+ label: a.name,
+ hint: `${a.platform}${a.last_seen_ip ? ` · ${a.last_seen_ip}` : ''}`,
+ dot: a.status !== 'approved' ? 'mu' : a.last_seen_at ? 'ok' : 'er',
+ to: `/agents/${a.id}`,
+ })),
+ ...lists.slice(0, 50).map((l) => ({ id: `list-${l.id}`, group: 'Списки', label: l.name, hint: l.type, dot: l.last_error ? 'er' : 'ok', to: `/lists/${l.id}` })),
+ ...sets.slice(0, 50).map((s) => ({ id: `set-${s.id}`, group: 'Наборы правил', label: s.name, hint: `${s.rules_count ?? 0} правил`, dot: s.enabled ? 'ok' : 'mu', to: `/rules/${s.id}` })),
+ ]
+
+ return {
+ navItems,
+ navGroups: groups,
+ brand: ,
+ user: { name, email, initials: initialsOf(name || email) },
+ securityTo: '/settings',
+ onLogout: logout,
+ fresh: freshFor,
+ monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/agents', footerLabel: 'Агенты' },
+ palette: {
+ actions: [
+ { id: 'act-agent', label: 'Добавить агента', hint: 'A', icon: ServerIcon, to: '/agents' },
+ { id: 'act-list', label: 'Создать список', hint: 'L', icon: PlusIcon, to: '/lists' },
+ { id: 'act-set', label: 'Создать набор правил', hint: 'R', icon: ShieldPlusIcon, to: '/rules' },
+ ],
+ objects,
+ assistantTo: '/agents',
+ example: 'Подсказка: «агенты офлайн»',
+ },
+ }
+ }, [can, dashQ.data, dashQ.isError, agentsQ.data, listsQ.data, setsQ.data])
+}
+
+/** Оболочка EvoFirewall: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */
+export function PortalShell({ children }: { children: ReactNode }) {
+ const [open, setOpen] = useState(true)
+ const [mounted, setMounted] = useState(false)
+ useEffect(() => {
+ // До 1100px меню сворачивается в колонку иконок (на телефоне его заменяет Sheet).
+ const mq = window.matchMedia('(min-width: 1100px)')
+ const sync = () => setOpen(mq.matches)
+ sync()
+ mq.addEventListener('change', sync)
+ setMounted(true)
+ return () => mq.removeEventListener('change', sync)
+ }, [])
+ const config = useChromeData()
+
+ if (!mounted) {
+ return (
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ К содержимому
+
+
+
+
+ {children}
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/layout/search-menu.tsx b/apps/web/src/components/layout/search-menu.tsx
deleted file mode 100644
index f3ef27d..0000000
--- a/apps/web/src/components/layout/search-menu.tsx
+++ /dev/null
@@ -1,220 +0,0 @@
-import { useEffect, useState } from 'react'
-import { useNavigate } from '@tanstack/react-router'
-import { useQuery } from '@tanstack/react-query'
-import {
- ListIcon,
- ListPlusIcon,
- PlusIcon,
- ServerIcon,
- ShieldIcon,
- ShieldPlusIcon,
-} from 'lucide-react'
-import { NAV_ITEMS } from '@/lib/nav'
-import { useCan } from '@/lib/permissions'
-import {
- agentsQueryOptions,
- listsQueryOptions,
- policySetsQueryOptions,
-} from '@/queries'
-import {
- CommandDialog,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
- CommandSeparator,
-} from '@evofw/ui/components/command'
-
-/**
- * Command-K palette — навигация, поиск ресурсов и действия.
- * DNA: https://reui.io/components/command/c-command-7 (hotkey-only по контракту).
- */
-
-export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
- void hotkeyOnly
- return
-}
-
-function SearchMenuDialog() {
- const [open, setOpen] = useState(false)
- const navigate = useNavigate()
- const can = useCan()
-
- const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: open })
- const listsQ = useQuery({ ...listsQueryOptions(), enabled: open })
- const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: open })
-
- useEffect(() => {
- function onKeyDown(event: KeyboardEvent) {
- if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
- event.preventDefault()
- setOpen((prev) => !prev)
- }
- }
-
- window.addEventListener('keydown', onKeyDown)
- return () => window.removeEventListener('keydown', onKeyDown)
- }, [])
-
- const go = (run: () => void) => {
- setOpen(false)
- run()
- }
-
- const navItems = NAV_ITEMS.filter(
- (item) => !item.permission || can(item.permission),
- )
-
- const agents = (agentsQ.data?.items ?? []).slice(0, 50)
- const lists = (listsQ.data?.items ?? []).slice(0, 50)
- const sets = (setsQ.data?.items ?? []).slice(0, 50)
-
- return (
-
-
-
- Ничего не найдено.
-
-
- {can('fw:agents:write') ? (
-
- go(() =>
- void navigate({
- to: '/agents',
- search: { view: 'cards', add: true },
- }),
- )
- }
- >
-
- Добавить агента
-
- ) : null}
- {can('fw:lists:write') ? (
-
- go(() =>
- void navigate({ to: '/lists', search: { create: true } }),
- )
- }
- >
-
- Создать список
-
- ) : null}
- {can('fw:policies:write') ? (
-
- go(() =>
- void navigate({ to: '/rules', search: { create: true } }),
- )
- }
- >
-
- Создать набор правил
-
- ) : null}
-
-
-
-
-
- {navItems.map((item) => (
- go(() => void navigate({ to: item.to }))}
- >
-
- {item.label}
-
- ))}
-
-
- {agents.length > 0 ? (
- <>
-
-
- {agents.map((a) => (
-
- go(() =>
- void navigate({
- to: '/agents',
- search: { view: 'cards', agent: a.id },
- }),
- )
- }
- >
-
- {a.name}
-
- {a.hostname ?? a.status}
-
-
- ))}
-
- >
- ) : null}
-
- {lists.length > 0 ? (
- <>
-
-
- {lists.map((l) => (
-
- go(() => void navigate({ to: '/lists/$id', params: { id: l.id } }))
- }
- >
-
- {l.name}
-
- ))}
-
- >
- ) : null}
-
- {sets.length > 0 ? (
- <>
-
-
- {sets.map((s) => (
-
- go(() =>
- void navigate({
- to: '/rules/$setId',
- params: { setId: s.id },
- }),
- )
- }
- >
-
- {s.name}
-
- ))}
-
- >
- ) : null}
-
-
- )
-}
diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx
deleted file mode 100644
index a9093cf..0000000
--- a/apps/web/src/components/layout/site-header.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { Link, useMatches, useRouterState } from '@tanstack/react-router'
-import { useMemo } from 'react'
-import {
- Breadcrumb,
- BreadcrumbItem,
- BreadcrumbLink,
- BreadcrumbList,
- BreadcrumbPage,
- BreadcrumbSeparator,
-} from '@evofw/ui/components/breadcrumb'
-import { Separator } from '@evofw/ui/components/separator'
-import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
-import { AppsMenu } from '@/components/layout/apps-menu'
-import { SidebarTrigger } from '@evofw/ui/components/sidebar'
-import { navLabel, navParentForDetail } from '@/lib/nav'
-
-export interface RouteBreadcrumbLoaderData {
- breadcrumb?: string
-}
-
-function getBreadcrumbs(
- pathname: string,
- dynamicLabels: Record,
-) {
- if (pathname === '/') {
- return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
- }
-
- const parentTo = navParentForDetail(pathname)
- if (parentTo) {
- const parentLabel = navLabel(parentTo)
- const fallback =
- parentTo === '/agents'
- ? 'Агент'
- : parentTo === '/lists'
- ? 'Список'
- : 'Набор'
- return [
- { label: parentLabel ?? parentTo, href: parentTo },
- { label: dynamicLabels[pathname] ?? fallback, href: pathname },
- ]
- }
-
- const title = navLabel(pathname)
- if (title) {
- return [{ label: title, href: pathname }]
- }
-
- return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
-}
-
-function useDynamicBreadcrumbLabels() {
- const matches = useMatches()
- return useMemo(() => {
- const labels: Record = {}
- for (const match of matches) {
- const data = match.loaderData as RouteBreadcrumbLoaderData | undefined
- if (data?.breadcrumb && match.pathname) {
- labels[match.pathname] = data.breadcrumb
- }
- }
- return labels
- }, [matches])
-}
-
-/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser. */
-export function SiteHeader() {
- const pathname = useRouterState({ select: (s) => s.location.pathname })
- const dynamicLabels = useDynamicBreadcrumbLabels()
- const crumbs = getBreadcrumbs(pathname, dynamicLabels)
-
- return (
-
-
-
-
-
- {crumbs.map((crumb, index) => {
- const isLast = index === crumbs.length - 1
- return (
-
- {index > 0 && (
-
- )}
-
- {isLast ? (
- {crumb.label}
- ) : (
- }>
- {crumb.label}
-
- )}
-
-
- )
- })}
-
-
-
-
- )
-}
diff --git a/apps/web/src/components/layout/system-monitor-popover.tsx b/apps/web/src/components/layout/system-monitor-popover.tsx
deleted file mode 100644
index 45f7d05..0000000
--- a/apps/web/src/components/layout/system-monitor-popover.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-import { useMemo, type CSSProperties, type ReactNode } from 'react'
-import { useQuery } from '@tanstack/react-query'
-import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react'
-import { formatTime } from '@/lib/format'
-
-import { Badge } from '@/components/reui/badge'
-import { cn } from '@evofw/ui/lib/utils'
-import { Item, ItemMedia } from '@evofw/ui/components/item'
-import { Popover, PopoverContent, PopoverTrigger } from '@evofw/ui/components/popover'
-import { Progress } from '@evofw/ui/components/progress'
-import {
- agentsQueryOptions,
- dashboardQueryOptions,
- listsQueryOptions,
- policySetsQueryOptions,
-} from '@/queries'
-
-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 (
-
-
-
- -
-
- {metric.icon}
-
-
-
- {metric.label}
-
-
-
- {metric.value}
-
- {metric.unit}
-
-
-
-
-
- )
-}
-
-/** Live system monitor — CFDM/EvoBGP chrome pill. Preview: app-shell-12 */
-export function SystemMonitorPopover() {
- const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 })
- const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 })
- const listsQ = useQuery({ ...listsQueryOptions(), refetchInterval: 60_000 })
- const setsQ = useQuery({
- ...policySetsQueryOptions(),
- refetchInterval: 60_000,
- })
-
- const d = dashQ.data
- const agents = agentsQ.data?.items ?? []
- const approved = agents.filter((a) => a.status === 'approved').length
- const online = d?.agents_online ?? agents.filter((a) => a.last_seen_at).length
- const pending =
- d?.agents_pending ?? agents.filter((a) => a.status === 'pending').length
- const onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0
- const listsCount = listsQ.data?.items?.length ?? d?.lists_total ?? 0
- const rulesCount =
- setsQ.data?.items.reduce((n, s) => n + (s.rules_count ?? 0), 0) ?? 0
- const apiOk = !dashQ.isError
-
- const metrics = useMemo(
- () => [
- {
- id: 'api',
- label: 'API',
- value: apiOk ? 'OK' : 'ERR',
- unit: '',
- percent: apiOk ? 100 : 0,
- icon: ,
- tone: apiOk ? 'success' : 'destructive',
- alert: !apiOk,
- },
- {
- id: 'agents',
- label: 'Агенты online',
- value: String(onlinePct),
- unit: '%',
- percent: onlinePct,
- icon: ,
- tone:
- onlinePct >= 80
- ? 'success'
- : onlinePct >= 40
- ? 'warning'
- : 'destructive',
- alert: pending > 0 || (approved > 0 && onlinePct < 50),
- },
- {
- id: 'lists',
- label: 'Списки IP',
- value: String(listsCount),
- unit: '',
- percent: Math.min(100, listsCount * 10),
- icon:
,
- tone: 'info',
- alert: false,
- },
- {
- id: 'rules',
- label: 'Правила',
- value: String(rulesCount),
- unit: '',
- percent: Math.min(100, rulesCount * 10),
- icon: ,
- tone: 'info',
- alert: false,
- },
- ],
- [apiOk, onlinePct, pending, approved, listsCount, rulesCount],
- )
-
- const spiking = metrics.some((m) => m.alert)
-
- return (
-
-
- }
- >
-
-
- {spiking ? (
-
- ) : null}
-
-
- Система
-
-
- {spiking ? 'Внимание' : 'Норма'}
-
-
-
-
-
-
- Монитор EvoFirewall
-
-
- {formatTime(new Date())}
-
-
-
- {metrics.map((metric, i) => (
-
= 2 && 'border-border border-t',
- )}
- >
-
-
- ))}
-
-
- Pending:{' '}
-
- {pending}
-
- {' · '}
- Online:{' '}
-
- {online}/{approved || '—'}
-
-
-
-
- )
-}
diff --git a/apps/web/src/components/portal/bits.tsx b/apps/web/src/components/portal/bits.tsx
new file mode 100644
index 0000000..858765d
--- /dev/null
+++ b/apps/web/src/components/portal/bits.tsx
@@ -0,0 +1,481 @@
+import { useState, type ReactNode } from 'react'
+import { CircleAlertIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-react'
+import { toast } from 'sonner'
+import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/reui/alert'
+import { Badge } from '@/components/reui/badge'
+import {
+ Frame,
+ FrameDescription,
+ FrameHeader,
+ FramePanel,
+ FrameTitle,
+} from '@/components/reui/frame'
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@evofw/ui/components/alert-dialog'
+import { Button } from '@evofw/ui/components/button'
+import { cn } from '@evofw/ui/lib/utils'
+import { Input } from '@evofw/ui/components/input'
+import { Label } from '@evofw/ui/components/label'
+import { Tabs, TabsList, TabsTrigger } from '@evofw/ui/components/tabs'
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from '@evofw/ui/components/sheet'
+import type { Tone } from './tone'
+import { TONE_BADGE } from './tone'
+
+const TONE_GLYPH: Record = { ok: '●', wn: '▲', er: '✕', mu: '○', in: '' }
+
+/** Плашка статуса: слово + глиф (● ▲ ✕ ○), чтобы цвет не был единственным признаком. */
+export function StatusBadge({ tone, children }: { tone: Tone; children: ReactNode }) {
+ const glyph = TONE_GLYPH[tone]
+ return (
+
+ {glyph ? {glyph} : null}
+ {children}
+
+ )
+}
+
+/** Заголовок страницы: название, описание, метка свежести и действия. */
+export function PageHead({
+ title,
+ sub,
+ actions,
+}: {
+ title: string
+ sub: string
+ actions?: ReactNode
+}) {
+ return (
+
+
+ {actions ? {actions}
: null}
+
+ )
+}
+
+const ALERT_ICON = {
+ er: ,
+ wn: ,
+ in: ,
+} as const
+
+const ALERT_VARIANT = {
+ er: 'destructive',
+ wn: 'warning',
+ in: 'info',
+} as const
+
+export function SignalAlert({
+ tone,
+ title,
+ children,
+ action,
+}: {
+ tone: 'er' | 'wn' | 'in'
+ title: string
+ children: ReactNode
+ action?: ReactNode
+}) {
+ return (
+
+ {ALERT_ICON[tone]}
+ {title}
+ {children}
+ {action ? {action} : null}
+
+ )
+}
+
+/** Боковое окно (как CreateUserSheet): заголовок, прокручиваемое тело, подвал. */
+export function PortalSheet({
+ open,
+ onOpenChange,
+ title,
+ description,
+ children,
+ footer,
+ size = 'md',
+ dirty,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ title: string
+ description: string
+ children: ReactNode
+ footer?: ReactNode
+ /** Ширина панели: md — форма, lg — редактор с двумя колонками. */
+ size?: 'md' | 'lg'
+ /** Явный признак изменений (иначе ловим ввод в полях и переключатели). */
+ dirty?: boolean
+}) {
+ const [touchedState, setTouched] = useState(false)
+ const touched = dirty ?? touchedState
+ const [guard, setGuard] = useState(false)
+
+ function requestChange(next: boolean) {
+ if (!next && touched) {
+ setGuard(true)
+ return
+ }
+ if (!next) setTouched(false)
+ onOpenChange(next)
+ }
+
+ return (
+ <>
+
+
+
+
+ {title}
+
+ }
+ >
+
+
+
+
+ {description}
+
+
+ setTouched(true)}
+ onClick={(e) => {
+ if ((e.target as HTMLElement).closest('[role=switch]')) setTouched(true)
+ }}
+ >
+ {children}
+
+ {footer ? (
+ {
+ // «Закрыть» и «Отмена» при изменённой форме тоже спрашивают подтверждение.
+ const label = (e.target as HTMLElement).closest('button')?.textContent?.trim()
+ if (touched && (label === 'Закрыть' || label === 'Отмена')) {
+ e.stopPropagation()
+ setGuard(true)
+ }
+ }}
+ >{footer}
+ ) : null}
+
+
+
+
+
+ Закрыть без сохранения?
+ Изменения в форме будут потеряны.
+
+
+ Остаться
+
+
+
+
+ >
+ )
+}
+
+/** Подтверждение опасного действия вводом слова (email, «сбросить», «очистить»…). */
+export function TypedConfirm({
+ open,
+ onOpenChange,
+ title,
+ description,
+ expected,
+ confirmLabel,
+ onConfirm,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ title: string
+ description: ReactNode
+ expected: string
+ confirmLabel: string
+ onConfirm: () => void
+}) {
+ const [value, setValue] = useState('')
+ const matches = value.trim().toLowerCase() === expected.toLowerCase()
+
+ return (
+ {
+ if (!next) setValue('')
+ onOpenChange(next)
+ }}
+ >
+
+
+ {title}
+ {description}
+
+
+
+ setValue(e.target.value)}
+ aria-invalid={value.length > 0 && !matches}
+ />
+
+
+ Отмена
+
+
+
+
+ )
+}
+
+export function KvFrame({
+ title,
+ description,
+ items,
+}: {
+ title: string
+ description: string
+ items: Array<{ label: string; value: ReactNode }>
+}) {
+ return (
+
+
+ {title}
+ {description}
+
+
+
+ {items.map((item) => (
+
+
- {item.label}
+ - {item.value}
+
+ ))}
+
+
+
+ )
+}
+
+/** Список «значение + подпись + копировать» (параметры issuer). */
+export function CopyList({
+ title,
+ description,
+ items,
+}: {
+ title: string
+ description: string
+ items: Array<{ label: string; value: string }>
+}) {
+ return (
+
+
+ {title}
+ {description}
+
+
+
+ {items.map((item) => (
+ -
+
{item.value}
+ {item.label}
+
+
+ ))}
+
+
+
+ )
+}
+
+/** Компактный список «ключ — значение» для боковых окон. */
+export function KvList({ items }: { items: Array<[string, string]> }) {
+ return (
+
+ {items.map(([k, v]) => (
+
+
- {k}
+ - {v}
+
+ ))}
+
+ )
+}
+
+export function CodeBlock({ label, children }: { label: string; children: string }) {
+ return (
+
+ )
+}
+
+/** Сегментированный переключатель на Tabs (вид таблицы, период). */
+export function Segmented({
+ label,
+ options,
+ value,
+ onChange,
+}: {
+ label: string
+ options: string[]
+ value: number
+ onChange: (index: number) => void
+}) {
+ return (
+ onChange(Number(v))} aria-label={label} className="max-w-full">
+
+ {options.map((o, i) => (
+
+ {o}
+
+ ))}
+
+
+ )
+}
+
+/** Плашка строки с накопленной правкой: «Не сохранено» или «Будет удалено». */
+export function PendingChip({ mark }: { mark: 'new' | 'dirty' | 'deleted' }) {
+ return (
+
+ {mark === 'deleted' ? 'Будет удалено' : 'Не сохранено'}
+
+ )
+}
+
+/** Вкладки страницы как на досках: линия-подчёркивание, число — мелким индексом. */
+export function PageTabsList({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function PageTab({ value, label, count }: { value: string; label: string; count?: number | string }) {
+ return (
+
+ {label}
+ {count !== undefined ? {count} : null}
+
+ )
+}
+
+/** Строка настройки как на досках: слева название и описание, справа элемент управления. */
+export function SettingRow({ title, description, children }: { title: string; description: string; children: ReactNode }) {
+ return (
+
+
+ {title}
+ {description}
+
+
{children}
+
+ )
+}
+
+/** Frame с заголовком, итоговой плашкой справа и строками настроек (блок «form» досок). */
+export function FormFrame({
+ title,
+ description,
+ badge,
+ tone = 'ok',
+ children,
+}: {
+ title: string
+ description: string
+ badge: string
+ tone?: Tone
+ children: ReactNode
+}) {
+ return (
+
+
+
+ {title}
+ {description}
+
+ {badge}
+
+ {children}
+
+ )
+}
diff --git a/apps/web/src/components/portal/chrome-context.tsx b/apps/web/src/components/portal/chrome-context.tsx
new file mode 100644
index 0000000..6d8aeb7
--- /dev/null
+++ b/apps/web/src/components/portal/chrome-context.tsx
@@ -0,0 +1,83 @@
+import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
+import { ChromeConfigContext, UiStoreContext, type ChromeConfig, type Contrast, type Density, type UiStore } from './chrome-store'
+
+export function ChromeConfigProvider({ value, children }: { value: ChromeConfig; children: ReactNode }) {
+ return {children}
+}
+
+function readStored(key: string, fallback: T): T {
+ try {
+ const raw = window.localStorage.getItem(key)
+ return raw ? (JSON.parse(raw) as T) : fallback
+ } catch {
+ return fallback
+ }
+}
+
+function writeStored(key: string, value: unknown) {
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value))
+ } catch {
+ /* без сохранения настроек */
+ }
+}
+
+/** Плотность, контраст, закреплённые пункты и палитра ⌘K. Атрибуты html[data-density|contrast] читает portal-theme.css. */
+export function PortalUiProvider({ children, storageKey = 'portal-ui' }: { children: ReactNode; storageKey?: string }) {
+ const [paletteOpen, setPaletteOpen] = useState(false)
+ const [density, setDensityState] = useState(() => readStored(`${storageKey}:density`, 'comfortable'))
+ const [contrast, setContrastState] = useState(() => readStored(`${storageKey}:contrast`, 'standard'))
+ const [pins, setPins] = useState(() => readStored(`${storageKey}:pins`, []))
+
+ const setDensity = useCallback(
+ (v: Density) => {
+ setDensityState(v)
+ writeStored(`${storageKey}:density`, v)
+ },
+ [storageKey],
+ )
+ const setContrast = useCallback(
+ (v: Contrast) => {
+ setContrastState(v)
+ writeStored(`${storageKey}:contrast`, v)
+ },
+ [storageKey],
+ )
+ const togglePin = useCallback(
+ (id: string) => {
+ setPins((prev) => {
+ const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
+ writeStored(`${storageKey}:pins`, next)
+ return next
+ })
+ },
+ [storageKey],
+ )
+
+ useEffect(() => {
+ const root = document.documentElement
+ root.dataset.density = density
+ root.dataset.contrast = contrast
+ return () => {
+ delete root.dataset.density
+ delete root.dataset.contrast
+ }
+ }, [density, contrast])
+
+ useEffect(() => {
+ function onKey(e: KeyboardEvent) {
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
+ e.preventDefault()
+ setPaletteOpen((v) => !v)
+ }
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [])
+
+ const value = useMemo(
+ () => ({ paletteOpen, setPaletteOpen, pins, togglePin, density, setDensity, contrast, setContrast }),
+ [paletteOpen, pins, togglePin, density, setDensity, contrast, setContrast],
+ )
+ return {children}
+}
diff --git a/apps/web/src/components/portal/chrome-store.ts b/apps/web/src/components/portal/chrome-store.ts
new file mode 100644
index 0000000..7763f0d
--- /dev/null
+++ b/apps/web/src/components/portal/chrome-store.ts
@@ -0,0 +1,86 @@
+import { createContext, useContext, type ComponentType, type ReactNode } from 'react'
+import type { LucideIcon } from 'lucide-react'
+
+export type NavItem = {
+ id: string
+ label: string
+ to: string
+ icon: LucideIcon
+ group: string
+ /** Точка проблемы в углу иконки. */
+ problem?: { tone: 'er' | 'wn'; label: string }
+}
+
+export type MonitorItem = {
+ id: string
+ tone: 'er' | 'wn' | 'ok'
+ title: string
+ text: string
+ age: string
+ to: string
+}
+
+export type MonitorMetric = { l: string; v: string; p: number; tone: 'warning' | 'success' }
+
+export type PaletteAction = {
+ id: string
+ label: string
+ /** Горячая буква справа. */
+ hint: string
+ icon: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
+ to: string
+}
+
+export type PaletteObject = {
+ id: string
+ group: string
+ label: string
+ hint: string
+ dot?: 'ok' | 'er' | 'mu'
+ to: string
+}
+
+/** Данные оболочки: меню, монитор, палитра и пользователь. В макете — суррогатные, в портале — из API. */
+export type ChromeConfig = {
+ navItems: NavItem[]
+ navGroups: string[]
+ /** Верх бокового меню: бренд и переключатель приложений. */
+ brand: ReactNode
+ user: { name: string; email: string; initials: string }
+ securityTo: string
+ onLogout: () => void
+ fresh: (pathname: string) => { badge: string; text: string }
+ crumbFor?: (pathname: string, label: string | undefined) => string
+ monitor: { items: MonitorItem[]; metrics: MonitorMetric[]; updated: string; footerTo: string; footerLabel: string }
+ palette: { actions: PaletteAction[]; objects: PaletteObject[]; assistantTo: string; example: string }
+}
+
+export const ChromeConfigContext = createContext(null)
+
+export function useChromeConfig(): ChromeConfig {
+ const ctx = useContext(ChromeConfigContext)
+ if (!ctx) throw new Error('ChromeConfigProvider отсутствует')
+ return ctx
+}
+
+export type Density = 'comfortable' | 'compact'
+export type Contrast = 'standard' | 'high'
+
+export type UiStore = {
+ paletteOpen: boolean
+ setPaletteOpen: (open: boolean) => void
+ pins: string[]
+ togglePin: (id: string) => void
+ density: Density
+ setDensity: (v: Density) => void
+ contrast: Contrast
+ setContrast: (v: Contrast) => void
+}
+
+export const UiStoreContext = createContext(null)
+
+export function useUiStore(): UiStore {
+ const ctx = useContext(UiStoreContext)
+ if (!ctx) throw new Error('PortalUiProvider отсутствует')
+ return ctx
+}
diff --git a/apps/web/src/components/portal/chrome.tsx b/apps/web/src/components/portal/chrome.tsx
new file mode 100644
index 0000000..b9be4d3
--- /dev/null
+++ b/apps/web/src/components/portal/chrome.tsx
@@ -0,0 +1,569 @@
+import {
+ useMemo,
+ useState,
+ type ComponentType,
+} from 'react'
+import {
+ Link,
+ useNavigate,
+ useRouterState,
+} from '@tanstack/react-router'
+import {
+ ActivityIcon,
+ ArrowRightIcon,
+ ChevronDownIcon,
+ ChevronsUpDownIcon,
+ FingerprintIcon,
+ InfoIcon,
+ LogOutIcon,
+ SearchIcon,
+ StarIcon,
+ MoonIcon,
+} from 'lucide-react'
+import {
+ useTheme,
+} from 'next-themes'
+import {
+ Badge,
+} from '@/components/reui/badge'
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbList,
+ BreadcrumbPage,
+} from '@evofw/ui/components/breadcrumb'
+import {
+ Button,
+} from '@evofw/ui/components/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+} from '@evofw/ui/components/dialog'
+import {
+ Kbd,
+} from '@evofw/ui/components/kbd'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@evofw/ui/components/popover'
+import {
+ Separator,
+} from '@evofw/ui/components/separator'
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+} from '@evofw/ui/components/sidebar'
+import {
+ cn,
+} from '@evofw/ui/lib/utils'
+import {
+ Segmented,
+} from './bits'
+import {
+ useChromeConfig,
+ useUiStore,
+ type NavItem,
+} from './chrome-store'
+
+const LABEL_CLASS = 'text-muted-foreground text-[11px] font-medium uppercase'
+
+function PortalNavUser() {
+ const { isMobile } = useSidebar()
+ const { theme, setTheme, resolvedTheme } = useTheme()
+ const { density, setDensity, contrast, setContrast } = useUiStore()
+ const { user, securityTo, onLogout } = useChromeConfig()
+ const navigate = useNavigate()
+ const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
+
+ return (
+
+
+
+
+ }
+ >
+
+ {user.initials}
+
+
+ {user.name}
+ {user.email}
+
+
+
+
+
+
+ {user.initials}
+
+
+ {user.name}
+ {user.email}
+
+
+
+
+
+ Тема
+ setTheme(i === 1 ? 'light' : 'dark')}
+ />
+ Плотность
+ setDensity(i === 1 ? 'compact' : 'comfortable')}
+ />
+ Контраст
+ setContrast(i === 1 ? 'high' : 'standard')}
+ />
+
+
+
+
+
+
+
+ )
+}
+
+const DOT_CLASS = { er: 'bg-destructive', wn: 'bg-warning' } as const
+
+function NavEntry({ item, pathname, pinned, onTogglePin }: { item: NavItem; pathname: string; pinned: boolean; onTogglePin: () => void }) {
+ const Icon = item.icon
+ const active = pathname === item.to || pathname.startsWith(`${item.to}/`)
+ return (
+
+ }
+ >
+
+ {item.label}
+ {item.problem ? (
+
+ ) : null}
+
+
+
+
+
+ )
+}
+
+export function PortalSidebar() {
+ const pathname = useRouterState({ select: (s) => s.location.pathname })
+ const { pins, togglePin } = useUiStore()
+ const { navItems, navGroups, brand } = useChromeConfig()
+ const [closed, setClosed] = useState>({})
+ const pinned = navItems.filter((i) => pins.includes(i.id))
+ return (
+
+
+ {brand}
+
+
+ {pinned.length > 0 ? (
+
+ Закреплённые
+
+
+ {pinned.map((item) => (
+ togglePin(item.id)} />
+ ))}
+
+
+
+ ) : null}
+ {navGroups.map((group, gi) => (
+
+ {gi > 0 ? : null}
+
+ setClosed((c) => ({ ...c, [group]: !c[group] }))}
+ />
+ }
+ className="h-7 cursor-pointer justify-between text-[11px] font-semibold tracking-wide uppercase"
+ >
+ {group}
+
+
+
+
+ {navItems.filter((i) => i.group === group).map((item) => (
+ togglePin(item.id)}
+ />
+ ))}
+
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+/** Единый центр состояния: внимание + сводка + список проблем. Адаптивен по ширине окна, как на досках. */
+function MonitorPopover() {
+ const { monitor } = useChromeConfig()
+ const MONITOR_ITEMS = monitor.items
+ const MONITOR_METRICS = monitor.metrics
+ const bad = MONITOR_ITEMS.filter((m) => m.tone !== 'ok')
+ const errors = bad.filter((m) => m.tone === 'er').length
+ const warnings = bad.length - errors
+ const ok = MONITOR_ITEMS.length - bad.length
+ return (
+
+
+ }
+ >
+
+ Внимание
+
+
+ {errors}
+
+ {warnings}
+
+
+ {bad.length}
+
+
+
+
+
+
+ Состояние системы
+ {monitor.updated}
+
+
0 ? "destructive-light" : warnings > 0 ? "warning-light" : "success-light"} size="sm">{errors > 0 ? "Сбой" : warnings > 0 ? "Внимание" : "Норма"}
+
+
+ {[
+ { n: errors, l: 'сбой', c: 'text-destructive' },
+ { n: warnings, l: 'предупреждения', c: 'text-warning' },
+ { n: ok, l: 'в норме', c: 'text-success' },
+ ].map((x) => (
+
+ {x.n}
+ {x.l}
+
+ ))}
+
+
+ {MONITOR_ITEMS.filter((m) => m.tone !== 'ok').map((m) => (
+ -
+
+
+
+ {m.title}
+ {m.text}
+
+ {m.age}
+
+
+ ))}
+
+
+ {MONITOR_METRICS.map((m) => (
+
+ ))}
+
+
+ {monitor.footerLabel}
+
+
+
+
+ )
+}
+
+function FreshnessPopover() {
+ const pathname = useRouterState({ select: (st) => st.location.pathname })
+ const { fresh: freshFor } = useChromeConfig()
+ const fresh = freshFor(pathname)
+ return (
+
+
+ }
+ >
+
+
+
+ {fresh.badge}
+ {fresh.text}
+
+
+ )
+}
+
+export function PortalHeader() {
+ const pathname = useRouterState({ select: (s) => s.location.pathname })
+ const { setPaletteOpen } = useUiStore()
+ const { navItems, crumbFor } = useChromeConfig()
+ const current = navItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`))
+
+ return (
+
+ )
+}
+
+type PaletteEntry = {
+ id: string
+ group: string
+ label: string
+ hint: string
+ icon?: ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
+ dot?: 'ok' | 'er' | 'mu'
+ run: () => void
+}
+
+const DOT_BG = { ok: 'bg-success', er: 'bg-destructive', mu: 'bg-muted-foreground' } as const
+
+/** Палитра ⌘K как на доске: 640px, группы заглавными, подсказки моноширинным справа, подвал с подсказкой. */
+export function CommandPalette() {
+ const { paletteOpen, setPaletteOpen } = useUiStore()
+ const { navItems, palette } = useChromeConfig()
+ const navigate = useNavigate()
+ const { theme, setTheme, resolvedTheme } = useTheme()
+ const [query, setQuery] = useState('')
+ const [active, setActive] = useState(0)
+
+ const shown = useMemo(() => {
+ const q = query.trim().toLowerCase()
+ const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
+ const go = (to: string) => () => void navigate({ to })
+ const current = theme === 'light' || theme === 'dark' ? theme : resolvedTheme === 'light' ? 'light' : 'dark'
+ const list: PaletteEntry[] = [
+ ...[
+ ...palette.actions.map((x) => ({ id: x.id, label: x.label, hint: x.hint, icon: x.icon, run: go(x.to) })),
+ { id: 'act-theme', label: 'Переключить тему', hint: 'T', icon: MoonIcon, run: () => setTheme(current === 'dark' ? 'light' : 'dark') },
+ ]
+ .filter((e) => match(e.label))
+ .map((e) => ({ ...e, group: 'Действия' })),
+ ...navItems.filter((i) => match(i.label, i.group))
+ .slice(0, 7)
+ .map((i) => ({ id: `nav-${i.id}`, group: 'Разделы', label: i.label, hint: i.group, icon: i.icon, run: go(i.to) })),
+ ...Object.values(
+ palette.objects
+ .filter((o) => match(o.label, o.hint))
+ .reduce>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
+ ).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
+ ]
+ // Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
+ if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
+ return [
+ {
+ id: 'ai',
+ group: 'Ассистент · запрос на естественном языке',
+ label: `Понял так: «${query.trim()}»`,
+ hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
+ run: () => void navigate({ to: palette.assistantTo }),
+ } as PaletteEntry,
+ ...list,
+ ]
+ }
+ return list
+ }, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
+
+ const groups = useMemo(() => {
+ const map = new Map>()
+ shown.forEach((e, index) => {
+ const list = map.get(e.group) ?? []
+ list.push({ ...e, index })
+ map.set(e.group, list)
+ })
+ return [...map.entries()]
+ }, [shown])
+
+ function close() {
+ setPaletteOpen(false)
+ setQuery('')
+ setActive(0)
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/portal/data-page.tsx b/apps/web/src/components/portal/data-page.tsx
new file mode 100644
index 0000000..2e3a6b3
--- /dev/null
+++ b/apps/web/src/components/portal/data-page.tsx
@@ -0,0 +1,87 @@
+/**
+ * Каркас списочной страницы как в MikrotikManager (/servers): карточка Frame без полей, тулбар
+ * «сегменты · фильтры · поиск · счётчик», таблица с подложкой шапки и ячейками py-3.
+ */
+import type { ReactNode } from 'react'
+import { SearchIcon } from 'lucide-react'
+import { Frame, FramePanel } from '@/components/reui/frame'
+import { InputGroup, InputGroupAddon, InputGroupInput } from '@evofw/ui/components/input-group'
+import { cn } from '@evofw/ui/lib/utils'
+
+export function DataPageCard({ children, className }: { children: ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function SegmentedControl({
+ value,
+ onChange,
+ options,
+ label,
+}: {
+ value: T
+ onChange: (value: T) => void
+ options: { value: T; label: string; count?: number }[]
+ label?: string
+}) {
+ return (
+
+ {options.map((o) => (
+
+ ))}
+
+ )
+}
+
+export function DataPageToolbar({
+ leading,
+ segmented,
+ filterControl,
+ search,
+ onSearchChange,
+ searchPlaceholder = 'Поиск…',
+ countLabel,
+ actions,
+}: {
+ leading?: ReactNode
+ segmented?: { value: string; onChange: (value: string) => void; options: { value: string; label: string; count?: number }[]; label?: string }
+ filterControl?: ReactNode
+ search?: string
+ onSearchChange?: (value: string) => void
+ searchPlaceholder?: string
+ countLabel?: string
+ actions?: ReactNode
+}) {
+ return (
+
+ {leading}
+ {segmented ? : null}
+ {filterControl}
+ {onSearchChange ? (
+
+
+
+
+ onSearchChange(e.target.value)} />
+
+ ) : null}
+ {countLabel ? {countLabel} : null}
+ {actions}
+
+ )
+}
diff --git a/apps/web/src/components/portal/format.ts b/apps/web/src/components/portal/format.ts
new file mode 100644
index 0000000..d825508
--- /dev/null
+++ b/apps/web/src/components/portal/format.ts
@@ -0,0 +1,45 @@
+const DATE = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })
+const SHORT = new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' })
+const TIME = new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' })
+
+function parse(iso: string | null | undefined): Date | null {
+ if (!iso) return null
+ const d = new Date(iso)
+ return Number.isNaN(d.getTime()) ? null : d
+}
+
+/** 12.09.2026 */
+export function fmtDate(iso: string | null | undefined, empty = '—') {
+ const d = parse(iso)
+ return d ? DATE.format(d) : empty
+}
+
+/** «сегодня 09:41», «вчера 22:15», иначе «12.09 09:41». */
+export function fmtWhen(iso: string | null | undefined, empty = '—', now = Date.now()) {
+ const d = parse(iso)
+ if (!d) return empty
+ const dayMs = 24 * 60 * 60 * 1000
+ const startOfToday = new Date(now)
+ startOfToday.setHours(0, 0, 0, 0)
+ const diff = startOfToday.getTime() - new Date(d).setHours(0, 0, 0, 0)
+ if (diff === 0) return `сегодня ${TIME.format(d)}`
+ if (diff === dayMs) return `вчера ${TIME.format(d)}`
+ return `${SHORT.format(d)} ${TIME.format(d)}`
+}
+
+/** Время дня 14:02. */
+export function fmtTime(iso: string | null | undefined, empty = '—') {
+ const d = parse(iso)
+ return d ? TIME.format(d) : empty
+}
+
+/** «5 дн назад», «3 ч назад», «только что». */
+export function fmtAgo(iso: string | null | undefined, empty = '—', now = Date.now()) {
+ const d = parse(iso)
+ if (!d) return empty
+ const sec = Math.max(0, Math.round((now - d.getTime()) / 1000))
+ if (sec < 60) return 'только что'
+ if (sec < 3600) return `${Math.floor(sec / 60)} мин назад`
+ if (sec < 86400) return `${Math.floor(sec / 3600)} ч назад`
+ return `${Math.floor(sec / 86400)} дн назад`
+}
diff --git a/apps/web/src/components/portal/kpi-strip.tsx b/apps/web/src/components/portal/kpi-strip.tsx
new file mode 100644
index 0000000..169f3f2
--- /dev/null
+++ b/apps/web/src/components/portal/kpi-strip.tsx
@@ -0,0 +1,74 @@
+import { Badge } from '@/components/reui/badge'
+import { IconTile } from '@/components/reui/icon-tile'
+import { Frame, FramePanel } from '@/components/reui/frame'
+import { cn } from '@evofw/ui/lib/utils'
+import type { KpiItem, KpiTone } from './kpi'
+
+const VALUE: Record = {
+ neutral: 'text-foreground',
+ ok: 'text-foreground',
+ in: 'text-foreground',
+ wn: 'text-warning',
+ er: 'text-destructive',
+}
+
+/** Колонки по числу ячеек и ширине окна (на досках — ширина приложения): 4 → 4/2/1, 3 → 3/1, 2 → 2/1. */
+const COLS: Record = {
+ 1: 'grid-cols-1',
+ 2: 'grid-cols-2 max-[640px]:grid-cols-1',
+ 3: 'grid-cols-1 min-[1240px]:grid-cols-3',
+ 4: 'grid-cols-1 min-[640px]:grid-cols-2 min-[1240px]:grid-cols-4',
+}
+
+const TILE: Record = {
+ neutral: 'text-muted-foreground',
+ ok: 'text-success',
+ in: 'text-info',
+ wn: 'text-warning',
+ er: 'text-destructive',
+}
+
+const GLYPH: Record = { neutral: '', ok: '●', in: '', wn: '▲', er: '✕' }
+
+const BADGE = {
+ neutral: 'outline',
+ ok: 'success-light',
+ in: 'info-light',
+ wn: 'warning-light',
+ er: 'destructive-light',
+} as const
+
+/**
+ * Полоса показателей как на досках: один Frame, ячейки разделены линиями,
+ * в ячейке подпись, крупное значение и плашка-подсказка.
+ */
+export function KpiStrip({ items, 'aria-label': ariaLabel }: { items: KpiItem[]; 'aria-label'?: string }) {
+ return (
+
+
+
+ {items.map((k) => {
+ const Icon = k.icon
+ return (
+
+
+
+
+
+
{k.label}
+
+ {k.value}
+
+ {GLYPH[k.tone] ? {GLYPH[k.tone]} : null}
+ {k.hint}
+
+
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/portal/kpi.tsx b/apps/web/src/components/portal/kpi.tsx
new file mode 100644
index 0000000..d9b4abb
--- /dev/null
+++ b/apps/web/src/components/portal/kpi.tsx
@@ -0,0 +1,24 @@
+import type { LucideIcon } from 'lucide-react'
+
+export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in'
+
+export type KpiItem = {
+ id: string
+ label: string
+ value: string
+ /** Плашка-подсказка рядом со значением. */
+ hint: string
+ tone: KpiTone
+ /** Иконка показателя: плитка слева от подписи. */
+ icon: LucideIcon
+}
+
+/** Плитка KPI: иконка, подпись, значение (цветом при wn/er) и плашка-подсказка. */
+export function kpi(
+ id: string,
+ label: string,
+ value: string,
+ opts: { icon: LucideIcon; tone?: KpiTone; badge: string },
+): KpiItem {
+ return { id, label, value, hint: opts.badge, tone: opts.tone ?? 'neutral', icon: opts.icon }
+}
diff --git a/apps/web/src/components/portal/tone.ts b/apps/web/src/components/portal/tone.ts
new file mode 100644
index 0000000..b45a9d4
--- /dev/null
+++ b/apps/web/src/components/portal/tone.ts
@@ -0,0 +1,28 @@
+export type Tone = 'ok' | 'wn' | 'er' | 'mu' | 'in'
+
+/** Пометка строки с накопленной правкой. */
+export type RowMark = 'new' | 'dirty' | 'deleted'
+
+
+type BadgeVariant =
+ | 'success-light'
+ | 'warning-light'
+ | 'destructive-light'
+ | 'info-light'
+ | 'outline'
+
+export const TONE_BADGE: Record = {
+ ok: 'success-light',
+ wn: 'warning-light',
+ er: 'destructive-light',
+ in: 'info-light',
+ mu: 'outline',
+}
+
+export const TONE_DOT: Record = {
+ ok: 'bg-success',
+ wn: 'bg-warning',
+ er: 'bg-destructive',
+ in: 'bg-success',
+ mu: 'bg-muted-foreground/50',
+}
diff --git a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx
index 86054ce..cf9c5e1 100644
--- a/apps/web/src/components/reui-kit/kpi-stat-grid.tsx
+++ b/apps/web/src/components/reui-kit/kpi-stat-grid.tsx
@@ -223,39 +223,61 @@ interface KpiStatGridProps {
'aria-label'?: string
}
-function KpiStatCardItem({ item }: { item: KpiStatItem }) {
- const onActivate = resolveActivate(item)
- const panelClass = panelClassName(item)
+/** Колонки полосы по ширине контейнера: 4 → 1/2/4, 3 → 1/3. */
+function stripCols(count: number): string {
+ 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 = (
+
+ {item.icon ? (
+
+ {item.icon}
+
+ ) : null}
+
+
{item.label}
+
+
+ {item.value}
+
+ {footer ? {footer} : null}
+
+
+
+ )
+ 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) {
return (
-
-
-
+
+
+ {body}
-
+
)
}
-
if (onActivate) {
return (
- handleCardKeyDown(onActivate, e)}
- >
-
-
+ handleCardKeyDown(onActivate, e)}>
+ {body}
+
)
}
-
- return (
-
-
-
- )
+ return {body}
}
/**
@@ -305,12 +327,14 @@ export function KpiStatGrid({
}
return (
-
-
- {list.map((item, index) => (
-
- ))}
-
+
+
+
+ {list.map((item, index) => (
+
+ ))}
+
+
)
}
diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx
index d38f9d2..dc3b7f1 100644
--- a/apps/web/src/components/reui-kit/resource-page.tsx
+++ b/apps/web/src/components/reui-kit/resource-page.tsx
@@ -12,7 +12,7 @@ import {
} from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FilterXIcon, SearchIcon } from 'lucide-react'
-import { CountedLineTabs } from '@/components/counted-line-tabs'
+import { SegmentedControl } from '@/components/portal/data-page'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
@@ -281,8 +281,10 @@ export function ResourcePage({
const tableLayout = useMemo(
() => ({
- dense: true,
+ dense: false,
headerSticky: true,
+ headerBackground: true,
+ rowBorder: true,
width: 'fixed' as const,
...tableLayoutProp,
...(columnPinningProp || Object.keys(columnPinning).length > 0
@@ -464,81 +466,68 @@ export function ResourcePage({
) : null}
- {countedTabs.length > 0 ? (
- <>
-
-
-
-
- >
- ) : null}
-
-
-
-
-
- Фильтры
-
- }
+
+ {countedTabs.length > 0 ? (
+ ({ value: t.id, label: t.label, count: t.count }))}
/>
- {showSearch ? (
-
-
-
-
- handleSearchChange(e.target.value)}
- placeholder={searchPlaceholder}
- aria-label={searchPlaceholder}
- />
-
- ) : null}
-
-
- {toolbarExtra}
- {selectedCount > 0 ? (
-
- {selectedCount} выбрано
-
- ) : null}
- {showClear ? (
-
- ) : null}
-
+ }
+ />
+ {showSearch ? (
+
+
+
+
+ handleSearchChange(e.target.value)}
+ placeholder={searchPlaceholder}
+ aria-label={searchPlaceholder}
+ />
+
+ ) : null}
+ {filteredData.length} записей
+ {toolbarExtra}
+ {selectedCount > 0 ? (
+
+ {selectedCount} выбрано
+
+ ) : null}
+ {showClear ? (
+
+ ) : null}
-
-
-
-
-
-
-
+
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 2f90648..90ee1fd 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -7,6 +7,7 @@ import { Toaster } from '@evofw/ui/components/sonner'
import { TooltipProvider } from '@evofw/ui/components/tooltip'
import { routeTree } from './routeTree.gen'
import '@evofw/ui/globals.css'
+import './portal-theme.css'
const queryClient = new QueryClient({
defaultOptions: {
diff --git a/apps/web/src/portal-theme.css b/apps/web/src/portal-theme.css
new file mode 100644
index 0000000..1b6a106
--- /dev/null
+++ b/apps/web/src/portal-theme.css
@@ -0,0 +1,23 @@
+/* Плотность портала. Палитра, шрифты и таблицы — нативные токены ReUI из @authportal/ui/globals.css. */
+html[data-density='compact'] [data-slot='data-grid-table'] :is(th, td) {
+ padding-block: 0.25rem;
+}
+
+html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
+ gap: 0.75rem;
+}
+
+/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
+html [data-slot='sidebar-wrapper'] :focus-visible,
+html #main-content :focus-visible {
+ outline: 2px solid var(--ring);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html #main-content *,
+ html [data-slot='sidebar-wrapper'] * {
+ animation: none !important;
+ transition: none !important;
+ }
+}
diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx
index 341f838..0238961 100644
--- a/apps/web/src/routes/_auth.tsx
+++ b/apps/web/src/routes/_auth.tsx
@@ -1,10 +1,10 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
-import { AppShell } from '@/components/layout/app-shell'
+import { PortalShell } from '@/components/layout/portal-shell'
export const Route = createFileRoute('/_auth')({
component: () => (
-
+
-
+
),
})