refactor(web): общие форматтеры, единая навигация, RBAC-гейтинг и чистка мёртвого кода

- lib/format.ts: ru-RU форматтеры дат/чисел в одном месте — убраны дубли
  из 8 компонентов (agent-card, fleet-grid, blocked-ips/ports, facts,
  lifecycle, host-firewall, lists-columns, system-monitor)
- lib/nav.ts: единый конфиг маршрутов — sidebar, ⌘K-поиск и breadcrumbs
  рендерятся из одного источника (было 3 расходящихся копии)
- lib/permissions.ts + useCan: клиентский RBAC — кнопки создания/подтверждения
  скрываются без fw:*:write/admin (сервер остаётся авторитетным)
- удалён мёртвый код ~2500 строк: stepper, data-grid dnd/virtual/visibility
  варианты, settings-shell
This commit is contained in:
Denozordec
2026-09-20 19:36:37 +07:00
parent 7a3f1fad25
commit 3dc8e6d5e2
26 changed files with 275 additions and 2178 deletions
+74
View File
@@ -0,0 +1,74 @@
/**
* Shared ru-RU formatters — single source for dates/numbers across the app.
* All timestamps are ISO strings from the API.
*/
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
const numberFmt = new Intl.NumberFormat('ru-RU')
const shortDateTimeFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
const dateTimeFmt = new Intl.DateTimeFormat('ru-RU')
const stampDateTimeFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const timeFmt = new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
/** Compact packet counter: 1,2K / 3,4M */
export function formatPackets(n: number | undefined | null): string {
if (n === undefined || n === null) return '—'
return packetFmt.format(n)
}
export function formatNumber(n: number | undefined | null): string {
if (n === undefined || n === null) return '—'
return numberFmt.format(n)
}
/** dd.MM HH:mm — dense "seen" stamps in grids/cards */
export function formatShortDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return shortDateTimeFmt.format(new Date(t))
}
/** Full locale date-time for detail panels */
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return iso
return dateTimeFmt.format(new Date(t))
}
/** dd.MM.yyyy HH:mm:ss — block-stats "seen" stamps */
export function formatStampDateTime(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return iso
return stampDateTimeFmt.format(new Date(t))
}
export function formatTime(d: Date): string {
return timeFmt.format(d)
}
+95
View File
@@ -0,0 +1,95 @@
import {
BarChart3Icon,
LayoutDashboardIcon,
ListIcon,
ServerIcon,
SettingsIcon,
ShieldIcon,
} from 'lucide-react'
/**
* Single source of the app's route structure: sidebar sections, ⌘K search and
* header breadcrumbs all render from this config.
*/
export type NavSectionId = 'overview' | 'ops' | 'system'
export type NavItem = {
to: string
label: string
/** Exact active-state match (root only); prefix match otherwise. */
exact?: boolean
keywords: string[]
icon: typeof ServerIcon
section: NavSectionId
}
export const NAV_ITEMS: readonly NavItem[] = [
{
to: '/',
label: 'Панель управления',
exact: true,
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
section: 'overview',
},
{
to: '/agents',
label: 'Агенты',
keywords: ['agents', 'агенты', 'nodes'],
icon: ServerIcon,
section: 'ops',
},
{
to: '/lists',
label: 'Списки',
keywords: ['lists', 'списки', 'blocklist'],
icon: ListIcon,
section: 'ops',
},
{
to: '/rules',
label: 'Наборы правил',
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
icon: ShieldIcon,
section: 'ops',
},
{
to: '/stats',
label: 'Статистика',
keywords: ['stats', 'статистика', 'packets'],
icon: BarChart3Icon,
section: 'ops',
},
{
to: '/settings',
label: 'Настройки',
keywords: ['settings', 'настройки'],
icon: SettingsIcon,
section: 'system',
},
]
export const NAV_SECTIONS = [
{ id: 'overview', label: 'Обзор' },
{ id: 'ops', label: 'Операции' },
{ id: 'system', label: 'Система' },
] as const satisfies readonly { id: NavSectionId; label: string }[]
export function navItemsForSection(section: NavSectionId): readonly NavItem[] {
return NAV_ITEMS.filter((item) => item.section === section)
}
export function navLabel(to: string): string | undefined {
return NAV_ITEMS.find((item) => item.to === to)?.label
}
/** Detail routes (…/:id) that nest under a nav route. */
const DETAIL_PARENT_RE = /^\/(agents|lists|rules)\/[^/]+$/
export function navParentForDetail(
pathname: string,
): string | undefined {
const m = pathname.match(DETAIL_PARENT_RE)
return m ? `/${m[1]}` : undefined
}
+19
View File
@@ -0,0 +1,19 @@
import { hasPermission } from '@evofw/shared'
import { getClaims, isAuthEnabled } from './auth'
/**
* Client-side RBAC gating. The backend remains authoritative; this only hides
* actions the current portal user cannot perform (fw:<section>:<read|write|admin>).
* When auth is disabled (dev) everything is allowed.
*/
export function can(permission: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (claims.is_admin) return true
return hasPermission(claims.permissions ?? [], permission)
}
export function useCan() {
return can
}