feat: update dependencies and enhance UI components
- Added new dependencies for drag-and-drop functionality with @dnd-kit packages. - Updated package versions for @tanstack/react-virtual and date-fns. - Refactored AppShell component to utilize AppSidebar and SiteHeader for improved layout. - Enhanced Frame component with new theming capabilities and improved structure. - Introduced filtering capabilities in Agents and Lists pages with new UI elements. - Added new utility functions for authentication claims management. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,9 +1,23 @@
|
||||
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 { useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
CpuIcon,
|
||||
ClockIcon,
|
||||
} from 'lucide-react'
|
||||
import { PageHeader, PageShell, DetailPanel } from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
agentQueryOptions,
|
||||
agentsQueryOptions,
|
||||
@@ -20,14 +34,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
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'
|
||||
import type { PolicyRule } from '@evofw/shared'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||
component: AgentDetailPage,
|
||||
@@ -83,154 +94,237 @@ function AgentDetailPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const ruleColumns: ColumnDef<PolicyRule>[] = useMemo(
|
||||
() => [
|
||||
{ accessorKey: 'priority', header: 'Prio' },
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.action === 'deny'
|
||||
? 'destructive-light'
|
||||
: 'success-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'Source',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.cidr ?? row.original.list_id ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const rulesTable = useReactTable({
|
||||
data: rulesQ.data?.items ?? [],
|
||||
columns: ruleColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r) => r.id,
|
||||
})
|
||||
|
||||
const a = agentQ.data
|
||||
if (!a) {
|
||||
return <PageShell><PageHeader title="Агент" description="Загрузка…" /></PageShell>
|
||||
if (agentQ.isLoading || !a) {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Агент" description="Загрузка…" />
|
||||
<Skeleton className="h-40 w-full rounded-xl" />
|
||||
</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')}
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={a.name}
|
||||
description={`${a.platform} · gen ${a.policy_generation}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
a.status === 'approved'
|
||||
? 'success-light'
|
||||
: a.status === 'pending'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{a.status}
|
||||
</Badge>
|
||||
<Link to="/agents" className="inline-flex">
|
||||
<Button variant="outline" type="button">
|
||||
К списку
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => addOverride.mutate()}
|
||||
disabled={!cidr || addOverride.isPending}
|
||||
>
|
||||
Добавить override
|
||||
</Button>
|
||||
</div>
|
||||
</Frame>
|
||||
}
|
||||
/>
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'dropped',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
label: 'Dropped',
|
||||
description: String(a.last_apply_packets_dropped ?? 0),
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
label: 'Accepted',
|
||||
description: String(a.last_apply_packets_accepted ?? 0),
|
||||
},
|
||||
{
|
||||
id: 'kernel',
|
||||
icon: <CpuIcon aria-hidden />,
|
||||
label: 'Kernel',
|
||||
description: a.last_apply_kernel_method ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
label: 'Last apply',
|
||||
description: a.last_apply_at ?? '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<DetailPanel.Section>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Политика</FrameTitle>
|
||||
<FrameDescription>
|
||||
blacklist = deny set; whitelist = allow set + default drop
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<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>
|
||||
</FramePanel>
|
||||
</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>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Мгновенный IP override</FrameTitle>
|
||||
<FrameDescription>
|
||||
Обновится на агенте на следующей итерации sync (~1 мин)
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<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) => {
|
||||
if (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>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Копировать правила</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Select
|
||||
value={cloneFrom || null}
|
||||
onValueChange={(v) => setCloneFrom(v ?? '')}
|
||||
>
|
||||
<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>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Правила агента</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<DataGrid
|
||||
table={rulesTable}
|
||||
recordCount={rulesQ.data?.items?.length ?? 0}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,26 +2,28 @@ 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 { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
installContextQueryOptions,
|
||||
} from '@/queries'
|
||||
PageHeader,
|
||||
PageShell,
|
||||
ResourcePage,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
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'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents')({
|
||||
component: AgentsPage,
|
||||
@@ -32,6 +34,8 @@ function AgentsPage() {
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const installQ = useQuery(installContextQueryOptions())
|
||||
const [name, setName] = useState('web-01')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
@@ -70,14 +74,132 @@ function AgentsPage() {
|
||||
const items = agentsQ.data?.items ?? []
|
||||
const pending = items.filter((a) => a.status === 'pending')
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Имя',
|
||||
type: 'text',
|
||||
placeholder: 'Поиск…',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'approved', label: 'approved' },
|
||||
{ value: 'pending', label: 'pending' },
|
||||
{ value: 'revoked', label: 'revoked' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'platform',
|
||||
label: 'Платформа',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'linux', label: 'linux' },
|
||||
{ value: 'mikrotik', label: 'mikrotik' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const getFilterFieldValue = useCallback((item: Agent, field: string) => {
|
||||
if (field === 'name') return item.name
|
||||
if (field === 'status') return item.status
|
||||
if (field === 'platform') return item.platform
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
const tabFilter = useCallback((item: Agent, tabId: string) => {
|
||||
if (tabId === 'all') return true
|
||||
return item.status === tabId
|
||||
}, [])
|
||||
|
||||
const columns: ColumnDef<Agent>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Имя',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to="/agents/$id"
|
||||
params={{ id: row.original.id }}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.original.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ accessorKey: 'platform', header: 'Платформа' },
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.status === 'approved'
|
||||
? 'success-light'
|
||||
: row.original.status === 'pending'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ accessorKey: 'policy_mode', header: 'Режим' },
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
header: 'Seen',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.last_seen_at ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[revoke, remove],
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Агенты"
|
||||
description="Linux / MikroTik — enroll, approve, policy mode. Preview: data-grid-filtering-2"
|
||||
description="Linux / MikroTik — enroll, approve, policy mode"
|
||||
/>
|
||||
|
||||
<Frame>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Установка Linux</FrameTitle>
|
||||
@@ -86,146 +208,114 @@ function AgentsPage() {
|
||||
</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}
|
||||
<FramePanel>
|
||||
<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}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<Frame>
|
||||
<Frame dense spacing="sm">
|
||||
<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}…
|
||||
<FramePanel>
|
||||
<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 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>
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</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>
|
||||
<ResourcePage
|
||||
title="Клиенты"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
tabs={[
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'approved', label: 'Approved' },
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'revoked', label: 'Revoked' },
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={agentsQ.isLoading}
|
||||
isError={agentsQ.isError}
|
||||
error={agentsQ.error}
|
||||
onRetry={() => void agentsQ.refetch()}
|
||||
emptyState={{
|
||||
title: 'Нет агентов',
|
||||
description: 'Установите agent на сервер и одобрите запрос.',
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
+232
-102
@@ -1,17 +1,40 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link } 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 { useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
ServerIcon,
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
ListIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
PageHeader,
|
||||
PageShell,
|
||||
OpsDashboard,
|
||||
QuickActionGrid,
|
||||
type KpiStatCard,
|
||||
type QuickActionItem,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
dashboardQueryOptions,
|
||||
agentsQueryOptions,
|
||||
recentStatsQueryOptions,
|
||||
} from '@/queries'
|
||||
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/')({
|
||||
component: DashboardPage,
|
||||
@@ -23,111 +46,218 @@ function DashboardPage() {
|
||||
const stats = useQuery(recentStatsQueryOptions())
|
||||
|
||||
const d = dash.data
|
||||
const items = [
|
||||
const items = agents.data?.items ?? []
|
||||
const pending = items.filter((a) => a.status === 'pending')
|
||||
const applyErrors = items.filter((a) => a.last_apply_error)
|
||||
|
||||
const kpiCards: KpiStatCard[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 'agents',
|
||||
label: 'Агенты',
|
||||
value: d?.agents_approved ?? '—',
|
||||
hint: `${d?.agents_online ?? 0} online / ${d?.agents_pending ?? 0} pending`,
|
||||
to: '/agents',
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'dropped',
|
||||
label: 'Dropped',
|
||||
value: d?.packets_dropped ?? '—',
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
variant: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
label: 'Accepted',
|
||||
value: d?.packets_accepted ?? '—',
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'lists',
|
||||
label: 'Списки IP',
|
||||
value: d?.lists_total ?? '—',
|
||||
to: '/lists',
|
||||
icon: <ListIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
},
|
||||
],
|
||||
[d],
|
||||
)
|
||||
|
||||
const quickActions: QuickActionItem[] = [
|
||||
{
|
||||
id: 'agents',
|
||||
label: 'Агенты',
|
||||
value: d?.agents_approved ?? '—',
|
||||
hint: `${d?.agents_online ?? 0} online / ${d?.agents_pending ?? 0} pending`,
|
||||
title: 'Агенты',
|
||||
description: 'Enroll и approve',
|
||||
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',
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'lists',
|
||||
label: 'Списки IP',
|
||||
value: d?.lists_total ?? '—',
|
||||
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',
|
||||
header: 'Имя',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to="/agents/$id"
|
||||
params={{ id: row.original.id }}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.original.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.status === 'approved'
|
||||
? 'success-light'
|
||||
: row.original.status === 'pending'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ accessorKey: 'policy_mode', header: 'Режим' },
|
||||
{
|
||||
accessorKey: 'last_apply_packets_dropped',
|
||||
header: 'Dropped',
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{row.original.last_apply_packets_dropped ?? 0}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
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 (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Дашборд"
|
||||
description="Обзор агентов и пакетной статистики — ReUI stats-12 / dashboard-1"
|
||||
description="Обзор агентов и пакетной статистики"
|
||||
/>
|
||||
<OpsDashboard
|
||||
isLoading={dash.isLoading}
|
||||
kpiCards={kpiCards}
|
||||
afterKpi={<QuickActionGrid actions={quickActions} />}
|
||||
charts={
|
||||
<>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Агенты</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<DataGrid table={agentsTable} recordCount={items.length}>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Последние samples</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{samples.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет данных</p>
|
||||
) : (
|
||||
samples.map((s, i) => (
|
||||
<div
|
||||
key={`${s.agent_id}-${s.recorded_at}-${i}`}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{s.recorded_at}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
↓{s.packets_dropped} / ↑{s.packets_accepted}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</>
|
||||
}
|
||||
queue={
|
||||
<div className="flex flex-col gap-2">
|
||||
{pending.length === 0 && applyErrors.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет проблем, требующих внимания
|
||||
</p>
|
||||
) : null}
|
||||
{pending.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span>
|
||||
Pending: <strong>{a.name}</strong>
|
||||
</span>
|
||||
<Link
|
||||
to="/agents/$id"
|
||||
params={{ id: a.id }}
|
||||
className="underline-offset-4 hover:underline"
|
||||
>
|
||||
Открыть
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
{applyErrors.map((a) => (
|
||||
<div key={`err-${a.id}`} className="text-sm">
|
||||
<span className="text-destructive font-medium">{a.name}</span>
|
||||
<span className="text-muted-foreground"> — {a.last_apply_error}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
+175
-116
@@ -1,9 +1,10 @@
|
||||
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 { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import { listsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
@@ -18,13 +19,14 @@ import {
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import type { IpList } from '@evofw/shared'
|
||||
|
||||
export const Route = createFileRoute('/_auth/lists')({
|
||||
component: ListsPage,
|
||||
@@ -33,11 +35,13 @@ export const Route = createFileRoute('/_auth/lists')({
|
||||
function ListsPage() {
|
||||
const qc = useQueryClient()
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<
|
||||
'static' | 'json_url' | 'domains' | 'evobgp_community'
|
||||
>('static')
|
||||
const [extra, setExtra] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -67,6 +71,7 @@ function ListsPage() {
|
||||
toast.success('Список создан')
|
||||
setName('')
|
||||
setExtra('')
|
||||
setSheetOpen(false)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -91,120 +96,174 @@ function ListsPage() {
|
||||
|
||||
const items = listsQ.data?.items ?? []
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Тип',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'static', label: 'static' },
|
||||
{ value: 'json_url', label: 'json_url' },
|
||||
{ value: 'domains', label: 'domains' },
|
||||
{ value: 'evobgp_community', label: 'evobgp_community' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const getFilterFieldValue = useCallback((item: IpList, field: string) => {
|
||||
if (field === 'name') return item.name
|
||||
if (field === 'type') return item.type
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
const columns: ColumnDef<IpList>[] = useMemo(
|
||||
() => [
|
||||
{ accessorKey: 'name', header: 'Имя' },
|
||||
{ accessorKey: 'type', header: 'Тип' },
|
||||
{
|
||||
accessorKey: 'entry_count',
|
||||
header: 'Entries',
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.entry_count ?? 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'refresh',
|
||||
header: 'Refresh',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.last_error ?? row.original.refreshed_at ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const l = row.original
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[refresh, remove],
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Списки IP"
|
||||
description="static · JSON URL · domains · EvoBGP community"
|
||||
actions={
|
||||
<Button onClick={() => setSheetOpen(true)}>Новый список</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<ResourcePage
|
||||
title="Списки"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={listsQ.isLoading}
|
||||
isError={listsQ.isError}
|
||||
error={listsQ.error}
|
||||
onRetry={() => void listsQ.refetch()}
|
||||
emptyState={{
|
||||
title: 'Пусто',
|
||||
description: 'Создайте первый список.',
|
||||
action: (
|
||||
<Button onClick={() => setSheetOpen(true)}>Новый список</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Новый список</SheetTitle>
|
||||
<SheetDescription>Источник префиксов для правил</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-1">
|
||||
<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>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button variant="outline" onClick={() => setSheetOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!name || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
+205
-118
@@ -1,10 +1,16 @@
|
||||
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 { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
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'
|
||||
@@ -17,13 +23,14 @@ import {
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import type { PolicyRule } from '@evofw/shared'
|
||||
|
||||
export const Route = createFileRoute('/_auth/rules')({
|
||||
component: RulesPage,
|
||||
@@ -34,10 +41,12 @@ function RulesPage() {
|
||||
const rulesQ = useQuery(rulesQueryOptions())
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [priority, setPriority] = useState('100')
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const [listId, setListId] = useState('')
|
||||
const [agentId, setAgentId] = useState('tenant')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -52,6 +61,7 @@ function RulesPage() {
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Правило создано')
|
||||
setSheetOpen(false)
|
||||
void qc.invalidateQueries({ queryKey: ['rules'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -63,120 +73,197 @@ function RulesPage() {
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['rules'] }),
|
||||
})
|
||||
|
||||
const items = rulesQ.data?.items ?? []
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'deny', label: 'deny' },
|
||||
{ value: 'allow', label: 'allow' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const getFilterFieldValue = useCallback((item: PolicyRule, field: string) => {
|
||||
if (field === 'action') return item.action
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
const columns: ColumnDef<PolicyRule>[] = useMemo(
|
||||
() => [
|
||||
{ accessorKey: 'priority', header: 'Prio' },
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.action === 'deny'
|
||||
? 'destructive-light'
|
||||
: 'success-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'agent_id',
|
||||
header: 'Agent',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.agent_id ?? 'tenant'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'List / CIDR',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.cidr ?? row.original.list_id ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => remove.mutate(row.original.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[remove],
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Правила"
|
||||
description="Упорядоченные allow/deny по списку или CIDR (tenant + per-agent)"
|
||||
description="Упорядоченные allow/deny по списку или CIDR"
|
||||
actions={
|
||||
<Button onClick={() => setSheetOpen(true)}>Новое правило</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<ResourcePage
|
||||
title="Правила"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={rulesQ.isLoading}
|
||||
isError={rulesQ.isError}
|
||||
error={rulesQ.error}
|
||||
onRetry={() => void rulesQ.refetch()}
|
||||
emptyState={{
|
||||
title: 'Нет правил',
|
||||
description: 'Создайте первое правило политики.',
|
||||
action: (
|
||||
<Button onClick={() => setSheetOpen(true)}>Новое правило</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Новое правило</SheetTitle>
|
||||
<SheetDescription>Priority + action + list scope</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 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 sm:col-span-2">
|
||||
<Label>Список</Label>
|
||||
<Select
|
||||
value={listId || null}
|
||||
onValueChange={(v) => setListId(v ?? '')}
|
||||
>
|
||||
<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 sm:col-span-2">
|
||||
<Label>Scope</Label>
|
||||
<Select
|
||||
value={agentId}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAgentId(v)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button variant="outline" onClick={() => setSheetOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!listId || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,18 @@ 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 {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
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,
|
||||
@@ -66,7 +72,7 @@ function SettingsPage() {
|
||||
title="Настройки"
|
||||
description="Интеграции и enroll — settings-16"
|
||||
/>
|
||||
<Frame>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Control plane</FrameTitle>
|
||||
@@ -75,26 +81,34 @@ function SettingsPage() {
|
||||
</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>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col">
|
||||
{fields.map((f, i) => (
|
||||
<SettingRow
|
||||
key={f.key}
|
||||
title={f.label}
|
||||
description={f.hint}
|
||||
labelFor={f.key}
|
||||
last={i === fields.length - 1}
|
||||
>
|
||||
<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 }))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
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 { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@evofw/ui/components/table'
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
ServerIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
PageHeader,
|
||||
PageShell,
|
||||
KpiStatGrid,
|
||||
ResourcePage,
|
||||
type KpiStatCard,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
@@ -19,6 +30,14 @@ import {
|
||||
} from '@evofw/ui/components/chart'
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
type StatRow = {
|
||||
id: string
|
||||
agent_id: string
|
||||
packets_dropped: number
|
||||
packets_accepted: number
|
||||
recorded_at: string
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/stats')({
|
||||
component: StatsPage,
|
||||
})
|
||||
@@ -31,6 +50,7 @@ const chartConfig = {
|
||||
function StatsPage() {
|
||||
const dash = useQuery(dashboardQueryOptions())
|
||||
const stats = useQuery(recentStatsQueryOptions())
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
|
||||
const series = [...(stats.data?.items ?? [])]
|
||||
.reverse()
|
||||
@@ -41,95 +61,157 @@ function StatsPage() {
|
||||
accepted: s.packets_accepted,
|
||||
}))
|
||||
|
||||
const rows: StatRow[] = useMemo(
|
||||
() =>
|
||||
(stats.data?.items ?? []).slice(0, 50).map((s, i) => ({
|
||||
id: `${s.agent_id}-${s.recorded_at}-${i}`,
|
||||
agent_id: s.agent_id,
|
||||
packets_dropped: s.packets_dropped,
|
||||
packets_accepted: s.packets_accepted,
|
||||
recorded_at: s.recorded_at,
|
||||
})),
|
||||
[stats.data],
|
||||
)
|
||||
|
||||
const kpiCards: KpiStatCard[] = [
|
||||
{
|
||||
id: 'd',
|
||||
label: 'Dropped (sum)',
|
||||
value: dash.data?.packets_dropped ?? 0,
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
variant: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
label: 'Accepted (sum)',
|
||||
value: dash.data?.packets_accepted ?? 0,
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'o',
|
||||
label: 'Online agents',
|
||||
value: dash.data?.agents_online ?? 0,
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
]
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: 'Agent',
|
||||
type: 'text',
|
||||
placeholder: 'agent id…',
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const getFilterFieldValue = useCallback((item: StatRow, field: string) => {
|
||||
if (field === 'agent_id') return item.agent_id
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
const columns: ColumnDef<StatRow>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'agent_id',
|
||||
header: 'Agent',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.agent_id.slice(0, 8)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'recorded_at',
|
||||
header: 'Time',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.recorded_at}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'packets_dropped',
|
||||
header: 'Drop',
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.packets_dropped}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'packets_accepted',
|
||||
header: 'Accept',
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.packets_accepted}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
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,
|
||||
},
|
||||
]}
|
||||
description="История apply-report counters"
|
||||
/>
|
||||
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
||||
|
||||
<Frame>
|
||||
<Frame dense spacing="sm">
|
||||
<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>
|
||||
)}
|
||||
<FramePanel>
|
||||
{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>
|
||||
)}
|
||||
</FramePanel>
|
||||
</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>
|
||||
<ResourcePage
|
||||
title="Samples"
|
||||
hideHeader
|
||||
data={rows}
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={stats.isLoading}
|
||||
emptyState={{
|
||||
title: 'Нет samples',
|
||||
description: 'Агенты ещё не отправили apply-report.',
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user