feat: реализовать EvoFirewall V1 control plane
API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
List,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Settings,
|
||||
LogOut,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarHeader,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@evofw/ui/components/sidebar'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import { logout, CURRENT_APP_ID } from '@/lib/auth'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/', label: 'Дашборд', icon: LayoutDashboard },
|
||||
{ to: '/agents', label: 'Агенты', icon: Server },
|
||||
{ to: '/lists', label: 'Списки IP', icon: List },
|
||||
{ to: '/rules', label: 'Правила', icon: Shield },
|
||||
{ to: '/stats', label: 'Статистика', icon: BarChart3 },
|
||||
{ to: '/settings', label: 'Настройки', icon: Settings },
|
||||
] as const
|
||||
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
style={
|
||||
{
|
||||
'--sidebar-width': '240px',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader className="border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Shield className="size-5" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold">EvoFirewall</span>
|
||||
<span className="text-muted-foreground text-[10px] uppercase">
|
||||
{CURRENT_APP_ID}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{NAV.map((item) => {
|
||||
const Icon = item.icon
|
||||
const active =
|
||||
item.to === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.to)
|
||||
return (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
isActive={active}
|
||||
render={<Link to={item.to} />}
|
||||
>
|
||||
<Icon />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="border-t p-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => logout()}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
Выйти
|
||||
</Button>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 items-center gap-2 border-b px-4">
|
||||
<SidebarTrigger />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-muted-foreground text-sm">Control plane</span>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Frame } from '@/components/reui/frame'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
export type KpiItem = {
|
||||
id: string
|
||||
label: string
|
||||
value: string | number
|
||||
hint?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
/** KPI grid — preview: https://reui.io/preview/base/stats-12 */
|
||||
export function KpiStatGrid({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: KpiItem[]
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-4', className)}>
|
||||
{items.map((item) => {
|
||||
const inner = (
|
||||
<Frame dense className="h-full transition-colors hover:bg-muted/40">
|
||||
<div className="text-muted-foreground text-xs font-medium uppercase tracking-wide">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums">{item.value}</div>
|
||||
{item.hint ? (
|
||||
<div className="text-muted-foreground mt-1 text-xs">{item.hint}</div>
|
||||
) : null}
|
||||
</Frame>
|
||||
)
|
||||
return item.to ? (
|
||||
<Link key={item.id} to={item.to} className="block no-underline">
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={item.id}>{inner}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageShell({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Frame className="flex flex-col items-center justify-center gap-2 py-12 text-center">
|
||||
<div className="font-medium">{title}</div>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground max-w-md text-sm">{description}</p>
|
||||
) : null}
|
||||
{action}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
/** Minimal Frame surface (ReUI Frame contract) — preview: https://reui.io/docs/components/base/frame */
|
||||
export function Frame({
|
||||
children,
|
||||
className,
|
||||
dense,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
dense?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card text-card-foreground rounded-xl border shadow-xs',
|
||||
dense ? 'p-3' : 'p-4 md:p-5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FrameHeader({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('mb-3 flex flex-wrap items-start justify-between gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FrameTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <h2 className={cn('text-base font-semibold tracking-tight', className)}>{children}</h2>
|
||||
}
|
||||
|
||||
export function FrameDescription({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <p className={cn('text-muted-foreground text-sm', className)}>{children}</p>
|
||||
}
|
||||
Reference in New Issue
Block a user