Compare commits
9
Commits
a0f0bff65a
...
27c1ba7e23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27c1ba7e23 | ||
|
|
19af67f8a3 | ||
|
|
8a8ea95da6 | ||
|
|
65434857bb | ||
|
|
3d4edbad8b | ||
|
|
28f1f35f16 | ||
|
|
0b426fa7ae | ||
|
|
98e4dcdefb | ||
|
|
0f7daf77e3 |
No files matched your search
@@ -212,6 +212,30 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.post('/agents/delete-bulk', async (req) => {
|
||||
const body = agentIdsBodySchema.parse(req.body)
|
||||
const deleted: { id: string; name: string }[] = []
|
||||
app.sqlite.transaction(() => {
|
||||
for (const id of body.agent_ids) {
|
||||
const a = repos.getAgent(app.db, id)
|
||||
if (!a) continue
|
||||
repos.deleteAgent(app.db, id)
|
||||
deleted.push({ id: a.id, name: a.name })
|
||||
}
|
||||
})()
|
||||
if (deleted.length > 0) {
|
||||
auditMutation(app, config, req, {
|
||||
action: 'agent.delete',
|
||||
severity: 'warning',
|
||||
targetType: 'app_resource',
|
||||
targetId: deleted[0]!.id,
|
||||
summary: `Массовое удаление агентов: ${deleted.length}`,
|
||||
details: { agent_ids: deleted.map((d) => d.id) },
|
||||
})
|
||||
}
|
||||
return { items: deleted.map((d) => d.id), deleted: deleted.length }
|
||||
})
|
||||
|
||||
app.post<{ Params: { id: string; sourceId: string } }>(
|
||||
'/agents/:id/clone-from/:sourceId',
|
||||
async (req) => {
|
||||
|
||||
@@ -152,4 +152,32 @@ describe('bulk agent operations', () => {
|
||||
})
|
||||
expect(bad.statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it('delete-bulk removes agents in one request and skips unknown ids', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const a1 = await createInvitedAgent(app, 'bulk-del-01')
|
||||
const a2 = await createInvitedAgent(app, 'bulk-del-02')
|
||||
const a3 = await createInvitedAgent(app, 'bulk-del-03')
|
||||
|
||||
const bulk = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/agents/delete-bulk',
|
||||
payload: { agent_ids: [a1, a2, 'no-such-agent'] },
|
||||
})
|
||||
expect(bulk.statusCode).toBe(200)
|
||||
const body = bulk.json() as { items: string[]; deleted: number }
|
||||
expect(body.deleted).toBe(2)
|
||||
expect(body.items.sort()).toEqual([a1, a2].sort())
|
||||
|
||||
for (const id of [a1, a2]) {
|
||||
const res = await app.inject({ method: 'GET', url: `/api/v1/agents/${id}` })
|
||||
expect(res.statusCode).toBe(404)
|
||||
}
|
||||
|
||||
// a3 не упомянут в запросе — остаётся на месте
|
||||
const kept = await app.inject({ method: 'GET', url: `/api/v1/agents/${a3}` })
|
||||
expect(kept.statusCode).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,8 @@
|
||||
"@tanstack/react-virtual": "^3.14.7",
|
||||
"@tanstack/router-plugin": "^1.120.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"cn": "^0.4.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
PanelRight,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import {
|
||||
agentHasTrafficSample,
|
||||
agentTrafficAccepted,
|
||||
@@ -13,6 +18,13 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
@@ -21,7 +33,7 @@ import {
|
||||
} from '@evofw/ui/components/item'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { formatPackets, formatShortDateTime } from '@/lib/format'
|
||||
import { formatPackets, formatRelativeTime } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
|
||||
@@ -48,20 +60,22 @@ export function AgentCard({
|
||||
dropped === '—' && accepted === '—'
|
||||
? '—'
|
||||
: `↓${dropped} · ↑${accepted}`
|
||||
const seen = formatShortDateTime(agent.last_seen_at ?? agent.last_apply_at)
|
||||
const seen = formatRelativeTime(
|
||||
agent.last_seen_at ?? agent.last_apply_at,
|
||||
)
|
||||
const defaultAction =
|
||||
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
||||
agent.default_action === 'drop' ? 'Блокировать' : 'Пропускать'
|
||||
const subtitle = [
|
||||
agent.hostname,
|
||||
platformLabel(agent.platform),
|
||||
`gen ${agent.policy_generation}`,
|
||||
`поколение ${agent.policy_generation}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Traffic',
|
||||
label: 'Трафик',
|
||||
value: traffic,
|
||||
valueClass:
|
||||
traffic === '—'
|
||||
@@ -79,7 +93,7 @@ export function AgentCard({
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Seen',
|
||||
label: 'Активность',
|
||||
value: seen,
|
||||
valueClass: 'text-muted-foreground',
|
||||
valueNode: seen,
|
||||
@@ -91,6 +105,7 @@ export function AgentCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(agent.id)}
|
||||
aria-label={`Открыть агента ${agent.name}`}
|
||||
className={cn(
|
||||
'w-full text-left outline-none',
|
||||
'focus-visible:ring-ring rounded-[calc(var(--frame-radius)+2px)] focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
@@ -113,7 +128,10 @@ export function AgentCard({
|
||||
<h3 className="truncate text-sm leading-tight font-semibold">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<StatusBadge status={agent.status} />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={agent} />
|
||||
<StatusBadge status={agent.status} />
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
agent.default_action === 'drop'
|
||||
@@ -173,16 +191,35 @@ export function AgentCard({
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive absolute top-3 right-3 z-10"
|
||||
aria-label="Удалить"
|
||||
onClick={() => onDelete(agent.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3 z-10"
|
||||
aria-label={`Действия с агентом ${agent.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreVerticalIcon className="size-3.5" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onSelect(agent.id)}>
|
||||
<PanelRight className="size-4" />
|
||||
Открыть
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(agent.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Удалить
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,10 +21,8 @@ import {
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentPlatformIcon, platformLabel } from '@/components/agents/agent-platform-icon'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import {
|
||||
agentTrafficAccepted,
|
||||
agentTrafficDropped,
|
||||
@@ -38,6 +36,7 @@ import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
@@ -47,6 +46,7 @@ import {
|
||||
agentQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
@@ -79,6 +79,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [revokeOpen, setRevokeOpen] = useState(false)
|
||||
const [resetStatsOpen, setResetStatsOpen] = useState(false)
|
||||
const [fwTab, setFwTab] = useState('host')
|
||||
|
||||
const revoke = useMutation({
|
||||
@@ -111,7 +113,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -135,8 +137,19 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription>
|
||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>
|
||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => void agentQ.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -146,8 +159,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const headerDesc = [
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`gen ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
||||
`поколение ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
@@ -162,7 +175,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
actions={
|
||||
<>
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<StatusBadge status={a.status} />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AgentOnlineDot agent={a} />
|
||||
<StatusBadge status={a.status} />
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
a.default_action === 'drop'
|
||||
@@ -180,17 +196,17 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
onClick={() => approve.mutate()}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
Approve
|
||||
Утвердить
|
||||
</Button>
|
||||
) : null}
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revoke.mutate()}
|
||||
onClick={() => setRevokeOpen(true)}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
Revoke
|
||||
Отозвать
|
||||
</Button>
|
||||
) : null}
|
||||
{a.install_curl ? (
|
||||
@@ -199,11 +215,11 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
toast.success('Команда установки скопирована')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Install
|
||||
Установка
|
||||
</Button>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
@@ -231,14 +247,14 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
toast.success('Команда установки скопирована')
|
||||
installRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Install curl
|
||||
Команда установки
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
@@ -262,7 +278,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
{a.last_apply_error ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка apply</AlertTitle>
|
||||
<AlertTitle>Ошибка применения политики</AlertTitle>
|
||||
<AlertDescription>{a.last_apply_error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
@@ -273,7 +289,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
id: 'traffic',
|
||||
icon: <ActivityIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
label: 'Traffic',
|
||||
label: 'Трафик',
|
||||
description: `↓${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
|
||||
hint: 'накопительно',
|
||||
variant: 'warning',
|
||||
@@ -283,7 +299,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={resetStats.isPending}
|
||||
onClick={() => resetStats.mutate()}
|
||||
onClick={() => setResetStatsOpen(true)}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
@@ -293,15 +309,18 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
id: 'kernel',
|
||||
icon: <CpuIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
label: 'Kernel',
|
||||
label: 'Ядро',
|
||||
description: a.last_apply_kernel_method ?? '—',
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
label: 'Last apply',
|
||||
description: a.last_apply_at ?? '—',
|
||||
label: 'Последнее применение',
|
||||
description: formatDateTime(a.last_apply_at),
|
||||
hint: a.last_apply_at
|
||||
? formatRelativeTime(a.last_apply_at)
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -333,9 +352,9 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
value={fwTab}
|
||||
onValueChange={setFwTab}
|
||||
tabs={[
|
||||
{ id: 'host', label: 'Host firewall' },
|
||||
{ id: 'acl', label: 'Port ACL' },
|
||||
{ id: 'hits', label: 'Blocked' },
|
||||
{ id: 'host', label: 'Хост-фаервол' },
|
||||
{ id: 'acl', label: 'Правила портов' },
|
||||
{ id: 'hits', label: 'Заблокированное' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="host" className="mt-3">
|
||||
@@ -374,6 +393,32 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
open={cloneOpen}
|
||||
onOpenChange={setCloneOpen}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={revokeOpen}
|
||||
onOpenChange={setRevokeOpen}
|
||||
title="Отозвать агента?"
|
||||
confirmLabel="Отозвать"
|
||||
description={`Агент «${a.name}» потеряет доступ к API управления и перестанет получать обновления политики. Действие нельзя отменить.`}
|
||||
onConfirm={() => {
|
||||
setRevokeOpen(false)
|
||||
revoke.mutate()
|
||||
}}
|
||||
disabled={revoke.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetStatsOpen}
|
||||
onOpenChange={setResetStatsOpen}
|
||||
title="Сбросить статистику?"
|
||||
confirmLabel="Сбросить"
|
||||
description={`Счётчики пакетов и история статистики агента «${a.name}» будут обнулены и удалены. Действие нельзя отменить.`}
|
||||
onConfirm={() => {
|
||||
setResetStatsOpen(false)
|
||||
resetStats.mutate()
|
||||
}}
|
||||
disabled={resetStats.isPending}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useCallback, useMemo, type MouseEvent, type ReactNode } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||
import { Check, Copy, PanelRight, Trash2 } from 'lucide-react'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
} from '@/components/reui/data-grid/data-grid-table'
|
||||
import {
|
||||
DataGridMutedCell,
|
||||
DataGridPrimaryCell,
|
||||
@@ -20,13 +24,15 @@ import {
|
||||
agentTrafficAccepted,
|
||||
agentTrafficDropped,
|
||||
} from '@/components/agents/agent-traffic'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import { formatRelativeTime } from '@/lib/format'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@evofw/ui/components/tooltip'
|
||||
import { formatPackets, formatShortDateTime } from '@/lib/format'
|
||||
import { formatPackets } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Fleet triage DataGrid — firewall ops density.
|
||||
@@ -54,7 +60,17 @@ export type AgentFleetDataGridProps = {
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
emptyAction?: ReactNode
|
||||
filteredEmptyMessage?: string
|
||||
toolbarExtra?: ReactNode
|
||||
/** Bulk-операции: controlled selection пробрасывается в ResourcePage. */
|
||||
enableRowSelection?: boolean
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
|
||||
selectionToolbar?: (ctx: {
|
||||
selectedIds: string[]
|
||||
selectedCount: number
|
||||
clearSelection: () => void
|
||||
}) => ReactNode
|
||||
onSelect: (id: string) => void
|
||||
onApprove: (id: string) => void
|
||||
approvePending?: boolean
|
||||
@@ -81,7 +97,12 @@ export function AgentFleetDataGrid({
|
||||
error,
|
||||
onRetry,
|
||||
emptyAction,
|
||||
filteredEmptyMessage,
|
||||
toolbarExtra,
|
||||
enableRowSelection,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
selectionToolbar,
|
||||
onSelect,
|
||||
onApprove,
|
||||
approvePending,
|
||||
@@ -98,6 +119,18 @@ export function AgentFleetDataGrid({
|
||||
|
||||
const columns: ColumnDef<Agent>[] = useMemo(
|
||||
() => [
|
||||
...(enableRowSelection
|
||||
? [
|
||||
{
|
||||
id: '__select__',
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: 44,
|
||||
header: () => <DataGridTableRowSelectAll />,
|
||||
cell: ({ row }) => <DataGridTableRowSelect row={row} />,
|
||||
} as ColumnDef<Agent>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
accessorKey: 'name',
|
||||
size: 260,
|
||||
@@ -136,12 +169,12 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
{
|
||||
id: 'default_action',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
size: 130,
|
||||
minSize: 120,
|
||||
maxSize: 150,
|
||||
accessorFn: (row) => row.default_action,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Default" />
|
||||
<DataGridColumnHeader column={column} title="Политика" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const drop = row.original.default_action === 'drop'
|
||||
@@ -151,19 +184,19 @@ export function AgentFleetDataGrid({
|
||||
size="sm"
|
||||
radius="full"
|
||||
>
|
||||
{drop ? 'Drop' : 'Accept'}
|
||||
{drop ? 'Блокировать' : 'Пропускать'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'apply',
|
||||
size: 100,
|
||||
minSize: 90,
|
||||
maxSize: 120,
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
accessorFn: (row) => row.last_apply_status ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Apply" />
|
||||
<DataGridColumnHeader column={column} title="Применение" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
@@ -176,7 +209,7 @@ export function AgentFleetDataGrid({
|
||||
}
|
||||
>
|
||||
<Badge variant="destructive-light" size="sm">
|
||||
error
|
||||
Ошибка
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-sm">
|
||||
@@ -188,9 +221,10 @@ export function AgentFleetDataGrid({
|
||||
if (!a.last_apply_status && !a.last_apply_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
const status = a.last_apply_status ?? 'ok'
|
||||
return (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{a.last_apply_status ?? 'ok'}
|
||||
{status === 'applied' ? 'Применено' : status}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
@@ -203,7 +237,7 @@ export function AgentFleetDataGrid({
|
||||
accessorFn: (row) =>
|
||||
agentTrafficDropped(row) + agentTrafficAccepted(row),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Traffic" />
|
||||
<DataGridColumnHeader column={column} title="Трафик" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
@@ -224,39 +258,44 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
maxSize: 180,
|
||||
size: 150,
|
||||
minSize: 130,
|
||||
maxSize: 190,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Seen" />
|
||||
<DataGridColumnHeader column={column} title="Активность" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
const short = formatShortDateTime(a.last_seen_at)
|
||||
if (!a.last_seen_at || short === '—') {
|
||||
if (!a.last_seen_at) {
|
||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||
}
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
accent="default"
|
||||
title={short}
|
||||
subtitle={a.last_seen_ip ?? undefined}
|
||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums"
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<AgentOnlineDot agent={a} />
|
||||
<DataGridPrimaryCell
|
||||
accent="default"
|
||||
title={formatRelativeTime(a.last_seen_at)}
|
||||
subtitle={a.last_seen_ip ?? undefined}
|
||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gen',
|
||||
size: 70,
|
||||
minSize: 60,
|
||||
maxSize: 90,
|
||||
size: 110,
|
||||
minSize: 100,
|
||||
maxSize: 130,
|
||||
accessorFn: (row) => row.policy_generation,
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Gen" />
|
||||
<DataGridColumnHeader column={column} title="Поколение" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
<span
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
title="Версия применённой политики — растёт при каждом apply"
|
||||
>
|
||||
{row.original.policy_generation}
|
||||
</span>
|
||||
),
|
||||
@@ -277,7 +316,7 @@ export function AgentFleetDataGrid({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Approve"
|
||||
aria-label="Утвердить"
|
||||
disabled={approvePending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -291,7 +330,7 @@ export function AgentFleetDataGrid({
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Copy install"
|
||||
aria-label="Скопировать команду установки"
|
||||
onClick={(e) => handleCopy(a.install_curl!, e)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
@@ -325,7 +364,7 @@ export function AgentFleetDataGrid({
|
||||
},
|
||||
},
|
||||
],
|
||||
[approvePending, handleCopy, onApprove, onDelete, onSelect],
|
||||
[approvePending, handleCopy, onApprove, onDelete, onSelect, enableRowSelection],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -346,6 +385,10 @@ export function AgentFleetDataGrid({
|
||||
getSearchText={getSearchText}
|
||||
tableLayout={{ width: 'fixed', columnsResizable: true }}
|
||||
onRowClick={(row) => onSelect(row.id)}
|
||||
enableRowSelection={enableRowSelection}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={onRowSelectionChange}
|
||||
selectionToolbar={selectionToolbar}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
@@ -355,10 +398,11 @@ export function AgentFleetDataGrid({
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
toolbarExtra={toolbarExtra}
|
||||
filteredEmptyMessage={filteredEmptyMessage}
|
||||
emptyState={{
|
||||
title: 'Нет агентов',
|
||||
description:
|
||||
'Создайте агента — он появится в списке как Invited с командой установки.',
|
||||
'Создайте агента — он появится в списке со статусом «Приглашён» и командой установки.',
|
||||
action: emptyAction,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
|
||||
/**
|
||||
* Agent lifecycle timeline.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
|
||||
type Step = {
|
||||
title: string
|
||||
date?: string | null
|
||||
detail?: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
function formatWhen(iso?: string | null): string | undefined {
|
||||
if (!iso) return undefined
|
||||
return formatDateTime(iso)
|
||||
}
|
||||
|
||||
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
|
||||
const steps: Step[] = [
|
||||
{
|
||||
title: 'Создан (Invited)',
|
||||
date: agent.created_at,
|
||||
detail: 'Install-ссылка выдана',
|
||||
done: true,
|
||||
},
|
||||
{
|
||||
title: 'Первый контакт',
|
||||
date: agent.last_seen_at,
|
||||
detail: agent.last_seen_ip
|
||||
? `IP ${agent.last_seen_ip}`
|
||||
: agent.hostname
|
||||
? agent.hostname
|
||||
: 'Ещё не подключался',
|
||||
done: Boolean(agent.last_seen_at),
|
||||
},
|
||||
{
|
||||
title: 'Approved',
|
||||
date: agent.approved_at,
|
||||
detail: agent.status === 'pending' ? 'Ожидает approve' : undefined,
|
||||
done: Boolean(agent.approved_at) || agent.status === 'approved',
|
||||
},
|
||||
{
|
||||
title: 'Last apply',
|
||||
date: agent.last_apply_at,
|
||||
detail: agent.last_apply_error
|
||||
? agent.last_apply_error
|
||||
: (agent.last_apply_status ??
|
||||
(agent.last_apply_prefix_count != null
|
||||
? `${agent.last_apply_prefix_count} prefixes`
|
||||
: undefined)),
|
||||
done: Boolean(agent.last_apply_at),
|
||||
},
|
||||
]
|
||||
|
||||
if (agent.revoked_at || agent.status === 'revoked') {
|
||||
steps.push({
|
||||
title: 'Revoked',
|
||||
date: agent.revoked_at,
|
||||
done: true,
|
||||
})
|
||||
}
|
||||
|
||||
const activeStep = Math.max(
|
||||
1,
|
||||
steps.reduce((acc, s, i) => (s.done ? i + 1 : acc), 1),
|
||||
)
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Жизненный цикл</FrameTitle>
|
||||
<FrameDescription>
|
||||
Invite → enroll → approve → apply
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<Timeline value={activeStep} className="gap-4 ps-6">
|
||||
{steps.map((s, i) => (
|
||||
<TimelineItem key={s.title} step={i + 1}>
|
||||
<TimelineSeparator />
|
||||
<TimelineIndicator />
|
||||
<TimelineHeader>
|
||||
<TimelineTitle>{s.title}</TimelineTitle>
|
||||
{s.date ? (
|
||||
<TimelineDate dateTime={s.date}>
|
||||
{formatWhen(s.date)}
|
||||
</TimelineDate>
|
||||
) : (
|
||||
<TimelineDate>—</TimelineDate>
|
||||
)}
|
||||
</TimelineHeader>
|
||||
{s.detail ? (
|
||||
<TimelineContent>{s.detail}</TimelineContent>
|
||||
) : null}
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import { isAgentOnline } from '@/lib/agent-online'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
/** Индикатор «на связи» (last_seen < 5 мин) — тот же порог, что в API. */
|
||||
export function AgentOnlineDot({
|
||||
agent,
|
||||
className,
|
||||
}: {
|
||||
agent: Pick<Agent, 'last_seen_at'> | null | undefined
|
||||
className?: string
|
||||
}) {
|
||||
const online = isAgentOnline(agent)
|
||||
const label = online ? 'На связи' : 'Не на связи'
|
||||
return (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'inline-block size-2 shrink-0 rounded-full',
|
||||
online ? 'bg-success' : 'bg-muted-foreground/30',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { parseListEntry } from '@evofw/shared'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
agentOverridesQueryOptions,
|
||||
@@ -8,8 +12,9 @@ import {
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||
import {
|
||||
@@ -40,6 +45,29 @@ type OverrideSheetProps = {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
const OVERRIDE_ACTION_ITEMS = [
|
||||
{ value: 'deny', label: 'deny — блокировать' },
|
||||
{ value: 'allow', label: 'allow — пропускать' },
|
||||
] as const
|
||||
|
||||
const overrideSchema = z.object({
|
||||
cidr: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Укажите CIDR или IP')
|
||||
.refine((v) => {
|
||||
try {
|
||||
const kind = parseListEntry(v).kind
|
||||
return kind === 'cidr' || kind === 'ip'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, 'Ожидается IP или CIDR, например 1.2.3.4/32'),
|
||||
action: z.enum(['deny', 'allow']),
|
||||
})
|
||||
|
||||
type OverrideValues = z.infer<typeof overrideSchema>
|
||||
|
||||
export function AgentOverrideSheet({
|
||||
agentId,
|
||||
open,
|
||||
@@ -50,18 +78,21 @@ export function AgentOverrideSheet({
|
||||
...agentOverridesQueryOptions(agentId),
|
||||
enabled: open,
|
||||
})
|
||||
const [cidr, setCidr] = useState('')
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const form = useForm<OverrideValues>({
|
||||
resolver: zodResolver(overrideSchema),
|
||||
defaultValues: { cidr: '', action: 'deny' },
|
||||
mode: 'onBlur',
|
||||
})
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (values: OverrideValues) =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cidr, action }),
|
||||
body: JSON.stringify(values),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Override добавлен — подхватится на следующей итерации sync')
|
||||
setCidr('')
|
||||
form.reset({ cidr: '', action: 'deny' })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
},
|
||||
@@ -96,42 +127,58 @@ export function AgentOverrideSheet({
|
||||
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="flex flex-col gap-4 py-2 pb-4">
|
||||
<div className="grid gap-3">
|
||||
<Field>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => add.mutateAsync(values))}
|
||||
className="grid gap-3"
|
||||
>
|
||||
<Field
|
||||
data-invalid={Boolean(form.formState.errors.cidr) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="ov-cidr">CIDR / IP</FieldLabel>
|
||||
<Input
|
||||
id="ov-cidr"
|
||||
placeholder="1.2.3.4/32"
|
||||
value={cidr}
|
||||
onChange={(e) => setCidr(e.target.value)}
|
||||
{...form.register('cidr')}
|
||||
aria-invalid={Boolean(form.formState.errors.cidr) || undefined}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.cidr]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Действие</FieldLabel>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAction(v as 'allow' | 'deny')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="action"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={[...OVERRIDE_ACTION_ITEMS]}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'allow' || v === 'deny') field.onChange(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OVERRIDE_ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
isLoading={add.isPending}
|
||||
className="self-start"
|
||||
disabled={!cidr.trim() || add.isPending}
|
||||
onClick={() => add.mutate()}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
</LoadingButton>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
|
||||
@@ -52,24 +52,24 @@ export function fleetKpiCards(
|
||||
return [
|
||||
{
|
||||
id: 'pending',
|
||||
label: 'Pending',
|
||||
label: 'Ожидают',
|
||||
value: counts.pending,
|
||||
hint: 'approve backlog',
|
||||
hint: 'одобрение',
|
||||
icon: icons.pending,
|
||||
iconClassName: 'text-warning',
|
||||
variant: counts.pending > 0 ? 'warning' : 'default',
|
||||
},
|
||||
{
|
||||
id: 'invited',
|
||||
label: 'Invited',
|
||||
label: 'Приглашённые',
|
||||
value: counts.invited,
|
||||
hint: 'ожидают install',
|
||||
hint: 'ждут установки',
|
||||
icon: icons.invited,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{
|
||||
id: 'approved',
|
||||
label: 'Approved',
|
||||
label: 'Одобренные',
|
||||
value: counts.approved,
|
||||
hint: 'в парке',
|
||||
icon: icons.approved,
|
||||
@@ -77,9 +77,9 @@ export function fleetKpiCards(
|
||||
},
|
||||
{
|
||||
id: 'stale',
|
||||
label: 'Offline / stale',
|
||||
label: 'Не на связи',
|
||||
value: counts.stale,
|
||||
hint: '>24ч без seen',
|
||||
hint: 'нет seen >24ч',
|
||||
icon: icons.stale,
|
||||
iconClassName: 'text-muted-foreground',
|
||||
variant: counts.stale > 0 ? 'warning' : 'default',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/layout/nav-user'
|
||||
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -24,17 +25,23 @@ function NavSection({
|
||||
label,
|
||||
items,
|
||||
pathname,
|
||||
can,
|
||||
}: {
|
||||
label: string
|
||||
items: readonly NavItem[]
|
||||
pathname: string
|
||||
can: (permission: string) => boolean
|
||||
}) {
|
||||
const visible = items.filter(
|
||||
(item) => !item.permission || can(item.permission),
|
||||
)
|
||||
if (visible.length === 0) return null
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
{visible.map((item) => (
|
||||
<SidebarMenuItem key={item.to}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
@@ -56,6 +63,7 @@ function NavSection({
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
const can = useCan()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon">
|
||||
@@ -69,6 +77,7 @@ export function AppSidebar() {
|
||||
label={section.label}
|
||||
items={navItemsForSection(section.id)}
|
||||
pathname={pathname}
|
||||
can={can}
|
||||
/>
|
||||
))}
|
||||
</SidebarContent>
|
||||
|
||||
@@ -18,6 +18,8 @@ interface ConfirmDialogProps {
|
||||
title: string
|
||||
description: string
|
||||
confirmLabel?: string
|
||||
/** 'default' для позитивных действий (утвердить), 'destructive' — для опасных. */
|
||||
confirmVariant?: 'default' | 'destructive'
|
||||
cancelLabel?: string
|
||||
onConfirm: () => void
|
||||
disabled?: boolean
|
||||
@@ -30,6 +32,7 @@ export function ConfirmDialog({
|
||||
title,
|
||||
description,
|
||||
confirmLabel = 'Удалить',
|
||||
confirmVariant = 'destructive',
|
||||
cancelLabel = 'Отмена',
|
||||
onConfirm,
|
||||
disabled,
|
||||
@@ -46,7 +49,7 @@ export function ConfirmDialog({
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={onConfirm}>
|
||||
<AlertDialogAction variant={confirmVariant} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@evofw/ui/components/field'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
|
||||
interface FormFieldSimpleProps {
|
||||
label: string
|
||||
htmlFor: string
|
||||
error?: { message?: string }
|
||||
hint?: string
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function FormFieldSimple({
|
||||
label,
|
||||
htmlFor,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
children,
|
||||
}: FormFieldSimpleProps) {
|
||||
return (
|
||||
<Field data-invalid={!!error} className={cn(className)}>
|
||||
<FieldLabel htmlFor={htmlFor}>{label}</FieldLabel>
|
||||
{children}
|
||||
{hint && !error ? <FieldDescription>{hint}</FieldDescription> : null}
|
||||
<FieldError errors={[error]} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,36 +1,55 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
ListIcon,
|
||||
ListPlusIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
ShieldIcon,
|
||||
ShieldPlusIcon,
|
||||
} from 'lucide-react'
|
||||
import { NAV_ITEMS } from '@/lib/nav'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@evofw/ui/components/dialog'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
agentsQueryOptions,
|
||||
listsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
} from '@/queries'
|
||||
import {
|
||||
Item,
|
||||
ItemContent,
|
||||
ItemGroup,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@evofw/ui/components/item'
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@evofw/ui/components/command'
|
||||
|
||||
/**
|
||||
* Command-K palette — навигация, поиск ресурсов и действия.
|
||||
* DNA: https://reui.io/components/command/c-command-7 (hotkey-only по контракту).
|
||||
*/
|
||||
|
||||
/** Command-K search — hotkey dialog (no header chrome trigger). */
|
||||
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
void hotkeyOnly
|
||||
return <SearchMenuDialog />
|
||||
}
|
||||
|
||||
function SearchMenuDialog() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInputId = useId()
|
||||
const navigate = useNavigate()
|
||||
const can = useCan()
|
||||
|
||||
const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: open })
|
||||
const listsQ = useQuery({ ...listsQueryOptions(), enabled: open })
|
||||
const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: open })
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
setOpen(true)
|
||||
setOpen((prev) => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,99 +57,164 @@ export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setQuery('')
|
||||
}, [open])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return NAV_ITEMS
|
||||
return NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(q) ||
|
||||
item.keywords.some((k) => k.includes(q)),
|
||||
)
|
||||
}, [query])
|
||||
|
||||
function goTo(to: string) {
|
||||
const go = (run: () => void) => {
|
||||
setOpen(false)
|
||||
void navigate({ to })
|
||||
run()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{hotkeyOnly ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Поиск"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon
|
||||
className="size-4.5 transition-colors"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
const navItems = NAV_ITEMS.filter(
|
||||
(item) => !item.permission || can(item.permission),
|
||||
)
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Поиск</DialogTitle>
|
||||
<DialogDescription>
|
||||
Переход к разделам приложения
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="max-w-md gap-0 overflow-hidden p-0 **:data-[slot=dialog-close]:top-3 **:data-[slot=dialog-close]:right-3 **:data-[slot=dialog-close]:opacity-60">
|
||||
<div className="relative flex items-center gap-3 border-b px-4 py-2">
|
||||
<SearchIcon
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none size-4 opacity-60 select-none"
|
||||
/>
|
||||
<Input
|
||||
id={searchInputId}
|
||||
className="h-10 border-none p-0 shadow-none outline-none focus-visible:ring-0"
|
||||
autoFocus
|
||||
placeholder="Перейти к разделу…"
|
||||
aria-label="Поиск разделов"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filtered[0]) {
|
||||
e.preventDefault()
|
||||
goTo(filtered[0].to)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ItemGroup className="max-h-72 overflow-y-auto p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-muted-foreground px-2 py-4 text-center text-sm">
|
||||
Ничего не найдено
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((item) => (
|
||||
<Item
|
||||
key={item.to}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="cursor-pointer border-0"
|
||||
render={<Link to={item.to} onClick={() => setOpen(false)} />}
|
||||
const agents = (agentsQ.data?.items ?? []).slice(0, 50)
|
||||
const lists = (listsQ.data?.items ?? []).slice(0, 50)
|
||||
const sets = (setsQ.data?.items ?? []).slice(0, 50)
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Палитра команд"
|
||||
description="Поиск разделов, агентов, списков и действий"
|
||||
className="max-w-xl"
|
||||
>
|
||||
<CommandInput placeholder="Поиск разделов, агентов, списков…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>Ничего не найдено.</CommandEmpty>
|
||||
|
||||
<CommandGroup heading="Действия">
|
||||
{can('fw:agents:write') ? (
|
||||
<CommandItem
|
||||
value="Добавить агента agent invite"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/agents',
|
||||
search: { view: 'cards', add: true },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<PlusIcon aria-hidden />
|
||||
Добавить агента
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{can('fw:lists:write') ? (
|
||||
<CommandItem
|
||||
value="Создать список blocklist"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({ to: '/lists', search: { create: true } }),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ListPlusIcon aria-hidden />
|
||||
Создать список
|
||||
</CommandItem>
|
||||
) : null}
|
||||
{can('fw:policies:write') ? (
|
||||
<CommandItem
|
||||
value="Создать набор правил policy"
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({ to: '/rules', search: { create: true } }),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldPlusIcon aria-hidden />
|
||||
Создать набор правил
|
||||
</CommandItem>
|
||||
) : null}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Навигация">
|
||||
{navItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.to}
|
||||
value={`${item.label} ${item.keywords.join(' ')}`}
|
||||
onSelect={() => go(() => void navigate({ to: item.to }))}
|
||||
>
|
||||
<item.icon aria-hidden />
|
||||
{item.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{agents.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Агенты">
|
||||
{agents.map((a) => (
|
||||
<CommandItem
|
||||
key={a.id}
|
||||
value={`${a.name} ${a.hostname ?? ''} ${a.last_seen_ip ?? ''}`}
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/agents',
|
||||
search: { view: 'cards', agent: a.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<item.icon aria-hidden="true" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{item.label}</ItemTitle>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
))
|
||||
)}
|
||||
</ItemGroup>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
<ServerIcon aria-hidden />
|
||||
<span className="truncate">{a.name}</span>
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{a.hostname ?? a.status}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{lists.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Списки">
|
||||
{lists.map((l) => (
|
||||
<CommandItem
|
||||
key={l.id}
|
||||
value={l.name}
|
||||
onSelect={() =>
|
||||
go(() => void navigate({ to: '/lists/$id', params: { id: l.id } }))
|
||||
}
|
||||
>
|
||||
<ListIcon aria-hidden />
|
||||
<span className="truncate">{l.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{sets.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Наборы правил">
|
||||
{sets.map((s) => (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
value={s.name}
|
||||
onSelect={() =>
|
||||
go(() =>
|
||||
void navigate({
|
||||
to: '/rules/$setId',
|
||||
params: { setId: s.id },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ShieldIcon aria-hidden />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Filters,
|
||||
type Filter,
|
||||
type FilterFieldConfig,
|
||||
type FilterI18nConfig,
|
||||
} from '@/components/reui/filters'
|
||||
import {
|
||||
Frame,
|
||||
@@ -51,6 +52,26 @@ type DataGridTableLayout = NonNullable<
|
||||
ComponentProps<typeof DataGrid>['tableLayout']
|
||||
>
|
||||
|
||||
/** Русская локаль фильтров (DNA data-grid-filtering-2, i18n по контракту). */
|
||||
const FILTERS_I18N: Partial<FilterI18nConfig> = {
|
||||
addFilter: 'Фильтр',
|
||||
searchFields: 'Фильтр…',
|
||||
noFieldsFound: 'Фильтры не найдены.',
|
||||
noResultsFound: 'Ничего не найдено.',
|
||||
select: 'Выберите…',
|
||||
true: 'Да',
|
||||
false: 'Нет',
|
||||
min: 'От',
|
||||
max: 'До',
|
||||
to: '—',
|
||||
typeAndPressEnter: 'Введите и нажмите Enter',
|
||||
selected: 'выбрано',
|
||||
selectedCount: 'выбрано',
|
||||
addFilterTitle: 'Добавить фильтр',
|
||||
loadingOptions: 'Загрузка…',
|
||||
errorLoadingOptions: 'Не удалось загрузить варианты.',
|
||||
}
|
||||
|
||||
export interface ResourcePageTab {
|
||||
id: string
|
||||
label: string
|
||||
@@ -78,8 +99,13 @@ export interface ResourcePageProps<T extends object> {
|
||||
onRetry?: () => void
|
||||
primaryAction?: ReactNode
|
||||
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||
/** Текст для пустого результата после фильтров/поиска (по вкладкам). */
|
||||
filteredEmptyMessage?: string
|
||||
pageSize?: number
|
||||
enableRowSelection?: boolean
|
||||
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
|
||||
rowSelection?: RowSelectionState
|
||||
onRowSelectionChange?: (rowSelection: RowSelectionState) => void
|
||||
selectionToolbar?: (ctx: {
|
||||
selectedIds: string[]
|
||||
selectedCount: number
|
||||
@@ -139,8 +165,11 @@ export function ResourcePage<T extends object>({
|
||||
onRetry,
|
||||
primaryAction,
|
||||
emptyState,
|
||||
filteredEmptyMessage,
|
||||
pageSize = 10,
|
||||
enableRowSelection = false,
|
||||
rowSelection: rowSelectionProp,
|
||||
onRowSelectionChange: onRowSelectionChangeProp,
|
||||
selectionToolbar,
|
||||
toolbarExtra,
|
||||
hideHeader = false,
|
||||
@@ -156,7 +185,30 @@ export function ResourcePage<T extends object>({
|
||||
const activeTab = controlledTab ?? internalTab
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const isSelectionControlled = rowSelectionProp !== undefined
|
||||
const [internalRowSelection, setInternalRowSelection] =
|
||||
useState<RowSelectionState>({})
|
||||
const rowSelection = isSelectionControlled
|
||||
? rowSelectionProp
|
||||
: internalRowSelection
|
||||
const handleRowSelectionChange = useCallback(
|
||||
(
|
||||
updater: RowSelectionState | ((prev: RowSelectionState) => RowSelectionState),
|
||||
) => {
|
||||
const current = isSelectionControlled
|
||||
? rowSelectionProp
|
||||
: internalRowSelection
|
||||
const next = typeof updater === 'function' ? updater(current) : updater
|
||||
if (isSelectionControlled) onRowSelectionChangeProp?.(next)
|
||||
else setInternalRowSelection(next)
|
||||
},
|
||||
[
|
||||
isSelectionControlled,
|
||||
rowSelectionProp,
|
||||
internalRowSelection,
|
||||
onRowSelectionChangeProp,
|
||||
],
|
||||
)
|
||||
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
|
||||
() => columnPinningProp ?? {},
|
||||
)
|
||||
@@ -224,8 +276,8 @@ export function ResourcePage<T extends object>({
|
||||
const selectedCount = selectedIds.length
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setRowSelection({})
|
||||
}, [])
|
||||
handleRowSelectionChange({})
|
||||
}, [handleRowSelectionChange])
|
||||
|
||||
const tableLayout = useMemo(
|
||||
() => ({
|
||||
@@ -253,7 +305,7 @@ export function ResourcePage<T extends object>({
|
||||
// без drag-handles — enableColumnResizing остаётся false.
|
||||
enableColumnResizing: false,
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
onPaginationChange: setPagination,
|
||||
onColumnPinningChange: setColumnPinning,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -357,7 +409,8 @@ export function ResourcePage<T extends object>({
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||
const emptyMessage =
|
||||
filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
@@ -431,6 +484,7 @@ export function ResourcePage<T extends object>({
|
||||
fields={filterFields}
|
||||
onChange={handleFiltersChange}
|
||||
size="default"
|
||||
i18n={FILTERS_I18N}
|
||||
trigger={
|
||||
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||
<FilterIcon className="size-4" aria-hidden="true" />
|
||||
@@ -480,9 +534,9 @@ export function ResourcePage<T extends object>({
|
||||
<DataGridPagination
|
||||
sizes={[5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from} - {to} of {count}"
|
||||
previousPageLabel="Предыдущая"
|
||||
nextPageLabel="Следующая"
|
||||
info="{from}–{to} из {count}"
|
||||
previousPageLabel="Назад"
|
||||
nextPageLabel="Вперёд"
|
||||
/>
|
||||
</FrameFooter>
|
||||
</FramePanel>
|
||||
|
||||
@@ -54,21 +54,21 @@ const STATUS_LABELS: Record<string, string> = {
|
||||
active: 'Активен',
|
||||
synced: 'Синхронизировано',
|
||||
pending_push: 'Ожидает отправки',
|
||||
pending: 'Pending',
|
||||
invited: 'Invited',
|
||||
approved: 'Approved',
|
||||
revoked: 'Revoked',
|
||||
pending: 'Ожидает одобрения',
|
||||
invited: 'Приглашён',
|
||||
approved: 'Одобрен',
|
||||
revoked: 'Отозван',
|
||||
enabled: 'Включён',
|
||||
disabled: 'Выключен',
|
||||
allow: 'allow',
|
||||
deny: 'deny',
|
||||
allow: 'Разрешить',
|
||||
deny: 'Блокировать',
|
||||
conflict: 'Конфликт',
|
||||
error: 'Ошибка',
|
||||
ok: 'OK',
|
||||
up: 'OK',
|
||||
warning: 'Предупреждение',
|
||||
degraded: 'Slow',
|
||||
down: 'Down',
|
||||
degraded: 'Замедление',
|
||||
down: 'Недоступен',
|
||||
expired: 'Истёк',
|
||||
static: 'Ручной',
|
||||
domains: 'Ручной',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { formatTime } from '@/lib/format'
|
||||
|
||||
/** «Обновлено в HH:mm:ss» — подпись свежести данных для polling-страниц. */
|
||||
export function UpdatedAtLabel({ updatedAt }: { updatedAt?: number }) {
|
||||
if (!updatedAt) return null
|
||||
return (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
Обновлено в {formatTime(new Date(updatedAt))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
/**
|
||||
* Порог «на связи» — тот же, что в API /dashboard: агент считается
|
||||
* онлайн, если last_seen_at свежее 5 минут.
|
||||
*/
|
||||
export const AGENT_ONLINE_WINDOW_MS = 5 * 60_000
|
||||
|
||||
export function isAgentOnline(
|
||||
agent: Pick<Agent, 'last_seen_at'> | null | undefined,
|
||||
): boolean {
|
||||
if (!agent?.last_seen_at) return false
|
||||
const t = Date.parse(agent.last_seen_at)
|
||||
if (Number.isNaN(t)) return false
|
||||
return Date.now() - t < AGENT_ONLINE_WINDOW_MS
|
||||
}
|
||||
@@ -34,6 +34,8 @@ const timeFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
const relativeFmt = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'auto' })
|
||||
|
||||
/** Compact packet counter: 1,2K / 3,4M */
|
||||
export function formatPackets(n: number | undefined | null): string {
|
||||
if (n === undefined || n === null) return '—'
|
||||
@@ -72,3 +74,20 @@ export function formatStampDateTime(iso: string | null | undefined): string {
|
||||
export function formatTime(d: Date): string {
|
||||
return timeFmt.format(d)
|
||||
}
|
||||
|
||||
/** «2 минуты назад» / «5 часов назад»; старше 7 дней — полная дата. */
|
||||
export function formatRelativeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—'
|
||||
const t = Date.parse(iso)
|
||||
if (Number.isNaN(t)) return '—'
|
||||
const diffMs = t - Date.now()
|
||||
const absMin = Math.round(Math.abs(diffMs) / 60_000)
|
||||
if (absMin < 1) return 'только что'
|
||||
const sign = diffMs < 0 ? -1 : 1
|
||||
if (absMin < 60) return relativeFmt.format(sign * absMin, 'minute')
|
||||
const absHours = Math.round(absMin / 60)
|
||||
if (absHours < 24) return relativeFmt.format(sign * absHours, 'hour')
|
||||
const absDays = Math.round(absHours / 24)
|
||||
if (absDays < 7) return relativeFmt.format(sign * absDays, 'day')
|
||||
return formatDateTime(iso)
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export type NavItem = {
|
||||
keywords: string[]
|
||||
icon: typeof ServerIcon
|
||||
section: NavSectionId
|
||||
/** Read-право для показа пункта (useCan); undefined — виден всем с доступом к app. */
|
||||
permission?: string
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: readonly NavItem[] = [
|
||||
@@ -32,6 +34,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['dashboard', 'панель', 'обзор'],
|
||||
icon: LayoutDashboardIcon,
|
||||
section: 'overview',
|
||||
permission: 'fw:dashboard:read',
|
||||
},
|
||||
{
|
||||
to: '/agents',
|
||||
@@ -39,6 +42,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['agents', 'агенты', 'nodes'],
|
||||
icon: ServerIcon,
|
||||
section: 'ops',
|
||||
permission: 'fw:agents:read',
|
||||
},
|
||||
{
|
||||
to: '/lists',
|
||||
@@ -46,6 +50,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['lists', 'списки', 'blocklist'],
|
||||
icon: ListIcon,
|
||||
section: 'ops',
|
||||
permission: 'fw:lists:read',
|
||||
},
|
||||
{
|
||||
to: '/rules',
|
||||
@@ -53,6 +58,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
|
||||
icon: ShieldIcon,
|
||||
section: 'ops',
|
||||
permission: 'fw:policies:read',
|
||||
},
|
||||
{
|
||||
to: '/stats',
|
||||
@@ -60,6 +66,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['stats', 'статистика', 'packets'],
|
||||
icon: BarChart3Icon,
|
||||
section: 'ops',
|
||||
permission: 'fw:stats:read',
|
||||
},
|
||||
{
|
||||
to: '/settings',
|
||||
@@ -67,6 +74,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
keywords: ['settings', 'настройки'],
|
||||
icon: SettingsIcon,
|
||||
section: 'system',
|
||||
permission: 'fw:settings:read',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
|
||||
/**
|
||||
* Сериализация состояния страниц (таб/поиск/фильтры) в URL search-параметры:
|
||||
* tab — id вкладки, q — строка поиска, f — JSON-массив фильтров.
|
||||
* Пустые значения не пишутся в URL (deep-link остаётся чистым).
|
||||
*/
|
||||
|
||||
export function filtersToSearch(filters: Filter[]): string | undefined {
|
||||
if (filters.length === 0) return undefined
|
||||
try {
|
||||
return JSON.stringify(filters)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function searchToFilters(raw: unknown): Filter[] {
|
||||
if (typeof raw !== 'string' || raw.length === 0) return []
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter(
|
||||
(x): x is Filter =>
|
||||
typeof x === 'object' &&
|
||||
x !== null &&
|
||||
typeof (x as Filter).field === 'string' &&
|
||||
typeof (x as Filter).operator === 'string' &&
|
||||
Array.isArray((x as Filter).values),
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Строка поиска: undefined при пустой, чтобы не писать пустой q в URL. */
|
||||
export function searchToQ(raw: unknown): string | undefined {
|
||||
return typeof raw === 'string' && raw.length > 0 ? raw : undefined
|
||||
}
|
||||
|
||||
/** Вкладка: валидируется по списку допустимых id. */
|
||||
export function searchToTab(
|
||||
raw: unknown,
|
||||
allowed: readonly string[],
|
||||
): string | undefined {
|
||||
return typeof raw === 'string' && allowed.includes(raw) ? raw : undefined
|
||||
}
|
||||
|
||||
/** Флаг из search-параметра: только `true` пишется в URL. */
|
||||
export function searchToFlag(raw: unknown): boolean | undefined {
|
||||
return raw === true || raw === '1' ? true : undefined
|
||||
}
|
||||
@@ -12,12 +12,14 @@ export const dashboardQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
export const agentsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
export const agentQueryOptions = (id: string) =>
|
||||
@@ -272,4 +274,5 @@ export const recentStatsQueryOptions = () =>
|
||||
recorded_at: string
|
||||
}[]
|
||||
}>('/api/v1/stats/recent'),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { RowSelectionState } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
Plus,
|
||||
ShieldIcon,
|
||||
TableIcon,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
WifiOff,
|
||||
} from 'lucide-react'
|
||||
@@ -27,13 +29,10 @@ import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
||||
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
|
||||
@@ -45,8 +44,17 @@ import {
|
||||
} from '@/components/agents/agents-fleet-kpis'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { isAgentOnline } from '@/lib/agent-online'
|
||||
import {
|
||||
searchToFilters,
|
||||
searchToFlag,
|
||||
searchToQ,
|
||||
searchToTab,
|
||||
} from '@/lib/search-params'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
@@ -66,44 +74,71 @@ import type { Agent } from '@evofw/shared'
|
||||
type AgentsSearch = {
|
||||
agent?: string
|
||||
view: 'cards' | 'table'
|
||||
/** Открыть sheet добавления агента (из ⌘K-палитры). */
|
||||
add?: boolean
|
||||
/** URL-состояние: вкладка, поиск, фильтры (deep-link и refresh сохраняют вид). */
|
||||
tab?: string
|
||||
q?: string
|
||||
f?: Filter[]
|
||||
}
|
||||
|
||||
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
|
||||
const view = search.view === 'table' ? 'table' : 'cards'
|
||||
const agent =
|
||||
typeof search.agent === 'string' && search.agent.length > 0
|
||||
? search.agent
|
||||
: undefined
|
||||
return { view, agent }
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/')({
|
||||
validateSearch: (search: Record<string, unknown>): AgentsSearch =>
|
||||
parseAgentsSearch(search),
|
||||
component: AgentsPage,
|
||||
})
|
||||
|
||||
const AGENT_TABS = [
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'invited', label: 'Invited' },
|
||||
{ id: 'pending', label: 'Pending' },
|
||||
{ id: 'approved', label: 'Approved' },
|
||||
{ id: 'revoked', label: 'Revoked' },
|
||||
{ id: 'invited', label: 'Приглашённые' },
|
||||
{ id: 'pending', label: 'Ожидают' },
|
||||
{ id: 'approved', label: 'Одобренные' },
|
||||
{ id: 'revoked', label: 'Отозванные' },
|
||||
] as const
|
||||
|
||||
const AGENT_TAB_IDS = AGENT_TABS.map((t) => t.id) as string[]
|
||||
|
||||
function parseAgentsSearch(search: Record<string, unknown>): AgentsSearch {
|
||||
const view = search.view === 'table' ? 'table' : 'cards'
|
||||
const agent = typeof search.agent === 'string' && search.agent.length > 0
|
||||
? search.agent
|
||||
: undefined
|
||||
const add = searchToFlag(search.add)
|
||||
const f = searchToFilters(search.f)
|
||||
return {
|
||||
view,
|
||||
agent,
|
||||
add,
|
||||
tab: searchToTab(search.tab, AGENT_TAB_IDS),
|
||||
q: searchToQ(search.q),
|
||||
f: f.length > 0 ? f : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/')({
|
||||
validateSearch: parseAgentsSearch,
|
||||
component: AgentsPage,
|
||||
})
|
||||
|
||||
const EMPTY_FILTERS: Filter[] = []
|
||||
|
||||
/** Список имён для подтверждений: первые 5, дальше «и ещё N». */
|
||||
function formatNames(names: string[]): string {
|
||||
if (names.length <= 5) return names.join(', ')
|
||||
return `${names.slice(0, 5).join(', ')} и ещё ${names.length - 5}`
|
||||
}
|
||||
|
||||
function AgentsPage() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const { agent: detailAgentId, view } = Route.useSearch()
|
||||
const { agent: detailAgentId, view, add: addParam } = Route.useSearch()
|
||||
const qc = useQueryClient()
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const canWrite = useCan()('fw:agents:write')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createOpenState, setCreateOpenState] = useState(false)
|
||||
const createOpen = createOpenState || addParam === true
|
||||
const [deleteAgentId, setDeleteAgentId] = useState<string | null>(null)
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [approveAllOpen, setApproveAllOpen] = useState(false)
|
||||
const [bulkDeleteIds, setBulkDeleteIds] = useState<string[] | null>(null)
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const activeTab = Route.useSearch().tab ?? 'all'
|
||||
const searchQuery = Route.useSearch().q ?? ''
|
||||
const filters = Route.useSearch().f ?? EMPTY_FILTERS
|
||||
|
||||
const detailOpen = Boolean(detailAgentId)
|
||||
const showQuickActions = settingsQ.data?.show_quick_actions !== 'false'
|
||||
@@ -119,6 +154,23 @@ function AgentsPage() {
|
||||
next.agent !== undefined
|
||||
? next.agent || undefined
|
||||
: base.agent,
|
||||
add:
|
||||
next.add !== undefined
|
||||
? next.add || undefined
|
||||
: base.add,
|
||||
tab: next.tab !== undefined
|
||||
? next.tab === 'all'
|
||||
? undefined
|
||||
: next.tab
|
||||
: base.tab,
|
||||
q: next.q !== undefined
|
||||
? next.q || undefined
|
||||
: base.q,
|
||||
f: next.f !== undefined
|
||||
? next.f.length > 0
|
||||
? next.f
|
||||
: undefined
|
||||
: base.f,
|
||||
} satisfies AgentsSearch
|
||||
},
|
||||
replace: true,
|
||||
@@ -156,7 +208,7 @@ function AgentsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const approveAllPending = useMutation({
|
||||
const approveBulk = useMutation({
|
||||
mutationFn: (ids: string[]) =>
|
||||
apiFetch('/api/v1/agents/approve-bulk', {
|
||||
method: 'POST',
|
||||
@@ -170,9 +222,39 @@ function AgentsPage() {
|
||||
),
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Все pending одобрены')
|
||||
onSuccess: (_data, ids) => {
|
||||
toast.success(`Одобрено агентов: ${ids.length}`)
|
||||
setRowSelection({})
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: (e: Error, _ids, rollback) => {
|
||||
rollback?.()
|
||||
toast.error(e.message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteBulk = useMutation({
|
||||
mutationFn: (ids: string[]) =>
|
||||
apiFetch('/api/v1/agents/delete-bulk', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent_ids: ids }),
|
||||
}),
|
||||
onMutate: (ids) => {
|
||||
const target = new Set(ids)
|
||||
return patchAgentsCache((items) =>
|
||||
items.filter((a) => !target.has(a.id)),
|
||||
)
|
||||
},
|
||||
onSuccess: (_data, ids) => {
|
||||
toast.success(`Удалено агентов: ${ids.length}`)
|
||||
setBulkDeleteIds(null)
|
||||
setRowSelection({})
|
||||
if (detailAgentId && ids.includes(detailAgentId)) {
|
||||
setSearch({ agent: '' })
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
onError: (e: Error, _ids, rollback) => {
|
||||
rollback?.()
|
||||
@@ -205,6 +287,77 @@ function AgentsPage() {
|
||||
() => items.filter((a) => a.status === 'pending').map((a) => a.id),
|
||||
[items],
|
||||
)
|
||||
const pendingNames = useMemo(
|
||||
() =>
|
||||
items.filter((a) => a.status === 'pending').map((a) => a.name),
|
||||
[items],
|
||||
)
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||
[rowSelection],
|
||||
)
|
||||
const selectedAgents = useMemo(
|
||||
() => items.filter((a) => selectedIds.includes(a.id)),
|
||||
[items, selectedIds],
|
||||
)
|
||||
const approvableSelected = useMemo(
|
||||
() =>
|
||||
selectedAgents.filter(
|
||||
(a) => a.status === 'pending' || a.status === 'invited',
|
||||
),
|
||||
[selectedAgents],
|
||||
)
|
||||
const clearSelection = useCallback(() => setRowSelection({}), [])
|
||||
|
||||
const selectionToolbar = useCallback(
|
||||
(ctx: { selectedCount: number }) => {
|
||||
if (ctx.selectedCount === 0 || !canWrite) return null
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{ctx.selectedCount} выбрано
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{approvableSelected.length > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={approveBulk.isPending}
|
||||
onClick={() =>
|
||||
approveBulk.mutate(approvableSelected.map((a) => a.id))
|
||||
}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Утвердить ({approvableSelected.length})
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
disabled={deleteBulk.isPending}
|
||||
onClick={() => setBulkDeleteIds(selectedIds)}
|
||||
>
|
||||
<Trash2 data-icon="inline-start" />
|
||||
Удалить
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={clearSelection}>
|
||||
Снять выделение
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
canWrite,
|
||||
approvableSelected,
|
||||
selectedIds,
|
||||
approveBulk.isPending,
|
||||
deleteBulk.isPending,
|
||||
clearSelection,
|
||||
],
|
||||
)
|
||||
|
||||
const kpiCards = useMemo(
|
||||
() =>
|
||||
@@ -216,10 +369,22 @@ function AgentsPage() {
|
||||
}).map((card) => ({
|
||||
...card,
|
||||
onSelect: () => {
|
||||
if (card.id === 'pending') setActiveTab('pending')
|
||||
else if (card.id === 'invited') setActiveTab('invited')
|
||||
else if (card.id === 'approved') setActiveTab('approved')
|
||||
else if (card.id === 'stale') setActiveTab('approved')
|
||||
if (card.id === 'pending') setSearch({ tab: 'pending' })
|
||||
else if (card.id === 'invited') setSearch({ tab: 'invited' })
|
||||
else if (card.id === 'approved') setSearch({ tab: 'approved' })
|
||||
else if (card.id === 'stale') {
|
||||
setSearch({
|
||||
tab: 'all',
|
||||
f: [
|
||||
{
|
||||
id: 'stale',
|
||||
field: 'online',
|
||||
operator: 'is',
|
||||
values: ['offline'],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
},
|
||||
})),
|
||||
[counts],
|
||||
@@ -238,10 +403,10 @@ function AgentsPage() {
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'invited', label: 'invited' },
|
||||
{ value: 'approved', label: 'approved' },
|
||||
{ value: 'pending', label: 'pending' },
|
||||
{ value: 'revoked', label: 'revoked' },
|
||||
{ value: 'invited', label: 'Приглашён' },
|
||||
{ value: 'pending', label: 'Ожидает одобрения' },
|
||||
{ value: 'approved', label: 'Одобрен' },
|
||||
{ value: 'revoked', label: 'Отозван' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -249,8 +414,17 @@ function AgentsPage() {
|
||||
label: 'Платформа',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'linux', label: 'linux' },
|
||||
{ value: 'mikrotik', label: 'mikrotik' },
|
||||
{ value: 'linux', label: 'Linux' },
|
||||
{ value: 'mikrotik', label: 'MikroTik' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'online',
|
||||
label: 'На связи',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'online', label: 'На связи' },
|
||||
{ value: 'offline', label: 'Не на связи' },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -261,6 +435,7 @@ function AgentsPage() {
|
||||
if (field === 'name') return item.name
|
||||
if (field === 'status') return item.status
|
||||
if (field === 'platform') return item.platform
|
||||
if (field === 'online') return isAgentOnline(item) ? 'online' : 'offline'
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
@@ -332,25 +507,25 @@ function AgentsPage() {
|
||||
{
|
||||
id: 'add',
|
||||
title: 'Добавить агента',
|
||||
description: 'Invite + install one-liner',
|
||||
description: 'Приглашение и команда установки',
|
||||
icon: <Plus aria-hidden />,
|
||||
iconClassName: 'text-primary [&_svg]:text-current',
|
||||
badgeLabel: 'Открыть',
|
||||
onSelect: () => setCreateOpen(true),
|
||||
onSelect: () => setCreateOpenState(true),
|
||||
},
|
||||
{
|
||||
id: 'pending',
|
||||
title: 'Pending',
|
||||
description: `${counts.pending} ждут approve`,
|
||||
title: 'Ожидают одобрения',
|
||||
description: `${counts.pending} ждут подтверждения`,
|
||||
icon: <Inbox aria-hidden />,
|
||||
iconClassName: 'text-warning [&_svg]:text-current',
|
||||
badgeLabel: 'Показать',
|
||||
onSelect: () => setActiveTab('pending'),
|
||||
onSelect: () => setSearch({ tab: 'pending' }),
|
||||
},
|
||||
{
|
||||
id: 'rules',
|
||||
title: 'Наборы правил',
|
||||
description: 'Политика firewall',
|
||||
description: 'Политика фаервола',
|
||||
to: '/rules',
|
||||
icon: <ShieldIcon aria-hidden />,
|
||||
iconClassName: 'text-info [&_svg]:text-current',
|
||||
@@ -367,6 +542,26 @@ function AgentsPage() {
|
||||
[counts.pending],
|
||||
)
|
||||
|
||||
const applyErrorAgents = useMemo(
|
||||
() => items.filter((a) => a.last_apply_error),
|
||||
[items],
|
||||
)
|
||||
|
||||
const filteredEmptyMessage = useMemo(() => {
|
||||
switch (activeTab) {
|
||||
case 'pending':
|
||||
return 'Нет агентов на одобрении — всё обработано.'
|
||||
case 'invited':
|
||||
return 'Нет приглашённых агентов.'
|
||||
case 'approved':
|
||||
return 'Нет одобренных агентов.'
|
||||
case 'revoked':
|
||||
return 'Нет отозванных агентов.'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
const handleSelectAgent = useCallback(
|
||||
(id: string) => {
|
||||
setSearch({ agent: id })
|
||||
@@ -384,9 +579,8 @@ function AgentsPage() {
|
||||
)
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters([])
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
setSearch({ f: [], q: '' })
|
||||
}, [setSearch])
|
||||
|
||||
const handleCopyInstall = useCallback(
|
||||
(curl: string) => {
|
||||
@@ -421,7 +615,7 @@ function AgentsPage() {
|
||||
)
|
||||
|
||||
const addButton = canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Добавить агента
|
||||
</Button>
|
||||
@@ -431,8 +625,13 @@ function AgentsPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Агенты"
|
||||
description="Ops console: invite, install, approve"
|
||||
actions={addButton}
|
||||
description="Приглашение, установка и одобрение агентов"
|
||||
actions={
|
||||
<>
|
||||
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
|
||||
{addButton}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
||||
@@ -443,16 +642,16 @@ function AgentsPage() {
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Attention — pending approve</FrameTitle>
|
||||
<FrameTitle>Требуют одобрения</FrameTitle>
|
||||
<FrameDescription>
|
||||
{counts.pending} агент(ов) ждут одобрения (DNA approval inbox)
|
||||
{counts.pending} агент(ов) ждут одобрения
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setActiveTab('pending')}
|
||||
onClick={() => setSearch({ tab: 'pending' })}
|
||||
>
|
||||
Показать
|
||||
</Button>
|
||||
@@ -460,12 +659,12 @@ function AgentsPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
approveAllPending.isPending || pendingIds.length === 0
|
||||
approveBulk.isPending || pendingIds.length === 0
|
||||
}
|
||||
onClick={() => approveAllPending.mutate(pendingIds)}
|
||||
onClick={() => setApproveAllOpen(true)}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Approve all
|
||||
Утвердить всех
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -473,15 +672,33 @@ function AgentsPage() {
|
||||
</Frame>
|
||||
) : null}
|
||||
|
||||
{counts.applyErrors > 0 ? (
|
||||
<Alert variant="warning">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибки apply</AlertTitle>
|
||||
<AlertDescription>
|
||||
У {counts.applyErrors} агент(ов) есть last_apply_error — откройте
|
||||
карточку для деталей.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{applyErrorAgents.length > 0 ? (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-px">
|
||||
<FrameTitle>Ошибки применения политики</FrameTitle>
|
||||
<FrameDescription>
|
||||
У {applyErrorAgents.length} агент(ов) последняя попытка apply
|
||||
завершилась ошибкой — откройте карточку для деталей.
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-wrap gap-2">
|
||||
{applyErrorAgents.map((a) => (
|
||||
<Button
|
||||
key={a.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
title={a.last_apply_error ?? undefined}
|
||||
onClick={() => setSearch({ agent: a.id })}
|
||||
>
|
||||
<CircleAlertIcon className="size-3.5" aria-hidden />
|
||||
{a.name}
|
||||
</Button>
|
||||
))}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
) : null}
|
||||
|
||||
{view === 'table' ? (
|
||||
@@ -489,22 +706,27 @@ function AgentsPage() {
|
||||
data={items}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onFiltersChange={(f) => setSearch({ f })}
|
||||
onClearFilters={handleClearFilters}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setSearch({ q })}
|
||||
getSearchText={getSearchText}
|
||||
tabs={[...AGENT_TABS]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={(tab) => setSearch({ tab })}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={agentsQ.isLoading}
|
||||
isError={agentsQ.isError}
|
||||
error={agentsQ.error}
|
||||
onRetry={() => void agentsQ.refetch()}
|
||||
emptyAction={addButton}
|
||||
filteredEmptyMessage={filteredEmptyMessage}
|
||||
toolbarExtra={viewToggle}
|
||||
enableRowSelection={canWrite}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
selectionToolbar={selectionToolbar}
|
||||
onSelect={handleSelectAgent}
|
||||
onApprove={(id) => approve.mutate(id)}
|
||||
approvePending={approve.isPending}
|
||||
@@ -520,13 +742,13 @@ function AgentsPage() {
|
||||
<AgentsFleetChrome
|
||||
tabs={countedTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={(tab) => setSearch({ tab })}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onFiltersChange={(f) => setSearch({ f })}
|
||||
onClearFilters={handleClearFilters}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setSearch({ q })}
|
||||
toolbarExtra={viewToggle}
|
||||
>
|
||||
<AgentCardsGrid
|
||||
@@ -542,7 +764,7 @@ function AgentsPage() {
|
||||
}
|
||||
emptyDescription={
|
||||
items.length === 0
|
||||
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
|
||||
? 'Создайте агента — он появится в каталоге со статусом «Приглашён» и командой установки.'
|
||||
: undefined
|
||||
}
|
||||
emptyAction={items.length === 0 ? addButton : undefined}
|
||||
@@ -551,7 +773,13 @@ function AgentsPage() {
|
||||
</QueryState>
|
||||
)}
|
||||
|
||||
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<AddAgentSheet
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpenState(open)
|
||||
if (!open && addParam) setSearch({ add: false })
|
||||
}}
|
||||
/>
|
||||
|
||||
<AgentDetailSheet
|
||||
agentId={detailAgentId ?? null}
|
||||
@@ -578,6 +806,45 @@ function AgentsPage() {
|
||||
}}
|
||||
disabled={removeAgent.isPending}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={approveAllOpen}
|
||||
onOpenChange={setApproveAllOpen}
|
||||
title="Утвердить всех ожидающих?"
|
||||
confirmLabel="Утвердить"
|
||||
confirmVariant="default"
|
||||
description={
|
||||
pendingIds.length > 0
|
||||
? `Будут одобрены ${pendingIds.length} агент(ов): ${formatNames(pendingNames)}. Агентам будет назначен общий набор правил по умолчанию.`
|
||||
: 'Нет агентов, ожидающих одобрения.'
|
||||
}
|
||||
onConfirm={() => {
|
||||
setApproveAllOpen(false)
|
||||
approveBulk.mutate(pendingIds)
|
||||
}}
|
||||
disabled={approveBulk.isPending || pendingIds.length === 0}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={bulkDeleteIds !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setBulkDeleteIds(null)
|
||||
}}
|
||||
title="Удалить выбранных агентов?"
|
||||
description={
|
||||
bulkDeleteIds && bulkDeleteIds.length > 0
|
||||
? `Будут удалены ${bulkDeleteIds.length} агент(ов): ${formatNames(
|
||||
selectedAgents
|
||||
.filter((a) => bulkDeleteIds.includes(a.id))
|
||||
.map((a) => a.name),
|
||||
)}. Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.`
|
||||
: 'Вместе с overrides, install-ссылками и статистикой. Действие нельзя отменить.'
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (bulkDeleteIds) deleteBulk.mutate(bulkDeleteIds)
|
||||
}}
|
||||
disabled={deleteBulk.isPending}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from '@/queries'
|
||||
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
||||
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
export const Route = createFileRoute('/_auth/')({
|
||||
@@ -75,7 +76,7 @@ function DashboardPage() {
|
||||
},
|
||||
{
|
||||
id: 'dropped',
|
||||
label: 'Dropped',
|
||||
label: 'Заблокировано',
|
||||
value: d?.packets_dropped ?? '—',
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
@@ -85,7 +86,7 @@ function DashboardPage() {
|
||||
},
|
||||
{
|
||||
id: 'accepted',
|
||||
label: 'Accepted',
|
||||
label: 'Пропущено',
|
||||
value: d?.packets_accepted ?? '—',
|
||||
hint: 'сумма counters',
|
||||
to: '/stats',
|
||||
@@ -109,7 +110,7 @@ function DashboardPage() {
|
||||
{
|
||||
id: 'agents',
|
||||
title: 'Агенты',
|
||||
description: 'Enroll и approve',
|
||||
description: 'Подключение и одобрение агентов',
|
||||
to: '/agents',
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -117,7 +118,7 @@ function DashboardPage() {
|
||||
{
|
||||
id: 'lists',
|
||||
title: 'Списки',
|
||||
description: 'Blocklists / sources',
|
||||
description: 'Блок-листы и источники',
|
||||
to: '/lists',
|
||||
icon: <ListIcon aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
@@ -125,7 +126,7 @@ function DashboardPage() {
|
||||
{
|
||||
id: 'rules',
|
||||
title: 'Правила',
|
||||
description: 'Allow / deny policy',
|
||||
description: 'Политика блокировки',
|
||||
to: '/rules',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
@@ -139,6 +140,7 @@ function DashboardPage() {
|
||||
<PageHeader
|
||||
title="Панель управления"
|
||||
description="Обзор агентов и пакетной статистики"
|
||||
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
|
||||
/>
|
||||
<OpsDashboard
|
||||
isLoading={dash.isLoading}
|
||||
|
||||
@@ -137,12 +137,14 @@ function ListDetailPage() {
|
||||
}),
|
||||
})
|
||||
}
|
||||
const text = addValue.trim()
|
||||
if (!text) throw new Error('Введите значение')
|
||||
// Backend auto-classifies each token via parseListEntry (ip / cidr / hostname)
|
||||
if (!addParse || addParse.valid === 0) {
|
||||
throw new Error('Нет валидных записей')
|
||||
}
|
||||
// Отправляем только валидные уникальные токены; backend классифицирует
|
||||
// каждый через parseListEntry (ip / cidr / hostname)
|
||||
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ values: [text] }),
|
||||
body: JSON.stringify({ values: addParse.values }),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -291,10 +293,35 @@ function ListDetailPage() {
|
||||
return [...kinds]
|
||||
}, [addMode, addValue])
|
||||
|
||||
/** Живой разбор вставки: валидные / не распознанные / дубликаты. */
|
||||
const addParse = useMemo(() => {
|
||||
if (addMode !== 'plaintext') return null
|
||||
const text = addValue.trim()
|
||||
if (!text) return null
|
||||
const seen = new Set<string>()
|
||||
let valid = 0
|
||||
let invalid = 0
|
||||
let duplicates = 0
|
||||
for (const token of splitListPlaintext(text)) {
|
||||
try {
|
||||
const parsed = parseListEntry(token)
|
||||
if (seen.has(parsed.value)) {
|
||||
duplicates++
|
||||
continue
|
||||
}
|
||||
seen.add(parsed.value)
|
||||
valid++
|
||||
} catch {
|
||||
invalid++
|
||||
}
|
||||
}
|
||||
return { valid, invalid, duplicates, values: [...seen] }
|
||||
}, [addMode, addValue])
|
||||
|
||||
const canAdd =
|
||||
addMode === 'nested'
|
||||
? Boolean(addListRef)
|
||||
: Boolean(addValue.trim())
|
||||
: Boolean(addParse && addParse.valid > 0)
|
||||
|
||||
if (listQ.isLoading) {
|
||||
return (
|
||||
@@ -311,6 +338,29 @@ function ListDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (listQ.isError) {
|
||||
return (
|
||||
<PageShell>
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{listQ.error?.message ?? 'Не удалось загрузить список'}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => void listQ.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -566,18 +616,38 @@ function ListDetailPage() {
|
||||
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
|
||||
onChange={(e) => setAddValue(e.target.value)}
|
||||
/>
|
||||
{detectedKinds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Определено:
|
||||
</span>
|
||||
{detectedKinds.map((kind) => (
|
||||
<StatusBadge key={kind} status={kind} />
|
||||
))}
|
||||
{addParse && addParse.valid > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{detectedKinds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Определено:
|
||||
</span>
|
||||
{detectedKinds.map((kind) => (
|
||||
<StatusBadge key={kind} status={kind} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-success">
|
||||
{addParse.valid} валидно
|
||||
</span>
|
||||
{addParse.invalid > 0 ? (
|
||||
<span className="text-destructive">
|
||||
{addParse.invalid} не распознано
|
||||
</span>
|
||||
) : null}
|
||||
{addParse.duplicates > 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{addParse.duplicates} дубликатов будет пропущено
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : addValue.trim() ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Не распознано — проверьте формат перед добавлением.
|
||||
<p className="text-destructive text-xs">
|
||||
Нет валидных записей — проверьте формат (IP, CIDR или
|
||||
домен).
|
||||
</p>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Plus, RefreshCwIcon } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import {
|
||||
@@ -16,6 +19,12 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
searchToFilters,
|
||||
searchToFlag,
|
||||
searchToQ,
|
||||
searchToTab,
|
||||
} from '@/lib/search-params'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteContent,
|
||||
@@ -25,6 +34,8 @@ import {
|
||||
AutocompleteList,
|
||||
} from '@/components/reui/autocomplete'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { FieldError } from '@evofw/ui/components/field'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
@@ -44,14 +55,61 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import { guessListSourceFromInput } from '@evofw/shared'
|
||||
|
||||
const LIST_TAB_IDS = LIST_TABS.map((t) => t.id) as string[]
|
||||
|
||||
type ListsSearch = {
|
||||
create?: boolean
|
||||
tab?: string
|
||||
q?: string
|
||||
f?: Filter[]
|
||||
}
|
||||
|
||||
/** URL-состояние страницы: create/tab/q/f (deep-link и refresh сохраняют вид). */
|
||||
function parseListsSearch(search: Record<string, unknown>): ListsSearch {
|
||||
const f = searchToFilters(search.f)
|
||||
return {
|
||||
create: searchToFlag(search.create),
|
||||
tab: searchToTab(search.tab, LIST_TAB_IDS),
|
||||
q: searchToQ(search.q),
|
||||
f: f.length > 0 ? f : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/lists/')({
|
||||
validateSearch: parseListsSearch,
|
||||
component: ListsPage,
|
||||
})
|
||||
|
||||
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
|
||||
|
||||
function buildCreateListSchema(resolveCommunity: (input: string) => string) {
|
||||
return z
|
||||
.object({
|
||||
name: z.string().trim().min(2, 'Минимум 2 символа'),
|
||||
source: z.enum(['static', 'json_url', 'evobgp_community']),
|
||||
extra: z.string(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.source === 'json_url' && !/^https?:\/\//i.test(v.extra.trim())) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['extra'],
|
||||
message: 'Укажите полный URL, начиная с http:// или https://',
|
||||
})
|
||||
}
|
||||
if (v.source === 'evobgp_community' && !resolveCommunity(v.extra)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['extra'],
|
||||
message: 'Выберите community из списка или укажите ID',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type CreateListValues = z.infer<ReturnType<typeof buildCreateListSchema>>
|
||||
|
||||
const CREATE_SOURCE_ITEMS = [
|
||||
{ value: 'static', label: 'Ручной' },
|
||||
{ value: 'json_url', label: 'JSON по URL' },
|
||||
@@ -66,24 +124,51 @@ const CREATE_SOURCE_ITEMS = [
|
||||
* Docs: https://reui.io/blocks
|
||||
*/
|
||||
function ListsPage() {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const qc = useQueryClient()
|
||||
const canWrite = useCan()('fw:lists:write')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [source, setSource] = useState<CreateSource>('static')
|
||||
const [extra, setExtra] = useState('')
|
||||
/** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */
|
||||
const [communityId, setCommunityId] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const { create: createParam, q: qParam, f: fParam } = Route.useSearch()
|
||||
const activeTab = Route.useSearch().tab ?? 'all'
|
||||
const searchQuery = qParam ?? ''
|
||||
const filters = fParam ?? []
|
||||
const [createOpenState, setCreateOpenState] = useState(false)
|
||||
const createOpen = createOpenState || createParam === true
|
||||
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
||||
|
||||
const createForm = useForm<CreateListValues>({
|
||||
resolver: zodResolver(buildCreateListSchema((input) => resolveCommunityId(input))),
|
||||
defaultValues: { name: '', source: 'static', extra: '' },
|
||||
mode: 'onBlur',
|
||||
})
|
||||
const createSource = createForm.watch('source') as CreateSource
|
||||
const createExtra = createForm.watch('extra')
|
||||
|
||||
/** Обновление URL-состояния (tab/q/f) без перезагрузки. */
|
||||
const setListSearch = useCallback(
|
||||
(next: { tab?: string; q?: string; f?: Filter[]; create?: boolean }) => {
|
||||
void navigate({
|
||||
search: (prev) => {
|
||||
const base = parseListsSearch(prev as Record<string, unknown>)
|
||||
return {
|
||||
create:
|
||||
next.create !== undefined
|
||||
? next.create || undefined
|
||||
: base.create,
|
||||
tab: next.tab !== undefined ? searchToTab(next.tab, LIST_TAB_IDS) : base.tab,
|
||||
q: next.q !== undefined ? searchToQ(next.q) : base.q,
|
||||
f: next.f !== undefined ? next.f : base.f,
|
||||
}
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const communitiesQ = useQuery({
|
||||
...evobgpCommunitiesQueryOptions(),
|
||||
enabled: createOpen && source === 'evobgp_community',
|
||||
enabled: createOpen && createSource === 'evobgp_community',
|
||||
})
|
||||
|
||||
const communityItems = useMemo(
|
||||
@@ -111,25 +196,22 @@ function ListsPage() {
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (values: CreateListValues) => {
|
||||
const config: Record<string, unknown> = {}
|
||||
if (source === 'json_url') {
|
||||
config.url = extra.trim()
|
||||
} else if (source === 'evobgp_community') {
|
||||
config.community_id = communityId || resolveCommunityId(extra)
|
||||
if (values.source === 'json_url') {
|
||||
config.url = values.extra.trim()
|
||||
} else if (values.source === 'evobgp_community') {
|
||||
config.community_id = resolveCommunityId(values.extra)
|
||||
}
|
||||
return apiFetch<{ id: string }>('/api/v1/lists', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, type: source, config }),
|
||||
body: JSON.stringify({ name: values.name, type: values.source, config }),
|
||||
})
|
||||
},
|
||||
onSuccess: (row) => {
|
||||
toast.success('Список создан')
|
||||
setName('')
|
||||
setExtra('')
|
||||
setCommunityId('')
|
||||
setSource('static')
|
||||
setCreateOpen(false)
|
||||
createForm.reset()
|
||||
setCreateOpenState(false)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
||||
},
|
||||
@@ -176,13 +258,6 @@ function ListsPage() {
|
||||
[],
|
||||
)
|
||||
|
||||
const canCreate =
|
||||
Boolean(name.trim()) &&
|
||||
(source === 'static' ||
|
||||
(source === 'json_url' && Boolean(extra.trim())) ||
|
||||
(source === 'evobgp_community' &&
|
||||
Boolean(communityId || resolveCommunityId(extra))))
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
@@ -202,7 +277,7 @@ function ListsPage() {
|
||||
Обновить
|
||||
</Button>
|
||||
{canWrite ? (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Создать
|
||||
</Button>
|
||||
@@ -219,16 +294,16 @@ function ListsPage() {
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={listFilterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
onFiltersChange={(f) => setListSearch({ f })}
|
||||
onClearFilters={() => setListSearch({ f: [], q: '' })}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setListSearch({ q })}
|
||||
searchPlaceholder="Поиск списков…"
|
||||
getSearchText={getSearchText}
|
||||
tabs={[...LIST_TABS]}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onTabChange={(tab) => setListSearch({ tab })}
|
||||
tabFilter={tabFilter}
|
||||
isLoading={listsQ.isLoading}
|
||||
isError={listsQ.isError}
|
||||
@@ -241,7 +316,7 @@ function ListsPage() {
|
||||
title: 'Нет списков',
|
||||
description: 'Создайте первый список для политики firewall.',
|
||||
action: (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Button size="sm" onClick={() => setCreateOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Создать
|
||||
</Button>
|
||||
@@ -262,123 +337,137 @@ function ListsPage() {
|
||||
disabled={removeList.isPending}
|
||||
/>
|
||||
|
||||
<Sheet open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новый список</SheetTitle>
|
||||
<SheetDescription>
|
||||
После создания откроется страница со содержимым списка.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="list-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Select
|
||||
items={[...CREATE_SOURCE_ITEMS]}
|
||||
value={source}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
setSource(v as CreateSource)
|
||||
setExtra('')
|
||||
setCommunityId('')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CREATE_SOURCE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{source === 'json_url' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||
<Input
|
||||
id="list-url"
|
||||
value={extra}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => {
|
||||
const v = e.target.value
|
||||
setExtra(v)
|
||||
if (guessListSourceFromInput(v) === 'json_url') {
|
||||
setSource('json_url')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'evobgp_community' ? (
|
||||
<Field>
|
||||
<FieldLabel>BGP community</FieldLabel>
|
||||
<Autocomplete
|
||||
items={communityItems}
|
||||
value={extra}
|
||||
onValueChange={(v) => {
|
||||
setExtra(v)
|
||||
// Base UI {value,label}: input stores label by default —
|
||||
// keep UUID separately for config.community_id.
|
||||
// Preview: https://reui.io/preview/base/components/c-autocomplete-9
|
||||
// Docs: https://reui.io/docs/components/base/autocomplete
|
||||
// Base UI: https://base-ui.com/react/components/autocomplete
|
||||
setCommunityId(resolveCommunityId(v))
|
||||
}}
|
||||
itemToStringValue={(item) => item.label}
|
||||
>
|
||||
<AutocompleteInput
|
||||
placeholder={
|
||||
communitiesQ.isError
|
||||
? 'ID вручную (EvoBGP недоступен)'
|
||||
: 'Поиск community…'
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>
|
||||
{communitiesQ.isLoading
|
||||
? 'Загрузка…'
|
||||
: 'Нет совпадений'}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>
|
||||
{item.label}
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
<FormSheet
|
||||
open={createOpen}
|
||||
onOpenChange={(open) => {
|
||||
setCreateOpenState(open)
|
||||
if (!open && createParam) {
|
||||
setListSearch({ create: false })
|
||||
}
|
||||
}}
|
||||
title="Новый список"
|
||||
description="После создания откроется страница со содержимым списка."
|
||||
form={createForm}
|
||||
onSubmit={async (values) => {
|
||||
await create.mutateAsync(values)
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateOpenState(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton
|
||||
disabled={!canCreate}
|
||||
isLoading={create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
<LoadingButton type="submit" isLoading={create.isPending}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field
|
||||
data-invalid={Boolean(createForm.formState.errors.name) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="list-name"
|
||||
placeholder="spamhaus-drop"
|
||||
{...createForm.register('name')}
|
||||
aria-invalid={
|
||||
Boolean(createForm.formState.errors.name) || undefined
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[createForm.formState.errors.name]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Controller
|
||||
control={createForm.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={[...CREATE_SOURCE_ITEMS]}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
field.onChange(v)
|
||||
createForm.setValue('extra', '')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CREATE_SOURCE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{createSource === 'json_url' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(createForm.formState.errors.extra) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||
<Input
|
||||
id="list-url"
|
||||
placeholder="https://…"
|
||||
{...createForm.register('extra')}
|
||||
aria-invalid={
|
||||
Boolean(createForm.formState.errors.extra) || undefined
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[createForm.formState.errors.extra]} />
|
||||
</Field>
|
||||
) : null}
|
||||
{createSource === 'evobgp_community' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(createForm.formState.errors.extra) || undefined}
|
||||
>
|
||||
<FieldLabel>BGP community</FieldLabel>
|
||||
<Autocomplete
|
||||
items={communityItems}
|
||||
value={createExtra}
|
||||
onValueChange={(v) => {
|
||||
createForm.setValue('extra', v)
|
||||
// Base UI {value,label}: input stores label by default —
|
||||
// UUID резолвится на submit через resolveCommunityId.
|
||||
// Preview: https://reui.io/preview/base/components/c-autocomplete-9
|
||||
// Docs: https://reui.io/docs/components/base/autocomplete
|
||||
// Base UI: https://base-ui.com/react/components/autocomplete
|
||||
}}
|
||||
itemToStringValue={(item) => item.label}
|
||||
>
|
||||
<AutocompleteInput
|
||||
placeholder={
|
||||
communitiesQ.isError
|
||||
? 'ID вручную (EvoBGP недоступен)'
|
||||
: 'Поиск community…'
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>
|
||||
{communitiesQ.isLoading ? 'Загрузка…' : 'Нет совпадений'}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>
|
||||
{item.label}
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
<FieldError errors={[createForm.formState.errors.extra]} />
|
||||
</Field>
|
||||
) : null}
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { parseListEntry } from '@evofw/shared'
|
||||
import {
|
||||
CircleAlertIcon,
|
||||
ListIcon,
|
||||
ShieldIcon,
|
||||
UsersIcon,
|
||||
@@ -9,10 +14,17 @@ import {
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||
import { PageShell, DetailPanel } from '@/components/reui-kit'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
@@ -23,7 +35,7 @@ import {
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Checkbox } from '@evofw/ui/components/checkbox'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
import {
|
||||
@@ -66,6 +78,68 @@ export const Route = createFileRoute('/_auth/rules/$setId')({
|
||||
|
||||
type SourceKind = 'list' | 'cidr' | 'hostname'
|
||||
|
||||
const ruleSchema = z
|
||||
.object({
|
||||
action: z.enum(['allow', 'deny']),
|
||||
source: z.enum(['list', 'cidr', 'hostname']),
|
||||
listId: z.string(),
|
||||
value: z.string(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.source === 'list') {
|
||||
if (!v.listId) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['listId'],
|
||||
message: 'Выберите список',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
const raw = v.value.trim()
|
||||
if (!raw) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['value'],
|
||||
message: v.source === 'cidr' ? 'Укажите CIDR' : 'Укажите DNS-имя',
|
||||
})
|
||||
return
|
||||
}
|
||||
let parsed: ReturnType<typeof parseListEntry> | null = null
|
||||
try {
|
||||
parsed = parseListEntry(raw)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
if (v.source === 'cidr' && (!parsed || (parsed.kind !== 'cidr' && parsed.kind !== 'ip'))) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['value'],
|
||||
message: 'Ожидается CIDR, например 203.0.113.0/24',
|
||||
})
|
||||
}
|
||||
if (v.source === 'hostname' && (!parsed || parsed.kind !== 'hostname')) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['value'],
|
||||
message: 'Ожидается DNS-имя, например bad.example.com',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
type RuleValues = z.infer<typeof ruleSchema>
|
||||
|
||||
const RULE_ACTION_ITEMS = [
|
||||
{ value: 'deny', label: 'deny — блокировать' },
|
||||
{ value: 'allow', label: 'allow — пропускать' },
|
||||
] as const
|
||||
|
||||
const RULE_SOURCE_ITEMS = [
|
||||
{ value: 'cidr', label: 'CIDR / IP' },
|
||||
{ value: 'hostname', label: 'DNS-имя' },
|
||||
{ value: 'list', label: 'IP-список' },
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Policy set detail — Sortable rules (ReUI PRO) + agents.
|
||||
* Preview: https://reui.io/preview/base/components/c-sortable-5 · settings-8 · settings-3
|
||||
@@ -80,14 +154,16 @@ function PolicySetDetailPage() {
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
|
||||
const [ruleOpen, setRuleOpen] = useState(false)
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const [source, setSource] = useState<SourceKind>('cidr')
|
||||
const [listId, setListId] = useState('')
|
||||
const [cidr, setCidr] = useState('')
|
||||
const [hostname, setHostname] = useState('')
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null)
|
||||
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null)
|
||||
|
||||
const ruleForm = useForm<RuleValues>({
|
||||
resolver: zodResolver(ruleSchema),
|
||||
defaultValues: { action: 'deny', source: 'cidr', listId: '', value: '' },
|
||||
mode: 'onBlur',
|
||||
})
|
||||
const ruleSource = ruleForm.watch('source')
|
||||
|
||||
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
|
||||
|
||||
const approvedAgents = useMemo(
|
||||
@@ -144,14 +220,16 @@ function PolicySetDetailPage() {
|
||||
})
|
||||
|
||||
const createRule = useMutation({
|
||||
mutationFn: () => {
|
||||
mutationFn: (values: RuleValues) => {
|
||||
const body: Record<string, unknown> = {
|
||||
set_id: setId,
|
||||
action,
|
||||
action: values.action,
|
||||
}
|
||||
if (values.source === 'list') body.list_id = values.listId
|
||||
if (values.source === 'cidr') body.cidr = values.value.trim()
|
||||
if (values.source === 'hostname') {
|
||||
body.hostname = values.value.trim().toLowerCase()
|
||||
}
|
||||
if (source === 'list') body.list_id = listId
|
||||
if (source === 'cidr') body.cidr = cidr.trim()
|
||||
if (source === 'hostname') body.hostname = hostname.trim()
|
||||
return apiFetch('/api/v1/rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
@@ -160,9 +238,7 @@ function PolicySetDetailPage() {
|
||||
onSuccess: () => {
|
||||
toast.success('Правило создано')
|
||||
setRuleOpen(false)
|
||||
setCidr('')
|
||||
setHostname('')
|
||||
setListId('')
|
||||
ruleForm.reset()
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
@@ -255,13 +331,6 @@ function PolicySetDetailPage() {
|
||||
state: { rowSelection: agentRowSelection },
|
||||
})
|
||||
|
||||
const canCreate =
|
||||
source === 'list'
|
||||
? Boolean(listId)
|
||||
: source === 'cidr'
|
||||
? Boolean(cidr.trim())
|
||||
: Boolean(hostname.trim())
|
||||
|
||||
if (setQ.isLoading) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -271,6 +340,31 @@ function PolicySetDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (setQ.isError) {
|
||||
return (
|
||||
<PageShell>
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>
|
||||
{setQ.error?.message ?? 'Не удалось загрузить набор правил'}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
onClick={() => void setQ.refetch()}
|
||||
>
|
||||
Повторить
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setQ.data) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -411,57 +505,103 @@ function PolicySetDetailPage() {
|
||||
disabled={removeRule.isPending}
|
||||
/>
|
||||
|
||||
<Sheet open={ruleOpen} onOpenChange={setRuleOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новое правило</SheetTitle>
|
||||
<SheetDescription>
|
||||
Один источник: список, CIDR или DNS-имя (priority — в конец)
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<FormSheet
|
||||
open={ruleOpen}
|
||||
onOpenChange={(open) => {
|
||||
setRuleOpen(open)
|
||||
if (!open) ruleForm.reset()
|
||||
}}
|
||||
title="Новое правило"
|
||||
description="Один источник: список, CIDR или DNS-имя (priority — в конец)"
|
||||
form={ruleForm}
|
||||
onSubmit={async (values) => {
|
||||
await createRule.mutateAsync(values)
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRuleOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="submit" isLoading={createRule.isPending}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel>Действие</FieldLabel>
|
||||
<Controller
|
||||
control={ruleForm.control}
|
||||
name="action"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={action}
|
||||
items={RULE_ACTION_ITEMS}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAction(v as 'allow' | 'deny')
|
||||
if (v === 'allow' || v === 'deny') field.onChange(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
{RULE_ACTION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Controller
|
||||
control={ruleForm.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={source}
|
||||
items={[...RULE_SOURCE_ITEMS]}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
if (v) setSource(v as SourceKind)
|
||||
if (v === 'list' || v === 'cidr' || v === 'hostname') {
|
||||
field.onChange(v)
|
||||
ruleForm.setValue('value', '')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cidr">CIDR / IP</SelectItem>
|
||||
<SelectItem value="hostname">DNS-имя</SelectItem>
|
||||
<SelectItem value="list">IP-список</SelectItem>
|
||||
{RULE_SOURCE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{source === 'list' ? (
|
||||
<Field>
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
{ruleSource === 'list' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(ruleForm.formState.errors.listId) || undefined}
|
||||
>
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
<Controller
|
||||
control={ruleForm.control}
|
||||
name="listId"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
items={listSelectItems}
|
||||
value={listId || null}
|
||||
onValueChange={(v) => setListId(v ?? '')}
|
||||
value={field.value || null}
|
||||
onValueChange={(v) => field.onChange(v ?? '')}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Выберите список" />
|
||||
@@ -474,44 +614,44 @@ function PolicySetDetailPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'cidr' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
|
||||
<Input
|
||||
id="cidr"
|
||||
value={cidr}
|
||||
onChange={(e) => setCidr(e.target.value)}
|
||||
placeholder="203.0.113.0/24"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'hostname' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
|
||||
<Input
|
||||
id="host"
|
||||
value={hostname}
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
placeholder="bad.example.com"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setRuleOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canCreate || createRule.isPending}
|
||||
onClick={() => createRule.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[ruleForm.formState.errors.listId]} />
|
||||
</Field>
|
||||
) : null}
|
||||
{ruleSource === 'cidr' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(ruleForm.formState.errors.value) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="rule-cidr">CIDR</FieldLabel>
|
||||
<Input
|
||||
id="rule-cidr"
|
||||
placeholder="203.0.113.0/24"
|
||||
{...ruleForm.register('value')}
|
||||
aria-invalid={
|
||||
Boolean(ruleForm.formState.errors.value) || undefined
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[ruleForm.formState.errors.value]} />
|
||||
</Field>
|
||||
) : null}
|
||||
{ruleSource === 'hostname' ? (
|
||||
<Field
|
||||
data-invalid={Boolean(ruleForm.formState.errors.value) || undefined}
|
||||
>
|
||||
<FieldLabel htmlFor="rule-host">DNS-имя</FieldLabel>
|
||||
<Input
|
||||
id="rule-host"
|
||||
placeholder="bad.example.com"
|
||||
{...ruleForm.register('value')}
|
||||
aria-invalid={
|
||||
Boolean(ruleForm.formState.errors.value) || undefined
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[ruleForm.formState.errors.value]} />
|
||||
</Field>
|
||||
) : null}
|
||||
</FormSheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { searchToFilters, searchToFlag, searchToQ } from '@/lib/search-params'
|
||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
@@ -28,7 +29,24 @@ import {
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import type { PolicySet } from '@evofw/shared'
|
||||
|
||||
type RulesSearch = {
|
||||
create?: boolean
|
||||
q?: string
|
||||
f?: Filter[]
|
||||
}
|
||||
|
||||
/** URL-состояние страницы: create/q/f (deep-link и refresh сохраняют вид). */
|
||||
function parseRulesSearch(search: Record<string, unknown>): RulesSearch {
|
||||
const f = searchToFilters(search.f)
|
||||
return {
|
||||
create: searchToFlag(search.create),
|
||||
q: searchToQ(search.q),
|
||||
f: f.length > 0 ? f : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/rules/')({
|
||||
validateSearch: parseRulesSearch,
|
||||
component: PolicySetsPage,
|
||||
})
|
||||
|
||||
@@ -39,14 +57,37 @@ export const Route = createFileRoute('/_auth/rules/')({
|
||||
* Create sheet: https://reui.io/preview/base/sheet-8
|
||||
*/
|
||||
function PolicySetsPage() {
|
||||
const navigate = useNavigate()
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const qc = useQueryClient()
|
||||
const setsQ = useQuery(policySetsQueryOptions())
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const { create: createParam, q: qParam, f: fParam } = Route.useSearch()
|
||||
const searchQuery = qParam ?? ''
|
||||
const filters = fParam ?? []
|
||||
const [sheetOpenState, setSheetOpenState] = useState(false)
|
||||
const sheetOpen = sheetOpenState || createParam === true
|
||||
|
||||
/** Обновление URL-состояния (q/f) без перезагрузки. */
|
||||
const setRulesSearch = useCallback(
|
||||
(next: { q?: string; f?: Filter[]; create?: boolean }) => {
|
||||
void navigate({
|
||||
search: (prev) => {
|
||||
const base = parseRulesSearch(prev as Record<string, unknown>)
|
||||
return {
|
||||
create:
|
||||
next.create !== undefined
|
||||
? next.create || undefined
|
||||
: base.create,
|
||||
q: next.q !== undefined ? searchToQ(next.q) : base.q,
|
||||
f: next.f !== undefined ? next.f : base.f,
|
||||
}
|
||||
},
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
const create = useMutation({
|
||||
@@ -63,7 +104,7 @@ function PolicySetsPage() {
|
||||
toast.success('Набор создан')
|
||||
setName('')
|
||||
setDescription('')
|
||||
setSheetOpen(false)
|
||||
setSheetOpenState(false)
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
|
||||
},
|
||||
@@ -203,7 +244,7 @@ function PolicySetsPage() {
|
||||
|
||||
const canCreate = useCan()('fw:policies:write')
|
||||
const addButton = canCreate ? (
|
||||
<Button size="sm" onClick={() => setSheetOpen(true)}>
|
||||
<Button size="sm" onClick={() => setSheetOpenState(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Новый набор
|
||||
</Button>
|
||||
@@ -225,11 +266,11 @@ function PolicySetsPage() {
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
onFiltersChange={(f) => setRulesSearch({ f })}
|
||||
onClearFilters={() => setRulesSearch({ f: [], q: '' })}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onSearchChange={(q) => setRulesSearch({ q })}
|
||||
searchPlaceholder="Поиск наборов…"
|
||||
getSearchText={getSearchText}
|
||||
isLoading={setsQ.isLoading}
|
||||
@@ -260,7 +301,15 @@ function PolicySetsPage() {
|
||||
disabled={remove.isPending}
|
||||
/>
|
||||
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<Sheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSheetOpenState(open)
|
||||
if (!open && createParam) {
|
||||
void navigate({ to: '/rules', search: { create: undefined }, replace: true })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новый набор</SheetTitle>
|
||||
@@ -289,7 +338,7 @@ function PolicySetsPage() {
|
||||
</Field>
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setSheetOpen(false)}>
|
||||
<Button variant="outline" onClick={() => setSheetOpenState(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
||||
import { PageHeader, PageShell } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
@@ -15,11 +20,14 @@ import { SettingRow } from '@/components/setting-row'
|
||||
import { settingsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldError } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
|
||||
/**
|
||||
* Control plane settings — single page: PageShell + Frame + SettingRow.
|
||||
* RHF+zod: валидация полей, dirty-state, reveal токена.
|
||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||
*/
|
||||
|
||||
@@ -27,26 +35,63 @@ export const Route = createFileRoute('/_auth/settings')({
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
const settingsSchema = z.object({
|
||||
enroll_seed: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Обязательное поле — используется в install.sh'),
|
||||
evobgp_api_url: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(v) => v === '' || /^https?:\/\//i.test(v),
|
||||
'URL должен начинаться с http:// или https://',
|
||||
),
|
||||
evobgp_api_token: z.string(),
|
||||
agent_sync_interval_sec: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => {
|
||||
const n = Number(v)
|
||||
return Number.isInteger(n) && n >= 30 && n <= 86400
|
||||
}, 'Целое число секунд, 30–86400'),
|
||||
show_quick_actions: z.string(),
|
||||
})
|
||||
|
||||
type SettingsValues = z.infer<typeof settingsSchema>
|
||||
|
||||
const EMPTY_VALUES: SettingsValues = {
|
||||
enroll_seed: '',
|
||||
evobgp_api_url: '',
|
||||
evobgp_api_token: '',
|
||||
agent_sync_interval_sec: '60',
|
||||
show_quick_actions: 'true',
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const qc = useQueryClient()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const canSave = useCan()('fw:settings:admin')
|
||||
const [form, setForm] = useState<Record<string, string>>({})
|
||||
|
||||
// Seed the form once — a background refetch must not wipe in-progress edits.
|
||||
const initialized = useRef(false)
|
||||
const form = useForm<SettingsValues>({
|
||||
resolver: zodResolver(settingsSchema),
|
||||
defaultValues: EMPTY_VALUES,
|
||||
mode: 'onBlur',
|
||||
})
|
||||
|
||||
// Seed once — background refetch не должен затирать правки в процессе.
|
||||
useEffect(() => {
|
||||
if (settingsQ.data && !initialized.current) {
|
||||
initialized.current = true
|
||||
setForm(settingsQ.data)
|
||||
if (settingsQ.data) {
|
||||
form.reset({ ...EMPTY_VALUES, ...settingsQ.data })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsQ.data])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
mutationFn: (values: SettingsValues) =>
|
||||
apiFetch('/api/v1/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(form),
|
||||
body: JSON.stringify(values),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Сохранено')
|
||||
@@ -56,97 +101,171 @@ function SettingsPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const showQuickActions = form.show_quick_actions !== 'false'
|
||||
|
||||
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',
|
||||
},
|
||||
]
|
||||
const isDirty = form.formState.isDirty
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Интеграции и enroll — settings-16"
|
||||
description="Интеграции, enroll и интервал синхронизации"
|
||||
actions={
|
||||
isDirty ? (
|
||||
<Badge variant="warning-light" size="sm">
|
||||
Есть несохранённые изменения
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Control plane</FrameTitle>
|
||||
<FrameDescription>
|
||||
Auth-portal app id: fw · JWT через AUTH_*
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col">
|
||||
{fields.map((f, i) => (
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => save.mutateAsync(values))}
|
||||
className="flex flex-col gap-4 md:gap-6"
|
||||
>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Control plane</FrameTitle>
|
||||
<FrameDescription>
|
||||
Auth-portal app id: fw · JWT через AUTH_*
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<SettingRow
|
||||
key={f.key}
|
||||
title={f.label}
|
||||
description={f.hint}
|
||||
labelFor={f.key}
|
||||
title="Enroll seed"
|
||||
description="X-EvoFW-Seed для install.sh"
|
||||
labelFor="enroll_seed"
|
||||
last={false}
|
||||
>
|
||||
<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 }))
|
||||
}
|
||||
<Field data-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}>
|
||||
<Input
|
||||
id="enroll_seed"
|
||||
{...form.register('enroll_seed')}
|
||||
aria-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.enroll_seed]} />
|
||||
</Field>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="EvoBGP API URL"
|
||||
description="Источник community prefixes"
|
||||
labelFor="evobgp_api_url"
|
||||
last={false}
|
||||
>
|
||||
<Field data-invalid={Boolean(form.formState.errors.evobgp_api_url) || undefined}>
|
||||
<Input
|
||||
id="evobgp_api_url"
|
||||
placeholder="https://…"
|
||||
{...form.register('evobgp_api_url')}
|
||||
aria-invalid={Boolean(form.formState.errors.evobgp_api_url) || undefined}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.evobgp_api_url]} />
|
||||
</Field>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="EvoBGP API token"
|
||||
description="Bearer для интеграции"
|
||||
labelFor="evobgp_api_token"
|
||||
last={false}
|
||||
>
|
||||
<TokenField form={form} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Интервал синхронизации агентов (сек)"
|
||||
description="Как часто агенты забирают политику (30–86400)"
|
||||
labelFor="agent_sync_interval_sec"
|
||||
last={false}
|
||||
>
|
||||
<Field data-invalid={Boolean(form.formState.errors.agent_sync_interval_sec) || undefined}>
|
||||
<Input
|
||||
id="agent_sync_interval_sec"
|
||||
inputMode="numeric"
|
||||
className="max-w-32"
|
||||
{...form.register('agent_sync_interval_sec')}
|
||||
aria-invalid={
|
||||
Boolean(form.formState.errors.agent_sync_interval_sec) ||
|
||||
undefined
|
||||
}
|
||||
/>
|
||||
<FieldError
|
||||
errors={[form.formState.errors.agent_sync_interval_sec]}
|
||||
/>
|
||||
</Field>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Быстрые действия"
|
||||
description="Показывать Quick Actions на панели и у агентов"
|
||||
last
|
||||
>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="show_quick_actions"
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
checked={field.value !== 'false'}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(checked ? 'true' : 'false')
|
||||
}
|
||||
aria-label="Быстрые действия"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
))}
|
||||
<SettingRow
|
||||
title="Быстрые действия"
|
||||
description="Показывать Quick Actions на панели и у агентов"
|
||||
last
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
{canSave ? (
|
||||
<div className="flex justify-end">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
isLoading={save.isPending}
|
||||
disabled={!settingsQ.isSuccess}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
<Switch
|
||||
checked={showQuickActions}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
show_quick_actions: checked ? 'true' : 'false',
|
||||
}))
|
||||
}
|
||||
aria-label="Быстрые действия"
|
||||
/>
|
||||
</SettingRow>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
{canSave ? (
|
||||
<div className="flex justify-end">
|
||||
<LoadingButton
|
||||
onClick={() => save.mutate()}
|
||||
isLoading={save.isPending}
|
||||
disabled={!initialized.current}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</div>
|
||||
) : null}
|
||||
) : null}
|
||||
</form>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
/** Password-поле с reveal (Eye toggle). */
|
||||
function TokenField({
|
||||
form,
|
||||
}: {
|
||||
form: ReturnType<typeof useForm<SettingsValues>>
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const invalid = Boolean(form.formState.errors.evobgp_api_token)
|
||||
return (
|
||||
<Field data-invalid={invalid || undefined}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="evobgp_api_token"
|
||||
type={visible ? 'text' : 'password'}
|
||||
className="pr-10"
|
||||
autoComplete="off"
|
||||
{...form.register('evobgp_api_token')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="absolute top-1/2 right-1 -translate-y-1/2"
|
||||
aria-label={visible ? 'Скрыть токен' : 'Показать токен'}
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
>
|
||||
{visible ? (
|
||||
<EyeOffIcon className="size-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldError errors={[form.formState.errors.evobgp_api_token]} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
||||
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||
import { searchToFilters } from '@/lib/search-params'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
@@ -38,28 +42,80 @@ type StatRow = {
|
||||
recorded_at: string
|
||||
}
|
||||
|
||||
type StatsSearch = {
|
||||
f?: Filter[]
|
||||
}
|
||||
|
||||
/** URL-состояние страницы: f (deep-link и refresh сохраняют вид). */
|
||||
function parseStatsSearch(search: Record<string, unknown>): StatsSearch {
|
||||
const f = searchToFilters(search.f)
|
||||
return {
|
||||
f: f.length > 0 ? f : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/stats')({
|
||||
validateSearch: parseStatsSearch,
|
||||
component: StatsPage,
|
||||
})
|
||||
|
||||
const chartConfig = {
|
||||
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
|
||||
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
|
||||
dropped: { label: 'Заблокировано', color: 'var(--chart-1)' },
|
||||
accepted: { label: 'Пропущено', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
function StatsPage() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const dash = useQuery(dashboardQueryOptions())
|
||||
const stats = useQuery(recentStatsQueryOptions())
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const filters = Route.useSearch().f ?? []
|
||||
|
||||
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,
|
||||
}))
|
||||
/** Обновление URL-состояния (f) без перезагрузки. */
|
||||
const setStatsFilters = useCallback(
|
||||
(nextFilters: Filter[]) => {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...parseStatsSearch(prev as Record<string, unknown>),
|
||||
f: nextFilters,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
},
|
||||
[navigate],
|
||||
)
|
||||
|
||||
/**
|
||||
* Часовая агрегация: пик счётчиков за час. Счётчики агентов смешанной
|
||||
* семантики (absolute/presence), поэтому суммирование сэмплов дало бы
|
||||
* двойной счёт — берём максимум наблюдавшегося уровня за час.
|
||||
*/
|
||||
const series = useMemo(() => {
|
||||
const byHour = new Map<
|
||||
string,
|
||||
{ t: string; label: string; dropped: number; accepted: number }
|
||||
>()
|
||||
for (const s of stats.data?.items ?? []) {
|
||||
const d = new Date(s.recorded_at)
|
||||
if (Number.isNaN(d.getTime())) continue
|
||||
const key = `${s.recorded_at.slice(0, 13)}:00:00`
|
||||
const entry = byHour.get(key) ?? {
|
||||
t: key,
|
||||
label: d.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
dropped: 0,
|
||||
accepted: 0,
|
||||
}
|
||||
entry.dropped = Math.max(entry.dropped, s.packets_dropped)
|
||||
entry.accepted = Math.max(entry.accepted, s.packets_accepted)
|
||||
byHour.set(key, entry)
|
||||
}
|
||||
return [...byHour.values()]
|
||||
.sort((a, b) => a.t.localeCompare(b.t))
|
||||
.slice(-48)
|
||||
.map(({ t: _t, ...rest }) => rest)
|
||||
}, [stats.data])
|
||||
|
||||
const rows: StatRow[] = useMemo(
|
||||
() =>
|
||||
@@ -76,7 +132,7 @@ function StatsPage() {
|
||||
const kpiCards: KpiStatCard[] = [
|
||||
{
|
||||
id: 'd',
|
||||
label: 'Dropped (sum)',
|
||||
label: 'Заблокировано (сумма)',
|
||||
value: dash.data?.packets_dropped ?? 0,
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-warning',
|
||||
@@ -84,14 +140,14 @@ function StatsPage() {
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
label: 'Accepted (sum)',
|
||||
label: 'Пропущено (сумма)',
|
||||
value: dash.data?.packets_accepted ?? 0,
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'o',
|
||||
label: 'Online agents',
|
||||
label: 'Агентов онлайн',
|
||||
value: dash.data?.agents_online ?? 0,
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
@@ -102,7 +158,7 @@ function StatsPage() {
|
||||
() => [
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: 'Agent',
|
||||
label: 'Агент',
|
||||
type: 'text',
|
||||
placeholder: 'agent id…',
|
||||
},
|
||||
@@ -119,7 +175,9 @@ function StatsPage() {
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'agent_id',
|
||||
header: 'Agent',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Агент" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.agent_id.slice(0, 8)}
|
||||
@@ -128,23 +186,35 @@ function StatsPage() {
|
||||
},
|
||||
{
|
||||
accessorKey: 'recorded_at',
|
||||
header: 'Time',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Время" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.recorded_at}</span>
|
||||
<span className="text-xs tabular-nums">
|
||||
{formatStampDateTime(row.original.recorded_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'packets_dropped',
|
||||
header: 'Drop',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Заблокировано" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.packets_dropped}</span>
|
||||
<span className="text-warning tabular-nums">
|
||||
{formatNumber(row.original.packets_dropped)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'packets_accepted',
|
||||
header: 'Accept',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Пропущено" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.packets_accepted}</span>
|
||||
<span className="text-success tabular-nums">
|
||||
{formatNumber(row.original.packets_accepted)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
@@ -156,12 +226,13 @@ function StatsPage() {
|
||||
<PageHeader
|
||||
title="Статистика"
|
||||
description="История apply-report counters"
|
||||
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
|
||||
/>
|
||||
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Тренд (последние samples)</FrameTitle>
|
||||
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{series.length === 0 ? (
|
||||
@@ -203,12 +274,13 @@ function StatsPage() {
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={filterFields}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
onFiltersChange={setStatsFilters}
|
||||
onClearFilters={() => setStatsFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={stats.isLoading}
|
||||
pageSize={20}
|
||||
emptyState={{
|
||||
title: 'Нет samples',
|
||||
title: 'Нет данных',
|
||||
description: 'Агенты ещё не отправили apply-report.',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.14",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@evofw/ui/components/dialog"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
} from "@evofw/ui/components/input-group"
|
||||
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||
className
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{/* cmdk-компоненты требуют корень Command — без него useSyncExternalStore падает */}
|
||||
<Command className="overflow-hidden">{children}</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
Reference in new issue
Block a user