fix(web): выровнять sheets и header chrome с CFDM/EvoBGP
Убрать растягивание полей в sheet (auto-rows-min + Field), footer в ряд; SystemMonitor pill; дашборд без DataGrid. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -22,7 +22,7 @@ import {
|
||||
} from '@evofw/ui/components/sidebar'
|
||||
|
||||
const overviewNav = [
|
||||
{ to: '/', label: 'Дашборд', icon: LayoutDashboardIcon, exact: true },
|
||||
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
||||
] as const
|
||||
|
||||
const opsNav = [
|
||||
|
||||
@@ -24,6 +24,11 @@ interface FormSheetProps<T extends FieldValues> {
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Side sheet form shell — shadcn sheet-demo + ReUI sheet-8 layout.
|
||||
* Preview: https://reui.io/preview/base/sheet-8
|
||||
* Docs: https://ui.shadcn.com/docs/components/base/sheet
|
||||
*/
|
||||
export function FormSheet<T extends FieldValues>({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -41,11 +46,11 @@ export function FormSheet<T extends FieldValues>({
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
className={cn('flex flex-col gap-0 overflow-hidden', className)}
|
||||
className={cn('flex flex-col gap-0 overflow-hidden sm:max-w-md', className)}
|
||||
>
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
{description && <SheetDescription>{description}</SheetDescription>}
|
||||
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||
</SheetHeader>
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
@@ -54,14 +59,16 @@ export function FormSheet<T extends FieldValues>({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4',
|
||||
'grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer ? (
|
||||
<SheetFooter className="shrink-0 border-t">{footer}</SheetFooter>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
{footer}
|
||||
</SheetFooter>
|
||||
) : null}
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
const NAV_ITEMS = [
|
||||
{
|
||||
to: '/',
|
||||
label: 'Дашборд',
|
||||
label: 'Панель управления',
|
||||
keywords: ['dashboard', 'панель', 'обзор'],
|
||||
icon: LayoutDashboardIcon,
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface RouteBreadcrumbLoaderData {
|
||||
}
|
||||
|
||||
const routeTitles: Record<string, string> = {
|
||||
'/': 'Дашборд',
|
||||
'/': 'Панель управления',
|
||||
'/agents': 'Агенты',
|
||||
'/lists': 'Списки IP',
|
||||
'/rules': 'Правила',
|
||||
@@ -32,7 +32,7 @@ function getBreadcrumbs(
|
||||
dynamicLabels: Record<string, string>,
|
||||
) {
|
||||
if (pathname === '/') {
|
||||
return [{ label: 'Дашборд', href: '/' }]
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
if (pathname.match(/^\/agents\/[^/]+$/)) {
|
||||
@@ -47,7 +47,7 @@ function getBreadcrumbs(
|
||||
return [{ label: title, href: pathname }]
|
||||
}
|
||||
|
||||
return [{ label: 'Дашборд', href: '/' }]
|
||||
return [{ label: 'Панель управления', href: '/' }]
|
||||
}
|
||||
|
||||
function useDynamicBreadcrumbLabels() {
|
||||
@@ -73,7 +73,10 @@ export function SiteHeader() {
|
||||
return (
|
||||
<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" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-vertical:h-4 data-vertical:self-center"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{crumbs.map((crumb, index) => {
|
||||
@@ -84,7 +87,9 @@ export function SiteHeader() {
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
)}
|
||||
<BreadcrumbItem
|
||||
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
||||
className={
|
||||
index === 0 && !isLast ? 'hidden md:block' : undefined
|
||||
}
|
||||
>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
dashboardQueryOptions,
|
||||
@@ -53,11 +52,18 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
{metric.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
||||
<span className="text-muted-foreground truncate text-[11px]">
|
||||
{metric.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
||||
<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 className="text-muted-foreground ml-0.5 text-[10px] font-normal">
|
||||
{metric.unit}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
@@ -69,7 +75,7 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor — FW dashboard/agents. Preview: app-shell-12 */
|
||||
/** 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 })
|
||||
@@ -80,7 +86,8 @@ export function SystemMonitorPopover() {
|
||||
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 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 = rulesQ.data?.items?.length ?? 0
|
||||
@@ -105,7 +112,12 @@ export function SystemMonitorPopover() {
|
||||
unit: '%',
|
||||
percent: onlinePct,
|
||||
icon: <Server aria-hidden />,
|
||||
tone: onlinePct >= 80 ? 'success' : onlinePct >= 40 ? 'warning' : 'destructive',
|
||||
tone:
|
||||
onlinePct >= 80
|
||||
? 'success'
|
||||
: onlinePct >= 40
|
||||
? 'warning'
|
||||
: 'destructive',
|
||||
alert: pending > 0 || (approved > 0 && onlinePct < 50),
|
||||
},
|
||||
{
|
||||
@@ -132,42 +144,86 @@ export function SystemMonitorPopover() {
|
||||
[apiOk, onlinePct, pending, approved, listsCount, rulesCount],
|
||||
)
|
||||
|
||||
const hasAlert = metrics.some((m) => m.alert)
|
||||
const spiking = metrics.some((m) => m.alert)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Мониторинг системы"
|
||||
className={cn(hasAlert && 'text-warning')}
|
||||
<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',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Activity className="size-4.5" aria-hidden />
|
||||
{hasAlert ? (
|
||||
<Badge
|
||||
variant="warning"
|
||||
size="sm"
|
||||
className="absolute top-1 right-1 size-1.5 rounded-full p-0"
|
||||
<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',
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{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" className="w-80 p-0" sideOffset={8}>
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="text-sm font-medium">Система</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Агенты и политики EvoFirewall
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Монитор EvoFirewall
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{new Date().toLocaleTimeString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 divide-x divide-y">
|
||||
{metrics.map((m) => (
|
||||
<MetricCell key={m.id} metric={m} />
|
||||
<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]">
|
||||
Pending:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{pending}
|
||||
</span>
|
||||
{' · '}
|
||||
Online:{' '}
|
||||
<span className="text-foreground font-medium tabular-nums">
|
||||
{online}/{approved || '—'}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
|
||||
@@ -115,7 +115,11 @@ function KpiStatCardItem({ card }: { card: KpiStatCard }) {
|
||||
if (card.to) {
|
||||
return (
|
||||
<FramePanel className={panelClass}>
|
||||
<Link to={card.to} search={card.search} className="focus-visible:outline-none">
|
||||
<Link
|
||||
to={card.to}
|
||||
{...(card.search ? { search: card.search } : {})}
|
||||
className="focus-visible:outline-none"
|
||||
>
|
||||
<KpiStatCardBody card={card} />
|
||||
</Link>
|
||||
</FramePanel>
|
||||
|
||||
@@ -99,7 +99,7 @@ export function QuickActionGrid({
|
||||
>
|
||||
<Link
|
||||
to={action.to}
|
||||
search={action.search}
|
||||
{...(action.search ? { search: action.search } : {})}
|
||||
className="focus-visible:outline-none"
|
||||
aria-label={`${action.title}: ${action.description}`}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user