feat: реализовать EvoFirewall V1 control plane
Build and Push EvoFirewall Docker Image / build-and-push (push) Failing after 25s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-20 19:50:54 +07:00
co-authored by Cursor
parent d71b45d86f
commit ebadf70e2b
107 changed files with 15196 additions and 99 deletions
@@ -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>
)
}
+104
View File
@@ -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>
)
}
+59
View File
@@ -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>
}
+34
View File
@@ -0,0 +1,34 @@
import { getToken, clearToken, redirectToPortalLogin, isAuthEnabled } from './auth'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
export async function apiFetch<T = unknown>(
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers)
if (!headers.has('Content-Type') && init.body) {
headers.set('Content-Type', 'application/json')
}
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(`${API_BASE}${path}`, { ...init, headers })
if (res.status === 401 && isAuthEnabled()) {
clearToken()
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
throw new Error('Unauthorized')
}
if (!res.ok) {
let message = res.statusText
try {
const body = (await res.json()) as { error?: { message?: string } }
message = body.error?.message ?? message
} catch {
/* ignore */
}
throw new Error(message)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
+126
View File
@@ -0,0 +1,126 @@
/** Portal JWT storage for EvoFirewall (app id `fw`). */
const TOKEN_KEY = 'fw_auth_token'
const HANDOFF_AT_KEY = 'fw_portal_handoff_at'
const HANDOFF_COOLDOWN_MS = 12_000
const API_BASE = import.meta.env.VITE_API_URL ?? ''
export type AccessClaims = {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
exp?: number
}
export type RuntimeAuthConfig = {
required: boolean
portalUrl: string
}
let runtimeConfig: RuntimeAuthConfig | null = null
function viteAuthEnabled(): boolean {
return (
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
import.meta.env.VITE_AUTH_ENABLED === '1'
)
}
function vitePortalUrl(): string {
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
/\/$/,
'',
)
}
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
try {
const res = await fetch(`${API_BASE}/api/auth/config`)
if (res.ok) {
const data = (await res.json()) as {
required?: boolean
portal_url?: string
}
runtimeConfig = {
required: Boolean(data.required) || viteAuthEnabled(),
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
}
return runtimeConfig
}
} catch {
/* fallback */
}
runtimeConfig = {
required: viteAuthEnabled(),
portalUrl: vitePortalUrl(),
}
return runtimeConfig
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function isAuthEnabled(): boolean {
return runtimeConfig?.required ?? viteAuthEnabled()
}
export function authPortalUrl(): string {
return runtimeConfig?.portalUrl ?? vitePortalUrl()
}
export function isPortalHandoffCoolingDown(): boolean {
const raw = sessionStorage.getItem(HANDOFF_AT_KEY)
if (!raw) return false
return Date.now() - Number(raw) < HANDOFF_COOLDOWN_MS
}
export function markPortalHandoff(): void {
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
}
export function parseClaims(token: string): AccessClaims | null {
try {
const payload = token.split('.')[1]
if (!payload) return null
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
return JSON.parse(json) as AccessClaims
} catch {
return null
}
}
export function isTokenValid(): boolean {
const t = getToken()
if (!t) return false
const c = parseClaims(t)
if (!c?.exp) return !!t
return c.exp * 1000 > Date.now() + 5_000
}
export function redirectToPortalLogin(returnTo: string) {
if (isPortalHandoffCoolingDown()) return
markPortalHandoff()
const portal = authPortalUrl()
const url = `${portal}/?return_to=${encodeURIComponent(returnTo)}`
window.location.href = url
}
export function logout() {
clearToken()
window.location.href = `${authPortalUrl()}/logout`
}
export const CURRENT_APP_ID = 'fw'
+39
View File
@@ -0,0 +1,39 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { ThemeProvider } from 'next-themes'
import { Toaster } from '@evofw/ui/components/sonner'
import { TooltipProvider } from '@evofw/ui/components/tooltip'
import { routeTree } from './routeTree.gen'
import '@evofw/ui/globals.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10_000, retry: 1 },
},
})
const router = createRouter({
routeTree,
context: { queryClient },
})
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<TooltipProvider>
<RouterProvider router={router} />
<Toaster />
</TooltipProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)
+82
View File
@@ -0,0 +1,82 @@
import { queryOptions } from '@tanstack/react-query'
import { apiFetch } from '@/lib/api'
import type { Agent, DashboardStats, IpList, PolicyRule } from '@evofw/shared'
export const dashboardQueryOptions = () =>
queryOptions({
queryKey: ['dashboard'],
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
})
export const agentsQueryOptions = () =>
queryOptions({
queryKey: ['agents'],
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
})
export const agentQueryOptions = (id: string) =>
queryOptions({
queryKey: ['agents', id],
queryFn: () => apiFetch<Agent>(`/api/v1/agents/${id}`),
})
export const listsQueryOptions = () =>
queryOptions({
queryKey: ['lists'],
queryFn: () => apiFetch<{ items: IpList[] }>('/api/v1/lists'),
})
export const rulesQueryOptions = (agentId?: string) =>
queryOptions({
queryKey: ['rules', agentId ?? 'all'],
queryFn: () =>
apiFetch<{ items: PolicyRule[] }>(
`/api/v1/rules${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ''}`,
),
})
export const installContextQueryOptions = () =>
queryOptions({
queryKey: ['install-context'],
queryFn: () =>
apiFetch<{
suggested_cp_url: string
enroll_seed: string
install_sh_url: string
mikrotik_url: string
sync_interval_sec: number
}>('/api/v1/install-context'),
})
export const settingsQueryOptions = () =>
queryOptions({
queryKey: ['settings'],
queryFn: () => apiFetch<Record<string, string>>('/api/v1/settings'),
})
export const agentStatsQueryOptions = (id: string) =>
queryOptions({
queryKey: ['agent-stats', id],
queryFn: () =>
apiFetch<{
items: {
packets_dropped: number
packets_accepted: number
recorded_at: string
}[]
}>(`/api/v1/agents/${id}/stats`),
})
export const recentStatsQueryOptions = () =>
queryOptions({
queryKey: ['stats-recent'],
queryFn: () =>
apiFetch<{
items: {
agent_id: string
packets_dropped: number
packets_accepted: number
recorded_at: string
}[]
}>('/api/v1/stats/recent'),
})
+244
View File
@@ -0,0 +1,244 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
import { Route as AuthAgentsRouteImport } from './routes/_auth/agents'
import { Route as AuthListsRouteImport } from './routes/_auth/lists'
import { Route as AuthRulesRouteImport } from './routes/_auth/rules'
import { Route as AuthSettingsRouteImport } from './routes/_auth/settings'
import { Route as AuthStatsRouteImport } from './routes/_auth/stats'
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
import { Route as AuthAgentsIdRouteImport } from './routes/_auth/agents.$id'
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const AuthIndexRoute = AuthIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthRoute,
} as any)
const AuthAgentsRoute = AuthAgentsRouteImport.update({
id: '/agents',
path: '/agents',
getParentRoute: () => AuthRoute,
} as any)
const AuthListsRoute = AuthListsRouteImport.update({
id: '/lists',
path: '/lists',
getParentRoute: () => AuthRoute,
} as any)
const AuthRulesRoute = AuthRulesRouteImport.update({
id: '/rules',
path: '/rules',
getParentRoute: () => AuthRoute,
} as any)
const AuthSettingsRoute = AuthSettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AuthRoute,
} as any)
const AuthStatsRoute = AuthStatsRouteImport.update({
id: '/stats',
path: '/stats',
getParentRoute: () => AuthRoute,
} as any)
const AuthCallbackRoute = AuthCallbackRouteImport.update({
id: '/auth/callback',
path: '/auth/callback',
getParentRoute: () => rootRouteImport,
} as any)
const AuthAgentsIdRoute = AuthAgentsIdRouteImport.update({
id: '/$id',
path: '/$id',
getParentRoute: () => AuthAgentsRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AuthIndexRoute
'/agents': typeof AuthAgentsRouteWithChildren
'/lists': typeof AuthListsRoute
'/rules': typeof AuthRulesRoute
'/settings': typeof AuthSettingsRoute
'/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/agents/$id': typeof AuthAgentsIdRoute
}
export interface FileRoutesByTo {
'/agents': typeof AuthAgentsRouteWithChildren
'/lists': typeof AuthListsRoute
'/rules': typeof AuthRulesRoute
'/settings': typeof AuthSettingsRoute
'/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/': typeof AuthIndexRoute
'/agents/$id': typeof AuthAgentsIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_auth': typeof AuthRouteWithChildren
'/_auth/agents': typeof AuthAgentsRouteWithChildren
'/_auth/lists': typeof AuthListsRoute
'/_auth/rules': typeof AuthRulesRoute
'/_auth/settings': typeof AuthSettingsRoute
'/_auth/stats': typeof AuthStatsRoute
'/auth/callback': typeof AuthCallbackRoute
'/_auth/': typeof AuthIndexRoute
'/_auth/agents/$id': typeof AuthAgentsIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/agents'
| '/lists'
| '/rules'
| '/settings'
| '/stats'
| '/auth/callback'
| '/agents/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/agents'
| '/lists'
| '/rules'
| '/settings'
| '/stats'
| '/auth/callback'
| '/'
| '/agents/$id'
id:
| '__root__'
| '/_auth'
| '/_auth/agents'
| '/_auth/lists'
| '/_auth/rules'
| '/_auth/settings'
| '/_auth/stats'
| '/auth/callback'
| '/_auth/'
| '/_auth/agents/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AuthRoute: typeof AuthRouteWithChildren
AuthCallbackRoute: typeof AuthCallbackRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/': {
id: '/_auth/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AuthIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/agents': {
id: '/_auth/agents'
path: '/agents'
fullPath: '/agents'
preLoaderRoute: typeof AuthAgentsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/lists': {
id: '/_auth/lists'
path: '/lists'
fullPath: '/lists'
preLoaderRoute: typeof AuthListsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/rules': {
id: '/_auth/rules'
path: '/rules'
fullPath: '/rules'
preLoaderRoute: typeof AuthRulesRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/settings': {
id: '/_auth/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AuthSettingsRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/stats': {
id: '/_auth/stats'
path: '/stats'
fullPath: '/stats'
preLoaderRoute: typeof AuthStatsRouteImport
parentRoute: typeof AuthRoute
}
'/auth/callback': {
id: '/auth/callback'
path: '/auth/callback'
fullPath: '/auth/callback'
preLoaderRoute: typeof AuthCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/agents/$id': {
id: '/_auth/agents/$id'
path: '/$id'
fullPath: '/agents/$id'
preLoaderRoute: typeof AuthAgentsIdRouteImport
parentRoute: typeof AuthAgentsRoute
}
}
}
interface AuthAgentsRouteChildren {
AuthAgentsIdRoute: typeof AuthAgentsIdRoute
}
const AuthAgentsRouteChildren: AuthAgentsRouteChildren = {
AuthAgentsIdRoute: AuthAgentsIdRoute,
}
const AuthAgentsRouteWithChildren = AuthAgentsRoute._addFileChildren(
AuthAgentsRouteChildren,
)
interface AuthRouteChildren {
AuthAgentsRoute: typeof AuthAgentsRouteWithChildren
AuthListsRoute: typeof AuthListsRoute
AuthRulesRoute: typeof AuthRulesRoute
AuthSettingsRoute: typeof AuthSettingsRoute
AuthStatsRoute: typeof AuthStatsRoute
AuthIndexRoute: typeof AuthIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
AuthAgentsRoute: AuthAgentsRouteWithChildren,
AuthListsRoute: AuthListsRoute,
AuthRulesRoute: AuthRulesRoute,
AuthSettingsRoute: AuthSettingsRoute,
AuthStatsRoute: AuthStatsRoute,
AuthIndexRoute: AuthIndexRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
const rootRouteChildren: RootRouteChildren = {
AuthRoute: AuthRouteWithChildren,
AuthCallbackRoute: AuthCallbackRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
View File
+25
View File
@@ -0,0 +1,25 @@
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import type { QueryClient } from '@tanstack/react-query'
import {
ensureAuthConfig,
isAuthEnabled,
isTokenValid,
redirectToPortalLogin,
} from '@/lib/auth'
export type RouterContext = {
queryClient: QueryClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: async ({ location }) => {
await ensureAuthConfig()
if (!isAuthEnabled()) return
if (location.pathname.startsWith('/auth/')) return
if (!isTokenValid()) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
throw new Error('redirecting to portal')
}
},
component: () => <Outlet />,
})
+10
View File
@@ -0,0 +1,10 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell'
export const Route = createFileRoute('/_auth')({
component: () => (
<AppShell>
<Outlet />
</AppShell>
),
})
+236
View File
@@ -0,0 +1,236 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
import {
agentQueryOptions,
agentsQueryOptions,
rulesQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
export const Route = createFileRoute('/_auth/agents/$id')({
component: AgentDetailPage,
})
function AgentDetailPage() {
const { id } = Route.useParams()
const qc = useQueryClient()
const agentQ = useQuery(agentQueryOptions(id))
const rulesQ = useQuery(rulesQueryOptions(id))
const agentsQ = useQuery(agentsQueryOptions())
const [cidr, setCidr] = useState('')
const [action, setAction] = useState<'allow' | 'deny'>('deny')
const [cloneFrom, setCloneFrom] = useState('')
const patchMode = useMutation({
mutationFn: (policy_mode: 'blacklist' | 'whitelist') =>
apiFetch(`/api/v1/agents/${id}`, {
method: 'PATCH',
body: JSON.stringify({ policy_mode }),
}),
onSuccess: () => {
toast.success('Режим обновлён')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
})
const addOverride = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/overrides`, {
method: 'POST',
body: JSON.stringify({ cidr, action }),
}),
onSuccess: () => {
toast.success('Override добавлен — подхватится на следующей итерации sync')
setCidr('')
void qc.invalidateQueries({ queryKey: ['agents', id] })
},
onError: (e: Error) => toast.error(e.message),
})
const clone = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/clone-from/${cloneFrom}`, {
method: 'POST',
body: JSON.stringify({ include_overrides: true }),
}),
onSuccess: () => {
toast.success('Правила скопированы')
void qc.invalidateQueries({ queryKey: ['rules'] })
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const a = agentQ.data
if (!a) {
return <PageShell><PageHeader title="Агент" description="Загрузка…" /></PageShell>
}
return (
<PageShell>
<PageHeader
title={a.name}
description={`${a.platform} · ${a.status} · gen ${a.policy_generation}`}
actions={
<Link to="/agents" className="inline-flex">
<Button variant="outline" type="button">
К списку
</Button>
</Link>
}
/>
<div className="grid gap-4 lg:grid-cols-2">
<Frame>
<FrameHeader>
<FrameTitle>Политика</FrameTitle>
<FrameDescription>
blacklist = deny set; whitelist = allow set + default drop
</FrameDescription>
</FrameHeader>
<div className="flex flex-wrap gap-2">
<Button
variant={a.policy_mode === 'blacklist' ? 'default' : 'outline'}
onClick={() => patchMode.mutate('blacklist')}
>
Blacklist
</Button>
<Button
variant={a.policy_mode === 'whitelist' ? 'default' : 'outline'}
onClick={() => patchMode.mutate('whitelist')}
>
Whitelist
</Button>
</div>
<dl className="mt-4 grid grid-cols-2 gap-2 text-sm">
<dt className="text-muted-foreground">Dropped</dt>
<dd className="tabular-nums">{a.last_apply_packets_dropped ?? 0}</dd>
<dt className="text-muted-foreground">Accepted</dt>
<dd className="tabular-nums">{a.last_apply_packets_accepted ?? 0}</dd>
<dt className="text-muted-foreground">Kernel</dt>
<dd>{a.last_apply_kernel_method ?? '—'}</dd>
<dt className="text-muted-foreground">Last apply</dt>
<dd className="text-xs">{a.last_apply_at ?? '—'}</dd>
</dl>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Мгновенный IP override</FrameTitle>
<FrameDescription>
Обновится на агенте на следующей итерации sync (~1 мин)
</FrameDescription>
</FrameHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<Label>CIDR / IP</Label>
<Input
placeholder="1.2.3.4/32"
value={cidr}
onChange={(e) => setCidr(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Действие</Label>
<Select
value={action}
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
</div>
<Button
onClick={() => addOverride.mutate()}
disabled={!cidr || addOverride.isPending}
>
Добавить override
</Button>
</div>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Копировать правила</FrameTitle>
</FrameHeader>
<div className="flex flex-col gap-3">
<Select value={cloneFrom} onValueChange={setCloneFrom}>
<SelectTrigger>
<SelectValue placeholder="Источник" />
</SelectTrigger>
<SelectContent>
{(agentsQ.data?.items ?? [])
.filter((x) => x.id !== id)
.map((x) => (
<SelectItem key={x.id} value={x.id}>
{x.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
disabled={!cloneFrom || clone.isPending}
onClick={() => clone.mutate()}
>
Клонировать (с overrides)
</Button>
</div>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Правила агента</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Prio</TableHead>
<TableHead>Action</TableHead>
<TableHead>Source</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(rulesQ.data?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>{r.action}</TableCell>
<TableCell className="font-mono text-xs">
{r.cidr ?? r.list_id ?? '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
</div>
</PageShell>
)
}
+231
View File
@@ -0,0 +1,231 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Copy, Check } from 'lucide-react'
import { useMemo, useState } from 'react'
import { PageHeader, PageShell, EmptyState } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
import {
agentsQueryOptions,
installContextQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
import { Badge } from '@evofw/ui/components/badge'
export const Route = createFileRoute('/_auth/agents')({
component: AgentsPage,
})
function AgentsPage() {
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
const installQ = useQuery(installContextQueryOptions())
const [name, setName] = useState('web-01')
const approve = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const revoke = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент отозван')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
onSuccess: () => {
toast.success('Удалён')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
})
const installCmd = useMemo(() => {
const cp = installQ.data?.suggested_cp_url ?? 'https://fw.example.com'
const seed = installQ.data?.enroll_seed ?? '<seed>'
return `curl -fsSL ${cp}/v1/agent/install.sh | \\\n EVOFW_CP_URL=${cp} \\\n EVOFW_SEED=${seed} \\\n EVOFW_CLIENT_NAME="${name}" \\\n bash`
}, [installQ.data, name])
const items = agentsQ.data?.items ?? []
const pending = items.filter((a) => a.status === 'pending')
return (
<PageShell>
<PageHeader
title="Агенты"
description="Linux / MikroTik — enroll, approve, policy mode. Preview: data-grid-filtering-2"
/>
<Frame>
<FrameHeader>
<div>
<FrameTitle>Установка Linux</FrameTitle>
<FrameDescription>
One-liner. После enroll одобрите агента ниже.
</FrameDescription>
</div>
</FrameHeader>
<div className="mb-3 flex max-w-sm flex-col gap-2">
<Label htmlFor="cname">Имя клиента</Label>
<Input
id="cname"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs">
{installCmd}
</pre>
<Button
className="mt-2"
variant="outline"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(installCmd.replace(/\\\n\s*/g, ' '))
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
{installQ.data?.mikrotik_url ? (
<p className="text-muted-foreground mt-3 text-sm">
MikroTik:{' '}
<a className="underline" href={installQ.data.mikrotik_url}>
mikrotik-install.rsc
</a>
</p>
) : null}
</Frame>
{pending.length > 0 ? (
<Frame>
<FrameHeader>
<FrameTitle>Запросы ({pending.length})</FrameTitle>
</FrameHeader>
<div className="flex flex-col gap-2">
{pending.map((a) => (
<div
key={a.id}
className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-0"
>
<div>
<div className="font-medium">{a.name}</div>
<div className="text-muted-foreground text-xs">
{a.platform} · {a.hostname ?? '—'} · {a.token_prefix}
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => approve.mutate(a.id)}
disabled={approve.isPending}
>
<Check data-icon="inline-start" />
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => remove.mutate(a.id)}
>
Удалить
</Button>
</div>
</div>
))}
</div>
</Frame>
) : null}
<Frame>
<FrameHeader>
<FrameTitle>Клиенты</FrameTitle>
</FrameHeader>
{items.length === 0 ? (
<EmptyState
title="Нет агентов"
description="Установите agent на сервер и одобрите запрос."
/>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Платформа</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Режим</TableHead>
<TableHead>Seen</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((a) => (
<TableRow key={a.id}>
<TableCell>
<Link
to="/agents/$id"
params={{ id: a.id }}
className="font-medium underline-offset-4 hover:underline"
>
{a.name}
</Link>
</TableCell>
<TableCell>{a.platform}</TableCell>
<TableCell>
<Badge variant="secondary">{a.status}</Badge>
</TableCell>
<TableCell>{a.policy_mode}</TableCell>
<TableCell className="text-muted-foreground text-xs">
{a.last_seen_at ?? '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{a.status === 'approved' ? (
<Button
size="sm"
variant="outline"
onClick={() => revoke.mutate(a.id)}
>
Revoke
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(a.id)}
>
Удалить
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Frame>
</PageShell>
)
}
+133
View File
@@ -0,0 +1,133 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { PageHeader, PageShell, KpiStatGrid } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { dashboardQueryOptions, agentsQueryOptions, recentStatsQueryOptions } from '@/queries'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
import { Skeleton } from '@evofw/ui/components/skeleton'
export const Route = createFileRoute('/_auth/')({
component: DashboardPage,
})
function DashboardPage() {
const dash = useQuery(dashboardQueryOptions())
const agents = useQuery(agentsQueryOptions())
const stats = useQuery(recentStatsQueryOptions())
const d = dash.data
const items = [
{
id: 'agents',
label: 'Агенты',
value: d?.agents_approved ?? '—',
hint: `${d?.agents_online ?? 0} online / ${d?.agents_pending ?? 0} pending`,
to: '/agents',
},
{
id: 'dropped',
label: 'Dropped',
value: d?.packets_dropped ?? '—',
hint: 'сумма counters',
to: '/stats',
},
{
id: 'accepted',
label: 'Accepted',
value: d?.packets_accepted ?? '—',
hint: 'сумма counters',
to: '/stats',
},
{
id: 'lists',
label: 'Списки IP',
value: d?.lists_total ?? '—',
to: '/lists',
},
]
return (
<PageShell>
<PageHeader
title="Дашборд"
description="Обзор агентов и пакетной статистики — ReUI stats-12 / dashboard-1"
/>
{dash.isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
) : (
<KpiStatGrid items={items} />
)}
<div className="grid gap-4 lg:grid-cols-2">
<Frame>
<FrameHeader>
<FrameTitle>Агенты</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Режим</TableHead>
<TableHead className="text-right">Dropped</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(agents.data?.items ?? []).slice(0, 8).map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.name}</TableCell>
<TableCell>{a.status}</TableCell>
<TableCell>{a.policy_mode}</TableCell>
<TableCell className="text-right tabular-nums">
{a.last_apply_packets_dropped ?? 0}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Последние samples</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Время</TableHead>
<TableHead className="text-right">Drop</TableHead>
<TableHead className="text-right">Accept</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(stats.data?.items ?? []).slice(0, 10).map((s, i) => (
<TableRow key={`${s.agent_id}-${s.recorded_at}-${i}`}>
<TableCell className="text-muted-foreground text-xs">
{s.recorded_at}
</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_dropped}
</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_accepted}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
</div>
</PageShell>
)
}
+210
View File
@@ -0,0 +1,210 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useState } from 'react'
import { PageHeader, PageShell, EmptyState } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { listsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import { Textarea } from '@evofw/ui/components/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
export const Route = createFileRoute('/_auth/lists')({
component: ListsPage,
})
function ListsPage() {
const qc = useQueryClient()
const listsQ = useQuery(listsQueryOptions())
const [name, setName] = useState('')
const [type, setType] = useState<
'static' | 'json_url' | 'domains' | 'evobgp_community'
>('static')
const [extra, setExtra] = useState('')
const create = useMutation({
mutationFn: async () => {
const config: Record<string, unknown> = {}
let entries: string[] | undefined
if (type === 'static') {
entries = extra
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
} else if (type === 'json_url') {
config.url = extra.trim()
} else if (type === 'domains') {
config.domains = extra
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
} else {
config.community_id = extra.trim()
}
return apiFetch('/api/v1/lists', {
method: 'POST',
body: JSON.stringify({ name, type, config, entries }),
})
},
onSuccess: () => {
toast.success('Список создан')
setName('')
setExtra('')
void qc.invalidateQueries({ queryKey: ['lists'] })
},
onError: (e: Error) => toast.error(e.message),
})
const refresh = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/lists/${id}/refresh`, { method: 'POST' }),
onSuccess: () => {
toast.success('Обновлено')
void qc.invalidateQueries({ queryKey: ['lists'] })
},
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['lists'] })
},
})
const items = listsQ.data?.items ?? []
return (
<PageShell>
<PageHeader
title="Списки IP"
description="static · JSON URL · domains · EvoBGP community"
/>
<Frame>
<FrameHeader>
<FrameTitle>Новый список</FrameTitle>
</FrameHeader>
<div className="grid max-w-xl gap-3">
<div className="flex flex-col gap-2">
<Label>Имя</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex flex-col gap-2">
<Label>Тип</Label>
<Select
value={type}
onValueChange={(v) =>
setType(v as typeof type)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static">static</SelectItem>
<SelectItem value="json_url">json_url</SelectItem>
<SelectItem value="domains">domains</SelectItem>
<SelectItem value="evobgp_community">evobgp_community</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>
{type === 'static'
? 'CIDR (через пробел/запятую)'
: type === 'json_url'
? 'URL JSON'
: type === 'domains'
? 'Домены'
: 'Community ID'}
</Label>
<Textarea
value={extra}
onChange={(e) => setExtra(e.target.value)}
rows={3}
/>
</div>
<Button
disabled={!name || create.isPending}
onClick={() => create.mutate()}
>
Создать
</Button>
</div>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Списки</FrameTitle>
</FrameHeader>
{items.length === 0 ? (
<EmptyState title="Пусто" description="Создайте первый список." />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Entries</TableHead>
<TableHead>Refresh</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((l) => (
<TableRow key={l.id}>
<TableCell className="font-medium">{l.name}</TableCell>
<TableCell>{l.type}</TableCell>
<TableCell className="tabular-nums">
{l.entry_count ?? 0}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{l.last_error ?? l.refreshed_at ?? '—'}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{l.type !== 'static' ? (
<Button
size="sm"
variant="outline"
onClick={() => refresh.mutate(l.id)}
>
Refresh
</Button>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(l.id)}
>
Удалить
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</Frame>
</PageShell>
)
}
+182
View File
@@ -0,0 +1,182 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { rulesQueryOptions, listsQueryOptions, agentsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
export const Route = createFileRoute('/_auth/rules')({
component: RulesPage,
})
function RulesPage() {
const qc = useQueryClient()
const rulesQ = useQuery(rulesQueryOptions())
const listsQ = useQuery(listsQueryOptions())
const agentsQ = useQuery(agentsQueryOptions())
const [priority, setPriority] = useState('100')
const [action, setAction] = useState<'allow' | 'deny'>('deny')
const [listId, setListId] = useState('')
const [agentId, setAgentId] = useState('tenant')
const create = useMutation({
mutationFn: () =>
apiFetch('/api/v1/rules', {
method: 'POST',
body: JSON.stringify({
priority: Number(priority),
action,
list_id: listId || null,
agent_id: agentId === 'tenant' ? null : agentId,
}),
}),
onSuccess: () => {
toast.success('Правило создано')
void qc.invalidateQueries({ queryKey: ['rules'] })
},
onError: (e: Error) => toast.error(e.message),
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/rules/${id}`, { method: 'DELETE' }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['rules'] }),
})
return (
<PageShell>
<PageHeader
title="Правила"
description="Упорядоченные allow/deny по списку или CIDR (tenant + per-agent)"
/>
<Frame>
<FrameHeader>
<FrameTitle>Новое правило</FrameTitle>
</FrameHeader>
<div className="grid max-w-xl gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-2">
<Label>Priority</Label>
<Input
value={priority}
onChange={(e) => setPriority(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Action</Label>
<Select
value={action}
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="deny">deny</SelectItem>
<SelectItem value="allow">allow</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Список</Label>
<Select value={listId} onValueChange={setListId}>
<SelectTrigger>
<SelectValue placeholder="IP list" />
</SelectTrigger>
<SelectContent>
{(listsQ.data?.items ?? []).map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Scope</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tenant">Tenant default</SelectItem>
{(agentsQ.data?.items ?? []).map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
className="sm:col-span-2"
disabled={!listId || create.isPending}
onClick={() => create.mutate()}
>
Создать
</Button>
</div>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Все правила</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Prio</TableHead>
<TableHead>Action</TableHead>
<TableHead>Agent</TableHead>
<TableHead>List / CIDR</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{(rulesQ.data?.items ?? []).map((r) => (
<TableRow key={r.id}>
<TableCell>{r.priority}</TableCell>
<TableCell>{r.action}</TableCell>
<TableCell className="text-xs">
{r.agent_id ?? 'tenant'}
</TableCell>
<TableCell className="font-mono text-xs">
{r.cidr ?? r.list_id ?? '—'}
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(r.id)}
>
Удалить
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
</PageShell>
)
}
+100
View File
@@ -0,0 +1,100 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useEffect, useState } from 'react'
import { PageHeader, PageShell } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame'
import { settingsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { Button } from '@evofw/ui/components/button'
import { Input } from '@evofw/ui/components/input'
import { Label } from '@evofw/ui/components/label'
export const Route = createFileRoute('/_auth/settings')({
component: SettingsPage,
})
function SettingsPage() {
const qc = useQueryClient()
const settingsQ = useQuery(settingsQueryOptions())
const [form, setForm] = useState<Record<string, string>>({})
useEffect(() => {
if (settingsQ.data) setForm(settingsQ.data)
}, [settingsQ.data])
const save = useMutation({
mutationFn: () =>
apiFetch('/api/v1/settings', {
method: 'PUT',
body: JSON.stringify(form),
}),
onSuccess: () => {
toast.success('Сохранено')
void qc.invalidateQueries({ queryKey: ['settings'] })
void qc.invalidateQueries({ queryKey: ['install-context'] })
},
onError: (e: Error) => toast.error(e.message),
})
const fields = [
{
key: 'enroll_seed',
label: 'Enroll seed',
hint: 'X-EvoFW-Seed для install.sh',
},
{
key: 'evobgp_api_url',
label: 'EvoBGP API URL',
hint: 'Источник community prefixes',
},
{
key: 'evobgp_api_token',
label: 'EvoBGP API token',
hint: 'Bearer для интеграции',
},
{
key: 'agent_sync_interval_sec',
label: 'Agent sync interval (sec)',
hint: 'Рекомендуется 30–60',
},
]
return (
<PageShell>
<PageHeader
title="Настройки"
description="Интеграции и enroll — settings-16"
/>
<Frame>
<FrameHeader>
<div>
<FrameTitle>Control plane</FrameTitle>
<FrameDescription>
Auth-portal app id: fw · JWT через AUTH_*
</FrameDescription>
</div>
</FrameHeader>
<div className="flex max-w-xl flex-col gap-4">
{fields.map((f) => (
<div key={f.key} className="flex flex-col gap-2">
<Label htmlFor={f.key}>{f.label}</Label>
<Input
id={f.key}
type={f.key.includes('token') ? 'password' : 'text'}
value={form[f.key] ?? ''}
onChange={(e) =>
setForm((prev) => ({ ...prev, [f.key]: e.target.value }))
}
/>
<p className="text-muted-foreground text-xs">{f.hint}</p>
</div>
))}
<Button onClick={() => save.mutate()} disabled={save.isPending}>
Сохранить
</Button>
</div>
</Frame>
</PageShell>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { PageHeader, PageShell, KpiStatGrid } from '@/components/reui-kit'
import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame'
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@evofw/ui/components/table'
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@evofw/ui/components/chart'
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
export const Route = createFileRoute('/_auth/stats')({
component: StatsPage,
})
const chartConfig = {
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
} satisfies ChartConfig
function StatsPage() {
const dash = useQuery(dashboardQueryOptions())
const stats = useQuery(recentStatsQueryOptions())
const series = [...(stats.data?.items ?? [])]
.reverse()
.slice(-40)
.map((s) => ({
t: s.recorded_at.slice(11, 19),
dropped: s.packets_dropped,
accepted: s.packets_accepted,
}))
return (
<PageShell>
<PageHeader
title="Статистика"
description="История apply-report counters — dashboard-1 / charts"
/>
<KpiStatGrid
items={[
{
id: 'd',
label: 'Dropped (sum)',
value: dash.data?.packets_dropped ?? 0,
},
{
id: 'a',
label: 'Accepted (sum)',
value: dash.data?.packets_accepted ?? 0,
},
{
id: 'o',
label: 'Online agents',
value: dash.data?.agents_online ?? 0,
},
]}
/>
<Frame>
<FrameHeader>
<FrameTitle>Тренд (последние samples)</FrameTitle>
</FrameHeader>
{series.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет данных дождитесь apply-report от агентов.
</p>
) : (
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
<AreaChart data={series}>
<CartesianGrid vertical={false} />
<XAxis dataKey="t" tickLine={false} axisLine={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
dataKey="dropped"
type="monotone"
fill="var(--color-dropped)"
stroke="var(--color-dropped)"
fillOpacity={0.3}
/>
<Area
dataKey="accepted"
type="monotone"
fill="var(--color-accepted)"
stroke="var(--color-accepted)"
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
)}
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Сырые samples</FrameTitle>
</FrameHeader>
<Table>
<TableHeader>
<TableRow>
<TableHead>Agent</TableHead>
<TableHead>Time</TableHead>
<TableHead className="text-right">Drop</TableHead>
<TableHead className="text-right">Accept</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(stats.data?.items ?? []).slice(0, 50).map((s, i) => (
<TableRow key={`${s.agent_id}-${i}`}>
<TableCell className="font-mono text-xs">{s.agent_id.slice(0, 8)}</TableCell>
<TableCell className="text-xs">{s.recorded_at}</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_dropped}
</TableCell>
<TableCell className="text-right tabular-nums">
{s.packets_accepted}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Frame>
</PageShell>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { createFileRoute } from '@tanstack/react-router'
import { setToken } from '@/lib/auth'
export const Route = createFileRoute('/auth/callback')({
component: AuthCallback,
})
function AuthCallback() {
const hash = typeof window !== 'undefined' ? window.location.hash : ''
const params = new URLSearchParams(hash.replace(/^#/, ''))
const token = params.get('access_token')
if (token) {
setToken(token)
window.location.replace('/')
} else {
return (
<div className="flex min-h-svh items-center justify-center p-6">
<p className="text-muted-foreground text-sm">
Нет access_token в URL. Войдите через auth-portal.
</p>
</div>
)
}
return null
}