9 Commits
Author SHA1 Message Date
Denozordec 27c1ba7e23 fix(web): колонка выделения в таблице агентов, корень Command в CommandDialog, RU-метки KPI и дашборда, пустой f не пишется в URL
quality / commitlint (push) Skipped
quality / changes (push) Successful in 11s
quality / docker-check (push) Skipped
quality / openapi (push) Failing after 36s
quality / web (push) Failing after 36s
quality / api (push) Failing after 36s
CD / quality (push) Failing after 2m7s
CD / publish (push) Skipped
2026-09-25 02:06:29 +07:00
Denozordec 19af67f8a3 test(api): покрытие POST /agents/delete-bulk 2026-09-25 01:55:08 +07:00
Denozordec 8a8ea95da6 refactor(web): RHF+zod валидация форм — создание списка, правила, override, настройки (dirty-state, reveal токена), живой разбор вставок 2026-09-25 01:53:07 +07:00
Denozordec 65434857bb feat(web): URL-состояние вкладок/поиска/фильтров на страницах агентов, списков, правил и статистики 2026-09-25 01:44:32 +07:00
Denozordec 3d4edbad8b feat(web): командная палитра ⌘K — поиск агентов/списков/наборов и действия (ReUI c-command-7 + cmdk) 2026-09-25 01:35:29 +07:00
Denozordec 28f1f35f16 fix(web): целостность состояний, русификация обвязки, RBAC-гейтинг навигации, чистка мёртвого кода 2026-09-25 01:26:14 +07:00
Denozordec 0b426fa7ae feat(web): live-данные — polling, онлайн-индикаторы, относительное время, actionable-очередь ошибок apply, часовая агрегация статистики 2026-09-25 01:17:33 +07:00
Denozordec 98e4dcdefb feat(web): bulk-операции в таблице агентов и подтверждения опасных действий 2026-09-25 01:07:05 +07:00
Denozordec 0f7daf77e3 feat(api): массовое удаление агентов (POST /agents/delete-bulk) 2026-09-25 00:59:50 +07:00
32 changed files with 2219 additions and 859 deletions

No files matched your search

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