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'
|
} from '@evofw/ui/components/sidebar'
|
||||||
|
|
||||||
const overviewNav = [
|
const overviewNav = [
|
||||||
{ to: '/', label: 'Дашборд', icon: LayoutDashboardIcon, exact: true },
|
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const opsNav = [
|
const opsNav = [
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ interface FormSheetProps<T extends FieldValues> {
|
|||||||
contentClassName?: string
|
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>({
|
export function FormSheet<T extends FieldValues>({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -41,11 +46,11 @@ export function FormSheet<T extends FieldValues>({
|
|||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<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">
|
<SheetHeader className="shrink-0">
|
||||||
<SheetTitle>{title}</SheetTitle>
|
<SheetTitle>{title}</SheetTitle>
|
||||||
{description && <SheetDescription>{description}</SheetDescription>}
|
{description ? <SheetDescription>{description}</SheetDescription> : null}
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form
|
<form
|
||||||
@@ -54,14 +59,16 @@ export function FormSheet<T extends FieldValues>({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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,
|
contentClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{footer ? (
|
{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}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{
|
{
|
||||||
to: '/',
|
to: '/',
|
||||||
label: 'Дашборд',
|
label: 'Панель управления',
|
||||||
keywords: ['dashboard', 'панель', 'обзор'],
|
keywords: ['dashboard', 'панель', 'обзор'],
|
||||||
icon: LayoutDashboardIcon,
|
icon: LayoutDashboardIcon,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface RouteBreadcrumbLoaderData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const routeTitles: Record<string, string> = {
|
const routeTitles: Record<string, string> = {
|
||||||
'/': 'Дашборд',
|
'/': 'Панель управления',
|
||||||
'/agents': 'Агенты',
|
'/agents': 'Агенты',
|
||||||
'/lists': 'Списки IP',
|
'/lists': 'Списки IP',
|
||||||
'/rules': 'Правила',
|
'/rules': 'Правила',
|
||||||
@@ -32,7 +32,7 @@ function getBreadcrumbs(
|
|||||||
dynamicLabels: Record<string, string>,
|
dynamicLabels: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
if (pathname === '/') {
|
if (pathname === '/') {
|
||||||
return [{ label: 'Дашборд', href: '/' }]
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.match(/^\/agents\/[^/]+$/)) {
|
if (pathname.match(/^\/agents\/[^/]+$/)) {
|
||||||
@@ -47,7 +47,7 @@ function getBreadcrumbs(
|
|||||||
return [{ label: title, href: pathname }]
|
return [{ label: title, href: pathname }]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{ label: 'Дашборд', href: '/' }]
|
return [{ label: 'Панель управления', href: '/' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDynamicBreadcrumbLabels() {
|
function useDynamicBreadcrumbLabels() {
|
||||||
@@ -73,7 +73,10 @@ export function SiteHeader() {
|
|||||||
return (
|
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">
|
<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" />
|
<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>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{crumbs.map((crumb, index) => {
|
{crumbs.map((crumb, index) => {
|
||||||
@@ -84,7 +87,9 @@ export function SiteHeader() {
|
|||||||
<BreadcrumbSeparator className="hidden md:block" />
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
)}
|
)}
|
||||||
<BreadcrumbItem
|
<BreadcrumbItem
|
||||||
className={index === 0 && !isLast ? 'hidden md:block' : undefined}
|
className={
|
||||||
|
index === 0 && !isLast ? 'hidden md:block' : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isLast ? (
|
{isLast ? (
|
||||||
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
<BreadcrumbPage>{crumb.label}</BreadcrumbPage>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { cn } from '@evofw/ui/lib/utils'
|
|||||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@evofw/ui/components/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@evofw/ui/components/popover'
|
||||||
import { Progress } from '@evofw/ui/components/progress'
|
import { Progress } from '@evofw/ui/components/progress'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
|
||||||
import {
|
import {
|
||||||
agentsQueryOptions,
|
agentsQueryOptions,
|
||||||
dashboardQueryOptions,
|
dashboardQueryOptions,
|
||||||
@@ -53,11 +52,18 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
|
|||||||
{metric.icon}
|
{metric.icon}
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
</Item>
|
</Item>
|
||||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
<span className="text-muted-foreground truncate text-[11px]">
|
||||||
|
{metric.label}
|
||||||
|
</span>
|
||||||
</div>
|
</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}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<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() {
|
export function SystemMonitorPopover() {
|
||||||
const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 })
|
const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 })
|
||||||
const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 })
|
const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 })
|
||||||
@@ -80,7 +86,8 @@ export function SystemMonitorPopover() {
|
|||||||
const agents = agentsQ.data?.items ?? []
|
const agents = agentsQ.data?.items ?? []
|
||||||
const approved = agents.filter((a) => a.status === 'approved').length
|
const approved = agents.filter((a) => a.status === 'approved').length
|
||||||
const online = d?.agents_online ?? agents.filter((a) => a.last_seen_at).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 onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0
|
||||||
const listsCount = listsQ.data?.items?.length ?? d?.lists_total ?? 0
|
const listsCount = listsQ.data?.items?.length ?? d?.lists_total ?? 0
|
||||||
const rulesCount = rulesQ.data?.items?.length ?? 0
|
const rulesCount = rulesQ.data?.items?.length ?? 0
|
||||||
@@ -105,7 +112,12 @@ export function SystemMonitorPopover() {
|
|||||||
unit: '%',
|
unit: '%',
|
||||||
percent: onlinePct,
|
percent: onlinePct,
|
||||||
icon: <Server aria-hidden />,
|
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),
|
alert: pending > 0 || (approved > 0 && onlinePct < 50),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -132,42 +144,86 @@ export function SystemMonitorPopover() {
|
|||||||
[apiOk, onlinePct, pending, approved, listsCount, rulesCount],
|
[apiOk, onlinePct, pending, approved, listsCount, rulesCount],
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasAlert = metrics.some((m) => m.alert)
|
const spiking = metrics.some((m) => m.alert)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="icon"
|
aria-label="Монитор системы"
|
||||||
aria-label="Мониторинг системы"
|
className={cn(
|
||||||
className={cn(hasAlert && 'text-warning')}
|
'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 />
|
<span className="relative flex size-3.5 items-center justify-center">
|
||||||
{hasAlert ? (
|
<Activity
|
||||||
<Badge
|
|
||||||
variant="warning"
|
|
||||||
size="sm"
|
|
||||||
className="absolute top-1 right-1 size-1.5 rounded-full p-0"
|
|
||||||
aria-hidden
|
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>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="w-80 p-0" sideOffset={8}>
|
|
||||||
<div className="border-b px-3 py-2">
|
<PopoverContent
|
||||||
<div className="text-sm font-medium">Система</div>
|
align="end"
|
||||||
<div className="text-muted-foreground text-xs">
|
sideOffset={8}
|
||||||
Агенты и политики EvoFirewall
|
className="flex w-80 flex-col gap-0! p-0!"
|
||||||
</div>
|
>
|
||||||
|
<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>
|
||||||
<div className="grid grid-cols-2 divide-x divide-y">
|
<div className="grid grid-cols-2">
|
||||||
{metrics.map((m) => (
|
{metrics.map((metric, i) => (
|
||||||
<MetricCell key={m.id} metric={m} />
|
<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>
|
||||||
|
<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>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -115,7 +115,11 @@ function KpiStatCardItem({ card }: { card: KpiStatCard }) {
|
|||||||
if (card.to) {
|
if (card.to) {
|
||||||
return (
|
return (
|
||||||
<FramePanel className={panelClass}>
|
<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} />
|
<KpiStatCardBody card={card} />
|
||||||
</Link>
|
</Link>
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export function QuickActionGrid({
|
|||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to={action.to}
|
to={action.to}
|
||||||
search={action.search}
|
{...(action.search ? { search: action.search } : {})}
|
||||||
className="focus-visible:outline-none"
|
className="focus-visible:outline-none"
|
||||||
aria-label={`${action.title}: ${action.description}`}
|
aria-label={`${action.title}: ${action.description}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import {
|
import {
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
BanIcon,
|
BanIcon,
|
||||||
@@ -23,32 +22,46 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemGroup,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@evofw/ui/components/item'
|
||||||
import {
|
import {
|
||||||
dashboardQueryOptions,
|
dashboardQueryOptions,
|
||||||
agentsQueryOptions,
|
agentsQueryOptions,
|
||||||
recentStatsQueryOptions,
|
recentStatsQueryOptions,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import type { Agent } from '@evofw/shared'
|
import type { Agent } from '@evofw/shared'
|
||||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
|
||||||
import {
|
|
||||||
getCoreRowModel,
|
|
||||||
useReactTable,
|
|
||||||
} from '@tanstack/react-table'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/')({
|
export const Route = createFileRoute('/_auth/')({
|
||||||
component: DashboardPage,
|
component: DashboardPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const EMPTY_AGENTS: Agent[] = []
|
||||||
|
|
||||||
function DashboardPage() {
|
function DashboardPage() {
|
||||||
const dash = useQuery(dashboardQueryOptions())
|
const dash = useQuery(dashboardQueryOptions())
|
||||||
const agents = useQuery(agentsQueryOptions())
|
const agents = useQuery(agentsQueryOptions())
|
||||||
const stats = useQuery(recentStatsQueryOptions())
|
const stats = useQuery(recentStatsQueryOptions())
|
||||||
|
|
||||||
const d = dash.data
|
const d = dash.data
|
||||||
const items = agents.data?.items ?? []
|
const items = agents.data?.items ?? EMPTY_AGENTS
|
||||||
const pending = items.filter((a) => a.status === 'pending')
|
|
||||||
const applyErrors = items.filter((a) => a.last_apply_error)
|
const pending = useMemo(
|
||||||
|
() => items.filter((a) => a.status === 'pending'),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
const applyErrors = useMemo(
|
||||||
|
() => items.filter((a) => a.last_apply_error),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
const previewAgents = useMemo(() => items.slice(0, 8), [items])
|
||||||
|
const samples = useMemo(
|
||||||
|
() => (stats.data?.items ?? []).slice(0, 10),
|
||||||
|
[stats.data?.items],
|
||||||
|
)
|
||||||
|
|
||||||
const kpiCards: KpiStatCard[] = useMemo(
|
const kpiCards: KpiStatCard[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -92,93 +105,40 @@ function DashboardPage() {
|
|||||||
[d],
|
[d],
|
||||||
)
|
)
|
||||||
|
|
||||||
const quickActions: QuickActionItem[] = [
|
const quickActions: QuickActionItem[] = useMemo(
|
||||||
{
|
|
||||||
id: 'agents',
|
|
||||||
title: 'Агенты',
|
|
||||||
description: 'Enroll и approve',
|
|
||||||
to: '/agents',
|
|
||||||
icon: <ServerIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-info',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lists',
|
|
||||||
title: 'Списки',
|
|
||||||
description: 'Blocklists / sources',
|
|
||||||
to: '/lists',
|
|
||||||
icon: <ListIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rules',
|
|
||||||
title: 'Правила',
|
|
||||||
description: 'Allow / deny policy',
|
|
||||||
to: '/rules',
|
|
||||||
icon: <BanIcon aria-hidden />,
|
|
||||||
iconClassName: 'text-warning',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const agentColumns: ColumnDef<Agent>[] = useMemo(
|
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'name',
|
id: 'agents',
|
||||||
header: 'Имя',
|
title: 'Агенты',
|
||||||
cell: ({ row }) => (
|
description: 'Enroll и approve',
|
||||||
<Link
|
to: '/agents',
|
||||||
to="/agents/$id"
|
icon: <ServerIcon aria-hidden />,
|
||||||
params={{ id: row.original.id }}
|
iconClassName: 'text-info',
|
||||||
className="font-medium underline-offset-4 hover:underline"
|
|
||||||
>
|
|
||||||
{row.original.name}
|
|
||||||
</Link>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'status',
|
id: 'lists',
|
||||||
header: 'Статус',
|
title: 'Списки',
|
||||||
cell: ({ row }) => (
|
description: 'Blocklists / sources',
|
||||||
<Badge
|
to: '/lists',
|
||||||
variant={
|
icon: <ListIcon aria-hidden />,
|
||||||
row.original.status === 'approved'
|
iconClassName: 'text-primary',
|
||||||
? 'success-light'
|
|
||||||
: row.original.status === 'pending'
|
|
||||||
? 'warning-light'
|
|
||||||
: 'secondary'
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{row.original.status}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ accessorKey: 'policy_mode', header: 'Режим' },
|
|
||||||
{
|
{
|
||||||
accessorKey: 'last_apply_packets_dropped',
|
id: 'rules',
|
||||||
header: 'Dropped',
|
title: 'Правила',
|
||||||
cell: ({ row }) => (
|
description: 'Allow / deny policy',
|
||||||
<span className="tabular-nums">
|
to: '/rules',
|
||||||
{row.original.last_apply_packets_dropped ?? 0}
|
icon: <BanIcon aria-hidden />,
|
||||||
</span>
|
iconClassName: 'text-warning',
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const agentsTable = useReactTable({
|
|
||||||
data: items.slice(0, 8),
|
|
||||||
columns: agentColumns,
|
|
||||||
getCoreRowModel: getCoreRowModel(),
|
|
||||||
getRowId: (r) => r.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
const samples = (stats.data?.items ?? []).slice(0, 10)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Дашборд"
|
title="Панель управления"
|
||||||
description="Обзор агентов и пакетной статистики"
|
description="Обзор агентов и пакетной статистики"
|
||||||
/>
|
/>
|
||||||
<OpsDashboard
|
<OpsDashboard
|
||||||
@@ -191,10 +151,45 @@ function DashboardPage() {
|
|||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
<FrameTitle>Агенты</FrameTitle>
|
<FrameTitle>Агенты</FrameTitle>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<FramePanel className="p-0">
|
<FramePanel>
|
||||||
<DataGrid table={agentsTable} recordCount={items.length}>
|
{previewAgents.length === 0 ? (
|
||||||
<DataGridTable />
|
<p className="text-muted-foreground text-sm">Нет агентов</p>
|
||||||
</DataGrid>
|
) : (
|
||||||
|
<ItemGroup className="gap-2">
|
||||||
|
{previewAgents.map((a) => (
|
||||||
|
<Item key={a.id} variant="outline" size="sm">
|
||||||
|
<ItemContent className="flex flex-row items-center justify-between gap-2">
|
||||||
|
<ItemTitle className="truncate font-medium">
|
||||||
|
<Link
|
||||||
|
to="/agents/$id"
|
||||||
|
params={{ id: a.id }}
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{a.name}
|
||||||
|
</Link>
|
||||||
|
</ItemTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
a.status === 'approved'
|
||||||
|
? 'success-light'
|
||||||
|
: a.status === 'pending'
|
||||||
|
? 'warning-light'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{a.status}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
↓{a.last_apply_packets_dropped ?? 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
</FramePanel>
|
</FramePanel>
|
||||||
</Frame>
|
</Frame>
|
||||||
<Frame dense spacing="sm">
|
<Frame dense spacing="sm">
|
||||||
@@ -252,7 +247,10 @@ function DashboardPage() {
|
|||||||
{applyErrors.map((a) => (
|
{applyErrors.map((a) => (
|
||||||
<div key={`err-${a.id}`} className="text-sm">
|
<div key={`err-${a.id}`} className="text-sm">
|
||||||
<span className="text-destructive font-medium">{a.name}</span>
|
<span className="text-destructive font-medium">{a.name}</span>
|
||||||
<span className="text-muted-foreground"> — {a.last_apply_error}</span>
|
<span className="text-muted-foreground">
|
||||||
|
{' '}
|
||||||
|
— {a.last_apply_error}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
|||||||
import { listsQueryOptions } from '@/queries'
|
import { listsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
|
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
import { Label } from '@evofw/ui/components/label'
|
|
||||||
import { Textarea } from '@evofw/ui/components/textarea'
|
import { Textarea } from '@evofw/ui/components/textarea'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -205,18 +205,22 @@ function ListsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
<SheetHeader>
|
<SheetHeader className="shrink-0">
|
||||||
<SheetTitle>Новый список</SheetTitle>
|
<SheetTitle>Новый список</SheetTitle>
|
||||||
<SheetDescription>Источник префиксов для правил</SheetDescription>
|
<SheetDescription>Источник префиксов для правил</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-1">
|
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label>Имя</Label>
|
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
<Input
|
||||||
</div>
|
id="list-name"
|
||||||
<div className="flex flex-col gap-2">
|
value={name}
|
||||||
<Label>Тип</Label>
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Тип</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={type}
|
value={type}
|
||||||
onValueChange={(v) => setType(v as typeof type)}
|
onValueChange={(v) => setType(v as typeof type)}
|
||||||
@@ -233,9 +237,9 @@ function ListsPage() {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label>
|
<FieldLabel htmlFor="list-extra">
|
||||||
{type === 'static'
|
{type === 'static'
|
||||||
? 'CIDR (через пробел/запятую)'
|
? 'CIDR (через пробел/запятую)'
|
||||||
: type === 'json_url'
|
: type === 'json_url'
|
||||||
@@ -243,15 +247,16 @@ function ListsPage() {
|
|||||||
: type === 'domains'
|
: type === 'domains'
|
||||||
? 'Домены'
|
? 'Домены'
|
||||||
: 'Community ID'}
|
: 'Community ID'}
|
||||||
</Label>
|
</FieldLabel>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
id="list-extra"
|
||||||
value={extra}
|
value={extra}
|
||||||
onChange={(e) => setExtra(e.target.value)}
|
onChange={(e) => setExtra(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter>
|
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||||
<Button variant="outline" onClick={() => setSheetOpen(false)}>
|
<Button variant="outline" onClick={() => setSheetOpen(false)}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
|
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
import { Label } from '@evofw/ui/components/label'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -183,21 +183,22 @@ function RulesPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||||
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
<SheetHeader>
|
<SheetHeader className="shrink-0">
|
||||||
<SheetTitle>Новое правило</SheetTitle>
|
<SheetTitle>Новое правило</SheetTitle>
|
||||||
<SheetDescription>Priority + action + list scope</SheetDescription>
|
<SheetDescription>Priority + action + list scope</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="grid flex-1 gap-3 sm:grid-cols-2">
|
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label>Priority</Label>
|
<FieldLabel htmlFor="rule-priority">Priority</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
|
id="rule-priority"
|
||||||
value={priority}
|
value={priority}
|
||||||
onChange={(e) => setPriority(e.target.value)}
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="flex flex-col gap-2">
|
<Field>
|
||||||
<Label>Action</Label>
|
<FieldLabel>Action</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={action}
|
value={action}
|
||||||
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
|
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
|
||||||
@@ -210,14 +211,14 @@ function RulesPage() {
|
|||||||
<SelectItem value="allow">allow</SelectItem>
|
<SelectItem value="allow">allow</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
<Field className="sm:col-span-2">
|
||||||
<Label>Список</Label>
|
<FieldLabel>Список</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={listId || null}
|
value={listId || null}
|
||||||
onValueChange={(v) => setListId(v ?? '')}
|
onValueChange={(v) => setListId(v ?? '')}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="IP list" />
|
<SelectValue placeholder="IP list" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -228,16 +229,16 @@ function RulesPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
<Field className="sm:col-span-2">
|
||||||
<Label>Scope</Label>
|
<FieldLabel>Scope</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
value={agentId}
|
value={agentId}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v) setAgentId(v)
|
if (v) setAgentId(v)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -249,9 +250,9 @@ function RulesPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter>
|
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||||
<Button variant="outline" onClick={() => setSheetOpen(false)}>
|
<Button variant="outline" onClick={() => setSheetOpen(false)}>
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sheet-footer"
|
data-slot="sheet-footer"
|
||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
className={cn("mt-auto flex flex-row flex-wrap gap-2 p-4", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user