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:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user