feat: harden firewall policies telemetry and operations
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
This commit is contained in:
1 parent
aa475848fd
commit
9fa6269174
242 files changed
+19872
-3057
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -83,7 +84,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||
onSuccess: (link) => {
|
||||
setCreated(link)
|
||||
toast.success('Агент создан')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void invalidateAfterMutation(qc, 'agent')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -101,12 +102,13 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Команда установки</SheetTitle>
|
||||
<SheetDescription>
|
||||
Агент уже в списке (Invited). Скопируйте one-liner и выполните на
|
||||
хосте.
|
||||
Агент уже в списке (Invited). Ссылка одноразовая, действует 24 часа и
|
||||
погашается при регистрации. Выполните команду на целевом хосте.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||
<p className="text-muted-foreground text-sm">Истекает: {created.expires_at ? new Date(created.expires_at).toLocaleString() : 'неизвестно'}. Повторная установка по использованной ссылке недоступна.</p>
|
||||
<Field>
|
||||
<FieldLabel>По id</FieldLabel>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -167,7 +169,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Добавить агента"
|
||||
description="Создайте агента и короткую install-ссылку."
|
||||
description="Создайте агента и одноразовую install-ссылку на 24 часа."
|
||||
form={form}
|
||||
onSubmit={async (values) => {
|
||||
await create.mutateAsync(values)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -137,6 +138,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<QueryState isLoading={q.isPending} isError={q.isError} error={q.error} onRetry={() => void q.refetch()}>
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
@@ -172,6 +174,7 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</QueryState>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -118,6 +119,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<QueryState isLoading={q.isPending} isError={q.isError} error={q.error} onRetry={() => void q.refetch()}>
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
@@ -149,6 +151,7 @@ export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</QueryState>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
adminCredentialStatusSchema,
|
||||
installLinkSchema,
|
||||
type InstallLink,
|
||||
} from '@evofw/shared'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
|
||||
export function installLinkState(
|
||||
link: Pick<InstallLink, 'revoked_at' | 'consumed_at' | 'expires_at'>,
|
||||
// Lifecycle expiry is wall-clock state, not a generated design value.
|
||||
now = Date.now(),
|
||||
) {
|
||||
if (link.revoked_at) return 'Отозвана'
|
||||
if (link.consumed_at) return 'Использована'
|
||||
if (link.expires_at && !Number.isFinite(Date.parse(link.expires_at)))
|
||||
return 'Срок неизвестен'
|
||||
if (!link.expires_at || Date.parse(link.expires_at) <= now) return 'Истекла'
|
||||
return 'Ожидает установки'
|
||||
}
|
||||
|
||||
export function AgentCredentialsSheet({
|
||||
agentId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
agentId: string
|
||||
open: boolean
|
||||
onOpenChange: (value: boolean) => void
|
||||
}) {
|
||||
const can = useCan()
|
||||
const read = can('fw:agents:read')
|
||||
const write = can('fw:agents:write')
|
||||
const qc = useQueryClient()
|
||||
const form = useForm<Record<string, never>>()
|
||||
const [confirm, setConfirm] = useState(false)
|
||||
const statusKey = ['agents', agentId, 'token-rotation']
|
||||
const status = useQuery({
|
||||
queryKey: statusKey,
|
||||
queryFn: async ({ signal }) =>
|
||||
adminCredentialStatusSchema.parse(
|
||||
await apiFetch(`/api/v1/agents/${agentId}/token-rotation`, { signal }),
|
||||
),
|
||||
enabled: open && read,
|
||||
refetchInterval: open ? 10_000 : false,
|
||||
})
|
||||
const links = useQuery({
|
||||
queryKey: ['install-links', 'metadata', agentId],
|
||||
queryFn: async ({ signal }) => {
|
||||
const schema = z.object({
|
||||
items: z.array(installLinkSchema),
|
||||
total: z.number(),
|
||||
})
|
||||
const response = schema.parse(
|
||||
await apiFetch('/api/v1/install-links?limit=100', { signal }),
|
||||
)
|
||||
const items = [...response.items]
|
||||
for (
|
||||
let offset = 100;
|
||||
offset < Math.min(response.total, 10_000);
|
||||
offset += 100
|
||||
)
|
||||
items.push(
|
||||
...schema.parse(
|
||||
await apiFetch(`/api/v1/install-links?limit=100&offset=${offset}`, {
|
||||
signal,
|
||||
}),
|
||||
).items,
|
||||
)
|
||||
// Keep only lifecycle metadata in the query cache; installation commands and URLs are not needed here.
|
||||
return items
|
||||
.filter((item) => item.agent_id === agentId)
|
||||
.map(
|
||||
({
|
||||
created_at,
|
||||
expires_at,
|
||||
consumed_at,
|
||||
revoked_at,
|
||||
last_used_at,
|
||||
use_count,
|
||||
id,
|
||||
}) => ({
|
||||
created_at,
|
||||
expires_at,
|
||||
consumed_at,
|
||||
revoked_at,
|
||||
last_used_at,
|
||||
use_count,
|
||||
id,
|
||||
}),
|
||||
)
|
||||
},
|
||||
enabled: open && read,
|
||||
})
|
||||
const rotate = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/token-rotation`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Ротация запрошена; ожидается агент')
|
||||
void qc.invalidateQueries({ queryKey: statusKey })
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
toast.error(e.message)
|
||||
void status.refetch()
|
||||
},
|
||||
})
|
||||
const current = status.data
|
||||
const pending = Boolean(
|
||||
// The request expiry changes whether a new rotation may be requested.
|
||||
current?.rotation && Date.parse(current.rotation.expires_at) > Date.now(),
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Учётные данные агента"
|
||||
description="Ротацию выполняет сам агент. Панель показывает только состояние и сроки."
|
||||
form={form}
|
||||
onSubmit={() => {}}
|
||||
size="lg"
|
||||
footer={
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Закрыть
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!read ? (
|
||||
<Alert>
|
||||
<AlertTitle>Нет доступа к агентам</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<QueryState
|
||||
isLoading={status.isLoading}
|
||||
isError={status.isError}
|
||||
hasData={Boolean(current)}
|
||||
error={status.error}
|
||||
onRetry={() => void status.refetch()}
|
||||
>
|
||||
{current && (
|
||||
<>
|
||||
<Badge
|
||||
variant={
|
||||
pending
|
||||
? 'warning-light'
|
||||
: current.completed_rotation
|
||||
? 'success-light'
|
||||
: 'info-light'
|
||||
}
|
||||
>
|
||||
{pending
|
||||
? 'Ожидается обмен ключа агентом'
|
||||
: current.rotation
|
||||
? 'Запрос ротации истёк'
|
||||
: current.completed_rotation
|
||||
? 'Ротация завершена'
|
||||
: 'Нет активного запроса'}
|
||||
</Badge>
|
||||
{current.rotation && (
|
||||
<p className="text-sm">
|
||||
Срок запроса:{' '}
|
||||
{formatDateTime(current.rotation.expires_at)}
|
||||
</p>
|
||||
)}
|
||||
{!current.supported && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Ротация не поддерживается</AlertTitle>
|
||||
<AlertDescription>
|
||||
Обновите агент до версии с поддержкой scoped enrollment
|
||||
и rotation.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{current.completed_rotation && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Завершён обмен credentials. Это не подтверждение
|
||||
применения firewall policy. Новый запрос может быть
|
||||
временно заблокирован окном восстановления агента.
|
||||
</p>
|
||||
)}
|
||||
{write && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={
|
||||
rotate.isPending ||
|
||||
pending ||
|
||||
!current.supported ||
|
||||
current.agent_status !== 'approved' ||
|
||||
status.isError
|
||||
}
|
||||
onClick={() => setConfirm(true)}
|
||||
>
|
||||
Запросить ротацию
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
<QueryState
|
||||
isLoading={links.isLoading}
|
||||
isError={links.isError}
|
||||
error={links.error}
|
||||
onRetry={() => void links.refetch()}
|
||||
>
|
||||
<ResourcePage
|
||||
title="Одноразовые ссылки установки"
|
||||
data={links.data ?? []}
|
||||
getRowId={(r) => r.id}
|
||||
columns={[
|
||||
{
|
||||
id: 'state',
|
||||
header: 'Состояние',
|
||||
cell: ({ row }) => installLinkState(row.original),
|
||||
},
|
||||
{
|
||||
id: 'expiry',
|
||||
header: 'Истекает',
|
||||
cell: ({ row }) => formatDateTime(row.original.expires_at),
|
||||
},
|
||||
{
|
||||
id: 'consumed',
|
||||
header: 'Использована',
|
||||
cell: ({ row }) => formatDateTime(row.original.consumed_at),
|
||||
},
|
||||
{
|
||||
id: 'revoked',
|
||||
header: 'Отозвана',
|
||||
cell: ({ row }) => formatDateTime(row.original.revoked_at),
|
||||
},
|
||||
]}
|
||||
filterFields={[]}
|
||||
filters={[]}
|
||||
onFiltersChange={() => {}}
|
||||
getFilterFieldValue={() => undefined}
|
||||
emptyState={{
|
||||
title: 'Нет ссылок установки',
|
||||
description:
|
||||
'Ротация существующего агента не требует новой install-ссылки.',
|
||||
}}
|
||||
/>
|
||||
</QueryState>
|
||||
</>
|
||||
)}
|
||||
</FormSheet>
|
||||
<ConfirmDialog
|
||||
open={confirm}
|
||||
onOpenChange={setConfirm}
|
||||
title="Запросить ротацию credentials?"
|
||||
description="При следующей синхронизации поддерживаемый агент выполнит обмен и сохранит новый ключ. Запрос не означает завершённую ротацию."
|
||||
confirmLabel="Запросить"
|
||||
confirmVariant="default"
|
||||
onConfirm={() => {
|
||||
rotate.mutate()
|
||||
setConfirm(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { useState } from 'react'
|
||||
import { lazy, Suspense, useState } from 'react'
|
||||
import { ActivityIcon, ArrowLeftIcon, CircleAlertIcon, ClockIcon, CpuIcon, ShieldIcon } from 'lucide-react'
|
||||
import { PageHead, PageTab, PageTabsList } from '@/components/portal/bits'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
@@ -13,13 +14,14 @@ import { agentTrafficAccepted, agentTrafficDropped } from '@/components/agents/a
|
||||
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
||||
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
||||
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||
import { AgentDiagnosticsPanel } from '@/components/agents/agent-diagnostics-panel'
|
||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
||||
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||
import { AgentCloneSetsSheet, AgentOverrideSheet } from '@/components/agents/agent-settings-sheets'
|
||||
const AgentBlockedIps = lazy(() => import('./agent-blocked-ips').then((module) => ({ default: module.AgentBlockedIps })))
|
||||
const AgentBlockedPorts = lazy(() => import('./agent-blocked-ports').then((module) => ({ default: module.AgentBlockedPorts })))
|
||||
const AgentHostFirewall = lazy(() => import('./agent-host-firewall').then((module) => ({ default: module.AgentHostFirewall })))
|
||||
const AgentPortAcl = lazy(() => import('./agent-port-acl').then((module) => ({ default: module.AgentPortAcl })))
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { agentPreviewQueryOptions, agentQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { formatDateTime, formatNumber, formatRelativeTime } from '@/lib/format'
|
||||
@@ -29,6 +31,16 @@ import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { Tabs, TabsContent } from '@evofw/ui/components/tabs'
|
||||
|
||||
const loadExplainSheet = () => import('./agent-explain-sheet')
|
||||
const loadSettingsSheets = () => import('./agent-settings-sheets')
|
||||
const loadLabels = () => import('./agent-labels-sheet')
|
||||
const loadCredentials = () => import('./agent-credentials-sheet')
|
||||
const AgentCredentialsSheet = lazy(() => loadCredentials().then((module) => ({ default: module.AgentCredentialsSheet })))
|
||||
const AgentLabelsSheet = lazy(() => loadLabels().then((module) => ({ default: module.AgentLabelsSheet })))
|
||||
const AgentExplainSheet = lazy(() => loadExplainSheet().then((module) => ({ default: module.AgentExplainSheet })))
|
||||
const AgentOverrideSheet = lazy(() => loadSettingsSheets().then((module) => ({ default: module.AgentOverrideSheet })))
|
||||
const AgentCloneSetsSheet = lazy(() => loadSettingsSheets().then((module) => ({ default: module.AgentCloneSetsSheet })))
|
||||
|
||||
type Confirm = 'revoke' | 'delete' | 'reset' | null
|
||||
|
||||
/**
|
||||
@@ -45,19 +57,27 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
|
||||
const [tab, setTab] = useState('policy')
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [explainOpen, setExplainOpen] = useState(false)
|
||||
const [labelsOpen, setLabelsOpen] = useState(false)
|
||||
const [labelsMounted, setLabelsMounted] = useState(false)
|
||||
const [credentialsOpen, setCredentialsOpen] = useState(false)
|
||||
const [credentialsMounted, setCredentialsMounted] = useState(false)
|
||||
const [mountedForms, setMountedForms] = useState({ override: false, clone: false, explain: false })
|
||||
const showSheet = (kind: keyof typeof mountedForms) => {
|
||||
setMountedForms((previous) => ({ ...previous, [kind]: true }))
|
||||
if (kind === 'override') setOverrideOpen(true)
|
||||
else if (kind === 'clone') setCloneOpen(true)
|
||||
else setExplainOpen(true)
|
||||
}
|
||||
const [confirm, setConfirm] = useState<Confirm>(null)
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
}
|
||||
const fail = (e: Error) => toast.error(e.message)
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
invalidate()
|
||||
void invalidateAfterMutation(qc, 'agent')
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
@@ -65,7 +85,7 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент отозван')
|
||||
invalidate()
|
||||
void invalidateAfterMutation(qc, 'agent')
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
@@ -73,12 +93,7 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}/stats/reset`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Статистика сброшена')
|
||||
invalidate()
|
||||
for (const key of ['stats', 'blocked-ips', 'blocked-ports']) {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, key] })
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['stats-recent'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
void invalidateAfterMutation(qc, 'agent')
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
@@ -86,8 +101,8 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
|
||||
mutationFn: () => apiFetch(`/api/v1/agents/${agentId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент удалён')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void navigate({ to: '/agents', search: { view: 'cards' } })
|
||||
void invalidateAfterMutation(qc, 'agent')
|
||||
},
|
||||
onError: fail,
|
||||
})
|
||||
@@ -168,12 +183,15 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
|
||||
<AgentOnlineDot agent={a} />
|
||||
<StatusBadge status={a.status} />
|
||||
</span>
|
||||
<Button type="button" variant="outline" onFocus={() => void loadExplainSheet()} onPointerEnter={() => void loadExplainSheet()} onClick={() => showSheet('explain')}>Почему принято решение</Button>
|
||||
<Button type="button" variant="outline" onFocus={() => void loadLabels()} onPointerEnter={() => void loadLabels()} onClick={() => { setLabelsMounted(true); setLabelsOpen(true) }}>Метки</Button>
|
||||
<Button type="button" variant="outline" onFocus={() => void loadCredentials()} onPointerEnter={() => void loadCredentials()} onClick={() => { setCredentialsMounted(true); setCredentialsOpen(true) }}>Учётные данные</Button>
|
||||
{canWrite ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOverrideOpen(true)}>
|
||||
<Button type="button" variant="outline" onFocus={() => void loadSettingsSheets()} onPointerEnter={() => void loadSettingsSheets()} onClick={() => showSheet('override')}>
|
||||
IP override
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setCloneOpen(true)}>
|
||||
<Button type="button" variant="outline" onFocus={() => void loadSettingsSheets()} onPointerEnter={() => void loadSettingsSheets()} onClick={() => showSheet('clone')}>
|
||||
Копировать наборы
|
||||
</Button>
|
||||
</>
|
||||
@@ -256,33 +274,43 @@ export function AgentDetailPage({ agentId }: { agentId: string }) {
|
||||
<PageTab value="hits" label="Заблокированное" />
|
||||
</PageTabsList>
|
||||
<TabsContent value="policy" className="flex min-w-0 flex-col gap-5">
|
||||
<AgentDiagnosticsPanel diagnostics={a.diagnostics} refreshing={agentQ.isFetching} onRefresh={() => void agentQ.refetch()} />
|
||||
<div className="@container grid gap-5 @4xl:grid-cols-3">
|
||||
<div className="@4xl:col-span-2">
|
||||
<AgentPolicyTrace preview={previewQ.data} isLoading={previewQ.isLoading} />
|
||||
<QueryState isLoading={previewQ.isPending} isError={previewQ.isError} error={previewQ.error} onRetry={() => void previewQ.refetch()}>
|
||||
<AgentPolicyTrace preview={previewQ.data} />
|
||||
</QueryState>
|
||||
</div>
|
||||
<AgentFactsPanel agent={a} />
|
||||
</div>
|
||||
<AgentPolicySetsSortable agentId={agentId} />
|
||||
<AgentEffectiveCidrs preview={previewQ.data} isLoading={previewQ.isLoading} />
|
||||
<QueryState isLoading={previewQ.isPending} isError={previewQ.isError} error={previewQ.error} onRetry={() => void previewQ.refetch()}>
|
||||
<AgentEffectiveCidrs preview={previewQ.data} />
|
||||
</QueryState>
|
||||
</TabsContent>
|
||||
{isLinux ? (
|
||||
<TabsContent value="host" className="min-w-0">
|
||||
<AgentHostFirewall agentId={agentId} />
|
||||
<Suspense fallback={<Skeleton className="h-40 w-full" />}><AgentHostFirewall agentId={agentId} /></Suspense>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
{isLinux ? (
|
||||
<TabsContent value="acl" className="min-w-0">
|
||||
<AgentPortAcl agentId={agentId} />
|
||||
<Suspense fallback={<Skeleton className="h-40 w-full" />}><AgentPortAcl agentId={agentId} /></Suspense>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
<TabsContent value="hits" className="flex min-w-0 flex-col gap-5">
|
||||
{isLinux ? <AgentBlockedPorts agentId={agentId} /> : null}
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
{isLinux ? <Suspense fallback={<Skeleton className="h-40 w-full" />}><AgentBlockedPorts agentId={agentId} /></Suspense> : null}
|
||||
<Suspense fallback={<Skeleton className="h-40 w-full" />}><AgentBlockedIps agentId={agentId} platform={a.platform} /></Suspense>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AgentOverrideSheet agentId={agentId} open={overrideOpen} onOpenChange={setOverrideOpen} />
|
||||
<AgentCloneSetsSheet agentId={agentId} open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||
<Suspense fallback={<p role="status" className="text-muted-foreground text-sm">Загрузка формы…</p>}>
|
||||
{labelsMounted ? <AgentLabelsSheet key={`labels-${agentId}`} agentId={agentId} open={labelsOpen} onOpenChange={setLabelsOpen} /> : null}
|
||||
{credentialsMounted ? <AgentCredentialsSheet key={`credentials-${agentId}`} agentId={agentId} open={credentialsOpen} onOpenChange={setCredentialsOpen} /> : null}
|
||||
{mountedForms.override ? <AgentOverrideSheet key={`override-${agentId}`} agentId={agentId} open={overrideOpen} onOpenChange={setOverrideOpen} /> : null}
|
||||
{mountedForms.explain ? <AgentExplainSheet key={`explain-${agentId}`} agentId={agentId} open={explainOpen} onOpenChange={setExplainOpen} /> : null}
|
||||
{mountedForms.clone ? <AgentCloneSetsSheet key={`clone-${agentId}`} agentId={agentId} open={cloneOpen} onOpenChange={setCloneOpen} /> : null}
|
||||
</Suspense>
|
||||
<ConfirmDialog
|
||||
open={confirm !== null}
|
||||
onOpenChange={(o) => !o && setConfirm(null)}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentDiagnostics } from '@evofw/shared'
|
||||
import { AgentDiagnosticsPanel } from './agent-diagnostics-panel'
|
||||
|
||||
const diagnostics: AgentDiagnostics = {
|
||||
desired_revision: 8, desired_hash: 'sha256:desired', applied_revision: 7, applied_hash: 'sha256:applied',
|
||||
agent_version: '2.0', capabilities: ['nftables', 'traffic-v2'], last_contact_at: '2026-10-24T00:05:00Z',
|
||||
last_telemetry_at: null, last_apply_at: null, apply_error: null, status: 'drift', telemetry_version: 2,
|
||||
quality: { partial: true, truncated: false, unsupported: ['port'], lost_reports: 3 },
|
||||
}
|
||||
|
||||
describe('agent diagnostic truthfulness', () => {
|
||||
it('does not infer synchronized state from missing diagnostics', () => {
|
||||
const html = renderToStaticMarkup(<AgentDiagnosticsPanel />)
|
||||
expect(html).toContain('Нет диагностики')
|
||||
expect(html).not.toContain('Синхронизирован')
|
||||
})
|
||||
it('keeps contact, telemetry, application and drift distinct', () => {
|
||||
const html = renderToStaticMarkup(<AgentDiagnosticsPanel diagnostics={diagnostics} />)
|
||||
expect(html).toContain('Расхождение политики')
|
||||
expect(html).toContain('Не получена')
|
||||
expect(html).toContain('Не подтверждено')
|
||||
expect(html).toContain('sha256:desired')
|
||||
expect(html).toContain('sha256:applied')
|
||||
expect(html).toContain('Неполный')
|
||||
expect(html).toContain('Потеряно отчётов: 3')
|
||||
expect(html).toContain('traffic-v2')
|
||||
})
|
||||
it('shows structured failure without falsely declaring synchronization', () => {
|
||||
const html = renderToStaticMarkup(<AgentDiagnosticsPanel diagnostics={{ ...diagnostics, status: 'failed', apply_error: { code: 'E_APPLY', stage: 'nft-check', message: 'Fixture validation failed', retryable: false } }} />)
|
||||
expect(html).toContain('E_APPLY')
|
||||
expect(html).toContain('nft-check')
|
||||
expect(html).toContain('Fixture validation failed')
|
||||
expect(html).not.toContain('Синхронизирован')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { AgentDiagnostics } from '@evofw/shared'
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { formatStampDateTime } from '@/lib/format'
|
||||
|
||||
const STATUS = {
|
||||
pending: { label: 'Ожидает применения', variant: 'secondary' },
|
||||
applying: { label: 'Применяется', variant: 'info-light' },
|
||||
in_sync: { label: 'Синхронизирован', variant: 'success-light' },
|
||||
failed: { label: 'Ошибка применения', variant: 'destructive-light' },
|
||||
drift: { label: 'Расхождение политики', variant: 'warning-light' },
|
||||
unsupported: { label: 'Подтверждение недоступно', variant: 'secondary' },
|
||||
} as const
|
||||
|
||||
export function AgentDiagnosticsPanel({ diagnostics, onRefresh, refreshing = false }: { diagnostics?: AgentDiagnostics; onRefresh?: () => void; refreshing?: boolean }) {
|
||||
const status = diagnostics ? STATUS[diagnostics.status] : null
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader className="flex-row flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<FrameTitle>Диагностика агента</FrameTitle>
|
||||
<FrameDescription>Связь, телеметрия и подтверждение применения политики</FrameDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{status ? <Badge variant={status.variant} size="sm">{status.label}</Badge> : <Badge variant="secondary" size="sm">Нет диагностики</Badge>}
|
||||
{onRefresh ? <Button type="button" size="sm" variant="outline" disabled={refreshing} onClick={onRefresh}>Обновить</Button> : null}
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-4">
|
||||
{!diagnostics ? <p className="text-muted-foreground text-sm">Агент ещё не передал диагностику. Наличие связи само по себе не подтверждает применение политики.</p> : <>
|
||||
<dl className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{[
|
||||
['Последний контакт', diagnostics.last_contact_at ? formatStampDateTime(diagnostics.last_contact_at) : 'Не получен'],
|
||||
['Телеметрия', diagnostics.last_telemetry_at ? formatStampDateTime(diagnostics.last_telemetry_at) : 'Не получена'],
|
||||
['Применение', diagnostics.last_apply_at ? formatStampDateTime(diagnostics.last_apply_at) : 'Не подтверждено'],
|
||||
['Ожидаемая ревизия', String(diagnostics.desired_revision)],
|
||||
['Применённая ревизия', diagnostics.applied_revision === null ? 'Не подтверждена' : String(diagnostics.applied_revision)],
|
||||
['Версия агента / телеметрии', `${diagnostics.agent_version ?? '—'} / ${diagnostics.telemetry_version ?? 'неизвестна'}`],
|
||||
].map(([label, value]) => <div key={label} className="flex min-w-0 flex-col gap-1"><dt className="text-muted-foreground text-xs">{label}</dt><dd className="text-sm tabular-nums">{value}</dd></div>)}
|
||||
</dl>
|
||||
<dl className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="min-w-0"><dt className="text-muted-foreground text-xs">Ожидаемый hash</dt><dd className="truncate font-mono text-xs" title={diagnostics.desired_hash ?? undefined}>{diagnostics.desired_hash ?? 'Не рассчитан'}</dd></div>
|
||||
<div className="min-w-0"><dt className="text-muted-foreground text-xs">Подтверждённый hash</dt><dd className="truncate font-mono text-xs" title={diagnostics.applied_hash ?? undefined}>{diagnostics.applied_hash ?? 'Не получен'}</dd></div>
|
||||
</dl>
|
||||
{diagnostics.apply_error ? <Alert variant="destructive">
|
||||
<AlertTitle>{diagnostics.apply_error.code}{diagnostics.apply_error.stage ? ` · ${diagnostics.apply_error.stage}` : ''}</AlertTitle>
|
||||
<AlertDescription>{diagnostics.apply_error.message}{diagnostics.apply_error.retryable === undefined ? '' : diagnostics.apply_error.retryable ? ' · Возможен повтор' : ' · Требуется вмешательство'}</AlertDescription>
|
||||
</Alert> : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">Качество последнего отчёта:</span>
|
||||
{diagnostics.telemetry_version !== 2 ? <Badge variant="secondary" size="sm">Legacy / v2 не подтверждён</Badge> : null}
|
||||
{diagnostics.quality.partial ? <Badge variant="warning-light" size="sm">Неполный</Badge> : null}
|
||||
{diagnostics.quality.truncated ? <Badge variant="warning-light" size="sm">Обрезан</Badge> : null}
|
||||
{diagnostics.quality.lost_reports > 0 ? <Badge variant="warning-light" size="sm">Потеряно отчётов: {diagnostics.quality.lost_reports}</Badge> : null}
|
||||
{diagnostics.quality.unsupported.map((metric) => <Badge key={metric} variant="secondary" size="sm">Нет поддержки: {metric}</Badge>)}
|
||||
{diagnostics.telemetry_version === 2 && !diagnostics.quality.partial && !diagnostics.quality.truncated && diagnostics.quality.lost_reports === 0 && diagnostics.quality.unsupported.length === 0 ? <span className="text-muted-foreground text-xs">Без отмеченных ограничений</span> : null}
|
||||
</div>
|
||||
{diagnostics.detail_counts ? <p className="text-muted-foreground text-xs">Детализация: IP {diagnostics.detail_counts.ip_sent} из {diagnostics.detail_counts.ip_found} · порты {diagnostics.detail_counts.port_sent} из {diagnostics.detail_counts.port_found}</p> : null}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">Возможности:</span>
|
||||
{diagnostics.capabilities.length ? diagnostics.capabilities.map((capability) => <Badge key={capability} variant="secondary" size="sm">{capability}</Badge>) : <span className="text-muted-foreground text-xs">Не переданы</span>}
|
||||
</div>
|
||||
</>}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { explainQuerySchema, explainResponseSchema, type ExplainQuery, type ExplainResponse } from '@evofw/shared'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Frame, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { formatStampDateTime } from '@/lib/format'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@evofw/ui/components/select'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
import { EXPLAIN_DEFAULTS, explainDraftInput, explainDraftSchema, explainSearch, type ExplainDraft } from './explain-validation'
|
||||
|
||||
const STAGES = { stateful: 'Существующее соединение', deny: 'Блокирующий источник', port_close: 'Закрытый порт', port_open: 'Открытый порт', port_implicit_close: 'Источник вне разрешений порта', allow: 'Разрешающий источник', default: 'Действие по умолчанию', external: 'Внешние правила', unsupported: 'Кандидат не поддерживается', transport_unknown: 'Недостаточно данных о порте' } as const
|
||||
const APPLIED_STATUS = { pending: 'Ожидает применения', applying: 'Применяется', in_sync: 'Синхронизирован', failed: 'Ошибка применения', drift: 'Расхождение политики', unsupported: 'Подтверждение недоступно' } as const
|
||||
const CONNECTION_STATE = { new: 'новое соединение', established: 'установленное соединение', related: 'связанное соединение' } as const
|
||||
const LIMITS: Record<string, string> = {
|
||||
desired_policy_not_kernel_observation: 'Расчёт по желаемой политике; это не наблюдение пакета в ядре.',
|
||||
external_firewall_may_override_accept: 'Разрешение EvoFirewall не гарантирует прохождение внешних правил и доступность сервиса.',
|
||||
non_loopback_ingress_assumed: 'Предполагается входящий трафик не с loopback.',
|
||||
port_acl_requires_nft_backend: 'Этот backend не применяет кандидаты с Port ACL.', mikrotik_port_acl_not_supported: 'Port ACL на MikroTik не поддерживается.',
|
||||
ipv6_policy_capability_not_reported: 'Агент не подтвердил поддержку IPv6-политик.', ipv6_port_acl_capability_not_reported: 'Агент не подтвердил IPv6 Port ACL.',
|
||||
established_related_precedes_owned_policy: 'Установленные и связанные соединения обрабатываются раньше управляемых правил.',
|
||||
port_protocol_required_for_l4_decision: 'Для решения Port ACL нужны порт и протокол.',
|
||||
mikrotik_input_allow_and_default_are_external: 'В этой цепочке MikroTik разрешение определяют внешние правила.',
|
||||
mikrotik_accept_default_is_external_fallthrough: 'После управляемых правил решение передаётся внешним правилам MikroTik.',
|
||||
ipset_forward_chain_not_managed: 'Этот backend не управляет транзитной цепочкой; решение определяют внешние правила.',
|
||||
ipset_accept_default_is_external_fallthrough: 'После управляемых правил решение передаётся внешним правилам хоста.',
|
||||
forward_port_acl_dnat_scope: 'Port ACL проверяется в области DNAT.', forward_port_acl_skipped_without_dnat: 'Обычный forward без DNAT не проверяет Port ACL.',
|
||||
ipv6_runtime_capability_unknown: 'Поддержка IPv6 во время исполнения неизвестна.', source_attribution_is_logical_witness: 'Источник совпадения логический; это не счётчик конкретного правила ядра.',
|
||||
}
|
||||
|
||||
export function AgentExplainResult({ result }: { result: ExplainResponse }) {
|
||||
const decision = result.desired.decision
|
||||
return <div className="flex flex-col gap-3">
|
||||
<p className="text-muted-foreground text-xs">Результат для {result.query.ip}{result.query.port ? ` · ${result.query.protocol}/${result.query.port}` : ''} · {result.query.chain === 'input' ? 'входящий на хост' : 'транзитный'} · {CONNECTION_STATE[result.query.connection_state]}{result.query.dnat ? ' · DNAT' : ''}</p>
|
||||
<Frame dense spacing="sm"><FrameHeader><FrameTitle>Желаемая политика</FrameTitle></FrameHeader><FramePanel className="flex flex-col gap-2">
|
||||
<Badge variant={decision === 'drop' ? 'destructive-light' : decision === 'accept' ? 'success-light' : 'secondary'}>{decision === 'drop' ? 'Блокировать правилами EvoFirewall' : decision === 'accept' ? 'Разрешить правилами EvoFirewall' : 'Решение не определено'}</Badge>
|
||||
<p className="text-sm">{STAGES[result.desired.stage]}</p><p className="text-muted-foreground text-xs">Ревизия {result.desired.revision}</p><p className="truncate font-mono text-xs" title={result.desired.hash}>{result.desired.hash}</p>
|
||||
{result.desired.match ? <p className="text-sm">Логический источник: {result.desired.match.label}{result.desired.match.cidr ? ` · ${result.desired.match.cidr}` : ''}</p> : null}
|
||||
</FramePanel></Frame>
|
||||
<Frame dense spacing="sm"><FrameHeader><FrameTitle>Последнее подтверждение агента</FrameTitle></FrameHeader><FramePanel className="flex flex-col gap-2">
|
||||
<Badge variant={result.applied.same_as_desired === true ? 'success-light' : result.applied.same_as_desired === false ? 'warning-light' : 'secondary'}>{result.applied.same_as_desired === true ? 'Совпадает с желаемой' : result.applied.same_as_desired === false ? 'Отличается от желаемой' : 'Совпадение неизвестно'}</Badge>
|
||||
<p className="text-muted-foreground text-xs">Ревизия: {result.applied.revision ?? 'не подтверждена'} · {APPLIED_STATUS[result.applied.status]}</p><p className="truncate font-mono text-xs" title={result.applied.hash ?? undefined}>{result.applied.hash ?? 'Hash не подтверждён'}</p>
|
||||
</FramePanel></Frame>
|
||||
<Frame dense spacing="sm"><FrameHeader><FrameTitle>Границы объяснения</FrameTitle></FrameHeader><FramePanel className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-xs">Внешний firewall: {result.external_firewall.observed_at ? `снимок ${formatStampDateTime(result.external_firewall.observed_at)}, правил ${result.external_firewall.rule_count}` : 'снимок не получен'}. Снимок неполный; итоговое прохождение пакета не подтверждается.</p>
|
||||
<ul className="flex flex-col gap-2 text-xs">{result.limits.map((limit) => <li key={limit}>{LIMITS[limit] ?? `Ограничение: ${limit}`}</li>)}</ul>
|
||||
</FramePanel></Frame>
|
||||
{result.matches.length ? <div className="flex flex-col gap-2"><p className="text-sm font-medium">Логические совпадения: {result.matches.length}/{result.matches_total}{result.truncated ? ' · список сокращён' : ''}</p><ul className="flex flex-col gap-2 text-xs">{result.matches.map((match, index) => <li key={`${match.rule_id ?? match.override_id ?? 'source'}-${index}`}>{match.label}{match.cidr ? ` · ${match.cidr}` : ''} · {STAGES[match.stage]}</li>)}</ul></div> : null}
|
||||
</div>
|
||||
}
|
||||
|
||||
export function AgentExplainSheet({ agentId, open, onOpenChange }: { agentId: string; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const canRead = useCan()('fw:agents:read')
|
||||
const form = useForm<ExplainDraft>({ resolver: zodResolver(explainDraftSchema), defaultValues: EXPLAIN_DEFAULTS })
|
||||
const [request, setRequest] = useState<ExplainQuery | null>(null)
|
||||
const search = request ? explainSearch(request) : ''
|
||||
const result = useQuery({ queryKey: ['agents', agentId, 'explain', search], enabled: open && canRead && request !== null,
|
||||
queryFn: async ({ signal }) => explainResponseSchema.parse(await apiFetch(`/api/v1/agents/${agentId}/explain?${search}`, { signal })), staleTime: 0 })
|
||||
return <FormSheet open={open} onOpenChange={onOpenChange} title="Почему принято решение" description="Желаемая политика и последнее подтверждение агента. Explain не отправляет пакет и не меняет правила." form={form}
|
||||
onSubmit={(values) => { const next = explainQuerySchema.parse(explainDraftInput(values)); form.reset(values); if (request && explainSearch(next) === search) void result.refetch(); else setRequest(next) }}
|
||||
footer={<><Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Закрыть</Button><LoadingButton type="submit" isLoading={result.isFetching} disabled={!canRead}>Проверить</LoadingButton></>}>
|
||||
<FieldGroup className="gap-3 py-2">
|
||||
<Field data-invalid={Boolean(form.formState.errors.ip) || undefined}><FieldLabel htmlFor="explain-ip">Исходный IP</FieldLabel><Input id="explain-ip" {...form.register('ip')} placeholder="203.0.113.10" aria-invalid={Boolean(form.formState.errors.ip) || undefined} /><FieldError errors={[form.formState.errors.ip]} /></Field>
|
||||
<Field data-invalid={Boolean(form.formState.errors.port) || undefined}><FieldLabel htmlFor="explain-port">Порт назначения (необязательно)</FieldLabel><Input id="explain-port" inputMode="numeric" {...form.register('port')} aria-invalid={Boolean(form.formState.errors.port) || undefined} /><FieldError errors={[form.formState.errors.port]} /></Field>
|
||||
{([
|
||||
['protocol', 'Протокол', [{ value: 'none', label: 'Без порта и протокола' }, { value: 'tcp', label: 'TCP' }, { value: 'udp', label: 'UDP' }]],
|
||||
['chain', 'Цепочка', [{ value: 'input', label: 'Входящий на хост (input)' }, { value: 'forward', label: 'Транзитный (forward)' }]],
|
||||
['connection_state', 'Состояние соединения', [{ value: 'new', label: 'Новое' }, { value: 'established', label: 'Установленное' }, { value: 'related', label: 'Связанное' }]],
|
||||
] as const).map(([name, label, items]) => <Field key={name}><FieldLabel htmlFor={`explain-${name}`}>{label}</FieldLabel><Controller control={form.control} name={name} render={({ field }) => <Select value={field.value} onValueChange={field.onChange} items={[...items]}><SelectTrigger id={`explain-${name}`}><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{items.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}</SelectGroup></SelectContent></Select>} /></Field>)}
|
||||
<Field><FieldLabel htmlFor="explain-dnat">DNAT</FieldLabel><Controller control={form.control} name="dnat" render={({ field }) => <Switch id="explain-dnat" checked={field.value} onCheckedChange={field.onChange} />} /><FieldDescription>Port ACL для forward проверяется только в области DNAT.</FieldDescription></Field>
|
||||
</FieldGroup>
|
||||
{request ? <div className="flex flex-col gap-3 pb-4">{form.formState.isDirty ? <Badge variant="warning-light">Форма изменена — запустите проверку снова</Badge> : null}<QueryState hasData={result.data !== undefined} isLoading={result.isPending} isError={result.isError} error={result.error} onRetry={() => void result.refetch()}>{result.data ? <AgentExplainResult result={result.data} /> : null}</QueryState></div> : null}
|
||||
</FormSheet>
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import {
|
||||
agentHostFirewallQueryOptions,
|
||||
@@ -231,10 +232,11 @@ export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
|
||||
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
|
||||
{q.data?.collected_at
|
||||
? ` Обновлено: ${formatDateTime(q.data.collected_at)}`
|
||||
: ' Пока нет снимка — дождитесь sync агента.'}
|
||||
: q.isSuccess ? ' Пока нет снимка — дождитесь sync агента.' : ''}
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3 p-4">
|
||||
<QueryState isLoading={q.isPending} isError={q.isError} error={q.error} onRetry={() => void q.refetch()}>
|
||||
<CountedLineTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
@@ -324,6 +326,7 @@ export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
|
||||
)}
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
</QueryState>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
agentLabelBodySchema,
|
||||
patchAgentLabelSchema,
|
||||
putAgentLabelsSchema,
|
||||
previewLabelAssignmentSchema,
|
||||
applyLabelAssignmentSchema,
|
||||
type AgentLabel,
|
||||
type LabelAssignmentPreview,
|
||||
type PreviewLabelAssignment,
|
||||
} from '@evofw/shared'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { NamedMultiSelect } from '@/components/named-multi-select'
|
||||
import { StatsAgentSelect } from '@/components/stats/stats-agent-select'
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { agentsQueryOptions, policySetsQueryOptions } from '@/queries'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import {
|
||||
Field,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
} from '@evofw/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
} from '@evofw/ui/components/select'
|
||||
|
||||
export const labelsQueryOptions = () => ({
|
||||
queryKey: ['agent-labels'],
|
||||
queryFn: async ({ signal }: { signal: AbortSignal }) => {
|
||||
const first = await apiFetch<{ items: AgentLabel[]; total: number }>(
|
||||
'/api/v1/agent-labels?limit=50',
|
||||
{ signal },
|
||||
)
|
||||
const items = [...first.items]
|
||||
for (let offset = 50; offset < Math.min(first.total, 10_000); offset += 50)
|
||||
items.push(
|
||||
...(
|
||||
await apiFetch<{ items: AgentLabel[] }>(
|
||||
`/api/v1/agent-labels?limit=50&offset=${offset}`,
|
||||
{ signal },
|
||||
)
|
||||
).items,
|
||||
)
|
||||
return { ...first, items }
|
||||
},
|
||||
})
|
||||
|
||||
export function AgentLabelsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
agentId,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (value: boolean) => void
|
||||
agentId?: string
|
||||
}) {
|
||||
const can = useCan()
|
||||
const qc = useQueryClient()
|
||||
const read = can('fw:agents:read')
|
||||
const write = can('fw:agents:write')
|
||||
const form = useForm<z.infer<typeof agentLabelBodySchema>>({
|
||||
resolver: zodResolver(agentLabelBodySchema),
|
||||
defaultValues: { name: '', color: null },
|
||||
})
|
||||
const [editing, setEditing] = useState<AgentLabel>()
|
||||
const [ids, setIds] = useState<string[]>([])
|
||||
const [labelDirty, setLabelDirty] = useState(false)
|
||||
const [selectorKind, setSelectorKind] = useState('labels')
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([])
|
||||
const [selectedLabels, setSelectedLabels] = useState<string[]>([])
|
||||
const [sets, setSets] = useState<string[]>([])
|
||||
const [mode, setMode] = useState<'add' | 'replace'>('add')
|
||||
const [preview, setPreview] = useState<{
|
||||
request: PreviewLabelAssignment
|
||||
response: LabelAssignmentPreview
|
||||
}>()
|
||||
const [confirmation, setConfirmation] = useState<{
|
||||
title: string
|
||||
text: string
|
||||
run: () => void
|
||||
}>()
|
||||
const labels = useQuery({ ...labelsQueryOptions(), enabled: open && read })
|
||||
const assigned = useQuery({
|
||||
queryKey: ['agents', agentId, 'labels'],
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{ agent_id: string; label_ids: string[] }>(
|
||||
`/api/v1/agents/${agentId}/labels`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: open && read && Boolean(agentId),
|
||||
})
|
||||
useEffect(() => {
|
||||
if (assigned.data && !labelDirty) setIds(assigned.data.label_ids)
|
||||
}, [assigned.data, labelDirty])
|
||||
const policySets = useQuery({
|
||||
...policySetsQueryOptions(),
|
||||
enabled: open && !agentId && can('fw:policies:read'),
|
||||
})
|
||||
const agents = useQuery({ ...agentsQueryOptions(), enabled: open && read })
|
||||
const mutate = useMutation({
|
||||
mutationFn: (task: () => Promise<unknown>) => task(),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['agent-labels'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
toast.error(e.message)
|
||||
setPreview(undefined)
|
||||
},
|
||||
})
|
||||
const send = (path: string, body: unknown, method = 'POST') =>
|
||||
apiFetch(path, { method, body: JSON.stringify(body) })
|
||||
const change = (task: () => void) => {
|
||||
task()
|
||||
setPreview(undefined)
|
||||
}
|
||||
const close = (value: boolean) => {
|
||||
if (!value && (form.formState.isDirty || labelDirty || preview)) {
|
||||
setConfirmation({
|
||||
title: 'Закрыть черновик?',
|
||||
text: 'Несохранённый черновик останется до ухода со страницы. Назначение не выполняется при закрытии.',
|
||||
run: () => onOpenChange(false),
|
||||
})
|
||||
return
|
||||
}
|
||||
onOpenChange(value)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={close}
|
||||
title={agentId ? 'Метки агента' : 'Метки и групповое назначение'}
|
||||
description="Метки — метаданные. Изменение метки не назначает политики автоматически."
|
||||
form={form}
|
||||
size="xl"
|
||||
onSubmit={(values) => {
|
||||
if (!write) return
|
||||
mutate.mutate(async () => {
|
||||
await send(
|
||||
editing
|
||||
? `/api/v1/agent-labels/${editing.id}`
|
||||
: '/api/v1/agent-labels',
|
||||
editing
|
||||
? patchAgentLabelSchema.parse(values)
|
||||
: agentLabelBodySchema.parse(values),
|
||||
editing ? 'PATCH' : 'POST',
|
||||
)
|
||||
form.reset({ name: '', color: null })
|
||||
setEditing(undefined)
|
||||
})
|
||||
}}
|
||||
footer={
|
||||
<Button type="button" variant="outline" onClick={() => close(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!read ? (
|
||||
<Alert>
|
||||
<AlertTitle>Нет доступа к агентам</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<QueryState
|
||||
isLoading={labels.isLoading}
|
||||
isError={labels.isError}
|
||||
error={labels.error}
|
||||
onRetry={() => void labels.refetch()}
|
||||
>
|
||||
{agentId ? (
|
||||
<QueryState
|
||||
isLoading={assigned.isLoading}
|
||||
isError={assigned.isError}
|
||||
error={assigned.error}
|
||||
onRetry={() => void assigned.refetch()}
|
||||
>
|
||||
<NamedMultiSelect
|
||||
id="agent-label-ids"
|
||||
label="Метки агента"
|
||||
items={labels.data?.items ?? []}
|
||||
value={ids}
|
||||
max={20}
|
||||
disabled={!write}
|
||||
onChange={(next) => {
|
||||
setIds(next)
|
||||
setLabelDirty(true)
|
||||
}}
|
||||
/>
|
||||
{write && (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
!labelDirty || mutate.isPending || assigned.isError
|
||||
}
|
||||
onClick={() =>
|
||||
mutate.mutate(async () => {
|
||||
await send(
|
||||
`/api/v1/agents/${agentId}/labels`,
|
||||
putAgentLabelsSchema.parse({ label_ids: ids }),
|
||||
'PUT',
|
||||
)
|
||||
setLabelDirty(false)
|
||||
})
|
||||
}
|
||||
>
|
||||
Сохранить метки агента
|
||||
</Button>
|
||||
)}
|
||||
</QueryState>
|
||||
) : (
|
||||
<>
|
||||
<ResourcePage
|
||||
title="Метки"
|
||||
data={labels.data?.items ?? []}
|
||||
getRowId={(r) => r.id}
|
||||
columns={[
|
||||
{ accessorKey: 'name', header: 'Название' },
|
||||
{
|
||||
accessorKey: 'color',
|
||||
header: 'Цвет',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline">
|
||||
{row.original.color ?? 'По умолчанию'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Действия',
|
||||
cell: ({ row }) =>
|
||||
write ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={form.formState.isDirty}
|
||||
onClick={() => {
|
||||
setEditing(row.original)
|
||||
form.reset({
|
||||
name: row.original.name,
|
||||
color: agentLabelBodySchema.shape.color.parse(
|
||||
row.original.color,
|
||||
),
|
||||
})
|
||||
}}
|
||||
>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={mutate.isPending}
|
||||
onClick={() =>
|
||||
setConfirmation({
|
||||
title: 'Удалить метку?',
|
||||
text: `Метка «${row.original.name}» будет снята с агентов. Назначенные наборы правил сохранятся.`,
|
||||
run: () =>
|
||||
mutate.mutate(() =>
|
||||
apiFetch(
|
||||
`/api/v1/agent-labels/${row.original.id}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
filterFields={[]}
|
||||
filters={[]}
|
||||
onFiltersChange={() => {}}
|
||||
getFilterFieldValue={() => undefined}
|
||||
emptyState={{
|
||||
title: 'Меток пока нет',
|
||||
description:
|
||||
'Создайте метку ниже, затем назначьте её агенту.',
|
||||
}}
|
||||
/>
|
||||
{can('fw:policies:read') && (
|
||||
<>
|
||||
<Alert variant="info">
|
||||
<AlertTitle>Материализованное назначение</AlertTitle>
|
||||
<AlertDescription>
|
||||
Предпросмотр фиксирует список агентов и поколения.
|
||||
«Добавить» сохраняет прежний порядок наборов; «Заменить»
|
||||
заменяет весь список. Изменения меток после операции не
|
||||
меняют назначения.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-selector-mode">Выбор целей</FieldLabel>
|
||||
<Select
|
||||
value={selectorKind}
|
||||
onValueChange={(value) =>
|
||||
change(() => setSelectorKind(value ?? 'labels'))
|
||||
}
|
||||
items={[
|
||||
{ value: 'labels', label: 'По меткам' },
|
||||
{ value: 'agents', label: 'Явные агенты' },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger id="group-selector-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="labels">По меткам</SelectItem>
|
||||
<SelectItem value="agents">Явные агенты</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{selectorKind === 'labels' ? (
|
||||
<NamedMultiSelect
|
||||
id="group-label-selector"
|
||||
label="Все выбранные метки (AND)"
|
||||
items={labels.data?.items ?? []}
|
||||
value={selectedLabels}
|
||||
max={20}
|
||||
onChange={(ids) => change(() => setSelectedLabels(ids))}
|
||||
/>
|
||||
) : (
|
||||
<StatsAgentSelect
|
||||
id="group-agents"
|
||||
label="Целевые агенты"
|
||||
emptyMeansFleet={false}
|
||||
value={selectedAgents}
|
||||
onChange={(ids) => change(() => setSelectedAgents(ids))}
|
||||
/>
|
||||
)}
|
||||
<QueryState
|
||||
isLoading={policySets.isLoading}
|
||||
isError={policySets.isError}
|
||||
error={policySets.error}
|
||||
onRetry={() => void policySets.refetch()}
|
||||
>
|
||||
<NamedMultiSelect
|
||||
id="group-sets"
|
||||
label="Наборы правил в выбранном порядке"
|
||||
items={policySets.data?.items ?? []}
|
||||
value={sets}
|
||||
onChange={(ids) => change(() => setSets(ids))}
|
||||
/>
|
||||
</QueryState>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="group-assignment-mode">Режим назначения</FieldLabel>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(value) =>
|
||||
change(() =>
|
||||
setMode(value === 'replace' ? 'replace' : 'add'),
|
||||
)
|
||||
}
|
||||
items={[
|
||||
{ value: 'add', label: 'Добавить к текущим' },
|
||||
{ value: 'replace', label: 'Заменить весь список' },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger id="group-assignment-mode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="add">
|
||||
Добавить к текущим
|
||||
</SelectItem>
|
||||
<SelectItem value="replace">
|
||||
Заменить весь список
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={
|
||||
mutate.isPending ||
|
||||
!(selectorKind === 'labels'
|
||||
? selectedLabels.length
|
||||
: selectedAgents.length) ||
|
||||
policySets.isError
|
||||
}
|
||||
onClick={() =>
|
||||
mutate.mutate(async () => {
|
||||
const request = previewLabelAssignmentSchema.parse({
|
||||
selector:
|
||||
selectorKind === 'labels'
|
||||
? { label_ids: selectedLabels }
|
||||
: { agent_ids: selectedAgents },
|
||||
mode,
|
||||
set_ids: sets,
|
||||
})
|
||||
const response = (await send(
|
||||
'/api/v1/agent-labels/preview-assignment',
|
||||
request,
|
||||
)) as LabelAssignmentPreview
|
||||
setPreview({ request, response })
|
||||
})
|
||||
}
|
||||
>
|
||||
Предпросмотр назначения
|
||||
</Button>
|
||||
{preview && (
|
||||
<>
|
||||
<ResourcePage
|
||||
title={`Будут обработаны: ${preview.response.total}`}
|
||||
data={preview.response.items}
|
||||
getRowId={(r) => r.agent_id}
|
||||
columns={[
|
||||
{
|
||||
id: 'agent',
|
||||
header: 'Агент',
|
||||
cell: ({ row }) =>
|
||||
agents.data?.items.find(
|
||||
(a) => a.id === row.original.agent_id,
|
||||
)?.name ?? 'Недоступный агент',
|
||||
},
|
||||
{
|
||||
id: 'sets',
|
||||
header: 'Итоговый порядок',
|
||||
cell: ({ row }) =>
|
||||
row.original.set_ids
|
||||
.map(
|
||||
(id) =>
|
||||
policySets.data?.items.find(
|
||||
(s) => s.id === id,
|
||||
)?.name ?? 'Недоступный набор',
|
||||
)
|
||||
.join(' → ') || 'Без наборов',
|
||||
},
|
||||
{
|
||||
id: 'changed',
|
||||
header: 'Изменится',
|
||||
cell: ({ row }) =>
|
||||
row.original.changed ? 'Да' : 'Нет',
|
||||
},
|
||||
]}
|
||||
filterFields={[]}
|
||||
filters={[]}
|
||||
onFiltersChange={() => {}}
|
||||
getFilterFieldValue={() => undefined}
|
||||
emptyState={{ title: 'Нет подходящих агентов' }}
|
||||
/>
|
||||
{can('fw:policies:write') && (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
mutate.isPending || !preview.response.items.length
|
||||
}
|
||||
onClick={() => {
|
||||
const current = preview
|
||||
setConfirmation({
|
||||
title: 'Применить групповое назначение?',
|
||||
text: `Режим: ${current.request.mode === 'replace' ? 'замена всего списка' : 'добавление'}. Агентов: ${current.response.total}. При изменении поколений или состава выборки операция будет отклонена; потребуется новый предпросмотр.`,
|
||||
run: () =>
|
||||
mutate.mutate(async () => {
|
||||
await send(
|
||||
'/api/v1/agent-labels/assign-policy-sets',
|
||||
applyLabelAssignmentSchema.parse({
|
||||
...current.request,
|
||||
expected: current.response.items.map(
|
||||
(item) => ({
|
||||
agent_id: item.agent_id,
|
||||
generation: item.generation,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
setPreview(undefined)
|
||||
}),
|
||||
})
|
||||
}}
|
||||
>
|
||||
Применить показанное назначение
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{write && (
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="label-name">
|
||||
{editing ? 'Изменить метку' : 'Новая метка'}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="label-name"
|
||||
{...form.register('name')}
|
||||
aria-invalid={Boolean(form.formState.errors.name)}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.name]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="label-color">Цвет метаданных</FieldLabel>
|
||||
<Select
|
||||
value={form.watch('color') ?? 'none'}
|
||||
onValueChange={(value) =>
|
||||
form.setValue(
|
||||
'color',
|
||||
value === 'none'
|
||||
? null
|
||||
: agentLabelBodySchema.shape.color.parse(value),
|
||||
{ shouldDirty: true },
|
||||
)
|
||||
}
|
||||
items={[
|
||||
'none',
|
||||
'primary',
|
||||
'success',
|
||||
'warning',
|
||||
'destructive',
|
||||
'secondary',
|
||||
'info',
|
||||
].map((value) => ({
|
||||
value,
|
||||
label: value === 'none' ? 'По умолчанию' : value,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger id="label-color">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{[
|
||||
'none',
|
||||
'primary',
|
||||
'success',
|
||||
'warning',
|
||||
'destructive',
|
||||
'secondary',
|
||||
'info',
|
||||
].map((color) => (
|
||||
<SelectItem key={color} value={color}>
|
||||
{color === 'none' ? 'По умолчанию' : color}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={mutate.isPending}
|
||||
>
|
||||
{editing ? 'Сохранить метку' : 'Создать метку'}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditing(undefined)
|
||||
form.reset({ name: '', color: null })
|
||||
}}
|
||||
>
|
||||
Отменить редактирование
|
||||
</Button>
|
||||
)}
|
||||
</FieldGroup>
|
||||
)}
|
||||
</QueryState>
|
||||
)}
|
||||
</FormSheet>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmation)}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) setConfirmation(undefined)
|
||||
}}
|
||||
title={confirmation?.title ?? ''}
|
||||
description={confirmation?.text ?? ''}
|
||||
confirmLabel="Подтвердить"
|
||||
onConfirm={() => {
|
||||
confirmation?.run()
|
||||
setConfirmation(undefined)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { PolicySetIcon } from '@/components/rules/policy-set-icon'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
agentPolicySetsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
@@ -60,34 +63,39 @@ export function AgentPolicySetsSortable({
|
||||
agentId,
|
||||
}: AgentPolicySetsSortableProps) {
|
||||
const qc = useQueryClient()
|
||||
const canWrite = useCan()('fw:agents:write')
|
||||
const assignedQ = useQuery(agentPolicySetsQueryOptions(agentId))
|
||||
const catalogQ = useQuery(policySetsQueryOptions())
|
||||
const [items, setItems] = useState<AgentPolicySetRow[]>([])
|
||||
const [addId, setAddId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setItems(assignedQ.data?.items ?? [])
|
||||
if (assignedQ.data) setItems(assignedQ.data.items)
|
||||
}, [assignedQ.data])
|
||||
|
||||
const canEdit = canWrite && assignedQ.isSuccess && catalogQ.isSuccess
|
||||
|
||||
const availableSets = useMemo(() => {
|
||||
const assigned = new Set(items.map((i) => i.set_id))
|
||||
return (catalogQ.data?.items ?? []).filter((s) => !assigned.has(s.id))
|
||||
}, [catalogQ.data?.items, items])
|
||||
|
||||
const persist = useMutation({
|
||||
mutationFn: (set_ids: string[]) =>
|
||||
apiFetch<{ items: AgentPolicySetRow[] }>(
|
||||
onMutate: () => qc.cancelQueries({ queryKey: agentPolicySetsQueryOptions(agentId).queryKey }),
|
||||
mutationFn: (set_ids: string[]) => {
|
||||
if (!canEdit) throw new Error('Дождитесь загрузки назначенных наборов и каталога')
|
||||
return apiFetch<{ items: AgentPolicySetRow[] }>(
|
||||
`/api/v1/agents/${agentId}/policy-sets`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ set_ids }),
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
setItems(res.items)
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
qc.setQueryData(agentPolicySetsQueryOptions(agentId).queryKey, res)
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
toast.error(e.message)
|
||||
@@ -95,6 +103,7 @@ export function AgentPolicySetsSortable({
|
||||
})
|
||||
|
||||
const handleRemove = (setId: string) => {
|
||||
if (!canEdit || persist.isPending) return
|
||||
const next = items.filter((i) => i.set_id !== setId)
|
||||
const prev = items
|
||||
setItems(next)
|
||||
@@ -108,7 +117,7 @@ export function AgentPolicySetsSortable({
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!addId) return
|
||||
if (!addId || !canEdit || persist.isPending) return
|
||||
const set = (catalogQ.data?.items ?? []).find((s) => s.id === addId)
|
||||
if (!set) return
|
||||
const next: AgentPolicySetRow[] = [
|
||||
@@ -150,6 +159,7 @@ export function AgentPolicySetsSortable({
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Select
|
||||
disabled={!canEdit || persist.isPending}
|
||||
value={addId}
|
||||
onValueChange={(v) => setAddId(v)}
|
||||
items={availableSets.map((s) => ({
|
||||
@@ -175,7 +185,7 @@ export function AgentPolicySetsSortable({
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!addId || persist.isPending}
|
||||
disabled={!addId || !canEdit || persist.isPending}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<Plus data-icon="inline-start" />
|
||||
@@ -184,6 +194,7 @@ export function AgentPolicySetsSortable({
|
||||
</div>
|
||||
</FrameHeader>
|
||||
|
||||
<QueryState isLoading={assignedQ.isPending || catalogQ.isPending} isError={assignedQ.isError || catalogQ.isError} error={assignedQ.error ?? catalogQ.error} onRetry={() => { void assignedQ.refetch(); void catalogQ.refetch() }}>
|
||||
{items.length === 0 ? (
|
||||
<FramePanel className="p-0">
|
||||
<EmptyState
|
||||
@@ -211,6 +222,7 @@ export function AgentPolicySetsSortable({
|
||||
<SortableItem
|
||||
key={row.set_id}
|
||||
value={row.set_id}
|
||||
disabled={!canEdit || persist.isPending}
|
||||
className="border-border flex items-center gap-3 border-b px-3 py-2.5 last:border-b-0"
|
||||
>
|
||||
<SortableItemHandle className="text-muted-foreground hover:text-foreground cursor-grab touch-none">
|
||||
@@ -254,7 +266,7 @@ export function AgentPolicySetsSortable({
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Снять набор"
|
||||
disabled={persist.isPending}
|
||||
disabled={!canEdit || persist.isPending}
|
||||
onClick={() => handleRemove(row.set_id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
@@ -264,6 +276,7 @@ export function AgentPolicySetsSortable({
|
||||
</Sortable>
|
||||
</FramePanel>
|
||||
)}
|
||||
</QueryState>
|
||||
</Frame>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@evofw/ui/components/select'
|
||||
import { portAclDraftSchema, portAclImportDraftSchema, type PortAclDraft, type PortAclImportDraft } from './port-acl-validation'
|
||||
|
||||
export type AclCatalog = { items: { value: string; label: string }[]; loading: boolean; ready: boolean; error: Error | null; retry: () => void }
|
||||
type BaseProps = { open: boolean; onOpenChange: (open: boolean) => void; pending: boolean; canWrite: boolean; lists: AclCatalog }
|
||||
|
||||
export function AgentPortRuleForm({ open, onOpenChange, pending, canWrite, lists, initialValues, title, description, onSubmit }: BaseProps & { initialValues: PortAclDraft; title: string; description: string; onSubmit: (values: PortAclDraft) => void }) {
|
||||
const form = useForm<PortAclDraft>({ resolver: zodResolver(portAclDraftSchema, undefined, { raw: true }), defaultValues: initialValues, mode: 'onBlur' })
|
||||
const [discardOpen, setDiscardOpen] = useState(false)
|
||||
useEffect(() => { if (open) form.reset(initialValues) }, [open, initialValues, form])
|
||||
const source = form.watch('src_kind')
|
||||
const close = (next: boolean) => { if (pending) return; if (!next && form.formState.isDirty) setDiscardOpen(true); else onOpenChange(next) }
|
||||
return <>
|
||||
<FormSheet open={open} onOpenChange={close} title={title} description={description} form={form} onSubmit={onSubmit} footer={<><Button type="button" variant="outline" disabled={pending} onClick={() => close(false)}>Отмена</Button><LoadingButton type="submit" isLoading={pending} disabled={!canWrite || (source === 'list' && !lists.ready)}>Сохранить</LoadingButton></>}>
|
||||
<fieldset disabled={!canWrite || pending} className="contents"><FieldGroup className="gap-3 py-2">
|
||||
{source === 'list' ? <QueryState isLoading={lists.loading} isError={Boolean(lists.error)} error={lists.error} onRetry={lists.retry}>{null}</QueryState> : null}
|
||||
{([
|
||||
['action', 'Действие', [{ value: 'open', label: 'Открыть' }, { value: 'close', label: 'Закрыть' }]],
|
||||
['protocol', 'Протокол', [{ value: 'tcp', label: 'TCP' }, { value: 'udp', label: 'UDP' }, { value: 'both', label: 'TCP и UDP' }]],
|
||||
] as const).map(([name, label, items]) => <Field key={name}><FieldLabel htmlFor={`acl-${name}`}>{label}</FieldLabel><Controller control={form.control} name={name} render={({ field }) => <Select disabled={!canWrite || pending} value={field.value} onValueChange={field.onChange} items={[...items]}><SelectTrigger id={`acl-${name}`} onBlur={field.onBlur}><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{items.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}</SelectGroup></SelectContent></Select>} /></Field>)}
|
||||
<div className="grid grid-cols-2 gap-2">{(['port_start', 'port_end'] as const).map((name) => <Field key={name} data-invalid={Boolean(form.formState.errors[name]) || undefined}><FieldLabel htmlFor={`acl-${name}`}>{name === 'port_start' ? 'Первый порт' : 'Последний порт (необязательно)'}</FieldLabel><Input id={`acl-${name}`} inputMode="numeric" {...form.register(name)} aria-invalid={Boolean(form.formState.errors[name]) || undefined} /><FieldError errors={[form.formState.errors[name]]} /></Field>)}</div>
|
||||
<Field><FieldLabel htmlFor="acl-source">Источник</FieldLabel><Controller control={form.control} name="src_kind" render={({ field }) => <Select value={field.value} onValueChange={field.onChange} disabled={!canWrite || pending} items={[{ value: 'all', label: 'Все адреса' }, { value: 'cidr', label: 'IP / CIDR' }, { value: 'list', label: 'IP-список' }]}><SelectTrigger id="acl-source" onBlur={field.onBlur}><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="all">Все адреса</SelectItem><SelectItem value="cidr">IP / CIDR</SelectItem><SelectItem value="list">IP-список</SelectItem></SelectGroup></SelectContent></Select>} /></Field>
|
||||
{source === 'cidr' ? <Field data-invalid={Boolean(form.formState.errors.src_cidr) || undefined}><FieldLabel htmlFor="acl-src-cidr">IP / CIDR</FieldLabel><Input id="acl-src-cidr" {...form.register('src_cidr')} aria-invalid={Boolean(form.formState.errors.src_cidr) || undefined} /><FieldError errors={[form.formState.errors.src_cidr]} /></Field> : null}
|
||||
{source === 'list' ? <Field data-invalid={Boolean(form.formState.errors.list_id) || undefined}><FieldLabel htmlFor="acl-list">IP-список</FieldLabel><Controller control={form.control} name="list_id" render={({ field }) => <Select value={field.value || null} onValueChange={(value) => field.onChange(value ?? '')} items={lists.items} disabled={!canWrite || pending || !lists.ready}><SelectTrigger id="acl-list" onBlur={field.onBlur} aria-invalid={Boolean(form.formState.errors.list_id) || undefined}><SelectValue placeholder="Выберите список" /></SelectTrigger><SelectContent><SelectGroup>{lists.items.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}</SelectGroup></SelectContent></Select>} /><FieldError errors={[form.formState.errors.list_id]} /></Field> : null}
|
||||
<Field data-invalid={Boolean(form.formState.errors.comment) || undefined}><FieldLabel htmlFor="acl-comment">Комментарий</FieldLabel><Input id="acl-comment" {...form.register('comment')} aria-invalid={Boolean(form.formState.errors.comment) || undefined} /><FieldError errors={[form.formState.errors.comment]} /></Field>
|
||||
</FieldGroup></fieldset>
|
||||
</FormSheet>
|
||||
<ConfirmDialog open={discardOpen} onOpenChange={setDiscardOpen} title="Закрыть без сохранения?" description="Несохранённые изменения правила будут сброшены." confirmLabel="Сбросить" onConfirm={() => { form.reset(initialValues); setDiscardOpen(false); onOpenChange(false) }} />
|
||||
</>
|
||||
}
|
||||
|
||||
const IMPORT_DEFAULTS: PortAclImportDraft = { from: 'list', listId: '', setId: '', action: 'open', protocol: 'tcp', ports: '22,80,443' }
|
||||
export function AgentPortImportForm({ open, onOpenChange, pending, canWrite, lists, sets, onSubmit }: BaseProps & { sets: AclCatalog; onSubmit: (values: PortAclImportDraft) => void }) {
|
||||
const form = useForm<PortAclImportDraft>({ resolver: zodResolver(portAclImportDraftSchema), defaultValues: IMPORT_DEFAULTS, mode: 'onBlur' })
|
||||
const [discardOpen, setDiscardOpen] = useState(false)
|
||||
useEffect(() => { if (!open && !pending) form.reset(IMPORT_DEFAULTS) }, [open, pending, form])
|
||||
const source = form.watch('from')
|
||||
const catalog = source === 'list' ? lists : sets
|
||||
const sourceField = source === 'list' ? 'listId' : 'setId'
|
||||
const close = (next: boolean) => { if (pending) return; if (!next && form.formState.isDirty) setDiscardOpen(true); else onOpenChange(next) }
|
||||
return <>
|
||||
<FormSheet open={open} onOpenChange={close} title="Импорт Port ACL" description="Порты и источники из выбранного списка или набора." form={form} onSubmit={onSubmit} footer={<><Button type="button" variant="outline" disabled={pending} onClick={() => close(false)}>Отмена</Button><LoadingButton type="submit" isLoading={pending} disabled={!canWrite || !catalog.ready}>Импортировать</LoadingButton></>}>
|
||||
<fieldset disabled={!canWrite || pending} className="contents"><FieldGroup className="gap-3 py-2">
|
||||
<QueryState isLoading={catalog.loading} isError={Boolean(catalog.error)} error={catalog.error} onRetry={catalog.retry}>{null}</QueryState>
|
||||
{([
|
||||
['from', 'Источник', [{ value: 'list', label: 'IP-список' }, { value: 'set', label: 'Набор правил' }]],
|
||||
['action', 'Действие', [{ value: 'open', label: 'Открыть' }, { value: 'close', label: 'Закрыть' }]],
|
||||
['protocol', 'Протокол', [{ value: 'tcp', label: 'TCP' }, { value: 'udp', label: 'UDP' }, { value: 'both', label: 'TCP и UDP' }]],
|
||||
] as const).map(([name, label, items]) => <Field key={name}><FieldLabel htmlFor={`acl-import-${name}`}>{label}</FieldLabel><Controller control={form.control} name={name} render={({ field }) => <Select disabled={!canWrite || pending} value={field.value} onValueChange={field.onChange} items={[...items]}><SelectTrigger id={`acl-import-${name}`} onBlur={field.onBlur}><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{items.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}</SelectGroup></SelectContent></Select>} /></Field>)}
|
||||
<Field data-invalid={Boolean(form.formState.errors[sourceField]) || undefined}><FieldLabel htmlFor="acl-import-source">{source === 'list' ? 'IP-список' : 'Набор правил'}</FieldLabel><Controller control={form.control} name={sourceField} render={({ field }) => <Select value={field.value || null} onValueChange={(value) => field.onChange(value ?? '')} items={catalog.items} disabled={!canWrite || pending || !catalog.ready}><SelectTrigger id="acl-import-source" onBlur={field.onBlur} aria-invalid={Boolean(form.formState.errors[sourceField]) || undefined}><SelectValue placeholder="Выберите источник" /></SelectTrigger><SelectContent><SelectGroup>{catalog.items.map((item) => <SelectItem key={item.value} value={item.value}>{item.label}</SelectItem>)}</SelectGroup></SelectContent></Select>} /><FieldError errors={[form.formState.errors[sourceField]]} /></Field>
|
||||
<Field data-invalid={Boolean(form.formState.errors.ports) || undefined}><FieldLabel htmlFor="acl-import-ports">Порты · до 50 портов или диапазонов</FieldLabel><Input id="acl-import-ports" {...form.register('ports')} placeholder="22,80,443 или 8000-8010" aria-invalid={Boolean(form.formState.errors.ports) || undefined} /><FieldError errors={[form.formState.errors.ports]} /></Field>
|
||||
</FieldGroup></fieldset>
|
||||
</FormSheet>
|
||||
<ConfirmDialog open={discardOpen} onOpenChange={setDiscardOpen} title="Закрыть без импорта?" description="Настройки несохранённого импорта будут сброшены." confirmLabel="Сбросить" onConfirm={() => { setDiscardOpen(false); onOpenChange(false) }} />
|
||||
</>
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
@@ -19,6 +20,10 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { parsePortRanges, portAclDraftSchema, portAclImportDraftSchema, type PortAclDraft, type PortAclImportDraft } from './port-acl-validation'
|
||||
import { AgentPortRuleForm, AgentPortImportForm } from './agent-port-acl-forms'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import {
|
||||
agentHostFirewallQueryOptions,
|
||||
agentPortRulesQueryOptions,
|
||||
@@ -32,8 +37,6 @@ import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -41,15 +44,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||
|
||||
/**
|
||||
* Desired Port ACL for Linux agent + system overlay from host snapshot.
|
||||
@@ -65,17 +59,7 @@ type AgentPortAclProps = {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
action: 'open' | 'close'
|
||||
protocol: 'tcp' | 'udp' | 'both'
|
||||
port_start: string
|
||||
port_end: string
|
||||
src_kind: 'all' | 'cidr' | 'list'
|
||||
src_cidr: string
|
||||
list_id: string
|
||||
comment: string
|
||||
enabled: boolean
|
||||
}
|
||||
type FormState = PortAclDraft
|
||||
|
||||
type PortAclOwner = 'evofw' | 'system'
|
||||
|
||||
@@ -132,7 +116,7 @@ function normalizeProto(value?: string): 'tcp' | 'udp' | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function parsePortRanges(value?: string): Array<{ start: number; end: number }> {
|
||||
function parseObservedPortRanges(value?: string): Array<{ start: number; end: number }> {
|
||||
if (!value) return []
|
||||
const t = value.replace(/[{}]/g, ' ').trim()
|
||||
const out: Array<{ start: number; end: number }> = []
|
||||
@@ -277,7 +261,7 @@ function collectSystemRows(
|
||||
}
|
||||
}
|
||||
const proto = normalizeProto(r.protocol)
|
||||
const ranges = parsePortRanges(r.dport)
|
||||
const ranges = parseObservedPortRanges(r.dport)
|
||||
if (!proto || !ranges.length) {
|
||||
const parsed = parseUfwLikeRaw(r.raw)
|
||||
for (const p of parsed) {
|
||||
@@ -316,6 +300,7 @@ function collectSystemRows(
|
||||
|
||||
export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
const qc = useQueryClient()
|
||||
const canWrite = useCan()('fw:agents:write')
|
||||
const q = useQuery(agentPortRulesQueryOptions(agentId))
|
||||
const hostQ = useQuery(agentHostFirewallQueryOptions(agentId))
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
@@ -323,16 +308,9 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AgentPortRuleDto | null>(null)
|
||||
const [overriding, setOverriding] = useState(false)
|
||||
const [form, setForm] = useState<FormState>(emptyForm)
|
||||
const [initialDraft, setInitialDraft] = useState<FormState>(emptyForm)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [impFrom, setImpFrom] = useState<'list' | 'set'>('list')
|
||||
const [impListId, setImpListId] = useState('')
|
||||
const [impSetId, setImpSetId] = useState('')
|
||||
const [impAction, setImpAction] = useState<'open' | 'close'>('open')
|
||||
const [impProtocol, setImpProtocol] = useState<'tcp' | 'udp' | 'both'>('tcp')
|
||||
const [impPorts, setImpPorts] = useState('22,80,443')
|
||||
const [ownerFilter, setOwnerFilter] = useState<'all' | PortAclOwner>('all')
|
||||
|
||||
const lists = listsQ.data?.items ?? []
|
||||
const sets = setsQ.data?.items ?? []
|
||||
const listSelectItems = useMemo(
|
||||
@@ -344,28 +322,12 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
[sets],
|
||||
)
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'port-rules'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'host-firewall'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const portStart = Number(form.port_start)
|
||||
const portEnd = form.port_end ? Number(form.port_end) : portStart
|
||||
const body = {
|
||||
action: form.action,
|
||||
protocol: form.protocol,
|
||||
port_start: portStart,
|
||||
port_end: portEnd,
|
||||
src_kind: form.src_kind,
|
||||
src_cidr: form.src_kind === 'cidr' ? form.src_cidr : undefined,
|
||||
list_id: form.src_kind === 'list' ? form.list_id : undefined,
|
||||
enabled: form.enabled,
|
||||
comment: form.comment || undefined,
|
||||
}
|
||||
mutationFn: async (values: PortAclDraft) => {
|
||||
if (!canWrite) throw new Error('Недостаточно прав для изменения Port ACL')
|
||||
const parsed = portAclDraftSchema.parse(values)
|
||||
const body = { ...parsed, port_end: parsed.port_end ?? parsed.port_start }
|
||||
if (editing) {
|
||||
return apiFetch(`/api/v1/agents/${agentId}/port-rules/${editing.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -388,8 +350,8 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
setFormOpen(false)
|
||||
setEditing(null)
|
||||
setOverriding(false)
|
||||
setForm(emptyForm())
|
||||
invalidate()
|
||||
setInitialDraft(emptyForm())
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -402,7 +364,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Состояние обновлено')
|
||||
invalidate()
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -414,35 +376,24 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Правило удалено')
|
||||
invalidate()
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const doImport = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ports = impPorts
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => {
|
||||
if (s.includes('-')) {
|
||||
const [a, b] = s.split('-')
|
||||
return {
|
||||
port_start: Number(a),
|
||||
port_end: Number(b),
|
||||
}
|
||||
}
|
||||
return { port_start: Number(s) }
|
||||
})
|
||||
mutationFn: async (input: PortAclImportDraft) => {
|
||||
if (!canWrite) throw new Error('Недостаточно прав для импорта Port ACL')
|
||||
const values = portAclImportDraftSchema.parse(input)
|
||||
const ports = parsePortRanges(values.ports)
|
||||
return apiFetch(`/api/v1/agents/${agentId}/port-rules/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
from: impFrom,
|
||||
list_id: impFrom === 'list' ? impListId : undefined,
|
||||
set_id: impFrom === 'set' ? impSetId : undefined,
|
||||
action: impAction,
|
||||
protocol: impProtocol,
|
||||
from: values.from,
|
||||
list_id: values.from === 'list' ? values.listId : undefined,
|
||||
set_id: values.from === 'set' ? values.setId : undefined,
|
||||
action: values.action,
|
||||
protocol: values.protocol,
|
||||
ports,
|
||||
}),
|
||||
})
|
||||
@@ -450,7 +401,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
onSuccess: () => {
|
||||
toast.success('Импорт выполнен')
|
||||
setImportOpen(false)
|
||||
invalidate()
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -458,14 +409,14 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setOverriding(false)
|
||||
setForm(emptyForm())
|
||||
setInitialDraft(emptyForm())
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: AgentPortRuleDto) => {
|
||||
setEditing(row)
|
||||
setOverriding(false)
|
||||
setForm({
|
||||
setInitialDraft({
|
||||
action: row.action,
|
||||
protocol: row.protocol,
|
||||
port_start: String(row.port_start),
|
||||
@@ -482,7 +433,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
const openOverride = (row: PortAclRow) => {
|
||||
setEditing(null)
|
||||
setOverriding(true)
|
||||
setForm({
|
||||
setInitialDraft({
|
||||
...emptyForm(),
|
||||
action: 'open',
|
||||
protocol: row.protocol,
|
||||
@@ -604,6 +555,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
cell: ({ row }) =>
|
||||
row.original.owner === 'evofw' ? (
|
||||
<Switch
|
||||
disabled={!canWrite || toggle.isPending || remove.isPending}
|
||||
checked={row.original.enabled}
|
||||
onCheckedChange={() => {
|
||||
const src = evofwRules.find((r) => r.id === row.original.id)
|
||||
@@ -631,6 +583,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
className="size-7"
|
||||
onClick={() => openOverride(row.original)}
|
||||
aria-label="Переопределить"
|
||||
disabled={!canWrite || save.isPending}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
@@ -646,6 +599,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
className="size-7"
|
||||
onClick={() => src && openEdit(src)}
|
||||
aria-label="Edit"
|
||||
disabled={!canWrite || save.isPending}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
</Button>
|
||||
@@ -656,6 +610,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
className="size-7"
|
||||
onClick={() => remove.mutate(row.original.id)}
|
||||
aria-label="Delete"
|
||||
disabled={!canWrite || remove.isPending || toggle.isPending}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
@@ -664,7 +619,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
},
|
||||
},
|
||||
],
|
||||
[toggle, remove, evofwRules],
|
||||
[toggle, remove, evofwRules, canWrite, save.isPending],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -699,11 +654,12 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canWrite || !q.isSuccess}
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
Импорт
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={openCreate}>
|
||||
<Button type="button" size="sm" disabled={!canWrite || !q.isSuccess} onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить
|
||||
</Button>
|
||||
@@ -711,6 +667,7 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3 p-4">
|
||||
<QueryState isLoading={isLoading} isError={q.isError || hostQ.isError} error={q.error ?? hostQ.error} onRetry={() => { void q.refetch(); void hostQ.refetch() }}>
|
||||
<Select
|
||||
items={[...OWNER_ITEMS]}
|
||||
value={ownerFilter}
|
||||
@@ -756,316 +713,17 @@ export function AgentPortAcl({ agentId }: AgentPortAclProps) {
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</QueryState>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Sheet
|
||||
open={formOpen}
|
||||
onOpenChange={(open) => {
|
||||
setFormOpen(open)
|
||||
if (!open) {
|
||||
setEditing(null)
|
||||
setOverriding(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>{sheetTitle}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{overriding
|
||||
? 'Open + список: порт станет whitelist, остальные внешние src — drop.'
|
||||
: 'Preview: https://reui.io/preview/base/sheet-8'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="flex flex-col gap-3 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
value={form.action}
|
||||
onValueChange={(v) =>
|
||||
v && setForm((f) => ({ ...f, action: v as 'open' | 'close' }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">open</SelectItem>
|
||||
<SelectItem value="close">close</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Protocol</FieldLabel>
|
||||
<Select
|
||||
value={form.protocol}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
protocol: v as FormState['protocol'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">tcp</SelectItem>
|
||||
<SelectItem value="udp">udp</SelectItem>
|
||||
<SelectItem value="both">both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field>
|
||||
<FieldLabel>Port start</FieldLabel>
|
||||
<Input
|
||||
value={form.port_start}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, port_start: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="443"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Port end</FieldLabel>
|
||||
<Input
|
||||
value={form.port_end}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, port_end: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="optional"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>Source</FieldLabel>
|
||||
<Select
|
||||
value={form.src_kind}
|
||||
onValueChange={(v) =>
|
||||
v &&
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
src_kind: v as FormState['src_kind'],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">all (0.0.0.0/0)</SelectItem>
|
||||
<SelectItem value="cidr">CIDR / IP</SelectItem>
|
||||
<SelectItem value="list">IP list</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{form.src_kind === 'cidr' ? (
|
||||
<Field>
|
||||
<FieldLabel>CIDR</FieldLabel>
|
||||
<Input
|
||||
value={form.src_cidr}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, src_cidr: e.target.value }))
|
||||
}
|
||||
placeholder="10.0.0.0/8"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{form.src_kind === 'list' ? (
|
||||
<Field>
|
||||
<FieldLabel>List</FieldLabel>
|
||||
<Select
|
||||
items={listSelectItems}
|
||||
value={form.list_id || null}
|
||||
onValueChange={(v) =>
|
||||
v && setForm((f) => ({ ...f, list_id: v }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Выберите список" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{listSelectItems.map((l) => (
|
||||
<SelectItem key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field>
|
||||
<FieldLabel>Comment</FieldLabel>
|
||||
<Input
|
||||
value={form.comment}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, comment: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="shrink-0 flex-row gap-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setFormOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
save.isPending ||
|
||||
!form.port_start ||
|
||||
(form.src_kind === 'list' && !form.list_id)
|
||||
}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Sheet open={importOpen} onOpenChange={setImportOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Импорт Port ACL</SheetTitle>
|
||||
<SheetDescription>
|
||||
Источник CIDR/list из набора или IP-list + порты.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="flex flex-col gap-3 py-2 pb-4">
|
||||
<Field>
|
||||
<FieldLabel>From</FieldLabel>
|
||||
<Select
|
||||
value={impFrom}
|
||||
onValueChange={(v) => v && setImpFrom(v as 'list' | 'set')}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="list">IP list</SelectItem>
|
||||
<SelectItem value="set">Policy set</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{impFrom === 'list' ? (
|
||||
<Field>
|
||||
<FieldLabel>List</FieldLabel>
|
||||
<Select
|
||||
items={listSelectItems}
|
||||
value={impListId || null}
|
||||
onValueChange={(v) => v && setImpListId(v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Список" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{listSelectItems.map((l) => (
|
||||
<SelectItem key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel>Set</FieldLabel>
|
||||
<Select
|
||||
items={setSelectItems}
|
||||
value={impSetId || null}
|
||||
onValueChange={(v) => v && setImpSetId(v)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Набор" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{setSelectItems.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
value={impAction}
|
||||
onValueChange={(v) =>
|
||||
v && setImpAction(v as 'open' | 'close')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">open</SelectItem>
|
||||
<SelectItem value="close">close</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Protocol</FieldLabel>
|
||||
<Select
|
||||
value={impProtocol}
|
||||
onValueChange={(v) =>
|
||||
v && setImpProtocol(v as typeof impProtocol)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tcp">tcp</SelectItem>
|
||||
<SelectItem value="udp">udp</SelectItem>
|
||||
<SelectItem value="both">both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Ports</FieldLabel>
|
||||
<Input
|
||||
value={impPorts}
|
||||
onChange={(e) => setImpPorts(e.target.value)}
|
||||
placeholder="22,80,443 или 8000-8010"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<SheetFooter className="shrink-0 flex-row gap-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setImportOpen(false)}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
doImport.isPending ||
|
||||
(impFrom === 'list' ? !impListId : !impSetId)
|
||||
}
|
||||
onClick={() => doImport.mutate()}
|
||||
>
|
||||
Импортировать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AgentPortRuleForm open={formOpen} onOpenChange={setFormOpen} title={sheetTitle}
|
||||
description={overriding ? 'Разрешить порт только источникам списка; остальные внешние источники будут заблокированы после sync.' : 'Входящие порты и разрешённые источники. Изменения применятся после sync агента.'}
|
||||
initialValues={initialDraft} canWrite={canWrite} pending={save.isPending} onSubmit={(values) => save.mutate(values)}
|
||||
lists={{ items: listSelectItems, loading: listsQ.isPending, ready: listsQ.isSuccess, error: listsQ.error, retry: () => void listsQ.refetch() }} />
|
||||
<AgentPortImportForm open={importOpen} onOpenChange={setImportOpen} canWrite={canWrite} pending={doImport.isPending} onSubmit={(values) => doImport.mutate(values)}
|
||||
lists={{ items: listSelectItems, loading: listsQ.isPending, ready: listsQ.isSuccess, error: listsQ.error, retry: () => void listsQ.refetch() }}
|
||||
sets={{ items: setSelectItems, loading: setsQ.isPending, ready: setsQ.isSuccess, error: setsQ.error, retry: () => void setsQ.refetch() }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { useState } from 'react'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { parseListEntry } from '@evofw/shared'
|
||||
import { createOverrideDraftSchema, overrideBody, OVERRIDE_DEFAULTS, type OverrideDraft } from './override-validation'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { formatStampDateTime } from '@/lib/format'
|
||||
import { Textarea } from '@evofw/ui/components/textarea'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
agentOverridesQueryOptions,
|
||||
@@ -14,13 +18,14 @@ import { apiFetch } from '@/lib/api'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectGroup,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
@@ -50,23 +55,7 @@ const OVERRIDE_ACTION_ITEMS = [
|
||||
{ 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>
|
||||
const overrideSchema = createOverrideDraftSchema()
|
||||
|
||||
export function AgentOverrideSheet({
|
||||
agentId,
|
||||
@@ -74,27 +63,27 @@ export function AgentOverrideSheet({
|
||||
onOpenChange,
|
||||
}: OverrideSheetProps) {
|
||||
const qc = useQueryClient()
|
||||
const canWrite = useCan()('fw:agents:write')
|
||||
const overridesQ = useQuery({
|
||||
...agentOverridesQueryOptions(agentId),
|
||||
enabled: open,
|
||||
})
|
||||
const form = useForm<OverrideValues>({
|
||||
const form = useForm<OverrideDraft>({
|
||||
resolver: zodResolver(overrideSchema),
|
||||
defaultValues: { cidr: '', action: 'deny' },
|
||||
defaultValues: OVERRIDE_DEFAULTS,
|
||||
mode: 'onBlur',
|
||||
})
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: (values: OverrideValues) =>
|
||||
mutationFn: (values: OverrideDraft) =>
|
||||
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(values),
|
||||
body: JSON.stringify(overrideBody(overrideSchema.parse(values))),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Override добавлен — подхватится на следующей итерации sync')
|
||||
form.reset({ cidr: '', action: 'deny' })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
form.reset(OVERRIDE_DEFAULTS)
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -106,8 +95,7 @@ export function AgentOverrideSheet({
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Override удалён')
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -115,22 +103,23 @@ export function AgentOverrideSheet({
|
||||
const items = overridesQ.data?.items ?? []
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<Sheet open={open} onOpenChange={(next) => { if (!add.isPending) onOpenChange(next) }}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>IP override</SheetTitle>
|
||||
<SheetDescription>
|
||||
Мгновенные allow/deny поверх политики. Обновятся на агенте на
|
||||
следующей итерации sync (~1 мин).
|
||||
Allow/deny применяются на следующем sync. Allow не обходит deny-наборы.
|
||||
Окончание срока меняет желаемую политику; офлайн-агент получит изменение при следующей связи.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="flex-1 px-4">
|
||||
<div className="flex flex-col gap-4 py-2 pb-4">
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => add.mutateAsync(values))}
|
||||
className="grid gap-3"
|
||||
onSubmit={form.handleSubmit((values) => add.mutate(values))}
|
||||
>
|
||||
<fieldset disabled={!canWrite || add.isPending} className="contents">
|
||||
<FieldGroup className="gap-3">
|
||||
<Field
|
||||
data-invalid={Boolean(form.formState.errors.cidr) || undefined}
|
||||
>
|
||||
@@ -170,6 +159,20 @@ export function AgentOverrideSheet({
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
<Field data-invalid={Boolean(form.formState.errors.reason) || undefined}>
|
||||
<FieldLabel htmlFor="ov-reason">Причина (необязательно)</FieldLabel>
|
||||
<Textarea id="ov-reason" {...form.register('reason')} aria-invalid={Boolean(form.formState.errors.reason) || undefined} />
|
||||
<FieldError errors={[form.formState.errors.reason]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ov-expiry-mode">Срок действия</FieldLabel>
|
||||
<Controller control={form.control} name="expiryMode" render={({ field }) => <Select value={field.value} onValueChange={field.onChange} items={[{ value: 'permanent', label: 'Без срока' }, { value: 'ttl', label: 'На время' }, { value: 'until', label: 'До даты' }]}>
|
||||
<SelectTrigger id="ov-expiry-mode"><SelectValue /></SelectTrigger><SelectContent><SelectGroup><SelectItem value="permanent">Без срока</SelectItem><SelectItem value="ttl">На время</SelectItem><SelectItem value="until">До даты</SelectItem></SelectGroup></SelectContent>
|
||||
</Select>} />
|
||||
</Field>
|
||||
{form.watch('expiryMode') === 'ttl' ? <Field data-invalid={Boolean(form.formState.errors.ttlMinutes) || undefined}><FieldLabel htmlFor="ov-ttl">Минут (1–43200)</FieldLabel><Input id="ov-ttl" inputMode="numeric" {...form.register('ttlMinutes')} aria-invalid={Boolean(form.formState.errors.ttlMinutes) || undefined} /><FieldError errors={[form.formState.errors.ttlMinutes]} /></Field> : null}
|
||||
{form.watch('expiryMode') === 'until' ? <Field data-invalid={Boolean(form.formState.errors.expiresLocal) || undefined}><FieldLabel htmlFor="ov-until">До даты · часовой пояс браузера</FieldLabel><Input id="ov-until" type="datetime-local" {...form.register('expiresLocal')} aria-invalid={Boolean(form.formState.errors.expiresLocal) || undefined} /><FieldError errors={[form.formState.errors.expiresLocal]} /></Field> : null}
|
||||
{form.formState.isDirty ? <Badge variant="warning-light" size="sm">Есть несохранённый override</Badge> : null}
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
size="sm"
|
||||
@@ -178,15 +181,18 @@ export function AgentOverrideSheet({
|
||||
>
|
||||
Добавить
|
||||
</LoadingButton>
|
||||
</FieldGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">
|
||||
Активные{' '}
|
||||
Overrides{' '}
|
||||
<Badge variant="secondary" size="sm">
|
||||
{items.length}
|
||||
{items.filter((item) => !item.expired).length} активных
|
||||
</Badge>
|
||||
</p>
|
||||
<QueryState hasData={overridesQ.data !== undefined} isLoading={overridesQ.isPending} isError={overridesQ.isError} error={overridesQ.error} onRetry={() => void overridesQ.refetch()}>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Пока нет overrides</p>
|
||||
) : (
|
||||
@@ -196,8 +202,11 @@ export function AgentOverrideSheet({
|
||||
key={o.id}
|
||||
className="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-sm">
|
||||
{o.cidr}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate font-mono text-sm">{o.cidr}</span>
|
||||
{o.reason ? <span className="text-muted-foreground text-xs">{o.reason}</span> : null}
|
||||
<span className="text-muted-foreground text-xs">Автор: {o.created_by_name ?? 'неизвестен'} · {o.expires_at ? `до ${formatStampDateTime(o.expires_at)}` : 'без срока'}</span>
|
||||
{o.expired ? <><Badge variant="secondary" size="sm">Истёк</Badge><span className="text-muted-foreground text-xs">Исключён из желаемой политики</span></> : null}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
@@ -214,7 +223,7 @@ export function AgentOverrideSheet({
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить"
|
||||
disabled={remove.isPending}
|
||||
disabled={!canWrite || remove.isPending}
|
||||
onClick={() => remove.mutate(o.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
@@ -223,12 +232,13 @@ export function AgentOverrideSheet({
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</QueryState>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button type="button" variant="outline" disabled={add.isPending} onClick={() => onOpenChange(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
@@ -265,8 +275,7 @@ export function AgentCloneSetsSheet({
|
||||
toast.success('Наборы скопированы')
|
||||
setCloneFrom(null)
|
||||
onOpenChange(false)
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
@@ -1,95 +1,24 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { recentStatsQueryOptions } from '@/queries'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@evofw/ui/components/chart'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
/**
|
||||
* Fleet apply throughput — live time-series from /api/v1/stats/recent.
|
||||
* DNA: https://reui.io/preview/base/solution-agents-1
|
||||
*/
|
||||
|
||||
const chartConfig = {
|
||||
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
|
||||
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TrafficTimeseriesChart } from '@/components/stats/traffic-timeseries-chart'
|
||||
import { timeseriesQueryOptions } from '@/queries/timeseries'
|
||||
import { useStatsWindow } from '@/hooks/use-stats-window'
|
||||
|
||||
/** Packet deltas from normalized v2 telemetry, grouped by UTC minute. */
|
||||
export function AgentsFleetChart() {
|
||||
const stats = useQuery(recentStatsQueryOptions())
|
||||
|
||||
const series = useMemo(() => {
|
||||
const items = [...(stats.data?.items ?? [])].reverse().slice(-40)
|
||||
return items.map((s) => ({
|
||||
t: s.recorded_at.slice(11, 19),
|
||||
dropped: s.packets_dropped,
|
||||
accepted: s.packets_accepted,
|
||||
}))
|
||||
}, [stats.data?.items])
|
||||
|
||||
const request = useStatsWindow('1h', [])
|
||||
const stats = useQuery(timeseriesQueryOptions(request))
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Throughput</FrameTitle>
|
||||
<FrameDescription>
|
||||
Dropped / accepted по последним apply-снимкам флота
|
||||
</FrameDescription>
|
||||
<FrameTitle>Пакеты за минуту</FrameTitle>
|
||||
<FrameDescription>Последний час · телеметрия v2 · время показано в часовом поясе браузера</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{stats.isLoading ? (
|
||||
<Skeleton className="aspect-[3/1] w-full rounded-lg" />
|
||||
) : series.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Пока нет статистики apply — появится после sync агентов.
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-[3/1] w-full">
|
||||
<AreaChart data={series} margin={{ left: 0, right: 8, top: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="t"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={11}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={11}
|
||||
width={36}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="dropped"
|
||||
stroke="var(--color-dropped)"
|
||||
fill="var(--color-dropped)"
|
||||
fillOpacity={0.2}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="accepted"
|
||||
stroke="var(--color-accepted)"
|
||||
fill="var(--color-accepted)"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
<QueryState hasData={stats.data !== undefined} isLoading={stats.isPending} isError={stats.isError} error={stats.error} onRetry={() => void stats.refetch()}>
|
||||
<TrafficTimeseriesChart items={stats.data?.items ?? []} compact />
|
||||
</QueryState>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod'
|
||||
import { explainQuerySchema, type ExplainQuery } from '@evofw/shared'
|
||||
|
||||
export const EXPLAIN_DEFAULTS = { ip: '', port: '', protocol: 'none' as const, chain: 'input' as const, dnat: false, connection_state: 'new' as const }
|
||||
export const explainDraftSchema = z.object({ ip: z.string(), port: z.string(), protocol: z.enum(['none', 'tcp', 'udp']), chain: z.enum(['input', 'forward']), dnat: z.boolean(), connection_state: z.enum(['new', 'established', 'related']) }).superRefine((draft, ctx) => {
|
||||
if (draft.port && !/^\d+$/.test(draft.port)) ctx.addIssue({ code: 'custom', path: ['port'], message: 'Укажите целый порт от 1 до 65535' })
|
||||
const parsed = explainQuerySchema.safeParse(explainDraftInput(draft))
|
||||
if (!parsed.success) for (const issue of parsed.error.issues) ctx.addIssue({ code: 'custom', path: issue.path.length ? issue.path : ['port'], message: issue.path.length ? issue.message : 'Порт и протокол указываются вместе' })
|
||||
})
|
||||
export type ExplainDraft = z.infer<typeof explainDraftSchema>
|
||||
export function explainDraftInput(draft: ExplainDraft) {
|
||||
return { ...draft, ip: draft.ip.trim(), port: draft.port === '' ? undefined : draft.port, protocol: draft.protocol === 'none' ? undefined : draft.protocol, dnat: draft.dnat ? 'true' : 'false' }
|
||||
}
|
||||
export function explainSearch(query: ExplainQuery) {
|
||||
const params = new URLSearchParams({ ip: query.ip, chain: query.chain, dnat: String(query.dnat), connection_state: query.connection_state })
|
||||
if (query.port !== undefined && query.protocol) { params.set('port', String(query.port)); params.set('protocol', query.protocol) }
|
||||
return params.toString()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ExplainResponse } from '@evofw/shared'
|
||||
import { explainDraftSchema, EXPLAIN_DEFAULTS } from './explain-validation'
|
||||
import { createOverrideDraftSchema, overrideBody, OVERRIDE_DEFAULTS } from './override-validation'
|
||||
import { AgentExplainResult } from './agent-explain-sheet'
|
||||
import { portAclImportDraftSchema } from './port-acl-validation'
|
||||
|
||||
describe('F1 validation and unknown semantics', () => {
|
||||
it('requires strict IP and paired port/protocol', () => {
|
||||
expect(explainDraftSchema.safeParse({ ...EXPLAIN_DEFAULTS, ip: '300.0.0.1' }).success).toBe(false)
|
||||
expect(explainDraftSchema.safeParse({ ...EXPLAIN_DEFAULTS, ip: '203.0.113.1', port: '443' }).success).toBe(false)
|
||||
expect(explainDraftSchema.safeParse({ ...EXPLAIN_DEFAULTS, ip: '203.0.113.1', port: '1e3', protocol: 'tcp' }).success).toBe(false)
|
||||
expect(explainDraftSchema.safeParse({ ...EXPLAIN_DEFAULTS, ip: '2001:db8::1', port: '443', protocol: 'tcp', chain: 'forward', dnat: true }).success).toBe(true)
|
||||
})
|
||||
it('bounds temporary overrides and emits only the selected expiry mechanism', () => {
|
||||
const now = Date.parse('2026-10-24T00:00:00Z')
|
||||
const schema = createOverrideDraftSchema(() => now)
|
||||
const draft = { ...OVERRIDE_DEFAULTS, cidr: '10.0.0.1', expiryMode: 'ttl' as const, ttlMinutes: '15', reason: ' Incident ' }
|
||||
expect(schema.safeParse(draft).success).toBe(true)
|
||||
expect(overrideBody(draft)).toMatchObject({ ttl_seconds: 900, reason: 'Incident' })
|
||||
expect(overrideBody(draft)).not.toHaveProperty('expires_at')
|
||||
for (const ttlMinutes of ['0', '43201', '1e2', '1.5']) expect(schema.safeParse({ ...draft, ttlMinutes }).success).toBe(false)
|
||||
expect(schema.safeParse({ ...draft, expiryMode: 'until', expiresLocal: '2026-10-23T23:59:00Z' }).success).toBe(false)
|
||||
expect(schema.safeParse({ ...draft, expiryMode: 'until', expiresLocal: '2026-12-24T00:00:00Z' }).success).toBe(false)
|
||||
const absolute = overrideBody({ ...draft, expiryMode: 'until', expiresLocal: '2026-10-24T01:00:00Z' })
|
||||
expect(absolute).toHaveProperty('expires_at', '2026-10-24T01:00:00.000Z')
|
||||
expect(absolute).not.toHaveProperty('ttl_seconds')
|
||||
expect(schema.safeParse({ ...draft, reason: 'x'.repeat(1025) }).success).toBe(false)
|
||||
})
|
||||
it('retains unknown/external desired decisions separately from applied confirmation', () => {
|
||||
const fixture: ExplainResponse = { agent_id: 'fixture', query: { ip: '203.0.113.1', port: null, protocol: null, chain: 'forward', dnat: false, connection_state: 'new' }, desired: { revision: 8, hash: 'desired', decision: null, stage: 'external', match: null }, applied: { revision: null, hash: null, same_as_desired: null, status: 'unsupported' }, matches: [], matches_total: 0, truncated: false, limits: ['desired_policy_not_kernel_observation', 'external_firewall_may_override_accept'], external_firewall: { observed_at: null, rule_count: 0, complete: false } }
|
||||
const html = renderToStaticMarkup(<AgentExplainResult result={fixture} />)
|
||||
expect(html).toContain('Решение не определено')
|
||||
expect(html).toContain('Совпадение неизвестно')
|
||||
expect(html).toContain('Снимок неполный')
|
||||
expect(html).not.toContain('Разрешить правилами EvoFirewall')
|
||||
})
|
||||
it('validates import source and strict ports through the shared form schema', () => {
|
||||
const draft = { from: 'list', listId: '', setId: '', action: 'open', protocol: 'tcp', ports: '80-' }
|
||||
const invalid = portAclImportDraftSchema.safeParse(draft)
|
||||
expect(invalid.success).toBe(false)
|
||||
if (!invalid.success) expect(invalid.error.issues.map((issue) => issue.path[0])).toEqual(['listId', 'ports'])
|
||||
expect(portAclImportDraftSchema.safeParse({ ...draft, listId: 'fixture-list', ports: '22,8000-8010' }).success).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
import { createOverrideBodySchema } from '@evofw/shared'
|
||||
|
||||
export const OVERRIDE_DEFAULTS = { cidr: '', action: 'deny' as const, reason: '', expiryMode: 'permanent' as const, ttlMinutes: '60', expiresLocal: '' }
|
||||
const draftSchema = z.object({ cidr: z.string(), action: z.enum(['allow', 'deny']), reason: z.string(), expiryMode: z.enum(['permanent', 'ttl', 'until']), ttlMinutes: z.string(), expiresLocal: z.string() })
|
||||
export type OverrideDraft = z.infer<typeof draftSchema>
|
||||
|
||||
export function overrideBody(draft: OverrideDraft) {
|
||||
return {
|
||||
cidr: draft.cidr.trim(), action: draft.action, reason: draft.reason.trim() || undefined,
|
||||
...(draft.expiryMode === 'ttl' ? { ttl_seconds: Number(draft.ttlMinutes) * 60 } : {}),
|
||||
...(draft.expiryMode === 'until' ? { expires_at: Number.isFinite(Date.parse(draft.expiresLocal)) ? new Date(draft.expiresLocal).toISOString() : draft.expiresLocal } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function createOverrideDraftSchema(clock: () => number = () => new Date().getTime()) {
|
||||
return draftSchema.superRefine((draft, ctx) => {
|
||||
const parsed = createOverrideBodySchema.safeParse(overrideBody(draft))
|
||||
if (!parsed.success) for (const issue of parsed.error.issues) ctx.addIssue({ code: 'custom', path: [issue.path[0] === 'ttl_seconds' ? 'ttlMinutes' : issue.path[0] === 'expires_at' ? 'expiresLocal' : String(issue.path[0] ?? 'cidr')], message: issue.message })
|
||||
if (draft.expiryMode === 'ttl' && !/^\d+$/.test(draft.ttlMinutes)) ctx.addIssue({ code: 'custom', path: ['ttlMinutes'], message: 'Укажите целое число минут от 1 до 43200' })
|
||||
if (draft.expiryMode === 'until') {
|
||||
const expires = Date.parse(draft.expiresLocal)
|
||||
const now = clock()
|
||||
if (!Number.isFinite(expires) || expires <= now || expires - now > 30 * 86_400_000) ctx.addIssue({ code: 'custom', path: ['expiresLocal'], message: 'Выберите будущую дату в пределах 30 дней' })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parsePortRanges, portAclDraftSchema } from './port-acl-validation'
|
||||
|
||||
const draft = { action: 'open', protocol: 'tcp', port_start: '443', port_end: '', src_kind: 'all', src_cidr: '', list_id: '', comment: '', enabled: true }
|
||||
|
||||
describe('Port ACL operator input', () => {
|
||||
it('rejects ambiguous numeric inputs and reversed single-rule ranges', () => {
|
||||
for (const port of ['0', '65536', '1e3', '1.5', 'NaN', '']) {
|
||||
expect(portAclDraftSchema.safeParse({ ...draft, port_start: port }).success).toBe(false)
|
||||
}
|
||||
expect(portAclDraftSchema.safeParse({ ...draft, port_start: '443', port_end: '80' }).success).toBe(false)
|
||||
})
|
||||
it('requires valid source input and emits canonical CIDRs', () => {
|
||||
expect(portAclDraftSchema.safeParse({ ...draft, src_kind: 'list' }).success).toBe(false)
|
||||
expect(portAclDraftSchema.safeParse({ ...draft, src_kind: 'cidr', src_cidr: '300.0.0.1/24' }).success).toBe(false)
|
||||
expect(portAclDraftSchema.parse({ ...draft, src_kind: 'cidr', src_cidr: '10.2.3.4/24' }).src_cidr).toBe('10.2.3.0/24')
|
||||
})
|
||||
it('accepts explicit import ranges and rejects malformed or excessive requests', () => {
|
||||
expect(parsePortRanges('22, 80 8000-8010')).toEqual([{ port_start: 22 }, { port_start: 80 }, { port_start: 8000, port_end: 8010 }])
|
||||
for (const value of ['', '80-', '-80', '80-70', '0', '65536', '1e3', '1-2-3', Array(51).fill('80').join(',')]) {
|
||||
expect(() => parsePortRanges(value)).toThrow()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod'
|
||||
import { createAgentPortRuleBodySchema } from '@evofw/shared'
|
||||
|
||||
const portInput = z.string().trim()
|
||||
.regex(/^\d+$/, 'Укажите целый порт от 1 до 65535')
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().min(1, 'Минимальный порт — 1').max(65535, 'Максимальный порт — 65535'))
|
||||
|
||||
export const portAclDraftSchema = z.object({
|
||||
action: z.enum(['open', 'close']),
|
||||
protocol: z.enum(['tcp', 'udp', 'both']),
|
||||
port_start: portInput,
|
||||
port_end: z.union([z.literal('').transform(() => undefined), portInput]),
|
||||
src_kind: z.enum(['all', 'cidr', 'list']),
|
||||
src_cidr: z.string(),
|
||||
list_id: z.string(),
|
||||
enabled: z.boolean(),
|
||||
comment: z.string(),
|
||||
}).transform((draft) => ({
|
||||
...draft,
|
||||
src_cidr: draft.src_kind === 'cidr' ? draft.src_cidr.trim() : undefined,
|
||||
list_id: draft.src_kind === 'list' ? draft.list_id : undefined,
|
||||
comment: draft.comment.trim() || undefined,
|
||||
})).transform((draft, ctx) => {
|
||||
const parsed = createAgentPortRuleBodySchema.safeParse(draft)
|
||||
if (!parsed.success) {
|
||||
for (const issue of parsed.error.issues) ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message })
|
||||
return z.NEVER
|
||||
}
|
||||
const { priority: _priority, ...body } = parsed.data
|
||||
return body
|
||||
})
|
||||
|
||||
/** Strict syntax prevents partial ranges, exponent notation and NaN from
|
||||
* silently becoming a different firewall request. */
|
||||
export function parsePortRanges(value: string): { port_start: number; port_end?: number }[] {
|
||||
const tokens = value.trim().split(/[,\s]+/).filter(Boolean)
|
||||
if (!tokens.length || tokens.length > 50) throw new Error('Укажите от 1 до 50 портов или диапазонов')
|
||||
return tokens.map((token) => {
|
||||
if (!/^\d+(?:-\d+)?$/.test(token)) throw new Error(`Некорректный порт или диапазон: ${token}`)
|
||||
const [start, end] = token.split('-')
|
||||
const port_start = Number(start)
|
||||
const port_end = end === undefined ? undefined : Number(end)
|
||||
if (!Number.isInteger(port_start) || port_start < 1 || port_start > 65535 ||
|
||||
(port_end !== undefined && (!Number.isInteger(port_end) || port_end < port_start || port_end > 65535))) {
|
||||
throw new Error(`Порты должны быть от 1 до 65535, начало не больше конца: ${token}`)
|
||||
}
|
||||
return port_end === undefined ? { port_start } : { port_start, port_end }
|
||||
})
|
||||
}
|
||||
|
||||
export type PortAclDraft = z.input<typeof portAclDraftSchema>
|
||||
export const portAclImportDraftSchema = z.object({ from: z.enum(['list', 'set']), listId: z.string(), setId: z.string(), action: z.enum(['open', 'close']), protocol: z.enum(['tcp', 'udp', 'both']), ports: z.string() }).superRefine((draft, ctx) => {
|
||||
if (draft.from === 'list' && !draft.listId) ctx.addIssue({ code: 'custom', path: ['listId'], message: 'Выберите список' })
|
||||
if (draft.from === 'set' && !draft.setId) ctx.addIssue({ code: 'custom', path: ['setId'], message: 'Выберите набор' })
|
||||
try { parsePortRanges(draft.ports) } catch (error) { ctx.addIssue({ code: 'custom', path: ['ports'], message: error instanceof Error ? error.message : 'Некорректные порты' }) }
|
||||
})
|
||||
export type PortAclImportDraft = z.infer<typeof portAclImportDraftSchema>
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { ResourcePage } from './reui-kit'
|
||||
import type { PolicyRollout, PolicySnapshotDiff } from '@evofw/shared'
|
||||
import { canConfirmRollout, rolloutReady } from './rules/policy-rollouts-sheet'
|
||||
import { webhookEditorBody, webhookEditorSchema } from './webhooks-sheet'
|
||||
import { installLinkState } from './agents/agent-credentials-sheet'
|
||||
import {
|
||||
parseListImportDraft,
|
||||
validatedListImportValues,
|
||||
} from '@/lib/list-import-draft'
|
||||
|
||||
const diff: PolicySnapshotDiff = {
|
||||
snapshot_id: 'snapshot',
|
||||
items: [
|
||||
{
|
||||
agent_id: 'target',
|
||||
expected_generation: 7,
|
||||
before_hash: 'old',
|
||||
candidate_hash: 'new',
|
||||
added: 1,
|
||||
removed: 0,
|
||||
examples: [],
|
||||
truncated: false,
|
||||
default_action_changed: false,
|
||||
port_rules_changed: false,
|
||||
conflict: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
const rollout: PolicyRollout = {
|
||||
id: 'rollout',
|
||||
snapshot_id: 'snapshot',
|
||||
state: 'canary',
|
||||
version: 3,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
targets: [
|
||||
{
|
||||
agent_id: 'canary',
|
||||
is_canary: true,
|
||||
phase: 'candidate',
|
||||
desired_revision: 8,
|
||||
desired_hash: 'new',
|
||||
applied_revision: 8,
|
||||
applied_hash: 'new',
|
||||
acknowledged: true,
|
||||
previous_desired_snapshot_id: 'previous',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('F2/F3 action gates and write-only drafts', () => {
|
||||
it('uses server totals and current-page rows without a second local filter or pagination', () => {
|
||||
const html = renderToStaticMarkup(<ResourcePage title="Server rows" data={[{ id: 'last', name: 'Visible server result' }]} getRowId={(r) => r.id} columns={[{ accessorKey: 'name', header: 'Name' }]} filterFields={[]} filters={[]} onFiltersChange={() => {}} getFilterFieldValue={() => undefined} searchQuery="server-filter-applied-before-page" getSearchText={(r) => r.name} onSearchChange={() => {}} serverPagination={{ value: { pageIndex: 999, pageSize: 100 }, total: 100000, onChange: () => {} }} />)
|
||||
expect(html).toContain('Visible server result')
|
||||
expect(html).toContain('99901–100000 из 100000')
|
||||
})
|
||||
it('requires the current snapshot and complete exact target diff without admission conflicts', () => {
|
||||
expect(rolloutReady(diff, ['target'], 'snapshot')).toBe(true)
|
||||
expect(rolloutReady(diff, ['target'], 'another-snapshot')).toBe(false)
|
||||
expect(rolloutReady(diff, ['target', 'other'], 'snapshot')).toBe(false)
|
||||
expect(rolloutReady(diff, ['other'], 'snapshot')).toBe(false)
|
||||
expect(
|
||||
rolloutReady(
|
||||
{ ...diff, items: [{ ...diff.items[0], conflict: 'POLICY_PINNED' }] },
|
||||
['target'],
|
||||
'snapshot',
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
it('keeps published, pending, rolled-back and released canary states separate from confirmation', () => {
|
||||
expect(canConfirmRollout(rollout)).toBe(true)
|
||||
for (const phase of ['pending', 'released', 'rollback'] as const)
|
||||
expect(
|
||||
canConfirmRollout({
|
||||
...rollout,
|
||||
targets: [{ ...rollout.targets[0], phase }],
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(
|
||||
canConfirmRollout({
|
||||
...rollout,
|
||||
targets: [{ ...rollout.targets[0], acknowledged: false }],
|
||||
}),
|
||||
).toBe(false)
|
||||
expect(canConfirmRollout({ ...rollout, targets: [] })).toBe(false)
|
||||
expect(canConfirmRollout({ ...rollout, state: 'confirmed' })).toBe(false)
|
||||
})
|
||||
it('omits an unchanged webhook secret and requires a valid new secret on creation', () => {
|
||||
const draft = {
|
||||
name: 'fixture',
|
||||
url: 'https://fixture.invalid/hook',
|
||||
events: ['agent.offline' as const],
|
||||
secret: '',
|
||||
enabled: false,
|
||||
}
|
||||
expect(webhookEditorBody(draft, true)).not.toHaveProperty('secret')
|
||||
expect(() => webhookEditorBody(draft, false)).toThrow()
|
||||
expect(
|
||||
webhookEditorSchema.safeParse({ ...draft, secret: 'short' }).success,
|
||||
).toBe(false)
|
||||
expect(
|
||||
webhookEditorBody(
|
||||
{ ...draft, secret: 'fixture-only-secret-never-sent-00000000' },
|
||||
false,
|
||||
),
|
||||
).toHaveProperty('enabled', false)
|
||||
})
|
||||
it('reports consumed, expired, revoked and active one-time links without exposing commands', () => {
|
||||
const now = Date.parse('2026-10-24T00:00:00Z')
|
||||
const link = {
|
||||
revoked_at: null,
|
||||
consumed_at: null,
|
||||
expires_at: '2026-10-25T00:00:00Z',
|
||||
}
|
||||
expect(installLinkState(link, now)).toBe('Ожидает установки')
|
||||
expect(installLinkState({ ...link, expires_at: null }, now)).toBe('Истекла')
|
||||
expect(installLinkState(link, now + 86400000)).toBe('Истекла')
|
||||
expect(
|
||||
installLinkState({ ...link, consumed_at: '2026-10-23T00:00:00Z' }, now),
|
||||
).toBe('Использована')
|
||||
expect(
|
||||
installLinkState(
|
||||
{
|
||||
...link,
|
||||
consumed_at: '2026-10-23T00:00:00Z',
|
||||
revoked_at: '2026-10-23T01:00:00Z',
|
||||
},
|
||||
now,
|
||||
),
|
||||
).toBe('Отозвана')
|
||||
})
|
||||
it('rejects mixed invalid imports before any request while normalizing valid duplicates', () => {
|
||||
const mixed = '203.0.113.1\n999.999.999.999'
|
||||
expect(parseListImportDraft(mixed)).toMatchObject({ valid: 1, invalid: 1 })
|
||||
expect(() => validatedListImportValues(mixed)).toThrow(
|
||||
'Частичный импорт не выполняется',
|
||||
)
|
||||
expect(validatedListImportValues('203.0.113.1\n203.0.113.1')).toEqual([
|
||||
'203.0.113.1',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -22,6 +22,7 @@ interface FormSheetProps<T extends FieldValues> {
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
size?: 'default' | 'lg' | 'xl'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,13 +41,14 @@ export function FormSheet<T extends FieldValues>({
|
||||
footer,
|
||||
className,
|
||||
contentClassName,
|
||||
size = 'default',
|
||||
}: FormSheetProps<T>) {
|
||||
const handleSubmit = form.handleSubmit(onSubmit)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
className={cn('flex flex-col gap-0 overflow-hidden sm:max-w-md', className)}
|
||||
className={cn('flex min-w-0 flex-col gap-0 overflow-hidden sm:max-w-md', size === 'lg' && 'data-[side=right]:w-full data-[side=right]:sm:max-w-2xl', size === 'xl' && 'data-[side=right]:w-full data-[side=right]:sm:max-w-3xl', className)}
|
||||
>
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
@@ -55,11 +57,11 @@ export function FormSheet<T extends FieldValues>({
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4',
|
||||
'grid min-w-0 flex-1 auto-rows-min grid-cols-[minmax(0,1fr)] gap-4 overflow-x-hidden overflow-y-auto px-4',
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,11 +5,14 @@ import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { CommandPalette, PortalHeader, PortalSidebar } from '@/components/portal/chrome'
|
||||
import { ChromeConfigProvider, PortalUiProvider } from '@/components/portal/chrome-context'
|
||||
import type { ChromeConfig, MonitorItem, MonitorMetric, NavItem, PaletteObject } from '@/components/portal/chrome-store'
|
||||
import { useUiStore } from '@/components/portal/chrome-store'
|
||||
import { getClaims, isAuthEnabled, logout } from '@/lib/auth'
|
||||
import { NAV_ITEMS, NAV_SECTIONS } from '@/lib/nav'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
import { agentsQueryOptions, dashboardQueryOptions, listsQueryOptions, policySetsQueryOptions } from '@/queries'
|
||||
import { agentsQueryOptions, uiSummaryQueryOptions, listsQueryOptions, policySetsQueryOptions } from '@/queries'
|
||||
import { isAgentOnline } from '@/lib/agent-online'
|
||||
import { formatRelativeTime } from '@/lib/format'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar'
|
||||
import { TooltipProvider } from '@evofw/ui/components/tooltip'
|
||||
@@ -39,24 +42,24 @@ function initialsOf(name: string) {
|
||||
/** Меню и данные оболочки EvoFirewall: пункты по правам, точки проблем и монитор из живых запросов. */
|
||||
function useChromeData(): ChromeConfig {
|
||||
const can = useCan()
|
||||
const dashQ = useQuery({ ...dashboardQueryOptions(), refetchInterval: 30_000 })
|
||||
const agentsQ = useQuery({ ...agentsQueryOptions(), refetchInterval: 30_000 })
|
||||
const listsQ = useQuery({ ...listsQueryOptions(), refetchInterval: 60_000 })
|
||||
const setsQ = useQuery({ ...policySetsQueryOptions(), refetchInterval: 60_000 })
|
||||
const { paletteOpen } = useUiStore()
|
||||
const summaryQ = useQuery(uiSummaryQueryOptions())
|
||||
// Full catalogs are needed only for object search, never to render the shell.
|
||||
const agentsQ = useQuery({ ...agentsQueryOptions(), enabled: paletteOpen && can('fw:agents:read'), refetchInterval: false, staleTime: 60_000 })
|
||||
const listsQ = useQuery({ ...listsQueryOptions(), enabled: paletteOpen && can('fw:lists:read'), staleTime: 60_000 })
|
||||
const setsQ = useQuery({ ...policySetsQueryOptions(), enabled: paletteOpen && can('fw:policies:read'), staleTime: 60_000 })
|
||||
|
||||
return useMemo<ChromeConfig>(() => {
|
||||
const d = dashQ.data
|
||||
const agents = agentsQ.data?.items ?? []
|
||||
const lists = listsQ.data?.items ?? []
|
||||
const sets = setsQ.data?.items ?? []
|
||||
const approved = agents.filter((a) => a.status === 'approved').length
|
||||
const online = d?.agents_online ?? agents.filter((a) => a.last_seen_at).length
|
||||
const pending = d?.agents_pending ?? agents.filter((a) => a.status === 'pending').length
|
||||
const offline = Math.max(0, approved - online)
|
||||
const summary = summaryQ.data
|
||||
const agents = can('fw:agents:read') ? agentsQ.data?.items ?? [] : []
|
||||
const lists = can('fw:lists:read') ? listsQ.data?.items ?? [] : []
|
||||
const sets = can('fw:policies:read') ? setsQ.data?.items ?? [] : []
|
||||
const approved = summary?.agents?.approved ?? 0
|
||||
const online = summary?.agents?.online ?? 0
|
||||
const pending = summary?.agents?.pending ?? 0
|
||||
const offline = summary?.agents?.offline ?? 0
|
||||
const onlinePct = approved > 0 ? Math.round((online / approved) * 100) : 0
|
||||
const apiOk = !dashQ.isError
|
||||
const failedLists = lists.filter((l) => l.last_error).length
|
||||
const rulesCount = sets.reduce((n, s) => n + (s.rules_count ?? 0), 0)
|
||||
const failedLists = summary?.lists?.failed ?? 0
|
||||
|
||||
const problems: Record<string, NavItem['problem']> = {
|
||||
'/agents': pending > 0 ? { tone: 'wn', label: `${pending} агентов ждут одобрения` } : offline > 0 ? { tone: 'er', label: `${offline} агентов офлайн` } : undefined,
|
||||
@@ -75,18 +78,23 @@ function useChromeData(): ChromeConfig {
|
||||
const groups = [...new Set(navItems.map((i) => i.group))]
|
||||
|
||||
const monitorItems: MonitorItem[] = []
|
||||
if (!apiOk) monitorItems.push({ id: 'api', tone: 'er', title: 'API недоступен', text: 'GET /api/v1/dashboard вернул ошибку', age: 'сейчас', to: '/' })
|
||||
if (summaryQ.isPending) monitorItems.push({ id: 'loading', tone: 'wn', title: 'Проверка состояния', text: 'Ожидаем сводку сервера', age: 'загрузка', to: '/' })
|
||||
if (summaryQ.isError) monitorItems.push({ id: 'api', tone: 'er', title: 'Не удалось обновить сводку', text: summaryQ.data ? 'Показаны последние полученные данные' : 'Состояние системы пока неизвестно', age: 'ошибка', to: '/' })
|
||||
if (pending > 0) monitorItems.push({ id: 'pending', tone: 'wn', title: `Ждут одобрения: ${pending}`, text: 'Новые агенты не получают политику, пока их не одобрят', age: 'сейчас', to: '/agents' })
|
||||
if (offline > 0) monitorItems.push({ id: 'offline', tone: 'er', title: `Агентов офлайн: ${offline}`, text: `Онлайн ${online} из ${approved} одобренных`, age: 'сейчас', to: '/agents' })
|
||||
if (failedLists > 0) monitorItems.push({ id: 'lists', tone: 'wn', title: `Списков с ошибкой: ${failedLists}`, text: 'Проверьте источник списка и повторите обновление', age: 'недавно', to: '/lists' })
|
||||
if (monitorItems.length === 0) monitorItems.push({ id: 'ok', tone: 'ok', title: 'Всё в норме', text: 'API, агенты и списки без проблем', age: 'сейчас', to: '/' })
|
||||
if (monitorItems.length === 0) monitorItems.push(
|
||||
summary && (summary.agents || summary.lists || summary.policies || summary.dashboard)
|
||||
? { id: 'ok', tone: 'ok', title: 'В норме', text: 'В доступных разделах сводки проблем не обнаружено', age: 'проверено', to: '/' }
|
||||
: { id: 'unknown', tone: 'wn', title: 'Нет доступной сводки', text: 'Нет данных для оценки состояния', age: 'неизвестно', to: '/' },
|
||||
)
|
||||
|
||||
const metrics: MonitorMetric[] = [
|
||||
{ l: 'API', v: apiOk ? 'OK' : 'ERR', p: apiOk ? 100 : 0, tone: apiOk ? 'success' : 'warning' },
|
||||
{ l: 'Агенты online', v: `${onlinePct}%`, p: onlinePct, tone: onlinePct >= 80 ? 'success' : 'warning' },
|
||||
{ l: 'Списки IP', v: String(d?.lists_total ?? lists.length), p: lists.length ? Math.round(((lists.length - failedLists) / lists.length) * 100) : 100, tone: failedLists > 0 ? 'warning' : 'success' },
|
||||
{ l: 'Правила', v: String(rulesCount), p: Math.min(100, rulesCount), tone: 'success' },
|
||||
{ l: 'API сводки', v: summaryQ.isSuccess ? 'OK' : summaryQ.isError ? 'ERR' : 'Проверка…', p: summaryQ.isSuccess ? 100 : 0, tone: summaryQ.isSuccess ? 'success' : 'warning' },
|
||||
]
|
||||
if (summary?.agents) metrics.push({ l: 'Агенты online', v: approved ? `${onlinePct}%` : 'Нет одобренных', p: onlinePct, tone: offline ? 'warning' : 'success' })
|
||||
if (summary?.lists) metrics.push({ l: 'Списки IP', v: String(summary.lists.total), p: summary.lists.total ? Math.round(((summary.lists.total - failedLists) / summary.lists.total) * 100) : 100, tone: failedLists ? 'warning' : 'success' })
|
||||
if (summary?.policies) metrics.push({ l: 'Правила', v: String(summary.policies.rules_total), p: 100, tone: 'success' })
|
||||
|
||||
const claims = getClaims()
|
||||
const name = claims?.name?.trim() || (isAuthEnabled() ? 'Пользователь' : 'Гость')
|
||||
@@ -98,7 +106,7 @@ function useChromeData(): ChromeConfig {
|
||||
group: 'Агенты',
|
||||
label: a.name,
|
||||
hint: `${a.platform}${a.last_seen_ip ? ` · ${a.last_seen_ip}` : ''}`,
|
||||
dot: a.status !== 'approved' ? 'mu' : a.last_seen_at ? 'ok' : 'er',
|
||||
dot: a.status !== 'approved' ? 'mu' : isAgentOnline(a) ? 'ok' : 'er',
|
||||
to: `/agents/${a.id}`,
|
||||
})),
|
||||
...lists.slice(0, 50).map<PaletteObject>((l) => ({ id: `list-${l.id}`, group: 'Списки', label: l.name, hint: l.type, dot: l.last_error ? 'er' : 'ok', to: `/lists/${l.id}` })),
|
||||
@@ -113,23 +121,27 @@ function useChromeData(): ChromeConfig {
|
||||
securityTo: '/settings',
|
||||
onLogout: logout,
|
||||
fresh: freshFor,
|
||||
monitor: { items: monitorItems, metrics, updated: 'обновлено сейчас', footerTo: '/agents', footerLabel: 'Агенты' },
|
||||
monitor: { items: monitorItems, metrics, updated: summary?.updated_at ? `Получено ${formatRelativeTime(summary.updated_at)}` : 'Данные ещё не получены', footerTo: can('fw:agents:read') ? '/agents' : '/', footerLabel: can('fw:agents:read') ? 'Агенты' : 'Обзор' },
|
||||
palette: {
|
||||
actions: [
|
||||
{ id: 'act-agent', label: 'Добавить агента', hint: 'A', icon: ServerIcon, to: '/agents' },
|
||||
{ id: 'act-list', label: 'Создать список', hint: 'L', icon: PlusIcon, to: '/lists' },
|
||||
{ id: 'act-set', label: 'Создать набор правил', hint: 'R', icon: ShieldPlusIcon, to: '/rules' },
|
||||
...(can('fw:agents:write') ? [{ id: 'act-agent', label: 'Добавить агента', hint: 'A', icon: ServerIcon, to: '/agents?add=true' }] : []),
|
||||
...(can('fw:lists:write') ? [{ id: 'act-list', label: 'Создать список', hint: 'L', icon: PlusIcon, to: '/lists?create=true' }] : []),
|
||||
...(can('fw:policies:write') ? [{ id: 'act-set', label: 'Создать набор правил', hint: 'R', icon: ShieldPlusIcon, to: '/rules?create=true' }] : []),
|
||||
],
|
||||
objects,
|
||||
assistantTo: '/agents',
|
||||
example: 'Подсказка: «агенты офлайн»',
|
||||
example: 'Поиск по названию объекта или раздела',
|
||||
},
|
||||
}
|
||||
}, [can, dashQ.data, dashQ.isError, agentsQ.data, listsQ.data, setsQ.data])
|
||||
}, [can, summaryQ.data, summaryQ.isPending, summaryQ.isError, summaryQ.isSuccess, agentsQ.data, listsQ.data, setsQ.data])
|
||||
}
|
||||
|
||||
/** Оболочка EvoFirewall: боковое меню, шапка, монитор, палитра ⌘K — блоки общего макета. */
|
||||
export function PortalShell({ children }: { children: ReactNode }) {
|
||||
return <PortalUiProvider><PortalShellContent>{children}</PortalShellContent></PortalUiProvider>
|
||||
}
|
||||
|
||||
function PortalShellContent({ children }: { children: ReactNode }) {
|
||||
const [open, setOpen] = useState(true)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => {
|
||||
@@ -153,7 +165,6 @@ export function PortalShell({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalUiProvider>
|
||||
<ChromeConfigProvider value={config}>
|
||||
<TooltipProvider delay={0}>
|
||||
<SidebarProvider open={open} onOpenChange={setOpen} style={{ '--sidebar-width': '240px' } as CSSProperties}>
|
||||
@@ -169,6 +180,5 @@ export function PortalShell({ children }: { children: ReactNode }) {
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
</ChromeConfigProvider>
|
||||
</PortalUiProvider>
|
||||
)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function createListColumns(opts: {
|
||||
title={row.original.name}
|
||||
subtitle={
|
||||
row.original.last_error
|
||||
? 'Ошибка обновления'
|
||||
? `Ошибка обновления · сохранено записей: ${row.original.entry_count ?? '—'}`
|
||||
: row.original.refreshed_at
|
||||
? `Обновлён ${formatDateTime(row.original.refreshed_at)}`
|
||||
: undefined
|
||||
@@ -96,18 +96,19 @@ export function createListColumns(opts: {
|
||||
meta: { headerTitle: 'Записей' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'updated_at',
|
||||
id: 'last_success',
|
||||
accessorFn: (row) => isManualListType(row.type) ? row.updated_at : row.refreshed_at ?? '',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Обновлено" />
|
||||
<DataGridColumnHeader column={column} title="Успешное обновление" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<DataGridMutedCell>
|
||||
{row.original.updated_at
|
||||
? formatDateTime(row.original.updated_at)
|
||||
: '—'}
|
||||
{(isManualListType(row.original.type) ? row.original.updated_at : row.original.refreshed_at)
|
||||
? formatDateTime((isManualListType(row.original.type) ? row.original.updated_at : row.original.refreshed_at)!)
|
||||
: 'Не было'}
|
||||
</DataGridMutedCell>
|
||||
),
|
||||
meta: { headerTitle: 'Обновлено' },
|
||||
meta: { headerTitle: 'Успешное обновление' },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Field, FieldLabel, FieldDescription } from '@evofw/ui/components/field'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
} from '@evofw/ui/components/combobox'
|
||||
type Choice = { id: string; name: string }
|
||||
export function NamedMultiSelect({
|
||||
id,
|
||||
label,
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
max = 100,
|
||||
disabled = false,
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
items: Choice[]
|
||||
value: string[]
|
||||
onChange: (ids: string[]) => void
|
||||
max?: number
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const anchor = useComboboxAnchor()
|
||||
const selected = useMemo(
|
||||
() =>
|
||||
value.map(
|
||||
(id) =>
|
||||
items.find((item) => item.id === id) ?? {
|
||||
id,
|
||||
name: 'Недоступный объект',
|
||||
},
|
||||
),
|
||||
[items, value],
|
||||
)
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<Combobox<Choice, true>
|
||||
multiple
|
||||
items={items}
|
||||
value={selected}
|
||||
disabled={disabled}
|
||||
itemToStringLabel={(item) => item.name}
|
||||
itemToStringValue={(item) => item.id}
|
||||
isItemEqualToValue={(a, b) => a.id === b.id}
|
||||
onValueChange={(next) => {
|
||||
if (next.length <= max) onChange(next.map((item) => item.id))
|
||||
}}
|
||||
>
|
||||
<ComboboxChips ref={anchor}>
|
||||
<ComboboxValue>
|
||||
{(choices: Choice[]) =>
|
||||
choices.map((item) => (
|
||||
<ComboboxChip key={item.id} aria-label={`Убрать ${item.name}`}>
|
||||
<span className="max-w-44 truncate">{item.name}</span>
|
||||
</ComboboxChip>
|
||||
))
|
||||
}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput id={id} placeholder="Поиск по имени…" />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
<ComboboxEmpty>Ничего не найдено</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(item: Choice) => (
|
||||
<ComboboxItem
|
||||
key={item.id}
|
||||
value={item}
|
||||
disabled={value.length >= max && !value.includes(item.id)}
|
||||
>
|
||||
{item.name}
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldDescription>
|
||||
Выбрано: {value.length}/{max}.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useMemo,
|
||||
useEffect,
|
||||
useState,
|
||||
type ComponentType,
|
||||
} from 'react'
|
||||
@@ -282,6 +283,8 @@ function MonitorPopover() {
|
||||
const errors = bad.filter((m) => m.tone === 'er').length
|
||||
const warnings = bad.length - errors
|
||||
const ok = MONITOR_ITEMS.length - bad.length
|
||||
const needsAttention = bad.length > 0
|
||||
const statusLabel = errors ? 'Сбой' : warnings ? 'Внимание' : 'В норме'
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
@@ -289,23 +292,23 @@ function MonitorPopover() {
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
aria-label={`Состояние системы: внимание, ${errors} сбой, ${warnings} предупреждения`}
|
||||
className="border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20 relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0"
|
||||
aria-label={`Состояние системы: ${statusLabel}, ${errors} сбой, ${warnings} предупреждения`}
|
||||
className={cn('relative h-8 gap-2 px-2 max-[760px]:w-8 max-[760px]:border-transparent max-[760px]:bg-transparent max-[760px]:px-0', needsAttention ? 'border-warning/40 bg-warning/10 hover:bg-warning/20 aria-expanded:bg-warning/20' : 'border-success/40 bg-success/10 hover:bg-success/20 aria-expanded:bg-success/20')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ActivityIcon className="text-warning size-4" aria-hidden />
|
||||
<span className="text-warning hidden text-sm font-medium min-[1000px]:inline">Внимание</span>
|
||||
<ActivityIcon className={cn('size-4', needsAttention ? 'text-warning' : 'text-success')} aria-hidden />
|
||||
<span className={cn('hidden text-sm font-medium min-[1000px]:inline', needsAttention ? 'text-warning' : 'text-success')}>{statusLabel}</span>
|
||||
<span className="hidden items-center gap-1.5 text-sm tabular-nums min-[1240px]:inline-flex">
|
||||
<span className="bg-destructive size-1.5 rounded-full" aria-hidden />
|
||||
{errors}
|
||||
<span className="bg-warning size-1.5 rounded-full" aria-hidden />
|
||||
{warnings}
|
||||
</span>
|
||||
<span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||
{needsAttention ? <span className="bg-destructive hidden size-4.5 min-w-4.5 items-center justify-center rounded-full px-1 text-[11px] font-semibold text-white min-[760px]:inline-flex min-[1240px]:hidden">
|
||||
{bad.length}
|
||||
</span>
|
||||
<span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden />
|
||||
</span> : null}
|
||||
{needsAttention ? <span className="bg-destructive ring-background absolute top-1 right-1 size-2 rounded-full ring-2 min-[760px]:hidden" aria-hidden /> : null}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="flex w-[24rem] max-w-[calc(100vw-1.5rem)] flex-col gap-2 p-2">
|
||||
<div className="flex items-start justify-between gap-2 px-1.5 pt-1.5">
|
||||
@@ -438,6 +441,11 @@ export function CommandPalette() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [active, setActive] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!paletteOpen) return
|
||||
document.querySelector<HTMLElement>('#command-results [aria-selected="true"]')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [active, paletteOpen, query])
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const match = (...t: string[]) => !q || t.some((x) => x.toLowerCase().includes(q))
|
||||
@@ -459,19 +467,6 @@ export function CommandPalette() {
|
||||
.reduce<Record<string, typeof palette.objects>>((acc, o) => ((acc[o.group] ??= []).push(o), acc), {}),
|
||||
).flatMap((g) => g.slice(0, 5).map((o) => ({ id: o.id, group: o.group, label: o.label, hint: o.hint, dot: o.dot, run: go(o.to) }))),
|
||||
]
|
||||
// Запрос из трёх и более слов — черновик ассистента (как на доске «Палитра ⌘K»).
|
||||
if (query.trim().split(/\s+/).filter(Boolean).length >= 3) {
|
||||
return [
|
||||
{
|
||||
id: 'ai',
|
||||
group: 'Ассистент · запрос на естественном языке',
|
||||
label: `Понял так: «${query.trim()}»`,
|
||||
hint: 'Черновик: запрос разобран автоматически, результат нужно проверить.',
|
||||
run: () => void navigate({ to: palette.assistantTo }),
|
||||
} as PaletteEntry,
|
||||
...list,
|
||||
]
|
||||
}
|
||||
return list
|
||||
}, [query, navigate, theme, resolvedTheme, setTheme, navItems, palette])
|
||||
|
||||
@@ -502,6 +497,11 @@ export function CommandPalette() {
|
||||
autoFocus
|
||||
value={query}
|
||||
aria-label="Поиск и команды"
|
||||
role="combobox"
|
||||
aria-expanded={paletteOpen}
|
||||
aria-autocomplete="list"
|
||||
aria-controls="command-results"
|
||||
aria-activedescendant={shown[active] ? `command-${shown[active].id}` : undefined}
|
||||
placeholder="Раздел, пользователь, приложение или действие…"
|
||||
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none"
|
||||
onChange={(e) => {
|
||||
@@ -511,7 +511,7 @@ export function CommandPalette() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActive((a) => Math.min(a + 1, shown.length - 1))
|
||||
setActive((a) => Math.max(0, Math.min(a + 1, shown.length - 1)))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActive((a) => Math.max(a - 1, 0))
|
||||
@@ -524,7 +524,7 @@ export function CommandPalette() {
|
||||
/>
|
||||
<Kbd>Esc</Kbd>
|
||||
</div>
|
||||
<div className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
|
||||
<div id="command-results" className="max-h-[430px] overflow-y-auto" role="listbox" aria-label="Результаты">
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-muted-foreground px-4 py-6 text-center text-sm">Ничего не найдено</p>
|
||||
) : (
|
||||
@@ -536,6 +536,8 @@ export function CommandPalette() {
|
||||
return (
|
||||
<button
|
||||
key={e.id}
|
||||
id={`command-${e.id}`}
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={e.index === active}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { QueryState } from './query-state'
|
||||
|
||||
describe('QueryState data availability', () => {
|
||||
it('does not render a misleading empty result when the initial request fails', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryState isError error={new Error('Offline')} onRetry={() => {}}>
|
||||
<p>No agents</p>
|
||||
</QueryState>,
|
||||
)
|
||||
expect(html).toContain('Offline')
|
||||
expect(html).toContain('Повторить')
|
||||
expect(html).not.toContain('No agents')
|
||||
})
|
||||
|
||||
it('keeps existing form values mounted after a background refresh fails', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryState isError hasData error={new Error('Offline')}>
|
||||
<input defaultValue="Unsaved operator edit" />
|
||||
</QueryState>,
|
||||
)
|
||||
expect(html).toContain('предыдущая версия')
|
||||
expect(html).toContain('Unsaved operator edit')
|
||||
})
|
||||
|
||||
it('announces the pending state without showing empty data', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryState isLoading><p>No agents</p></QueryState>,
|
||||
)
|
||||
expect(html).toContain('aria-busy="true"')
|
||||
expect(html).not.toContain('No agents')
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
interface QueryStateProps {
|
||||
isLoading?: boolean
|
||||
isError?: boolean
|
||||
hasData?: boolean
|
||||
error?: Error | null
|
||||
onRetry?: () => void
|
||||
skeleton?: React.ReactNode
|
||||
@@ -29,24 +30,24 @@ function DefaultSkeleton() {
|
||||
export function QueryState({
|
||||
isLoading,
|
||||
isError,
|
||||
hasData = false,
|
||||
error,
|
||||
onRetry,
|
||||
skeleton,
|
||||
children,
|
||||
}: QueryStateProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div role="status" aria-live="polite" aria-busy="true">
|
||||
{skeleton ?? <DefaultSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
// Keep the child at the same reconciliation position when a background
|
||||
// request fails. Changing the wrapper would unmount uncontrolled form fields.
|
||||
return (
|
||||
<>
|
||||
{isLoading ? (
|
||||
<div role="status" aria-live="polite" aria-busy="true">
|
||||
{skeleton ?? <DefaultSkeleton />}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Не удалось загрузить данные</AlertTitle>
|
||||
<AlertTitle>{hasData ? 'Не удалось обновить данные — показана предыдущая версия' : 'Не удалось загрузить данные'}</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2">
|
||||
<span>{error?.message ?? 'Произошла ошибка при загрузке.'}</span>
|
||||
{onRetry ? (
|
||||
@@ -55,9 +56,9 @@ export function QueryState({
|
||||
</Button>
|
||||
) : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
</Alert>
|
||||
) : null}
|
||||
{!isLoading && (!isError || hasData) ? children : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -102,6 +102,8 @@ export interface ResourcePageProps<T extends object> {
|
||||
/** Текст для пустого результата после фильтров/поиска (по вкладкам). */
|
||||
filteredEmptyMessage?: string
|
||||
pageSize?: number
|
||||
/** API owns filtering, ordering and pagination for the entire collection. */
|
||||
serverPagination?: { value: PaginationState; total: number; onChange: (value: PaginationState) => void }
|
||||
enableRowSelection?: boolean
|
||||
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
|
||||
rowSelection?: RowSelectionState
|
||||
@@ -167,6 +169,7 @@ export function ResourcePage<T extends object>({
|
||||
emptyState,
|
||||
filteredEmptyMessage,
|
||||
pageSize = 10,
|
||||
serverPagination,
|
||||
enableRowSelection = false,
|
||||
rowSelection: rowSelectionProp,
|
||||
onRowSelectionChange: onRowSelectionChangeProp,
|
||||
@@ -212,16 +215,21 @@ export function ResourcePage<T extends object>({
|
||||
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>(
|
||||
() => columnPinningProp ?? {},
|
||||
)
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
const [internalPagination, setInternalPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize,
|
||||
})
|
||||
const pagination = serverPagination?.value ?? internalPagination
|
||||
const setPagination = useCallback((updater: PaginationState | ((previous: PaginationState) => PaginationState)) => {
|
||||
if (serverPagination) serverPagination.onChange(typeof updater === 'function' ? updater(serverPagination.value) : updater)
|
||||
else setInternalPagination(updater)
|
||||
}, [serverPagination])
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
setPagination((current) =>
|
||||
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
|
||||
)
|
||||
}, [])
|
||||
}, [setPagination])
|
||||
|
||||
const applySearch = useCallback(
|
||||
(items: T[]) => {
|
||||
@@ -235,6 +243,7 @@ export function ResourcePage<T extends object>({
|
||||
)
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (serverPagination) return data
|
||||
let result = applySearch(data)
|
||||
result = applyFiltersToData(result, filters, getFilterFieldValue)
|
||||
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||
@@ -249,6 +258,7 @@ export function ResourcePage<T extends object>({
|
||||
tabs,
|
||||
tabFilter,
|
||||
activeTab,
|
||||
serverPagination,
|
||||
])
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
@@ -309,6 +319,10 @@ export function ResourcePage<T extends object>({
|
||||
onSortingChange: setSorting,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: Boolean(serverPagination),
|
||||
manualSorting: Boolean(serverPagination),
|
||||
enableSorting: !serverPagination,
|
||||
rowCount: serverPagination?.total,
|
||||
onColumnPinningChange: setColumnPinning,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
@@ -380,7 +394,7 @@ export function ResourcePage<T extends object>({
|
||||
)
|
||||
}
|
||||
|
||||
if (data.length === 0 && emptyState) {
|
||||
if (data.length === 0 && emptyState && !serverPagination) {
|
||||
return (
|
||||
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||
{!hideHeader ? (
|
||||
@@ -415,7 +429,7 @@ export function ResourcePage<T extends object>({
|
||||
filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="min-w-0 w-full max-w-full">
|
||||
{selectionToolbar && selectedCount > 0
|
||||
? selectionToolbar({
|
||||
selectedIds,
|
||||
@@ -425,7 +439,7 @@ export function ResourcePage<T extends object>({
|
||||
: null}
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={filteredData.length}
|
||||
recordCount={serverPagination?.total ?? filteredData.length}
|
||||
emptyMessage={emptyMessage}
|
||||
tableLayout={tableLayout}
|
||||
onRowClick={onRowClick}
|
||||
@@ -443,7 +457,7 @@ export function ResourcePage<T extends object>({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="tabular-nums">
|
||||
{filteredData.length} записей
|
||||
{serverPagination?.total ?? filteredData.length} записей
|
||||
</span>
|
||||
{selectedCount > 0 ? (
|
||||
<>
|
||||
@@ -501,7 +515,7 @@ export function ResourcePage<T extends object>({
|
||||
/>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
<span className="text-muted-foreground ml-auto text-sm tabular-nums">{filteredData.length} записей</span>
|
||||
<span className="text-muted-foreground ml-auto text-sm tabular-nums">{serverPagination?.total ?? filteredData.length} записей</span>
|
||||
{toolbarExtra}
|
||||
{selectedCount > 0 ? (
|
||||
<Badge size="sm" variant="secondary">
|
||||
@@ -522,7 +536,7 @@ export function ResourcePage<T extends object>({
|
||||
|
||||
<DataGridPagination
|
||||
className="px-5 py-3"
|
||||
sizes={[5, 10, 20, 50]}
|
||||
sizes={serverPagination ? [20, 50, 100, 200] : [5, 10, 20, 50]}
|
||||
rowsPerPageLabel="Строк на странице"
|
||||
info="{from}–{to} из {count}"
|
||||
previousPageLabel="Назад"
|
||||
|
||||
@@ -1244,7 +1244,18 @@ function DataGridTableBodyRow<TData>({
|
||||
data-index={dataIndex}
|
||||
data-row-pinned={isRowPinned || undefined}
|
||||
data-row-pinned-boundary={pinnedBoundary}
|
||||
onClick={() => props.onRowClick && props.onRowClick(row.original)}
|
||||
tabIndex={props.onRowClick ? 0 : undefined}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget || !props.onRowClick) return
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault()
|
||||
props.onRowClick(row.original)
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if ((event.target as HTMLElement).closest('button, a, input, select, textarea, [role="button"], [role="checkbox"]')) return
|
||||
props.onRowClick?.(row.original)
|
||||
}}
|
||||
className={cn(
|
||||
"hover:bg-muted/40 data-[state=selected]:bg-muted/50",
|
||||
props.onRowClick && "cursor-pointer",
|
||||
@@ -1848,4 +1859,4 @@ export {
|
||||
hasDataGridTableRightPinnedColumns,
|
||||
}
|
||||
|
||||
export type { DataGridTablePinnedBoundary }
|
||||
export type { DataGridTablePinnedBoundary }
|
||||
@@ -50,6 +50,7 @@ import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
// Sortable Item Context
|
||||
const SortableItemContext = createContext<{
|
||||
attributes?: ReturnType<typeof useSortable>["attributes"]
|
||||
listeners: DraggableSyntheticListeners | undefined
|
||||
isDragging?: boolean
|
||||
disabled?: boolean
|
||||
@@ -338,7 +339,6 @@ function SortableItem({
|
||||
"data-disabled": disabled,
|
||||
ref: setNodeRef,
|
||||
style,
|
||||
...attributes,
|
||||
className: cn(
|
||||
isSortableDragging && "opacity-50 z-50",
|
||||
disabled && "opacity-50",
|
||||
@@ -352,7 +352,7 @@ function SortableItem({
|
||||
value={
|
||||
isOverlay
|
||||
? { listeners: undefined, isDragging: true, disabled: false }
|
||||
: { listeners, isDragging: isSortableDragging, disabled }
|
||||
: { listeners, attributes, isDragging: isSortableDragging, disabled }
|
||||
}
|
||||
>
|
||||
{useRender({
|
||||
@@ -374,12 +374,17 @@ function SortableItemHandle({
|
||||
cursor = true,
|
||||
...props
|
||||
}: SortableItemHandleProps) {
|
||||
const { listeners, isDragging, disabled } = useContext(SortableItemContext)
|
||||
const { listeners, attributes, isDragging, disabled } = useContext(SortableItemContext)
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "sortable-item-handle",
|
||||
"data-dragging": isDragging,
|
||||
"data-disabled": disabled,
|
||||
...attributes,
|
||||
role: "button",
|
||||
tabIndex: disabled ? -1 : 0,
|
||||
"aria-label": "Изменить порядок: пробел, затем стрелки, пробел для сохранения",
|
||||
"aria-disabled": disabled || undefined,
|
||||
...listeners,
|
||||
className: cn(
|
||||
cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"),
|
||||
@@ -436,4 +441,4 @@ function SortableOverlay({
|
||||
)
|
||||
}
|
||||
|
||||
export { Sortable, SortableItem, SortableItemHandle, SortableOverlay }
|
||||
export { Sortable, SortableItem, SortableItemHandle, SortableOverlay }
|
||||
@@ -0,0 +1,596 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
createPolicySnapshotSchema,
|
||||
createPolicyRolloutSchema,
|
||||
policySnapshotDiffSchema,
|
||||
policyRolloutTransitionSchema,
|
||||
releasePolicyPinSchema,
|
||||
type PolicySnapshotSummary,
|
||||
type PolicySnapshotDiff,
|
||||
type PolicyRollout,
|
||||
} from '@evofw/shared'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StatsAgentSelect } from '@/components/stats/stats-agent-select'
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { agentsQueryOptions } from '@/queries'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
} from '@evofw/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
} from '@evofw/ui/components/select'
|
||||
|
||||
const draftSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
source: z.array(z.string()).length(1),
|
||||
})
|
||||
const key = ['policy-rollouts']
|
||||
const stateLabel = {
|
||||
canary: 'Canary · пробная группа',
|
||||
confirmed: 'Опубликован всем целям',
|
||||
rolled_back: 'Опубликован откат',
|
||||
} as const
|
||||
export const rolloutReady = (
|
||||
diff: PolicySnapshotDiff | undefined,
|
||||
selected: string[],
|
||||
snapshotId: string,
|
||||
) =>
|
||||
Boolean(
|
||||
diff &&
|
||||
diff.snapshot_id === snapshotId &&
|
||||
diff.items.length === selected.length &&
|
||||
new Set(diff.items.map((item) => item.agent_id)).size === selected.length &&
|
||||
selected.length > 0 &&
|
||||
diff.items.every(
|
||||
(item) => selected.includes(item.agent_id) && !item.conflict,
|
||||
),
|
||||
)
|
||||
export const canConfirmRollout = (rollout: PolicyRollout) =>
|
||||
rollout.state === 'canary' &&
|
||||
rollout.targets.some((t) => t.is_canary) &&
|
||||
rollout.targets
|
||||
.filter((t) => t.is_canary)
|
||||
.every((t) => t.phase === 'candidate' && t.acknowledged)
|
||||
|
||||
export function PolicyRolloutsSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (value: boolean) => void
|
||||
}) {
|
||||
const can = useCan()
|
||||
const write = can('fw:policies:write')
|
||||
const read = can('fw:policies:read')
|
||||
const qc = useQueryClient()
|
||||
const form = useForm<z.infer<typeof draftSchema>>({
|
||||
resolver: zodResolver(draftSchema),
|
||||
defaultValues: { name: '', source: [] },
|
||||
})
|
||||
const [snapshot, setSnapshot] = useState('')
|
||||
const [targets, setTargets] = useState<string[]>([])
|
||||
const [canaries, setCanaries] = useState<string[]>([])
|
||||
const [diff, setDiff] = useState<PolicySnapshotDiff>()
|
||||
const [rolloutId, setRolloutId] = useState('')
|
||||
const [confirmation, setConfirmation] = useState<{
|
||||
title: string
|
||||
description: string
|
||||
run: () => void
|
||||
}>()
|
||||
const snapshots = useQuery({
|
||||
queryKey: [...key, 'snapshots'],
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{ items: PolicySnapshotSummary[] }>(
|
||||
'/api/v1/policies/snapshots?limit=50',
|
||||
{ signal },
|
||||
),
|
||||
enabled: open && read,
|
||||
})
|
||||
const history = useQuery({
|
||||
queryKey: [...key, 'history'],
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{
|
||||
items: Pick<PolicyRollout, 'id' | 'state' | 'version' | 'created_at'>[]
|
||||
}>('/api/v1/policies/rollouts?limit=50', { signal }),
|
||||
enabled: open && read,
|
||||
})
|
||||
const detail = useQuery({
|
||||
queryKey: [...key, rolloutId],
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<PolicyRollout>(`/api/v1/policies/rollouts/${rolloutId}`, {
|
||||
signal,
|
||||
}),
|
||||
enabled: open && read && Boolean(rolloutId),
|
||||
refetchInterval: open && rolloutId ? 10_000 : false,
|
||||
})
|
||||
const agents = useQuery({
|
||||
...agentsQueryOptions(),
|
||||
enabled: open && can('fw:agents:read'),
|
||||
})
|
||||
const name = (id: string) =>
|
||||
agents.data?.items.find((a) => a.id === id)?.name ?? 'Недоступный агент'
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (task: () => Promise<unknown>) => task(),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: key })
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
toast.error(e.message)
|
||||
setDiff(undefined)
|
||||
if (rolloutId) void detail.refetch()
|
||||
},
|
||||
})
|
||||
const send = (path: string, body: unknown, method = 'POST') =>
|
||||
apiFetch(path, { method, body: JSON.stringify(body) })
|
||||
const changeTargets = (ids: string[]) => {
|
||||
setTargets(ids)
|
||||
setCanaries((old) => old.filter((id) => ids.includes(id)))
|
||||
setDiff(undefined)
|
||||
}
|
||||
const close = (value: boolean) => {
|
||||
if (!value && (form.formState.isDirty || targets.length)) {
|
||||
setConfirmation({
|
||||
title: 'Закрыть черновик?',
|
||||
description:
|
||||
'Введённые данные сохранятся до ухода со страницы. Развёртывание не запускается при закрытии.',
|
||||
run: () => onOpenChange(false),
|
||||
})
|
||||
return
|
||||
}
|
||||
onOpenChange(value)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={close}
|
||||
title="Снимки и развёртывания"
|
||||
description="Зафиксируйте политику staging-агента, изучите diff, затем запустите canary. Подтверждение выполняется вручную."
|
||||
form={form}
|
||||
onSubmit={(values) => {
|
||||
if (!write) return
|
||||
mutation.mutate(async () => {
|
||||
const created = (await send(
|
||||
'/api/v1/policies/snapshots',
|
||||
createPolicySnapshotSchema.parse({
|
||||
source_agent_id: values.source[0],
|
||||
name: values.name,
|
||||
}),
|
||||
)) as PolicySnapshotSummary
|
||||
setSnapshot(created.id)
|
||||
setDiff(undefined)
|
||||
form.reset(values)
|
||||
toast.success('Снимок сохранён')
|
||||
})
|
||||
}}
|
||||
size="xl"
|
||||
footer={
|
||||
<Button type="button" variant="outline" onClick={() => close(false)}>
|
||||
Закрыть
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{!read ? (
|
||||
<Alert>
|
||||
<AlertTitle>Нет доступа к политикам</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{write && (
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="snapshot-name">
|
||||
Название снимка
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="snapshot-name"
|
||||
aria-invalid={Boolean(form.formState.errors.name)}
|
||||
{...form.register('name')}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.name]} />
|
||||
</Field>
|
||||
<StatsAgentSelect
|
||||
id="snapshot-source"
|
||||
label="Staging / источник"
|
||||
max={1}
|
||||
emptyMeansFleet={false}
|
||||
value={form.watch('source')}
|
||||
onChange={(value) =>
|
||||
form.setValue('source', value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.source]} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Создать снимок
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
)}
|
||||
<QueryState
|
||||
isLoading={snapshots.isLoading}
|
||||
isError={snapshots.isError}
|
||||
error={snapshots.error}
|
||||
onRetry={() => void snapshots.refetch()}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rollout-snapshot">Снимок (последние 50)</FieldLabel>
|
||||
<Select
|
||||
value={snapshot}
|
||||
onValueChange={(id) => {
|
||||
setSnapshot(id ?? '')
|
||||
setDiff(undefined)
|
||||
}}
|
||||
items={(snapshots.data?.items ?? []).map((s) => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger id="rollout-snapshot" className="w-full">
|
||||
<SelectValue placeholder="Выберите снимок" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{snapshots.data?.items.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{!snapshots.data?.items.length && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Снимков пока нет. Создайте снимок источника.
|
||||
</p>
|
||||
)}
|
||||
</QueryState>
|
||||
<StatsAgentSelect
|
||||
id="rollout-targets"
|
||||
label="Целевые агенты"
|
||||
emptyMeansFleet={false}
|
||||
value={targets}
|
||||
onChange={changeTargets}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!snapshot || !targets.length || mutation.isPending}
|
||||
onClick={() =>
|
||||
mutation.mutate(async () => {
|
||||
setDiff(
|
||||
(await send(
|
||||
`/api/v1/policies/snapshots/${snapshot}/diff`,
|
||||
policySnapshotDiffSchema.parse({ agent_ids: targets }),
|
||||
)) as PolicySnapshotDiff,
|
||||
)
|
||||
})
|
||||
}
|
||||
>
|
||||
Предпросмотр diff
|
||||
</Button>
|
||||
{diff && (
|
||||
<ResourcePage
|
||||
title="Изменения политики"
|
||||
data={diff.items}
|
||||
getRowId={(r) => r.agent_id}
|
||||
columns={[
|
||||
{
|
||||
id: 'agent',
|
||||
header: 'Агент',
|
||||
cell: ({ row }) => name(row.original.agent_id),
|
||||
},
|
||||
{
|
||||
id: 'diff',
|
||||
header: 'CIDR',
|
||||
cell: ({ row }) =>
|
||||
`+${row.original.added} / −${row.original.removed}`,
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
header: 'Изменения',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span>
|
||||
{row.original.default_action_changed
|
||||
? 'Default action изменится. '
|
||||
: ''}
|
||||
{row.original.port_rules_changed
|
||||
? 'Правила портов изменятся. '
|
||||
: ''}
|
||||
{row.original.conflict ?? 'Допущен'}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.examples
|
||||
.map((e) => `${e.change} ${e.cidr}`)
|
||||
.join('; ')}
|
||||
{row.original.truncated ? ' · примеры сокращены' : ''}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
filterFields={[]}
|
||||
filters={[]}
|
||||
onFiltersChange={() => {}}
|
||||
getFilterFieldValue={() => undefined}
|
||||
/>
|
||||
)}
|
||||
{write && (
|
||||
<>
|
||||
<StatsAgentSelect
|
||||
id="rollout-canaries"
|
||||
label="Canary (подмножество целей)"
|
||||
emptyMeansFleet={false}
|
||||
allowedIds={targets}
|
||||
value={canaries}
|
||||
onChange={setCanaries}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={
|
||||
!rolloutReady(diff, targets, snapshot) ||
|
||||
!canaries.length ||
|
||||
mutation.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
setConfirmation({
|
||||
title: 'Запустить canary?',
|
||||
description:
|
||||
'Полная политика снимка заменит целевые overrides. Сейчас изменятся только canary; остальные цели ждут ручного подтверждения.',
|
||||
run: () =>
|
||||
mutation.mutate(async () => {
|
||||
const body = createPolicyRolloutSchema.parse({
|
||||
snapshot_id: snapshot,
|
||||
targets: diff!.items.map((t) => ({
|
||||
agent_id: t.agent_id,
|
||||
expected_generation: t.expected_generation,
|
||||
})),
|
||||
canary_ids: canaries,
|
||||
})
|
||||
const result = (await send(
|
||||
'/api/v1/policies/rollouts',
|
||||
body,
|
||||
)) as PolicyRollout
|
||||
setRolloutId(result.id)
|
||||
setDiff(undefined)
|
||||
setTargets([])
|
||||
setCanaries([])
|
||||
toast.success(
|
||||
'Canary опубликован; ожидается ACK агентов',
|
||||
)
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
Запустить canary
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<QueryState
|
||||
isLoading={history.isLoading}
|
||||
isError={history.isError}
|
||||
error={history.error}
|
||||
onRetry={() => void history.refetch()}
|
||||
>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rollout-history">Развёртывание (последние 50)</FieldLabel>
|
||||
<Select
|
||||
value={rolloutId}
|
||||
onValueChange={(id) => setRolloutId(id ?? '')}
|
||||
items={(history.data?.items ?? []).map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.created_at} · ${stateLabel[r.state]}`,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger id="rollout-history" className="w-full">
|
||||
<SelectValue placeholder="Выберите развёртывание" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{history.data?.items.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.created_at} · {stateLabel[r.state]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{!history.data?.items.length && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Развёртываний пока нет.
|
||||
</p>
|
||||
)}
|
||||
</QueryState>
|
||||
{rolloutId && (
|
||||
<QueryState
|
||||
isLoading={detail.isLoading}
|
||||
isError={detail.isError}
|
||||
hasData={Boolean(detail.data)}
|
||||
error={detail.error}
|
||||
onRetry={() => void detail.refetch()}
|
||||
>
|
||||
{detail.data && (
|
||||
<>
|
||||
<Badge variant="info-light">
|
||||
{stateLabel[detail.data.state]} · версия{' '}
|
||||
{detail.data.version}
|
||||
</Badge>
|
||||
<ResourcePage
|
||||
title="Применение агентами"
|
||||
data={detail.data.targets}
|
||||
getRowId={(r) => r.agent_id}
|
||||
columns={[
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Агент',
|
||||
cell: ({ row }) =>
|
||||
`${name(row.original.agent_id)}${row.original.is_canary ? ' · canary' : ''}`,
|
||||
},
|
||||
{
|
||||
id: 'ack',
|
||||
header: 'ACK',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.acknowledged
|
||||
? 'success-light'
|
||||
: 'warning-light'
|
||||
}
|
||||
>
|
||||
{row.original.phase === 'released'
|
||||
? 'Pin снят'
|
||||
: row.original.phase === 'pending'
|
||||
? 'Не опубликован'
|
||||
: row.original.acknowledged
|
||||
? 'Подтверждён агентом'
|
||||
: 'Ожидается ACK'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'revision',
|
||||
header: 'Ревизия: целевая / применённая',
|
||||
cell: ({ row }) =>
|
||||
`${row.original.desired_revision ?? '—'} / ${row.original.applied_revision ?? '—'}`,
|
||||
},
|
||||
{
|
||||
id: 'release',
|
||||
header: 'Pin',
|
||||
cell: ({ row }) =>
|
||||
can('fw:agents:write') &&
|
||||
row.original.desired_revision !== null &&
|
||||
row.original.phase !== 'released' ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || detail.isError}
|
||||
onClick={() => {
|
||||
const target = row.original
|
||||
const id = detail.data!.id
|
||||
setConfirmation({
|
||||
title:
|
||||
'Снять pin и вернуться к live policy?',
|
||||
description:
|
||||
'Текущая живая политика будет проверена и скомпилирована перед снятием pin. Она может отличаться от снимка. При ошибке pin сохранится.',
|
||||
run: () =>
|
||||
mutation.mutate(() =>
|
||||
send(
|
||||
`/api/v1/agents/${target.agent_id}/policy-pin/release`,
|
||||
releasePolicyPinSchema.parse({
|
||||
rollout_id: id,
|
||||
expected_generation:
|
||||
target.desired_revision,
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
}}
|
||||
>
|
||||
Снять pin
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
filterFields={[]}
|
||||
filters={[]}
|
||||
onFiltersChange={() => {}}
|
||||
getFilterFieldValue={() => undefined}
|
||||
/>
|
||||
{write && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['confirm', 'rollback'] as const).map((action) => (
|
||||
<Button
|
||||
key={action}
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={
|
||||
mutation.isPending ||
|
||||
detail.isError ||
|
||||
(action === 'confirm'
|
||||
? !canConfirmRollout(detail.data!)
|
||||
: detail.data!.state === 'rolled_back')
|
||||
}
|
||||
onClick={() => {
|
||||
const current = detail.data!
|
||||
setConfirmation({
|
||||
title:
|
||||
action === 'confirm'
|
||||
? 'Подтвердить развёртывание?'
|
||||
: 'Откатить развёртывание?',
|
||||
description:
|
||||
action === 'confirm'
|
||||
? 'После проверки свежих точных ACK canary политика будет опубликована оставшимся целям. Это ещё не подтверждение применения всеми агентами.'
|
||||
: 'Будет опубликована предыдущая desired policy с новой ревизией. Применение требует ACK, pin останется активен.',
|
||||
run: () =>
|
||||
mutation.mutate(() =>
|
||||
send(
|
||||
`/api/v1/policies/rollouts/${current.id}/${action}`,
|
||||
policyRolloutTransitionSchema.parse({
|
||||
expected_version: current.version,
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
}}
|
||||
>
|
||||
{action === 'confirm'
|
||||
? 'Подтвердить после ACK'
|
||||
: 'Откатить'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormSheet>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmation)}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) setConfirmation(undefined)
|
||||
}}
|
||||
title={confirmation?.title ?? ''}
|
||||
description={confirmation?.description ?? ''}
|
||||
confirmLabel="Подтвердить"
|
||||
onConfirm={() => {
|
||||
confirmation?.run()
|
||||
setConfirmation(undefined)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
BanIcon,
|
||||
@@ -95,8 +96,7 @@ export function PolicyRulesSortable({
|
||||
body: JSON.stringify({ ordered_ids }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
toast.error(e.message)
|
||||
@@ -110,8 +110,7 @@ export function PolicyRulesSortable({
|
||||
body: JSON.stringify({ enabled }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { agentsQueryOptions } from '@/queries'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Combobox, ComboboxChips, ComboboxChip, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor } from '@evofw/ui/components/combobox'
|
||||
|
||||
type Choice = { id: string; name: string; hostname?: string | null }
|
||||
|
||||
export function StatsAgentSelect({ value, onChange, label = 'Агенты', id = 'stats-agents', max = 100, emptyMeansFleet = true, allowedIds }: { value: string[]; onChange: (ids: string[]) => void; label?: string; id?: string; max?: number; emptyMeansFleet?: boolean; allowedIds?: string[] }) {
|
||||
const canRead = useCan()('fw:agents:read')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const anchor = useComboboxAnchor()
|
||||
const catalog = useQuery({ ...agentsQueryOptions(), enabled: canRead && (open || value.length > 0), staleTime: 60_000, refetchInterval: false })
|
||||
const choices = (catalog.data?.items ?? []).filter((agent) => !allowedIds || allowedIds.includes(agent.id))
|
||||
const selected = useMemo(() => value.map((id) => choices.find((agent) => agent.id === id) ?? { id, name: 'Недоступный агент' }), [choices, value])
|
||||
return <Field className="min-w-48 flex-1" data-invalid={Boolean(error) || undefined}>
|
||||
<div className="flex items-center justify-between gap-2"><FieldLabel htmlFor={id}>{label}</FieldLabel><Button type="button" size="sm" variant="ghost" disabled={!value.length} onClick={() => { setError(undefined); onChange([]) }}>{emptyMeansFleet ? 'Весь флот' : 'Очистить'}</Button></div>
|
||||
<Combobox<Choice, true> multiple items={choices} value={selected} disabled={!canRead} onOpenChange={setOpen}
|
||||
itemToStringLabel={(agent) => `${agent.name}${agent.hostname ? ` · ${agent.hostname}` : ''}`} itemToStringValue={(agent) => agent.id} isItemEqualToValue={(a, b) => a.id === b.id}
|
||||
onValueChange={(next) => { if (next.length > max) { setError(`Можно выбрать не более ${max} агентов`); return }; setError(undefined); onChange(next.map((agent) => agent.id).sort()) }}>
|
||||
<ComboboxChips ref={anchor}>
|
||||
<ComboboxValue>{(agents: Choice[]) => agents.map((agent) => <ComboboxChip key={agent.id} className="max-w-full" aria-label={`Убрать ${agent.name}`}><span className="max-w-52 truncate">{agent.name}</span></ComboboxChip>)}</ComboboxValue>
|
||||
<ComboboxChipsInput id={id} aria-label={label} placeholder={value.length ? 'Добавить агента…' : `${emptyMeansFleet ? 'Весь флот · ' : ''}Поиск по имени`} />
|
||||
</ComboboxChips>
|
||||
<ComboboxContent anchor={anchor}>
|
||||
{catalog.isFetching && !catalog.data ? <p className="text-muted-foreground p-2 text-sm" role="status">Загрузка агентов…</p> : null}
|
||||
<ComboboxEmpty>{catalog.isError ? 'Не удалось загрузить агентов' : 'Агенты не найдены'}</ComboboxEmpty>
|
||||
<ComboboxList>{(agent: Choice) => <ComboboxItem key={agent.id} value={agent} disabled={value.length >= max && !value.includes(agent.id)}><span className="flex min-w-0 flex-col"><span>{agent.name}</span>{agent.hostname ? <span className="text-muted-foreground text-xs">{agent.hostname}</span> : null}</span></ComboboxItem>}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldDescription>{canRead ? `${emptyMeansFleet ? 'Пустой выбор — весь флот. ' : 'Только явно выбранные агенты. '}Выбрано: ${value.length}/${max}.` : 'Выбор отдельных агентов недоступен по вашим правам.'}</FieldDescription>
|
||||
<FieldError>{error}</FieldError>
|
||||
{open && catalog.isError ? <QueryState isError error={catalog.error} onRetry={() => void catalog.refetch()}>{null}</QueryState> : null}
|
||||
</Field>
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { TimeseriesPoint } from '@evofw/shared'
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@evofw/ui/components/chart'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { formatNumber } from '@/lib/format'
|
||||
import { summarizeTimeseries } from '@/lib/timeseries'
|
||||
|
||||
const chartConfig = {
|
||||
packets_dropped: { label: 'Заблокировано, пакетов', color: 'var(--chart-1)' },
|
||||
packets_accepted: { label: 'Разрешено правилами, пакетов', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
export function formatBucketLabel(timestamp: string, includeDate = false) {
|
||||
const date = new Date(timestamp)
|
||||
if (!Number.isFinite(date.getTime())) return '—'
|
||||
return date.toLocaleString('ru-RU', includeDate
|
||||
? { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }
|
||||
: { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function TimeseriesQualitySummary({ items }: { items: TimeseriesPoint[] }) {
|
||||
const summary = summarizeTimeseries(items)
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2" role="status">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Покрытие: {summary.coverage === null ? '—' : `${Math.round(summary.coverage * 100)}%`} ·
|
||||
{' '}интервалов с данными: {summary.knownBuckets}/{items.length}
|
||||
</span>
|
||||
{summary.missingBuckets > 0 ? <Badge variant="warning-light" size="sm">Пропуски: {summary.missingBuckets}</Badge> : null}
|
||||
{summary.estimatedBuckets > 0 ? <Badge variant="info-light" size="sm">Оценка: {summary.estimatedBuckets}</Badge> : null}
|
||||
{summary.partialBuckets > 0 ? <Badge variant="warning-light" size="sm">Неполные: {summary.partialBuckets}</Badge> : null}
|
||||
{summary.truncatedBuckets > 0 ? <Badge variant="warning-light" size="sm">Обрезанные: {summary.truncatedBuckets}</Badge> : null}
|
||||
{summary.lostReports > 0 ? <Badge variant="warning-light" size="sm">Есть потери отчётов</Badge> : null}
|
||||
{summary.unsupported.length > 0 ? <Badge variant="secondary" size="sm" title={summary.unsupported.join(', ')}>Неподдерживаемые метрики: {summary.unsupported.length}</Badge> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** API buckets pass through unchanged: null remains a gap, including when only
|
||||
* one selected agent has no usable sample. Never connect across missing data. */
|
||||
export function TrafficTimeseriesChart({ items, compact = false, daily = false }: { items: TimeseriesPoint[]; compact?: boolean; daily?: boolean }) {
|
||||
const summary = summarizeTimeseries(items)
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<TimeseriesQualitySummary items={items} />
|
||||
{summary.knownBuckets === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">За этот период нет измерений пакетов v2. Пропуски не означают нулевой трафик.</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className={compact ? 'aspect-[3/1] w-full' : 'aspect-[2/1] w-full'} aria-label="Пакеты за интервалы времени; пропуски данных показаны разрывами">
|
||||
<AreaChart data={items} margin={{ left: 0, right: 8, top: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="timestamp" tickLine={false} axisLine={false} tickFormatter={(value) => daily ? new Date(value).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit' }) : formatBucketLabel(value)} />
|
||||
<YAxis tickLine={false} axisLine={false} width={48} tickFormatter={(value) => formatNumber(value)} />
|
||||
<ChartTooltip filterNull={false} content={<ChartTooltipContent
|
||||
labelFormatter={(_label, payload) => {
|
||||
const point = payload?.[0]?.payload as TimeseriesPoint | undefined
|
||||
return point ? <div className="flex flex-col gap-1">
|
||||
<span>{formatBucketLabel(point.timestamp, true)}</span>
|
||||
<span className="text-muted-foreground text-xs">Покрытие {Math.round(point.coverage * 100)}% · {point.estimated ? 'оценка' : 'измерение'}</span>
|
||||
<span className="text-muted-foreground text-xs">Пакетов/с: {point.pps === null || point.coverage !== 1 || point.quality.partial ? '— · неполное измерение' : formatNumber(point.pps)}{point.quality.partial ? ' · неполный отчёт' : ''}{point.quality.truncated ? ' · данные обрезаны' : ''}{point.quality.lost_reports > 0 ? ' · есть потери отчётов' : ''}</span>
|
||||
</div> : null
|
||||
}}
|
||||
formatter={(value, name) => <><span className="text-muted-foreground">{name === 'packets_dropped' ? 'Заблокировано' : 'Разрешено EvoFirewall'}</span><span className="ml-auto font-mono tabular-nums">{value === null || value === undefined ? '—' : formatNumber(Number(value))}</span></>}
|
||||
/>} />
|
||||
<Area type="monotone" dataKey="packets_dropped" stroke="var(--color-packets_dropped)" fill="var(--color-packets_dropped)" fillOpacity={0.2} strokeWidth={2} connectNulls={false} isAnimationActive={false} />
|
||||
<Area type="monotone" dataKey="packets_accepted" stroke="var(--color-packets_accepted)" fill="var(--color-packets_accepted)" fillOpacity={0.15} strokeWidth={2} connectNulls={false} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
webhookBodySchema,
|
||||
webhookPatchSchema,
|
||||
webhookEventTypeSchema,
|
||||
type WebhookEndpoint,
|
||||
type WebhooksResponse,
|
||||
type WebhookDeliveriesResponse,
|
||||
} from '@evofw/shared'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { ResourcePage } from '@/components/reui-kit'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/reui/alert'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
import { Checkbox } from '@evofw/ui/components/checkbox'
|
||||
import {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldSet,
|
||||
FieldLegend,
|
||||
FieldDescription,
|
||||
} from '@evofw/ui/components/field'
|
||||
|
||||
export const webhookEditorSchema = webhookBodySchema.extend({
|
||||
enabled: z.boolean(),
|
||||
secret: z
|
||||
.string()
|
||||
.refine(
|
||||
(value) => value === '' || (value.length >= 32 && value.length <= 256),
|
||||
'Секрет: 32–256 символов',
|
||||
),
|
||||
})
|
||||
const defaults = {
|
||||
name: '',
|
||||
url: '',
|
||||
secret: '',
|
||||
events: ['agent.offline'] as z.infer<typeof webhookEventTypeSchema>[],
|
||||
enabled: false,
|
||||
}
|
||||
export function webhookEditorBody(
|
||||
values: z.infer<typeof webhookEditorSchema>,
|
||||
editing: boolean,
|
||||
) {
|
||||
if (!editing) return webhookBodySchema.parse(values)
|
||||
const { secret, ...rest } = values
|
||||
return webhookPatchSchema.parse(secret ? values : rest)
|
||||
}
|
||||
|
||||
export function WebhooksSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (value: boolean) => void
|
||||
}) {
|
||||
const allowed = useCan()('fw:settings:admin')
|
||||
const qc = useQueryClient()
|
||||
const [edit, setEdit] = useState<WebhookEndpoint>()
|
||||
const [deliveryId, setDeliveryId] = useState('')
|
||||
const [discard, setDiscard] = useState(false)
|
||||
const [remove, setRemove] = useState<WebhookEndpoint>()
|
||||
const form = useForm<z.infer<typeof webhookEditorSchema>>({
|
||||
resolver: zodResolver(webhookEditorSchema),
|
||||
defaultValues: defaults,
|
||||
})
|
||||
const list = useQuery({
|
||||
queryKey: ['webhooks'],
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<WebhooksResponse>('/api/v1/webhooks', { signal }),
|
||||
enabled: open && allowed,
|
||||
})
|
||||
const deliveries = useQuery({
|
||||
queryKey: ['webhooks', deliveryId, 'deliveries'],
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<WebhookDeliveriesResponse>(
|
||||
`/api/v1/webhooks/${deliveryId}/deliveries`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: open && allowed && Boolean(deliveryId),
|
||||
refetchInterval: open && deliveryId ? 30_000 : false,
|
||||
})
|
||||
const save = useMutation({
|
||||
mutationFn: (values: z.infer<typeof webhookEditorSchema>) =>
|
||||
apiFetch(edit ? `/api/v1/webhooks/${edit.id}` : '/api/v1/webhooks', {
|
||||
method: edit ? 'PATCH' : 'POST',
|
||||
body: JSON.stringify(webhookEditorBody(values, Boolean(edit))),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
form.reset(defaults)
|
||||
setEdit(undefined)
|
||||
toast.success('Webhook сохранён')
|
||||
void qc.invalidateQueries({ queryKey: ['webhooks'] })
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
if (!edit && !form.getValues('secret'))
|
||||
form.setError('secret', {
|
||||
message: 'Для нового webhook нужен секрет от 32 символов',
|
||||
})
|
||||
else toast.error(e.message)
|
||||
},
|
||||
})
|
||||
const destroy = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/webhooks/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: (_data, id) => {
|
||||
if (deliveryId === id) setDeliveryId('')
|
||||
if (edit?.id === id) {
|
||||
setEdit(undefined)
|
||||
form.reset(defaults)
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['webhooks'] })
|
||||
toast.success('Webhook удалён')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
const close = (value: boolean) => {
|
||||
if (!value && form.formState.isDirty) {
|
||||
setDiscard(true)
|
||||
return
|
||||
}
|
||||
if (!value) {
|
||||
form.reset(defaults)
|
||||
setEdit(undefined)
|
||||
}
|
||||
onOpenChange(value)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormSheet
|
||||
open={open}
|
||||
onOpenChange={close}
|
||||
form={form}
|
||||
title="Webhooks"
|
||||
description="Уведомления о сбоях агентов и источников. Секрет хранится зашифрованным и не возвращается при чтении."
|
||||
size="xl"
|
||||
onSubmit={(values) => {
|
||||
if (!allowed || !list.data?.configured) return
|
||||
if (!edit && values.secret.length < 32) {
|
||||
form.setError('secret', {
|
||||
message: 'Для нового webhook нужен секрет от 32 символов',
|
||||
})
|
||||
return
|
||||
}
|
||||
save.mutate(values)
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => close(false)}
|
||||
>
|
||||
Закрыть
|
||||
</Button>
|
||||
{allowed && (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!list.data?.configured || list.isError || save.isPending
|
||||
}
|
||||
>
|
||||
{edit ? 'Сохранить изменения' : 'Добавить webhook'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{!allowed ? (
|
||||
<Alert>
|
||||
<AlertTitle>Требуются права администратора настроек</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<QueryState
|
||||
isLoading={list.isLoading}
|
||||
isError={list.isError}
|
||||
hasData={Boolean(list.data)}
|
||||
error={list.error}
|
||||
onRetry={() => void list.refetch()}
|
||||
>
|
||||
{list.data && (
|
||||
<>
|
||||
{!list.data.configured && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>Шифрование webhook не настроено</AlertTitle>
|
||||
<AlertDescription>
|
||||
На сервере отсутствует ключ шифрования. Добавление и
|
||||
изменение endpoint недоступны; настройте ключ на control
|
||||
plane.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Очередь: {list.data.queue.pending} ожидают ·{' '}
|
||||
{list.data.queue.dead} исчерпали попытки ·{' '}
|
||||
{list.data.queue.dropped} пропущено. Лимит endpoint:{' '}
|
||||
{list.data.limits.endpoints}.
|
||||
</p>
|
||||
<ResourcePage
|
||||
title="Endpoints"
|
||||
data={list.data.items}
|
||||
getRowId={(r) => r.id}
|
||||
columns={[
|
||||
{ accessorKey: 'name', header: 'Название' },
|
||||
{
|
||||
id: 'status',
|
||||
header: 'Состояние',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.enabled ? 'success-light' : 'secondary'
|
||||
}
|
||||
>
|
||||
{row.original.enabled ? 'Включён' : 'Выключен'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Действия',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={
|
||||
form.formState.isDirty || !list.data?.configured
|
||||
}
|
||||
onClick={() => {
|
||||
setEdit(row.original)
|
||||
const endpoint = row.original
|
||||
form.reset({
|
||||
name: endpoint.name,
|
||||
url: endpoint.url,
|
||||
events: endpoint.events,
|
||||
enabled: endpoint.enabled,
|
||||
secret: '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDeliveryId(row.original.id)}
|
||||
>
|
||||
Доставки
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={destroy.isPending}
|
||||
onClick={() => setRemove(row.original)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
filterFields={[]}
|
||||
filters={[]}
|
||||
onFiltersChange={() => {}}
|
||||
getFilterFieldValue={() => undefined}
|
||||
emptyState={{
|
||||
title: 'Webhook пока нет',
|
||||
description:
|
||||
'Заполните форму ниже. Новый endpoint по умолчанию выключен.',
|
||||
}}
|
||||
/>
|
||||
<fieldset
|
||||
disabled={!list.data.configured || save.isPending}
|
||||
className="contents"
|
||||
>
|
||||
<FieldGroup>
|
||||
{edit && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge variant="info-light">
|
||||
Редактирование: {edit.name}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEdit(undefined)
|
||||
form.reset(defaults)
|
||||
}}
|
||||
>
|
||||
Отменить редактирование
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Field
|
||||
data-invalid={
|
||||
Boolean(form.formState.errors.name) || undefined
|
||||
}
|
||||
>
|
||||
<FieldLabel htmlFor="webhook-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="webhook-name"
|
||||
{...form.register('name')}
|
||||
aria-invalid={Boolean(form.formState.errors.name)}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.name]} />
|
||||
</Field>
|
||||
<Field
|
||||
data-invalid={
|
||||
Boolean(form.formState.errors.url) || undefined
|
||||
}
|
||||
>
|
||||
<FieldLabel htmlFor="webhook-url">URL</FieldLabel>
|
||||
<Input
|
||||
id="webhook-url"
|
||||
{...form.register('url')}
|
||||
aria-invalid={Boolean(form.formState.errors.url)}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.url]} />
|
||||
</Field>
|
||||
<Field
|
||||
data-invalid={
|
||||
Boolean(form.formState.errors.secret) || undefined
|
||||
}
|
||||
>
|
||||
<FieldLabel htmlFor="webhook-secret">
|
||||
{edit ? 'Новый секрет (необязательно)' : 'Секрет'}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="webhook-secret"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
{...form.register('secret')}
|
||||
aria-invalid={Boolean(form.formState.errors.secret)}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{edit
|
||||
? 'Пустое поле сохраняет существующий секрет. '
|
||||
: ''}
|
||||
32–256 символов. Секрет не отображается повторно.
|
||||
</FieldDescription>
|
||||
<FieldError errors={[form.formState.errors.secret]} />
|
||||
</Field>
|
||||
<FieldSet>
|
||||
<FieldLegend>События</FieldLegend>
|
||||
<Controller
|
||||
name="events"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<FieldGroup>
|
||||
{webhookEventTypeSchema.options.map((event) => (
|
||||
<Field orientation="horizontal" key={event}>
|
||||
<Checkbox
|
||||
id={`event-${event}`}
|
||||
checked={field.value.includes(event)}
|
||||
onCheckedChange={(checked) =>
|
||||
field.onChange(
|
||||
checked
|
||||
? [...field.value, event]
|
||||
: field.value.filter(
|
||||
(e) => e !== event,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FieldLabel htmlFor={`event-${event}`}>
|
||||
{event}
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
))}
|
||||
</FieldGroup>
|
||||
)}
|
||||
/>
|
||||
<FieldError errors={[form.formState.errors.events]} />
|
||||
</FieldSet>
|
||||
<Field orientation="horizontal">
|
||||
<Controller
|
||||
name="enabled"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
id="webhook-enabled"
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<FieldLabel htmlFor="webhook-enabled">
|
||||
Включить доставки
|
||||
</FieldLabel>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</fieldset>
|
||||
{deliveryId && (
|
||||
<QueryState
|
||||
isLoading={deliveries.isLoading}
|
||||
isError={deliveries.isError}
|
||||
error={deliveries.error}
|
||||
onRetry={() => void deliveries.refetch()}
|
||||
>
|
||||
<ResourcePage
|
||||
title={`Доставки · ${list.data.items.find((r) => r.id === deliveryId)?.name ?? ''}`}
|
||||
data={deliveries.data?.items ?? []}
|
||||
getRowId={(r) => r.id}
|
||||
columns={[
|
||||
{ accessorKey: 'event_type', header: 'Событие' },
|
||||
{ accessorKey: 'status', header: 'Состояние' },
|
||||
{ accessorKey: 'attempts', header: 'Попытки' },
|
||||
{
|
||||
accessorKey: 'next_attempt_at',
|
||||
header: 'Следующая попытка',
|
||||
},
|
||||
{ accessorKey: 'last_error', header: 'Ошибка' },
|
||||
]}
|
||||
filterFields={[]}
|
||||
filters={[]}
|
||||
onFiltersChange={() => {}}
|
||||
getFilterFieldValue={() => undefined}
|
||||
emptyState={{
|
||||
title: 'Нет ожидающих или исчерпавших попытки доставок',
|
||||
description:
|
||||
'Успешные доставки не хранятся в этой очереди.',
|
||||
}}
|
||||
/>
|
||||
</QueryState>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</QueryState>
|
||||
)}
|
||||
</FormSheet>
|
||||
<ConfirmDialog
|
||||
open={discard}
|
||||
onOpenChange={setDiscard}
|
||||
title="Сбросить изменения webhook?"
|
||||
description="Несохранённый черновик и введённый секрет будут удалены."
|
||||
confirmLabel="Сбросить и закрыть"
|
||||
onConfirm={() => {
|
||||
form.reset(defaults)
|
||||
setEdit(undefined)
|
||||
setDiscard(false)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={Boolean(remove)}
|
||||
onOpenChange={(value) => {
|
||||
if (!value) setRemove(undefined)
|
||||
}}
|
||||
title="Удалить webhook?"
|
||||
description={`Endpoint «${remove?.name ?? ''}» и его ожидающие доставки будут удалены.`}
|
||||
onConfirm={() => {
|
||||
if (remove) destroy.mutate(remove.id)
|
||||
setRemove(undefined)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { rollingStatsRequest, type StatsPreset } from '@/lib/timeseries'
|
||||
|
||||
export function useStatsWindow(preset: StatsPreset, agentIds: string[]) {
|
||||
// Minute ticks are the minimum supported telemetry bucket, not animation.
|
||||
const [now, setNow] = useState(() => new Date().getTime())
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(new Date().getTime()), 60_000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
return rollingStatsRequest(preset, agentIds, now)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError, shouldRetryQuery } from './api'
|
||||
|
||||
describe('query retry policy', () => {
|
||||
it('does not retry authorization, missing resources or rejected input', () => {
|
||||
for (const status of [400, 401, 403, 404, 409, 422]) {
|
||||
expect(shouldRetryQuery(0, new ApiError('Request failed', status))).toBe(false)
|
||||
}
|
||||
})
|
||||
it('allows one retry for transient server and network errors', () => {
|
||||
for (const error of [new ApiError('Unavailable', 503), new ApiError('Throttled', 429), new TypeError('Network error')]) {
|
||||
expect(shouldRetryQuery(0, error)).toBe(true)
|
||||
expect(shouldRetryQuery(1, error)).toBe(false)
|
||||
}
|
||||
})
|
||||
it('never retries cancelled requests', () => {
|
||||
expect(shouldRetryQuery(0, new DOMException('Aborted', 'AbortError'))).toBe(false)
|
||||
})
|
||||
})
|
||||
+22
-4
@@ -2,10 +2,23 @@ import { getToken, clearToken, redirectToPortalLogin, isAuthEnabled } from './au
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? ''
|
||||
|
||||
export async function apiFetch<T = unknown>(
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, public readonly status: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldRetryQuery(failureCount: number, error: Error): boolean {
|
||||
if (error.name === 'AbortError') return false
|
||||
if (error instanceof ApiError && error.status < 500 && error.status !== 408 && error.status !== 429) return false
|
||||
return failureCount < 1
|
||||
}
|
||||
|
||||
export async function apiRequest(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (!headers.has('Content-Type') && init.body) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
@@ -17,7 +30,7 @@ export async function apiFetch<T = unknown>(
|
||||
if (res.status === 401 && isAuthEnabled()) {
|
||||
clearToken()
|
||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||
throw new Error('Unauthorized')
|
||||
throw new ApiError('Unauthorized', 401)
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
@@ -27,8 +40,13 @@ export async function apiFetch<T = unknown>(
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(message)
|
||||
throw new ApiError(message, res.status)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
export async function apiFetch<T = unknown>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await apiRequest(path, init)
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { parseListEntry, splitListPlaintext } from '@evofw/shared'
|
||||
|
||||
export function parseListImportDraft(text: string) {
|
||||
const seen = new Set<string>()
|
||||
let invalid = 0
|
||||
let duplicates = 0
|
||||
for (const token of splitListPlaintext(text.trim())) {
|
||||
try {
|
||||
const parsed = parseListEntry(token)
|
||||
if (seen.has(parsed.value)) duplicates++
|
||||
else seen.add(parsed.value)
|
||||
} catch {
|
||||
invalid++
|
||||
}
|
||||
}
|
||||
return { valid: seen.size, invalid, duplicates, values: [...seen] }
|
||||
}
|
||||
|
||||
export function validatedListImportValues(text: string): string[] {
|
||||
const parsed = parseListImportDraft(text)
|
||||
if (parsed.invalid)
|
||||
throw new Error(
|
||||
'Исправьте все нераспознанные записи. Частичный импорт не выполняется.',
|
||||
)
|
||||
if (!parsed.valid) throw new Error('Нет валидных записей')
|
||||
return parsed.values
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { fetchStatsCsv } from './stats-csv'
|
||||
import { rollingStatsRequest } from './timeseries'
|
||||
|
||||
vi.mock('./auth', () => ({ getToken: () => 'fixture-csv-token', clearToken: vi.fn(), redirectToPortalLogin: vi.fn(), isAuthEnabled: () => false }))
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('authenticated CSV export', () => {
|
||||
it('sends the bearer header and exact bounded range without exposing a token in the URL', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response('timestamp,packets\n2026-10-24,12', { headers: { 'Content-Type': 'text/csv' } }))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
const request = rollingStatsRequest('24h', ['fixture-agent'], Date.parse('2026-10-24T12:00:00Z'))
|
||||
const blob = await fetchStatsCsv(request)
|
||||
expect(await blob.text()).toContain('timestamp,packets')
|
||||
const [url, init] = fetch.mock.calls[0]
|
||||
expect(url).toContain('/stats/timeseries.csv?')
|
||||
expect(url).not.toContain('fixture-csv-token')
|
||||
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer fixture-csv-token')
|
||||
expect(new URLSearchParams(String(url).split('?')[1]).get('agent_ids')).toBe('fixture-agent')
|
||||
})
|
||||
it('rejects oversized or non-CSV successful responses', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('no', { headers: { 'Content-Type': 'text/csv', 'Content-Length': '9999999' } })))
|
||||
const request = rollingStatsRequest('1h', [], Date.parse('2026-10-24T12:00:00Z'))
|
||||
await expect(fetchStatsCsv(request)).rejects.toThrow('размер')
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({ error: 'unexpected' })))
|
||||
await expect(fetchStatsCsv(request)).rejects.toThrow('CSV')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { timeseriesSearch, type TimeseriesRequest } from './timeseries'
|
||||
|
||||
const MAX_CSV_BYTES = 2 * 1024 * 1024
|
||||
|
||||
/** Uses the same authorization, HTTP errors and bounded request as JSON. A
|
||||
* bearer token never appears in an export URL or download filename. */
|
||||
export async function fetchStatsCsv(request: TimeseriesRequest, signal?: AbortSignal): Promise<Blob> {
|
||||
const search = timeseriesSearch(request)
|
||||
const response = await apiRequest(`/api/v1/stats/timeseries.csv?${search}`, { signal, headers: { Accept: 'text/csv' } })
|
||||
if (!response.headers.get('Content-Type')?.toLowerCase().includes('text/csv')) throw new Error('Сервер не вернул CSV')
|
||||
if (Number(response.headers.get('Content-Length')) > MAX_CSV_BYTES) throw new Error('CSV превышает допустимый размер')
|
||||
const blob = await response.blob()
|
||||
if (blob.size > MAX_CSV_BYTES) throw new Error('CSV превышает допустимый размер')
|
||||
return blob
|
||||
}
|
||||
|
||||
export function saveStatsCsv(blob: Blob, request: TimeseriesRequest) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `evofw-packets-${request.resolution}-${request.from.slice(0, 10)}-${request.to.slice(0, 10)}.csv`
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TimeseriesPoint } from '@evofw/shared'
|
||||
import { parseStatsAgentIds, rollingStatsRequest, summarizeTimeseries, timeseriesSearch, previousStatsRequest, trafficRates, compareTrafficPeriods } from './timeseries'
|
||||
|
||||
export const point: TimeseriesPoint = { timestamp: '2026-10-24T00:00:00.000Z', packets_dropped: 12, packets_accepted: 30, pps: 0.7, coverage: 1, estimated: false, quality: { partial: false, truncated: false, unsupported: [], lost_reports: 0 } }
|
||||
|
||||
describe('bounded UTC statistics requests', () => {
|
||||
it('aligns completed half-open minute buckets and normalizes agent IDs', () => {
|
||||
const request = rollingStatsRequest('1h', ['b', 'a'], Date.parse('2026-10-24T12:34:56.789Z'))
|
||||
expect(request.from).toBe('2026-10-24T11:34:00.000Z')
|
||||
expect(request.to).toBe('2026-10-24T12:34:00.000Z')
|
||||
expect(new URLSearchParams(timeseriesSearch(request)).get('agent_ids')).toBe('a,b')
|
||||
})
|
||||
it('bounds exports and rejects invalid selections before a network request', () => {
|
||||
expect(() => parseStatsAgentIds(Array.from({ length: 101 }, (_, index) => `agent-${index}`).join(','))).toThrow()
|
||||
expect(() => parseStatsAgentIds('agent-a,invalid/id')).toThrow()
|
||||
expect(parseStatsAgentIds('agent-a, agent-a,agent-b')).toEqual(['agent-a', 'agent-b'])
|
||||
expect(() => timeseriesSearch({ from: '2026-10-01T00:00:00.000Z', to: '2026-10-24T00:00:00.000Z', resolution: '1m', agentIds: [] })).toThrow()
|
||||
expect(() => timeseriesSearch({ from: '2026-10-24T00:00:01.000Z', to: '2026-10-24T01:00:00.000Z', resolution: '1m', agentIds: [] })).toThrow()
|
||||
})
|
||||
it('leaves absent measurements unknown and labels coverage and estimates', () => {
|
||||
const missing = { ...point, packets_dropped: null, packets_accepted: null, pps: null, coverage: 0 }
|
||||
expect(summarizeTimeseries([missing])).toMatchObject({ dropped: null, accepted: null, knownBuckets: 0, missingBuckets: 1, coverage: 0 })
|
||||
const estimated = { ...point, estimated: true, coverage: 0.5, quality: { ...point.quality, partial: true, truncated: true, lost_reports: 2, unsupported: ['accepted'] } }
|
||||
expect(summarizeTimeseries([missing, estimated])).toMatchObject({ dropped: 12, accepted: 30, knownBuckets: 1, missingBuckets: 1, coverage: 0.25, estimatedBuckets: 1, partialBuckets: 1, truncatedBuckets: 1, lostReports: 2, unsupported: ['accepted'] })
|
||||
expect(missing.packets_dropped).toBeNull()
|
||||
})
|
||||
it('uses an equal preceding UTC window with identical resolution and selection', () => {
|
||||
const current = rollingStatsRequest('7d', ['fixture'], Date.parse('2026-10-24T12:34:56Z'))
|
||||
const previous = previousStatsRequest(current)
|
||||
expect(previous.to).toBe(current.from)
|
||||
expect(Date.parse(previous.to) - Date.parse(previous.from)).toBe(Date.parse(current.to) - Date.parse(current.from))
|
||||
expect(previous.resolution).toBe(current.resolution)
|
||||
expect(previous.agentIds).toEqual(current.agentIds)
|
||||
})
|
||||
it('gates rates and comparisons on full traffic coverage and a nonzero baseline', () => {
|
||||
expect(trafficRates([point])).toMatchObject({ complete: true, meanPps: 0.7, peakBucketPps: 0.7, dropRate: 12 / 42 * 100 })
|
||||
for (const incomplete of [{ ...point, coverage: 0.5 }, { ...point, packets_accepted: null }, { ...point, quality: { ...point.quality, partial: true } }]) {
|
||||
expect(trafficRates([incomplete])).toMatchObject({ complete: false, meanPps: null, peakBucketPps: null, dropRate: null })
|
||||
expect(compareTrafficPeriods([point], [incomplete]).droppedPercent).toBeNull()
|
||||
}
|
||||
const zero = { ...point, packets_dropped: 0, packets_accepted: 0, pps: 0 }
|
||||
expect(trafficRates([zero]).dropRate).toBeNull()
|
||||
expect(compareTrafficPeriods([point], [zero]).droppedPercent).toBeNull()
|
||||
expect(compareTrafficPeriods([point], []).droppedPercent).toBeNull()
|
||||
expect(compareTrafficPeriods([{ ...point, packets_dropped: 24 }], [point]).droppedPercent).toBe(100)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { StatsResolution, TimeseriesPoint } from '@evofw/shared'
|
||||
|
||||
export type TimeseriesRequest = { from: string; to: string; resolution: StatsResolution; agentIds: string[] }
|
||||
export const STATS_PRESETS = {
|
||||
'1h': { label: 'Последний час · 1 мин', resolution: '1m', buckets: 60 },
|
||||
'24h': { label: '24 часа · 1 час', resolution: '1h', buckets: 24 },
|
||||
'7d': { label: '7 дней · 1 час', resolution: '1h', buckets: 168 },
|
||||
'30d': { label: '30 дней · 1 день', resolution: '1d', buckets: 30 },
|
||||
} as const
|
||||
export type StatsPreset = keyof typeof STATS_PRESETS
|
||||
const BUCKET_MS: Record<StatsResolution, number> = { '1m': 60_000, '1h': 3_600_000, '1d': 86_400_000 }
|
||||
|
||||
export function parseStatsAgentIds(value: string): string[] {
|
||||
const ids = [...new Set(value.split(',').map((id) => id.trim()).filter(Boolean))].sort()
|
||||
if (ids.length > 100) throw new Error('Можно выбрать не более 100 агентов')
|
||||
if (ids.some((id) => !/^[a-zA-Z0-9_-]{1,128}$/.test(id))) throw new Error('Укажите ID агентов через запятую')
|
||||
return ids
|
||||
}
|
||||
|
||||
/** Completed UTC buckets, with an exclusive upper bound. The clock is injected
|
||||
* so rounding and request bounds are deterministic in tests. */
|
||||
export function rollingStatsRequest(preset: StatsPreset, agentIds: string[], now: number): TimeseriesRequest {
|
||||
const config = STATS_PRESETS[preset]
|
||||
const step = BUCKET_MS[config.resolution]
|
||||
const to = Math.floor(now / step) * step
|
||||
return { from: new Date(to - config.buckets * step).toISOString(), to: new Date(to).toISOString(), resolution: config.resolution, agentIds }
|
||||
}
|
||||
|
||||
export function timeseriesSearch(request: TimeseriesRequest): string {
|
||||
const from = Date.parse(request.from)
|
||||
const to = Date.parse(request.to)
|
||||
const step = BUCKET_MS[request.resolution]
|
||||
if (!Number.isFinite(from) || !Number.isFinite(to) || !step || to <= from || (to - from) / step > 1440) {
|
||||
throw new Error('Период должен содержать от 1 до 1440 интервалов')
|
||||
}
|
||||
if (from % step || to % step) throw new Error('Границы периода должны совпадать с UTC-интервалами')
|
||||
const agentIds = parseStatsAgentIds(request.agentIds.join(','))
|
||||
const params = new URLSearchParams({ from: request.from, to: request.to, resolution: request.resolution })
|
||||
if (agentIds.length) params.set('agent_ids', agentIds.join(','))
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
export function summarizeTimeseries(items: TimeseriesPoint[]) {
|
||||
const known = items.filter((point) => point.packets_dropped !== null || point.packets_accepted !== null)
|
||||
return {
|
||||
dropped: items.some((point) => point.packets_dropped !== null) ? items.reduce((sum, point) => sum + (point.packets_dropped ?? 0), 0) : null,
|
||||
accepted: items.some((point) => point.packets_accepted !== null) ? items.reduce((sum, point) => sum + (point.packets_accepted ?? 0), 0) : null,
|
||||
coverage: items.length ? items.reduce((sum, point) => sum + point.coverage, 0) / items.length : null,
|
||||
knownBuckets: known.length,
|
||||
missingBuckets: items.filter((point) => point.packets_dropped === null || point.packets_accepted === null).length,
|
||||
estimatedBuckets: items.filter((point) => point.estimated).length,
|
||||
partialBuckets: items.filter((point) => point.quality.partial).length,
|
||||
truncatedBuckets: items.filter((point) => point.quality.truncated).length,
|
||||
lostReports: items.reduce((sum, point) => sum + point.quality.lost_reports, 0),
|
||||
unsupported: [...new Set(items.flatMap((point) => point.quality.unsupported))],
|
||||
}
|
||||
}
|
||||
|
||||
export function previousStatsRequest(request: TimeseriesRequest): TimeseriesRequest {
|
||||
const duration = Date.parse(request.to) - Date.parse(request.from)
|
||||
return { ...request, from: new Date(Date.parse(request.from) - duration).toISOString(), to: request.from }
|
||||
}
|
||||
|
||||
export function trafficRates(items: TimeseriesPoint[]) {
|
||||
const complete = items.length > 0 && items.every((point) => point.coverage === 1 && !point.quality.partial && point.packets_dropped !== null && point.packets_accepted !== null)
|
||||
const totals = summarizeTimeseries(items)
|
||||
const denominator = complete ? (totals.dropped ?? 0) + (totals.accepted ?? 0) : 0
|
||||
const ppsComplete = complete && items.every((point) => point.pps !== null)
|
||||
return {
|
||||
complete,
|
||||
meanPps: ppsComplete ? items.reduce((sum, point) => sum + point.pps!, 0) / items.length : null,
|
||||
peakBucketPps: ppsComplete ? Math.max(...items.map((point) => point.pps!)) : null,
|
||||
dropRate: denominator > 0 ? totals.dropped! / denominator * 100 : null,
|
||||
estimated: items.some((point) => point.estimated),
|
||||
}
|
||||
}
|
||||
|
||||
export function compareTrafficPeriods(current: TimeseriesPoint[], previous: TimeseriesPoint[]) {
|
||||
const currentRates = trafficRates(current)
|
||||
const previousRates = trafficRates(previous)
|
||||
const currentTotals = summarizeTimeseries(current)
|
||||
const previousTotals = summarizeTimeseries(previous)
|
||||
const complete = currentRates.complete && previousRates.complete && current.length === previous.length
|
||||
const percent = (now: number | null, before: number | null) => complete && now !== null && before !== null && before > 0 ? (now - before) / before * 100 : null
|
||||
return { complete, droppedPercent: percent(currentTotals.dropped, previousTotals.dropped), acceptedPercent: percent(currentTotals.accepted, previousTotals.accepted) }
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import { TooltipProvider } from '@evofw/ui/components/tooltip'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import '@evofw/ui/globals.css'
|
||||
import './portal-theme.css'
|
||||
import { shouldRetryQuery } from './lib/api'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 10_000, retry: 1 },
|
||||
queries: { staleTime: 10_000, retry: shouldRetryQuery },
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,15 @@ html[data-density='compact'] [data-slot='sidebar-inset'] > div:last-child {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* User-selected accessibility mode; standard and system themes keep their
|
||||
existing tokens. Foreground/background stay theme-aware in both modes. */
|
||||
html[data-contrast='high'] {
|
||||
--muted-foreground: var(--foreground);
|
||||
--border: color-mix(in oklab, var(--foreground) 65%, var(--background));
|
||||
--input: var(--border);
|
||||
--ring: var(--foreground);
|
||||
}
|
||||
|
||||
/* Видимый фокус и reduced motion — как в макете MikrotikManager v2. */
|
||||
html [data-slot='sidebar-wrapper'] :focus-visible,
|
||||
html #main-content :focus-visible {
|
||||
|
||||
@@ -5,10 +5,11 @@ import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
|
||||
|
||||
export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const
|
||||
|
||||
async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
|
||||
async function fetchPortalAppSwitcher(signal?: AbortSignal): Promise<AppSwitcherConfig> {
|
||||
const { portalUrl } = await ensureAuthConfig()
|
||||
const base = portalUrl.replace(/\/$/, '')
|
||||
const res = await fetch(`${base}/api/v1/app-switcher`, {
|
||||
signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -20,7 +21,7 @@ async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
|
||||
export function appSwitcherQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: appSwitcherQueryKey,
|
||||
queryFn: fetchPortalAppSwitcher,
|
||||
queryFn: ({ signal }) => fetchPortalAppSwitcher(signal),
|
||||
staleTime: 60_000,
|
||||
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
|
||||
retry: 1,
|
||||
|
||||
@@ -1,93 +1,86 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { listDetailPageQuerySchema } from '@evofw/shared'
|
||||
import type {
|
||||
Agent,
|
||||
DashboardStats,
|
||||
IpList,
|
||||
PolicyRule,
|
||||
PolicySet,
|
||||
UiSummary,
|
||||
IpOverride,
|
||||
ListDetailPage,
|
||||
} from '@evofw/shared'
|
||||
|
||||
export const uiSummaryQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['ui-summary'],
|
||||
queryFn: ({ signal }) => apiFetch<UiSummary>('/api/v1/ui/summary', { signal }),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
export const dashboardQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
|
||||
queryFn: ({ signal }) => apiFetch<DashboardStats>('/api/v1/dashboard', { signal }),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
export const agentsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
|
||||
queryFn: ({ signal }) => apiFetch<{ items: Agent[] }>('/api/v1/agents', { signal }),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
export const agentQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', id],
|
||||
queryFn: () => apiFetch<Agent>(`/api/v1/agents/${id}`),
|
||||
queryFn: ({ signal }) => apiFetch<Agent>(`/api/v1/agents/${id}`, { signal }),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
export const listsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['lists'],
|
||||
queryFn: () => apiFetch<{ items: IpList[] }>('/api/v1/lists'),
|
||||
queryFn: ({ signal }) => apiFetch<{ items: IpList[] }>('/api/v1/lists?view=summary', { signal }),
|
||||
})
|
||||
|
||||
export const listQueryOptions = (id: string) =>
|
||||
export const listQueryOptions = (id: string, page = { limit: 100, offset: 0, filters: [] as unknown[], q: '' }) =>
|
||||
queryOptions({
|
||||
queryKey: ['lists', id],
|
||||
queryFn: () =>
|
||||
apiFetch<{
|
||||
id: string
|
||||
name: string
|
||||
type: IpList['type']
|
||||
config_json: string
|
||||
content_hash?: string | null
|
||||
refreshed_at?: string | null
|
||||
last_error?: string | null
|
||||
entries: string[]
|
||||
items: {
|
||||
kind: 'ip' | 'cidr' | 'hostname' | 'list'
|
||||
value: string
|
||||
list_name?: string | null
|
||||
resolved_count: number
|
||||
resolved_cidrs: string[]
|
||||
}[]
|
||||
entry_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>(`/api/v1/lists/${id}`),
|
||||
queryKey: ['lists', id, 'page', page],
|
||||
queryFn: ({ signal }) => {
|
||||
const query = listDetailPageQuerySchema.parse({ view: 'page', ...page })
|
||||
return apiFetch<ListDetailPage>(`/api/v1/lists/${id}?${new URLSearchParams({ view: query.view, limit: String(query.limit), offset: String(query.offset), filters: JSON.stringify(query.filters), q: query.q })}`, { signal })
|
||||
},
|
||||
})
|
||||
|
||||
export const policySetsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['policy-sets'],
|
||||
queryFn: () => apiFetch<{ items: PolicySet[] }>('/api/v1/policy-sets'),
|
||||
queryFn: ({ signal }) => apiFetch<{ items: PolicySet[] }>('/api/v1/policy-sets', { signal }),
|
||||
})
|
||||
|
||||
export const policySetQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['policy-sets', id],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<PolicySet & { agent_ids: string[] }>(
|
||||
`/api/v1/policy-sets/${id}`,
|
||||
),
|
||||
`/api/v1/policy-sets/${id}`, { signal }),
|
||||
})
|
||||
|
||||
export const policySetRulesQueryOptions = (setId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['policy-sets', setId, 'rules'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{ items: PolicyRule[] }>(
|
||||
`/api/v1/policy-sets/${setId}/rules`,
|
||||
),
|
||||
`/api/v1/policy-sets/${setId}/rules`, { signal }),
|
||||
})
|
||||
|
||||
export const agentPolicySetsQueryOptions = (agentId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', agentId, 'policy-sets'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{
|
||||
items: {
|
||||
set_id: string
|
||||
@@ -96,25 +89,24 @@ export const agentPolicySetsQueryOptions = (agentId: string) =>
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
}[]
|
||||
}>(`/api/v1/agents/${agentId}/policy-sets`),
|
||||
}>(`/api/v1/agents/${agentId}/policy-sets`, { signal }),
|
||||
})
|
||||
|
||||
export const agentPreviewQueryOptions = (agentId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', agentId, 'preview'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<import('@evofw/shared').AgentPolicyPreview>(
|
||||
`/api/v1/agents/${agentId}/preview?limit_cidrs=100`,
|
||||
),
|
||||
`/api/v1/agents/${agentId}/preview?limit_cidrs=100`, { signal }),
|
||||
})
|
||||
|
||||
export const evobgpCommunitiesQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['integrations', 'evobgp', 'communities'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{
|
||||
items: import('@evofw/shared').EvobgpCommunity[]
|
||||
}>('/api/v1/integrations/evobgp/communities'),
|
||||
}>('/api/v1/integrations/evobgp/communities', { signal }),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})
|
||||
@@ -122,55 +114,46 @@ export const evobgpCommunitiesQueryOptions = () =>
|
||||
export const agentOverridesQueryOptions = (agentId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', agentId, 'overrides'],
|
||||
queryFn: () =>
|
||||
apiFetch<{
|
||||
items: {
|
||||
id: string
|
||||
agent_id: string
|
||||
cidr: string
|
||||
action: 'allow' | 'deny'
|
||||
comment?: string | null
|
||||
created_at: string
|
||||
}[]
|
||||
}>(`/api/v1/agents/${agentId}/overrides`),
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{ items: IpOverride[] }>(`/api/v1/agents/${agentId}/overrides`, { signal }),
|
||||
})
|
||||
|
||||
export const installContextQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['install-context'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{
|
||||
suggested_cp_url: string
|
||||
enroll_seed: string
|
||||
enroll_seed_configured: boolean
|
||||
install_sh_url: string
|
||||
mikrotik_url: string
|
||||
sync_interval_sec: number
|
||||
}>('/api/v1/install-context'),
|
||||
}>('/api/v1/install-context', { signal }),
|
||||
})
|
||||
|
||||
export const settingsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => apiFetch<Record<string, string>>('/api/v1/settings'),
|
||||
queryFn: ({ signal }) => apiFetch<Record<string, string>>('/api/v1/settings', { signal }),
|
||||
})
|
||||
|
||||
export const agentStatsQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', id, 'stats'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{
|
||||
items: {
|
||||
packets_dropped: number
|
||||
packets_accepted: number
|
||||
recorded_at: string
|
||||
}[]
|
||||
}>(`/api/v1/agents/${id}/stats`),
|
||||
}>(`/api/v1/agents/${id}/stats`, { signal }),
|
||||
})
|
||||
|
||||
export const agentBlockedIpsQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', id, 'blocked-ips'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{
|
||||
items: {
|
||||
ip: string
|
||||
@@ -183,13 +166,13 @@ export const agentBlockedIpsQueryOptions = (id: string) =>
|
||||
packets: number
|
||||
}[]
|
||||
}[]
|
||||
}>(`/api/v1/agents/${id}/blocked-ips`),
|
||||
}>(`/api/v1/agents/${id}/blocked-ips`, { signal }),
|
||||
})
|
||||
|
||||
export const agentBlockedPortsQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', id, 'blocked-ports'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{
|
||||
items: {
|
||||
port: number
|
||||
@@ -197,7 +180,7 @@ export const agentBlockedPortsQueryOptions = (id: string) =>
|
||||
packets: number
|
||||
last_seen_at: string
|
||||
}[]
|
||||
}>(`/api/v1/agents/${id}/blocked-ports`),
|
||||
}>(`/api/v1/agents/${id}/blocked-ports`, { signal }),
|
||||
})
|
||||
|
||||
export type AgentPortRuleDto = {
|
||||
@@ -221,10 +204,9 @@ export type AgentPortRuleDto = {
|
||||
export const agentPortRulesQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', id, 'port-rules'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{ items: AgentPortRuleDto[] }>(
|
||||
`/api/v1/agents/${id}/port-rules`,
|
||||
),
|
||||
`/api/v1/agents/${id}/port-rules`, { signal }),
|
||||
})
|
||||
|
||||
export type HostFwRuleDto = {
|
||||
@@ -252,20 +234,20 @@ export type HostListenerDto = {
|
||||
export const agentHostFirewallQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', id, 'host-firewall'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{
|
||||
collected_at: string | null
|
||||
raw_digest: string | null
|
||||
rules: HostFwRuleDto[]
|
||||
listeners: HostListenerDto[]
|
||||
}>(`/api/v1/agents/${id}/host-firewall`),
|
||||
}>(`/api/v1/agents/${id}/host-firewall`, { signal }),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
export const recentStatsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['stats-recent'],
|
||||
queryFn: () =>
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{
|
||||
items: {
|
||||
agent_id: string
|
||||
@@ -273,6 +255,6 @@ export const recentStatsQueryOptions = () =>
|
||||
packets_accepted: number
|
||||
recorded_at: string
|
||||
}[]
|
||||
}>('/api/v1/stats/recent'),
|
||||
}>('/api/v1/stats/recent', { signal }),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { invalidateAfterMutation } from './invalidation'
|
||||
|
||||
describe('mutation dependencies', () => {
|
||||
it('invalidates derived agent policies and shell counts after a list change', async () => {
|
||||
const client = new QueryClient()
|
||||
const related = [['lists'], ['lists', 'source'], ['policy-sets', 'policy'], ['agents', 'node', 'preview'], ['ui-summary'], ['dashboard']]
|
||||
for (const key of [...related, ['install-context']]) client.setQueryData(key, {})
|
||||
await invalidateAfterMutation(client, 'list')
|
||||
for (const key of related) expect(client.getQueryState(key)?.isInvalidated).toBe(true)
|
||||
expect(client.getQueryState(['install-context'])?.isInvalidated).toBe(false)
|
||||
client.clear()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
const affectedRoots = {
|
||||
agent: ['agents', 'policy-sets', 'dashboard', 'ui-summary', 'stats-recent', 'stats-timeseries'],
|
||||
policy: ['policy-sets', 'agents', 'ui-summary'],
|
||||
list: ['lists', 'policy-sets', 'agents', 'dashboard', 'ui-summary'],
|
||||
settings: ['settings', 'install-context', 'integrations', 'ui-summary'],
|
||||
} as const
|
||||
|
||||
/** Keep counts, assignments, derived policy previews and the shell consistent
|
||||
* with the resource that was changed. Inactive queries become stale without
|
||||
* being fetched, so closed palette catalogs stay lazy. */
|
||||
export function invalidateAfterMutation(client: QueryClient, kind: keyof typeof affectedRoots) {
|
||||
return Promise.all(affectedRoots[kind].map((root) => client.invalidateQueries({ queryKey: [root] })))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { listQueryOptions, listsQueryOptions } from './index'
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(async () => ({})) }))
|
||||
|
||||
describe('bounded list queries', () => {
|
||||
it('keys and sends page/search/filter state with a cancellation signal', async () => {
|
||||
const client = new QueryClient()
|
||||
const page = { limit: 100, offset: 99900, q: 'last entry', filters: [{ id: 'filter', field: 'kind', operator: 'is', values: ['cidr'] }] }
|
||||
const options = listQueryOptions('fixture-list', page)
|
||||
await client.fetchQuery(options)
|
||||
const [path, init] = vi.mocked(apiFetch).mock.calls.at(-1)!
|
||||
const url = new URL(path, 'http://fixture.invalid')
|
||||
expect(url.searchParams.get('view')).toBe('page')
|
||||
expect(url.searchParams.get('offset')).toBe('99900')
|
||||
expect(url.searchParams.get('q')).toBe('last entry')
|
||||
expect(JSON.parse(url.searchParams.get('filters')!)).toEqual(page.filters)
|
||||
expect(init?.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(options.queryKey).not.toEqual(listQueryOptions('fixture-list').queryKey)
|
||||
client.clear()
|
||||
})
|
||||
it('loads catalog metadata through summary view rather than raw source config', async () => {
|
||||
const client = new QueryClient()
|
||||
await client.fetchQuery(listsQueryOptions())
|
||||
expect(vi.mocked(apiFetch).mock.calls.at(-1)?.[0]).toBe('/api/v1/lists?view=summary')
|
||||
client.clear()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { timeseriesResponseSchema } from '@evofw/shared'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { timeseriesSearch, type TimeseriesRequest } from '@/lib/timeseries'
|
||||
|
||||
export function timeseriesQueryOptions(request: TimeseriesRequest) {
|
||||
const search = timeseriesSearch(request)
|
||||
return queryOptions({
|
||||
queryKey: ['stats-timeseries', search],
|
||||
queryFn: async ({ signal }) => timeseriesResponseSchema.parse(await apiFetch(`/api/v1/stats/timeseries?${search}`, { signal })),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { RowSelectionState } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import {
|
||||
Check,
|
||||
@@ -63,6 +64,9 @@ import {
|
||||
} from '@evofw/ui/components/toggle-group'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
const loadLabels = () => import('@/components/agents/agent-labels-sheet')
|
||||
const AgentLabelsSheet = lazy(() => loadLabels().then((m) => ({ default: m.AgentLabelsSheet })))
|
||||
|
||||
/**
|
||||
* Agents ops console — cards/table toggle + full detail Sheet.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12
|
||||
@@ -122,6 +126,9 @@ function formatNames(names: string[]): string {
|
||||
}
|
||||
|
||||
function AgentsPage() {
|
||||
const [labelsOpen, setLabelsOpen] = useState(false)
|
||||
const [labelsMounted, setLabelsMounted] = useState(false)
|
||||
const labelsAllowed = useCan()('fw:agents:read')
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const { agent: detailAgentId, view, add: addParam } = Route.useSearch()
|
||||
const qc = useQueryClient()
|
||||
@@ -199,13 +206,15 @@ function AgentsPage() {
|
||||
const approve = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
|
||||
onMutate: (id) =>
|
||||
patchAgentsCache((items) =>
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: ['agents'] })
|
||||
return patchAgentsCache((items) =>
|
||||
items.map((a) => (a.id === id ? { ...a, status: 'approved' } : a)),
|
||||
),
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Агент одобрен')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void invalidateAfterMutation(qc, 'agent')
|
||||
},
|
||||
onError: (e: Error, _id, rollback) => {
|
||||
rollback?.()
|
||||
@@ -219,7 +228,8 @@ function AgentsPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent_ids: ids }),
|
||||
}),
|
||||
onMutate: (ids) => {
|
||||
onMutate: async (ids) => {
|
||||
await qc.cancelQueries({ queryKey: ['agents'] })
|
||||
const target = new Set(ids)
|
||||
return patchAgentsCache((items) =>
|
||||
items.map((a) =>
|
||||
@@ -230,8 +240,7 @@ function AgentsPage() {
|
||||
onSuccess: (_data, ids) => {
|
||||
toast.success(`Одобрено агентов: ${ids.length}`)
|
||||
setRowSelection({})
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
void invalidateAfterMutation(qc, 'agent')
|
||||
},
|
||||
onError: (e: Error, _ids, rollback) => {
|
||||
rollback?.()
|
||||
@@ -245,7 +254,8 @@ function AgentsPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ agent_ids: ids }),
|
||||
}),
|
||||
onMutate: (ids) => {
|
||||
onMutate: async (ids) => {
|
||||
await qc.cancelQueries({ queryKey: ['agents'] })
|
||||
const target = new Set(ids)
|
||||
return patchAgentsCache((items) =>
|
||||
items.filter((a) => !target.has(a.id)),
|
||||
@@ -258,8 +268,7 @@ function AgentsPage() {
|
||||
if (detailAgentId && ids.includes(detailAgentId)) {
|
||||
setSearch({ agent: '' })
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
void invalidateAfterMutation(qc, 'agent')
|
||||
},
|
||||
onError: (e: Error, _ids, rollback) => {
|
||||
rollback?.()
|
||||
@@ -270,15 +279,17 @@ function AgentsPage() {
|
||||
const removeAgent = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
|
||||
onMutate: (id) => patchAgentsCache((items) => items.filter((a) => a.id !== id)),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: ['agents'] })
|
||||
return patchAgentsCache((items) => items.filter((a) => a.id !== id))
|
||||
},
|
||||
onSuccess: (_data, id) => {
|
||||
toast.success('Агент удалён')
|
||||
setDeleteAgentId(null)
|
||||
if (detailAgentId === id) {
|
||||
setSearch({ agent: '' })
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
void invalidateAfterMutation(qc, 'agent')
|
||||
},
|
||||
onError: (e: Error, _id, rollback) => {
|
||||
rollback?.()
|
||||
@@ -569,9 +580,9 @@ function AgentsPage() {
|
||||
|
||||
const handleSelectAgent = useCallback(
|
||||
(id: string) => {
|
||||
setSearch({ agent: id })
|
||||
void navigate({ to: '/agents/$id', params: { id } })
|
||||
},
|
||||
[setSearch],
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const handleDeleteAgent = useCallback((id: string) => {
|
||||
@@ -634,11 +645,13 @@ function AgentsPage() {
|
||||
actions={
|
||||
<>
|
||||
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
|
||||
{labelsAllowed && <Button size="sm" variant="outline" onFocus={() => void loadLabels()} onPointerEnter={() => void loadLabels()} onClick={() => { setLabelsMounted(true); setLabelsOpen(true) }}>Метки и назначение</Button>}
|
||||
{addButton}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Suspense fallback={<p role="status">Загрузка формы…</p>}>{labelsMounted && <AgentLabelsSheet open={labelsOpen} onOpenChange={setLabelsOpen} />}</Suspense>
|
||||
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
||||
|
||||
{showQuickActions ? <QuickActionGrid actions={quickActions} /> : null}
|
||||
@@ -696,7 +709,7 @@ function AgentsPage() {
|
||||
variant="outline"
|
||||
className="text-destructive"
|
||||
title={a.last_apply_error ?? undefined}
|
||||
onClick={() => setSearch({ agent: a.id })}
|
||||
onClick={() => handleSelectAgent(a.id)}
|
||||
>
|
||||
<CircleAlertIcon className="size-3.5" aria-hidden />
|
||||
{a.name}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
||||
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
|
||||
import { platformLabel } from '@/components/agents/agent-platform-icon'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { formatNumber, formatRelativeTime } from '@/lib/format'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
@@ -181,6 +182,7 @@ function DashboardPage() {
|
||||
sub="Обзор агентов и пакетной статистики"
|
||||
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
|
||||
/>
|
||||
<QueryState isLoading={dash.isPending} isError={dash.isError} error={dash.error} onRetry={() => void dash.refetch()}>
|
||||
<OpsDashboard
|
||||
isLoading={dash.isLoading}
|
||||
kpiCards={kpiCards}
|
||||
@@ -188,18 +190,21 @@ function DashboardPage() {
|
||||
charts={
|
||||
<>
|
||||
<AgentsFleetChart />
|
||||
<QueryState isLoading={agents.isPending} isError={agents.isError} error={agents.error} onRetry={() => void agents.refetch()}>
|
||||
<SimpleGrid
|
||||
title="Агенты"
|
||||
description="Первые 8 · клик открывает карточку агента"
|
||||
columns={agentColumns}
|
||||
data={previewAgents}
|
||||
getRowId={(a) => a.id}
|
||||
onRowClick={(a) => void navigate({ to: '/agents', search: { agent: a.id, view: 'cards' } })}
|
||||
onRowClick={(a) => void navigate({ to: '/agents/$id', params: { id: a.id } })}
|
||||
emptyMessage="Нет агентов"
|
||||
/>
|
||||
</QueryState>
|
||||
</>
|
||||
}
|
||||
queue={
|
||||
<QueryState isLoading={agents.isPending} isError={agents.isError} error={agents.error} onRetry={() => void agents.refetch()}>
|
||||
<SimpleGrid
|
||||
title="Требует внимания"
|
||||
description="Ожидают одобрения и ошибки применения политики"
|
||||
@@ -211,11 +216,13 @@ function DashboardPage() {
|
||||
columns={attentionColumns}
|
||||
data={attention}
|
||||
getRowId={(r) => r.id}
|
||||
onRowClick={(r) => void navigate({ to: '/agents', search: { agent: r.agent.id, view: 'cards' } })}
|
||||
onRowClick={(r) => void navigate({ to: '/agents/$id', params: { id: r.agent.id } })}
|
||||
emptyMessage="Нет проблем, требующих внимания"
|
||||
/>
|
||||
</QueryState>
|
||||
}
|
||||
/>
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { listImportSummarySchema } from '@evofw/shared'
|
||||
import { parseListImportDraft, validatedListImportValues } from '@/lib/list-import-draft'
|
||||
import { getActiveFilters } from '@/components/reui-kit/filter-utils'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
@@ -11,8 +15,8 @@ import {
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ColumnDef, PaginationState } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import {
|
||||
DetailPanel,
|
||||
@@ -76,6 +80,7 @@ type ListItem = {
|
||||
list_name?: string | null
|
||||
resolved_count: number
|
||||
resolved_cidrs: string[]
|
||||
resolved_truncated?: boolean
|
||||
}
|
||||
|
||||
const ADD_MODE_ITEMS = [
|
||||
@@ -96,14 +101,21 @@ function ListDetailPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const listQ = useQuery(listQueryOptions(id))
|
||||
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
|
||||
const [entrySearch, setEntrySearch] = useState('')
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 100 })
|
||||
const listQ = useQuery({ ...listQueryOptions(id, { limit: pagination.pageSize, offset: pagination.pageIndex * pagination.pageSize, filters: getActiveFilters(entryFilters), q: entrySearch }), placeholderData: (previous, query) => query?.queryKey[1] === id ? previous : undefined })
|
||||
useEffect(() => {
|
||||
if (!listQ.data || listQ.isPlaceholderData) return
|
||||
const last = Math.max(0, Math.ceil(listQ.data.total / pagination.pageSize) - 1)
|
||||
if (pagination.pageIndex > last) setPagination((current) => ({ ...current, pageIndex: last }))
|
||||
}, [listQ.data, listQ.isPlaceholderData, pagination.pageIndex, pagination.pageSize])
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
/** plaintext = auto-detect IP/CIDR/hostname; nested = link another list */
|
||||
const [addMode, setAddMode] = useState<'plaintext' | 'nested'>('plaintext')
|
||||
const [addValue, setAddValue] = useState('')
|
||||
const [addListRef, setAddListRef] = useState('')
|
||||
const [entryFilters, setEntryFilters] = useState<Filter[]>([])
|
||||
const [deleteListOpen, setDeleteListOpen] = useState(false)
|
||||
const [deleteValue, setDeleteValue] = useState<string | null>(null)
|
||||
|
||||
@@ -112,7 +124,7 @@ function ListDetailPage() {
|
||||
apiFetch(`/api/v1/lists/${id}/refresh`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Обновлено')
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
void invalidateAfterMutation(qc, 'list')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -122,8 +134,8 @@ function ListDetailPage() {
|
||||
onSuccess: () => {
|
||||
toast.success('Удалён')
|
||||
setDeleteListOpen(false)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
void navigate({ to: '/lists' })
|
||||
void invalidateAfterMutation(qc, 'list')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -132,22 +144,18 @@ function ListDetailPage() {
|
||||
mutationFn: async () => {
|
||||
if (addMode === 'nested') {
|
||||
if (!addListRef) throw new Error('Выберите список')
|
||||
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
||||
return listImportSummarySchema.parse(await apiFetch(`/api/v1/lists/${id}/entries?return=summary`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
items: [{ kind: 'list', value: addListRef }],
|
||||
}),
|
||||
})
|
||||
}))
|
||||
}
|
||||
if (!addParse || addParse.valid === 0) {
|
||||
throw new Error('Нет валидных записей')
|
||||
}
|
||||
// Отправляем только валидные уникальные токены; backend классифицирует
|
||||
// каждый через parseListEntry (ip / cidr / hostname)
|
||||
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
||||
const values = validatedListImportValues(addValue)
|
||||
return listImportSummarySchema.parse(await apiFetch(`/api/v1/lists/${id}/entries?return=summary`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ values: addParse.values }),
|
||||
})
|
||||
body: JSON.stringify({ values }),
|
||||
}))
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Добавлено')
|
||||
@@ -155,21 +163,21 @@ function ListDetailPage() {
|
||||
setAddListRef('')
|
||||
setAddMode('plaintext')
|
||||
setAddOpen(false)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
void invalidateAfterMutation(qc, 'list')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const removeEntry = useMutation({
|
||||
mutationFn: (value: string) =>
|
||||
apiFetch(`/api/v1/lists/${id}/entries`, {
|
||||
mutationFn: async (value: string) =>
|
||||
listImportSummarySchema.parse(await apiFetch(`/api/v1/lists/${id}/entries?return=summary`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ value }),
|
||||
}),
|
||||
})),
|
||||
onSuccess: () => {
|
||||
toast.success('Удалено')
|
||||
setDeleteValue(null)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
void invalidateAfterMutation(qc, 'list')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -232,7 +240,7 @@ function ListDetailPage() {
|
||||
row.original.kind === 'list'
|
||||
? row.original.value
|
||||
: row.original.resolved_count > 0
|
||||
? `${row.original.resolved_count} CIDR`
|
||||
? `${row.original.resolved_count} CIDR${row.original.resolved_truncated ? ' · предпросмотр сокращён' : ''}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -270,6 +278,7 @@ function ListDetailPage() {
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить"
|
||||
disabled={listQ.isPlaceholderData}
|
||||
onClick={() => setDeleteValue(row.original.value)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
@@ -278,7 +287,7 @@ function ListDetailPage() {
|
||||
) : null,
|
||||
},
|
||||
],
|
||||
[manual],
|
||||
[manual, listQ.isPlaceholderData],
|
||||
)
|
||||
|
||||
const detectedKinds = useMemo(() => {
|
||||
@@ -300,30 +309,13 @@ function ListDetailPage() {
|
||||
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] }
|
||||
return parseListImportDraft(text)
|
||||
}, [addMode, addValue])
|
||||
|
||||
const canAdd =
|
||||
addMode === 'nested'
|
||||
? Boolean(addListRef)
|
||||
: Boolean(addParse && addParse.valid > 0)
|
||||
: Boolean(addParse && addParse.valid > 0 && addParse.invalid === 0)
|
||||
|
||||
if (listQ.isLoading) {
|
||||
return (
|
||||
@@ -477,9 +469,9 @@ function ListDetailPage() {
|
||||
id: 'updated',
|
||||
icon: <ClockIcon aria-hidden />,
|
||||
iconClassName: 'text-muted-foreground',
|
||||
label: 'Обновлён',
|
||||
description: formatRelativeTime(detail.refreshed_at ?? detail.updated_at),
|
||||
hint: formatDateTime(detail.refreshed_at ?? detail.updated_at),
|
||||
label: manual ? 'Изменён' : 'Успешное обновление',
|
||||
description: manual ? formatRelativeTime(detail.updated_at) : detail.refreshed_at ? formatRelativeTime(detail.refreshed_at) : 'Не было',
|
||||
hint: manual ? formatDateTime(detail.updated_at) : detail.refreshed_at ? formatDateTime(detail.refreshed_at) : 'Источник ещё не загружен',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -488,7 +480,7 @@ function ListDetailPage() {
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>Ошибка обновления</AlertTitle>
|
||||
<AlertDescription>{detail.last_error}</AlertDescription>
|
||||
<AlertDescription>{detail.last_error} · Последнее успешное обновление: {detail.refreshed_at ? formatDateTime(detail.refreshed_at) : 'не было'} · сохранено записей: {detail.entry_count ?? detail.entries.length}.</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
@@ -503,6 +495,12 @@ function ListDetailPage() {
|
||||
filters={entryFilters}
|
||||
onFiltersChange={setEntryFilters}
|
||||
onClearFilters={() => setEntryFilters([])}
|
||||
searchQuery={entrySearch}
|
||||
onSearchChange={setEntrySearch}
|
||||
getSearchText={(item) => `${getEntryFilterValue(item, 'value')} ${item.kind}`}
|
||||
searchPlaceholder="Поиск по всему списку…"
|
||||
serverPagination={{ value: pagination, total: detail.total, onChange: setPagination }}
|
||||
toolbarExtra={listQ.isFetching ? <span role="status" className="text-muted-foreground text-xs">Обновление списка…</span> : <span className="text-muted-foreground text-xs">Фильтры и поиск по всему списку</span>}
|
||||
getFilterFieldValue={getEntryFilterValue}
|
||||
emptyState={{
|
||||
title: 'Нет записей',
|
||||
@@ -620,12 +618,14 @@ function ListDetailPage() {
|
||||
<Field>
|
||||
<FieldLabel htmlFor="entry-value">Значение</FieldLabel>
|
||||
<Textarea
|
||||
aria-invalid={Boolean(addParse?.invalid)}
|
||||
id="entry-value"
|
||||
rows={5}
|
||||
value={addValue}
|
||||
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
|
||||
onChange={(e) => setAddValue(e.target.value)}
|
||||
/>
|
||||
{Boolean(addParse?.invalid) && <p role="alert" className="text-destructive text-sm">Исправьте все нераспознанные записи. Частичный импорт не выполняется.</p>}
|
||||
{addParse && addParse.valid > 0 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{detectedKinds.length > 0 ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
@@ -212,8 +213,8 @@ function ListsPage() {
|
||||
toast.success('Список создан')
|
||||
createForm.reset()
|
||||
setCreateOpenState(false)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
||||
void invalidateAfterMutation(qc, 'list')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -223,7 +224,7 @@ function ListsPage() {
|
||||
apiFetch(`/api/v1/lists/${id}/refresh`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Обновлено')
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
void invalidateAfterMutation(qc, 'list')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -234,7 +235,7 @@ function ListsPage() {
|
||||
onSuccess: () => {
|
||||
toast.success('Удалён')
|
||||
setDeleteListId(null)
|
||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||
void invalidateAfterMutation(qc, 'list')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
@@ -25,6 +26,7 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FormSheet } from '@/components/form-sheet'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
@@ -183,8 +185,9 @@ function PolicySetDetailPage() {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onMutate: (body) => {
|
||||
onMutate: async (body) => {
|
||||
const key = ['policy-sets', setId] as const
|
||||
await qc.cancelQueries({ queryKey: key })
|
||||
const prev = qc.getQueryData<PolicySet & { agent_ids: string[] }>(key)
|
||||
if (prev && body.enabled !== undefined) {
|
||||
qc.setQueryData(key, { ...prev, enabled: body.enabled })
|
||||
@@ -195,8 +198,7 @@ function PolicySetDetailPage() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Набор обновлён')
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error, _body, rollback) => {
|
||||
rollback?.()
|
||||
@@ -213,8 +215,7 @@ function PolicySetDetailPage() {
|
||||
onSuccess: () => {
|
||||
toast.success('Назначение агентов сохранено')
|
||||
setSelectedAgents(null)
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -239,7 +240,7 @@ function PolicySetDetailPage() {
|
||||
toast.success('Правило создано')
|
||||
setRuleOpen(false)
|
||||
ruleForm.reset()
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -250,7 +251,7 @@ function PolicySetDetailPage() {
|
||||
onSuccess: () => {
|
||||
toast.success('Удалено')
|
||||
setDeleteRuleId(null)
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
})
|
||||
|
||||
@@ -444,6 +445,7 @@ function PolicySetDetailPage() {
|
||||
/>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<QueryState isLoading={rulesQ.isPending} isError={rulesQ.isError} error={rulesQ.error} onRetry={() => void rulesQ.refetch()}>
|
||||
<PolicyRulesSortable
|
||||
setId={setId}
|
||||
rules={rules}
|
||||
@@ -451,6 +453,7 @@ function PolicySetDetailPage() {
|
||||
onDelete={(id) => setDeleteRuleId(id)}
|
||||
onAdd={() => setRuleOpen(true)}
|
||||
/>
|
||||
</QueryState>
|
||||
</DetailPanel.Section>
|
||||
|
||||
<DetailPanel.Section
|
||||
@@ -465,6 +468,7 @@ function PolicySetDetailPage() {
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<QueryState isLoading={agentsQ.isPending} isError={agentsQ.isError} error={agentsQ.error} onRetry={() => void agentsQ.refetch()}>
|
||||
{approvedAgents.length === 0 ? (
|
||||
<p className="text-muted-foreground p-4 text-sm">
|
||||
Нет approved-агентов
|
||||
@@ -478,6 +482,7 @@ function PolicySetDetailPage() {
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</QueryState>
|
||||
</FramePanel>
|
||||
<div className="border-t px-4 py-3">
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { lazy, Suspense, useCallback, useMemo, useState } from 'react'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
@@ -29,6 +30,9 @@ import {
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import type { PolicySet } from '@evofw/shared'
|
||||
|
||||
const loadRollouts = () => import('@/components/rules/policy-rollouts-sheet')
|
||||
const PolicyRolloutsSheet = lazy(() => loadRollouts().then((m) => ({ default: m.PolicyRolloutsSheet })))
|
||||
|
||||
type RulesSearch = {
|
||||
create?: boolean
|
||||
q?: string
|
||||
@@ -57,6 +61,9 @@ export const Route = createFileRoute('/_auth/rules/')({
|
||||
* Create sheet: https://reui.io/preview/base/sheet-8
|
||||
*/
|
||||
function PolicySetsPage() {
|
||||
const [rolloutsOpen, setRolloutsOpen] = useState(false)
|
||||
const [rolloutsMounted, setRolloutsMounted] = useState(false)
|
||||
const canReadPolicies = useCan()('fw:policies:read')
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const qc = useQueryClient()
|
||||
const setsQ = useQuery(policySetsQueryOptions())
|
||||
@@ -105,8 +112,8 @@ function PolicySetsPage() {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setSheetOpenState(false)
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -117,7 +124,7 @@ function PolicySetsPage() {
|
||||
onSuccess: () => {
|
||||
toast.success('Набор удалён')
|
||||
setDeleteId(null)
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void invalidateAfterMutation(qc, 'policy')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -255,9 +262,10 @@ function PolicySetsPage() {
|
||||
<PageHeader
|
||||
title="Наборы правил"
|
||||
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
|
||||
actions={addButton}
|
||||
actions={<>{canReadPolicies && <Button size="sm" variant="outline" onFocus={() => void loadRollouts()} onPointerEnter={() => void loadRollouts()} onClick={() => { setRolloutsMounted(true); setRolloutsOpen(true) }}>Снимки и canary</Button>}{addButton}</>}
|
||||
/>
|
||||
|
||||
<Suspense fallback={<p role="status">Загрузка формы…</p>}>{rolloutsMounted && <PolicyRolloutsSheet open={rolloutsOpen} onOpenChange={setRolloutsOpen} />}</Suspense>
|
||||
<ResourcePage
|
||||
title="Наборы"
|
||||
hideHeader
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { invalidateAfterMutation } from '@/queries/invalidation'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { lazy, Suspense, useEffect, useState } from 'react'
|
||||
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
||||
import { PageHeader, PageShell } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
} from '@/components/reui/frame'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SettingRow } from '@/components/setting-row'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { settingsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCan } from '@/lib/permissions'
|
||||
@@ -25,6 +27,9 @@ import { Field, FieldError } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
|
||||
const loadWebhooks = () => import('@/components/webhooks-sheet')
|
||||
const WebhooksSheet = lazy(() => loadWebhooks().then((m) => ({ default: m.WebhooksSheet })))
|
||||
|
||||
/**
|
||||
* Control plane settings — single page: PageShell + Frame + SettingRow.
|
||||
* RHF+zod: валидация полей, dirty-state, reveal токена.
|
||||
@@ -39,7 +44,7 @@ const settingsSchema = z.object({
|
||||
enroll_seed: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Обязательное поле — используется в install.sh'),
|
||||
.min(1, 'Укажите новый ключ или сохраните маску текущего'),
|
||||
evobgp_api_url: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -69,6 +74,8 @@ const EMPTY_VALUES: SettingsValues = {
|
||||
}
|
||||
|
||||
function SettingsPage() {
|
||||
const [webhooksOpen, setWebhooksOpen] = useState(false)
|
||||
const [webhooksMounted, setWebhooksMounted] = useState(false)
|
||||
const qc = useQueryClient()
|
||||
const settingsQ = useQuery(settingsQueryOptions())
|
||||
const canSave = useCan()('fw:settings:admin')
|
||||
@@ -79,35 +86,38 @@ function SettingsPage() {
|
||||
mode: 'onBlur',
|
||||
})
|
||||
|
||||
// Seed once — background refetch не должен затирать правки в процессе.
|
||||
const isDirty = form.formState.isDirty
|
||||
|
||||
// Refresh clean forms only; focus refetches must preserve an in-progress edit.
|
||||
useEffect(() => {
|
||||
if (settingsQ.data) {
|
||||
if (settingsQ.data && !form.formState.isDirty) {
|
||||
form.reset({ ...EMPTY_VALUES, ...settingsQ.data })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsQ.data])
|
||||
}, [settingsQ.data, form])
|
||||
|
||||
const save = useMutation({
|
||||
onMutate: () => qc.cancelQueries({ queryKey: settingsQueryOptions().queryKey }),
|
||||
mutationFn: (values: SettingsValues) =>
|
||||
apiFetch('/api/v1/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(values),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_result, values) => {
|
||||
const masked = { ...values, enroll_seed: '********', evobgp_api_token: values.evobgp_api_token ? '********' : '' }
|
||||
qc.setQueryData(settingsQueryOptions().queryKey, masked)
|
||||
form.reset(masked)
|
||||
toast.success('Сохранено')
|
||||
void qc.invalidateQueries({ queryKey: ['settings'] })
|
||||
void qc.invalidateQueries({ queryKey: ['install-context'] })
|
||||
void invalidateAfterMutation(qc, 'settings')
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const isDirty = form.formState.isDirty
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Интеграции, enroll и интервал синхронизации"
|
||||
description="Интеграции, ключ подписи install-ссылок и интервал синхронизации"
|
||||
actions={
|
||||
isDirty ? (
|
||||
<Badge variant="warning-light" size="sm">
|
||||
@@ -116,10 +126,12 @@ function SettingsPage() {
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<QueryState hasData={settingsQ.data !== undefined} isLoading={settingsQ.isPending} isError={settingsQ.isError} error={settingsQ.error} onRetry={() => void settingsQ.refetch()}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) => save.mutateAsync(values))}
|
||||
className="flex flex-col gap-4 md:gap-6"
|
||||
>
|
||||
<fieldset disabled={!canSave || save.isPending} className="contents">
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div>
|
||||
@@ -132,14 +144,16 @@ function SettingsPage() {
|
||||
<FramePanel className="p-0">
|
||||
<div className="flex flex-col">
|
||||
<SettingRow
|
||||
title="Enroll seed"
|
||||
description="X-EvoFW-Seed для install.sh"
|
||||
title="Ключ подписи install-ссылок"
|
||||
description="Скрыт при чтении. Сохраните маску, чтобы оставить текущий ключ. Замена ключа делает недействительными credentials ещё не использованных приглашений; для установки используйте одноразовые ссылки."
|
||||
labelFor="enroll_seed"
|
||||
last={false}
|
||||
>
|
||||
<Field data-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}>
|
||||
<Input
|
||||
id="enroll_seed"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
{...form.register('enroll_seed')}
|
||||
aria-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}
|
||||
/>
|
||||
@@ -214,12 +228,13 @@ function SettingsPage() {
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</fieldset>
|
||||
{canSave ? (
|
||||
<div className="flex justify-end">
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
isLoading={save.isPending}
|
||||
disabled={!settingsQ.isSuccess}
|
||||
disabled={!settingsQ.isSuccess || !isDirty}
|
||||
loadingLabel="Сохранение…"
|
||||
>
|
||||
Сохранить
|
||||
@@ -227,6 +242,9 @@ function SettingsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
</QueryState>
|
||||
{canSave && <Frame dense spacing="sm"><FrameHeader><div><FrameTitle>Webhooks</FrameTitle><FrameDescription>Уведомления о сбоях, управление endpoint и состояние доставок.</FrameDescription></div></FrameHeader><FramePanel><Button type="button" variant="outline" onFocus={() => void loadWebhooks()} onPointerEnter={() => void loadWebhooks()} onClick={() => { setWebhooksMounted(true); setWebhooksOpen(true) }}>Управление webhooks</Button></FramePanel></Frame>}
|
||||
<Suspense fallback={<p role="status">Загрузка формы…</p>}>{webhooksMounted && <WebhooksSheet open={webhooksOpen} onOpenChange={setWebhooksOpen} />}</Suspense>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
ServerIcon,
|
||||
DownloadIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
PageHeader,
|
||||
@@ -24,17 +25,22 @@ import {
|
||||
import { PageTab, PageTabsList, SignalAlert } from '@/components/portal/bits'
|
||||
import { Tabs, TabsContent } from '@evofw/ui/components/tabs'
|
||||
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
||||
import { recentStatsQueryOptions } from '@/queries'
|
||||
import { timeseriesQueryOptions } from '@/queries/timeseries'
|
||||
import { useStatsWindow } from '@/hooks/use-stats-window'
|
||||
import { STATS_PRESETS, summarizeTimeseries, previousStatsRequest, trafficRates, compareTrafficPeriods, type StatsPreset, type TimeseriesRequest } from '@/lib/timeseries'
|
||||
import { TrafficTimeseriesChart } from '@/components/stats/traffic-timeseries-chart'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { fetchStatsCsv, saveStatsCsv } from '@/lib/stats-csv'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { StatsAgentSelect } from '@/components/stats/stats-agent-select'
|
||||
import { Field, FieldGroup, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@evofw/ui/components/select'
|
||||
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||
import { searchToFilters } from '@/lib/search-params'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@evofw/ui/components/chart'
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'
|
||||
|
||||
type StatRow = {
|
||||
id: string
|
||||
@@ -61,15 +67,39 @@ export const Route = createFileRoute('/_auth/stats')({
|
||||
component: StatsPage,
|
||||
})
|
||||
|
||||
const chartConfig = {
|
||||
dropped: { label: 'Заблокировано', color: 'var(--chart-1)' },
|
||||
accepted: { label: 'Пропущено', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
function StatsPage() {
|
||||
const navigate = useNavigate({ from: Route.fullPath })
|
||||
const dash = useQuery(dashboardQueryOptions())
|
||||
const stats = useQuery(recentStatsQueryOptions())
|
||||
const [tab, setTab] = useState('activity')
|
||||
const [preset, setPreset] = useState<StatsPreset>('24h')
|
||||
const [agentIds, setAgentIds] = useState<string[]>([])
|
||||
const request = useStatsWindow(preset, agentIds)
|
||||
const timeseries = useQuery(timeseriesQueryOptions(request))
|
||||
const previousRequest = previousStatsRequest(request)
|
||||
const previous = useQuery({ ...timeseriesQueryOptions(previousRequest), enabled: tab === 'activity' })
|
||||
const rates = trafficRates(timeseries.data?.items ?? [])
|
||||
const comparison = compareTrafficPeriods(timeseries.data?.items ?? [], previous.data?.items ?? [])
|
||||
const sameAgents = Boolean(timeseries.data && previous.data) && JSON.stringify([...(timeseries.data?.agent_ids ?? [])].sort()) === JSON.stringify([...(previous.data?.agent_ids ?? [])].sort())
|
||||
const previousSummary = summarizeTimeseries(previous.data?.items ?? [])
|
||||
const comparisonHint = (percent: number | null, baseline: number | null) => {
|
||||
if (!previous.isSuccess) return 'предыдущий период недоступен'
|
||||
if (!sameAgents) return 'сравнение: изменился состав агентов'
|
||||
if (!comparison.complete) return 'сравнение: неполное покрытие'
|
||||
return percent === null ? `предыдущий: ${formatNumber(baseline)} · % не рассчитывается` : `${percent > 0 ? '+' : ''}${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 1 }).format(percent)}% к предыдущему периоду`
|
||||
}
|
||||
const stats = useQuery({ ...recentStatsQueryOptions(), enabled: tab === 'samples' })
|
||||
const summary = summarizeTimeseries(timeseries.data?.items ?? [])
|
||||
const exportController = useRef<AbortController | null>(null)
|
||||
useEffect(() => () => exportController.current?.abort(), [])
|
||||
const csv = useMutation({
|
||||
mutationFn: async (snapshot: TimeseriesRequest) => {
|
||||
const controller = new AbortController()
|
||||
exportController.current = controller
|
||||
const blob = await fetchStatsCsv(snapshot, controller.signal)
|
||||
saveStatsCsv(blob, snapshot)
|
||||
},
|
||||
onError: (error: Error) => { if (error.name !== 'AbortError') toast.error(error.message) },
|
||||
onSettled: () => { exportController.current = null },
|
||||
})
|
||||
const filters = Route.useSearch().f ?? []
|
||||
|
||||
/** Обновление URL-состояния (f) без перезагрузки. */
|
||||
@@ -86,39 +116,6 @@ function StatsPage() {
|
||||
[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(
|
||||
() =>
|
||||
(stats.data?.items ?? []).slice(0, 50).map((s, i) => ({
|
||||
@@ -134,29 +131,32 @@ function StatsPage() {
|
||||
const kpiCards: KpiStatCard[] = [
|
||||
{
|
||||
id: 'd',
|
||||
label: 'Заблокировано (сумма)',
|
||||
hint: 'пакетов · сумма пиков',
|
||||
value: dash.data?.packets_dropped ?? 0,
|
||||
label: 'Заблокировано за период',
|
||||
hint: `пакетов · ${comparisonHint(comparison.droppedPercent, previousSummary.dropped)}`,
|
||||
value: summary.dropped ?? '—',
|
||||
icon: <BanIcon aria-hidden />,
|
||||
iconClassName: 'text-destructive',
|
||||
variant: 'destructive',
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
label: 'Пропущено (сумма)',
|
||||
hint: 'пакетов · сумма пиков',
|
||||
value: dash.data?.packets_accepted ?? 0,
|
||||
label: 'Разрешено правилами за период',
|
||||
hint: `пакетов · ${comparisonHint(comparison.acceptedPercent, previousSummary.accepted)}`,
|
||||
value: summary.accepted ?? '—',
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
},
|
||||
{
|
||||
id: 'o',
|
||||
label: 'Агентов онлайн',
|
||||
hint: `из ${dash.data?.agents_approved ?? 0} одобренных`,
|
||||
value: dash.data?.agents_online ?? 0,
|
||||
label: 'Покрытие периода',
|
||||
hint: `${summary.knownBuckets} интервалов с данными`,
|
||||
value: summary.coverage === null ? '—' : `${Math.round(summary.coverage * 100)}%`,
|
||||
icon: <ServerIcon aria-hidden />,
|
||||
iconClassName: 'text-info',
|
||||
},
|
||||
{ id: 'mean-pps', label: 'Средние пакеты/с', value: rates.meanPps === null ? '—' : formatNumber(rates.meanPps), hint: rates.complete ? rates.estimated ? 'оценка по интервалам' : 'по полному периоду' : 'нужно полное покрытие', icon: <ServerIcon aria-hidden />, iconClassName: 'text-info' },
|
||||
{ id: 'peak-pps', label: 'Пик средних пакетов/с', value: rates.peakBucketPps === null ? '—' : formatNumber(rates.peakBucketPps), hint: 'максимум среднего за интервал', icon: <ServerIcon aria-hidden />, iconClassName: 'text-info' },
|
||||
{ id: 'drop-rate', label: 'Доля блокировки EvoFirewall', value: rates.dropRate === null ? '—' : `${new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 1 }).format(rates.dropRate)}%`, hint: rates.complete ? 'среди решений управляемых правил' : 'нужно полное покрытие', icon: <BanIcon aria-hidden />, iconClassName: 'text-destructive' },
|
||||
]
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
@@ -203,7 +203,7 @@ function StatsPage() {
|
||||
{
|
||||
accessorKey: 'packets_dropped',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Заблокировано" />
|
||||
<DataGridColumnHeader column={column} title="Сырой счётчик drop" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-warning tabular-nums">
|
||||
@@ -214,7 +214,7 @@ function StatsPage() {
|
||||
{
|
||||
accessorKey: 'packets_accepted',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Пропущено" />
|
||||
<DataGridColumnHeader column={column} title="Сырой счётчик accept" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-success tabular-nums">
|
||||
@@ -230,59 +230,55 @@ function StatsPage() {
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Статистика"
|
||||
description="История apply-report counters · заблокированный и пропущенный трафик агентов"
|
||||
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
|
||||
description="Пакеты и качество телеметрии v2 за выбранный период"
|
||||
actions={<div className="flex flex-wrap items-center gap-2">
|
||||
<UpdatedAtLabel updatedAt={timeseries.dataUpdatedAt} />
|
||||
<LoadingButton type="button" variant="outline" isLoading={csv.isPending} disabled={!timeseries.isSuccess} onClick={() => csv.mutate(request)}><DownloadIcon data-icon="inline-start" />CSV</LoadingButton>
|
||||
</div>}
|
||||
/>
|
||||
<SignalAlert tone="in" title="Как считаются значения.">
|
||||
Счётчики агентов смешанной семантики (накопительные и сбросные): суммирование сэмплов дало бы двойной счёт,
|
||||
поэтому за час берём максимум наблюдавшегося уровня.
|
||||
<SignalAlert tone="in" title="Качество измерений.">
|
||||
Пакеты считаются по дельтам телеметрии v2. Пропуски показаны разрывами; оценённые и неполные интервалы отмечены.
|
||||
Суммы включают только доступные измерения. Разрешение EvoFirewall не гарантирует проход через остальные правила хоста.
|
||||
Экспорт ограничен 1440 интервалами и 100 агентами.
|
||||
</SignalAlert>
|
||||
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
||||
<Tabs defaultValue="activity" className="min-w-0 gap-4">
|
||||
<FieldGroup className="flex-row flex-wrap items-end gap-3">
|
||||
<Field className="w-auto">
|
||||
<FieldLabel htmlFor="stats-period">Период и интервал</FieldLabel>
|
||||
<Select value={preset} items={Object.entries(STATS_PRESETS).map(([value, config]) => ({ value, label: config.label }))} onValueChange={(value) => { if (value && value in STATS_PRESETS) setPreset(value as StatsPreset) }}>
|
||||
<SelectTrigger id="stats-period"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectGroup>{Object.entries(STATS_PRESETS).map(([value, config]) => <SelectItem key={value} value={value}>{config.label}</SelectItem>)}</SelectGroup></SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<StatsAgentSelect value={agentIds} onChange={setAgentIds} />
|
||||
</FieldGroup>
|
||||
<QueryState hasData={timeseries.data !== undefined} isLoading={timeseries.isPending} isError={timeseries.isError} error={timeseries.error} onRetry={() => void timeseries.refetch()}>
|
||||
<KpiStatGrid cards={kpiCards} isLoading={timeseries.isPending} />
|
||||
</QueryState>
|
||||
{previous.isError && tab === 'activity' ? <QueryState isError error={previous.error} onRetry={() => void previous.refetch()}>{null}</QueryState> : null}
|
||||
<Tabs value={tab} onValueChange={setTab} className="min-w-0 gap-4">
|
||||
<PageTabsList>
|
||||
<PageTab value="activity" label="Активность" />
|
||||
<PageTab value="samples" label="Samples" count={rows.length} />
|
||||
<PageTab value="samples" label="Legacy samples" count={rows.length} />
|
||||
</PageTabsList>
|
||||
<TabsContent value="activity" className="min-w-0">
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
||||
<FrameTitle>Пакеты по интервалам</FrameTitle>
|
||||
<p className="text-muted-foreground text-xs">Сравнение с предыдущим равным периодом, тот же интервал. {comparison.complete && sameAgents ? 'Покрытие обоих периодов полное.' : 'При неполном покрытии или разном составе агентов процент изменения не рассчитывается.'}</p>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{series.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет данных — дождитесь apply-report от агентов.
|
||||
</p>
|
||||
) : (
|
||||
<ChartContainer config={chartConfig} className="aspect-[2/1] w-full">
|
||||
<AreaChart data={series}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="t" tickLine={false} axisLine={false} />
|
||||
<YAxis tickLine={false} axisLine={false} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
dataKey="dropped"
|
||||
type="monotone"
|
||||
fill="var(--color-dropped)"
|
||||
stroke="var(--color-dropped)"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
<Area
|
||||
dataKey="accepted"
|
||||
type="monotone"
|
||||
fill="var(--color-accepted)"
|
||||
stroke="var(--color-accepted)"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
<QueryState hasData={timeseries.data !== undefined} isLoading={timeseries.isPending} isError={timeseries.isError} error={timeseries.error} onRetry={() => void timeseries.refetch()}>
|
||||
<TrafficTimeseriesChart items={timeseries.data?.items ?? []} daily={request.resolution === '1d'} />
|
||||
<p className="text-muted-foreground mt-3 text-xs">{request.from} — {request.to} · границы UTC, время графика в часовом поясе браузера · {agentIds.length ? `выбрано агентов: ${agentIds.length}` : 'весь флот'}</p>
|
||||
</QueryState>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</TabsContent>
|
||||
<TabsContent value="samples" className="min-w-0">
|
||||
<TabsContent value="samples" className="flex min-w-0 flex-col gap-3">
|
||||
<SignalAlert tone="in" title="Legacy samples — исходные отчёты.">Не нормализованы по интервалам. Накопительные счётчики и presence/hit snapshots старых агентов нельзя суммировать или считать пакетами за час. Выбор агентов выше относится к графику v2 и CSV; таблица имеет отдельные фильтры.</SignalAlert>
|
||||
<QueryState hasData={stats.data !== undefined} isLoading={stats.isPending} isError={stats.isError} error={stats.error} onRetry={() => void stats.refetch()}>
|
||||
<ResourcePage
|
||||
title="Samples"
|
||||
title="Legacy samples"
|
||||
hideHeader
|
||||
data={rows}
|
||||
columns={columns}
|
||||
@@ -299,6 +295,7 @@ function StatsPage() {
|
||||
description: 'Агенты ещё не отправили apply-report.',
|
||||
}}
|
||||
/>
|
||||
</QueryState>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageShell>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><title>EvoFirewall F1 interaction checks</title></head><body><h1>Isolated F1 interaction checks</h1><pre id="results">Running…</pre><pre id="metrics"></pre><div id="fixture"></div><script type="module" src="/test/f1.tsx"></script></body></html>
|
||||
@@ -0,0 +1,112 @@
|
||||
import { act, Profiler, useState, type ReactNode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { ExplainResponse } from '@evofw/shared'
|
||||
import { AgentPortRuleForm, AgentPortImportForm } from '@/components/agents/agent-port-acl-forms'
|
||||
import { portAclDraftSchema } from '@/components/agents/port-acl-validation'
|
||||
import { AgentExplainSheet } from '@/components/agents/agent-explain-sheet'
|
||||
import { AgentOverrideSheet } from '@/components/agents/agent-settings-sheets'
|
||||
import { StatsAgentSelect } from '@/components/stats/stats-agent-select'
|
||||
import '@evofw/ui/globals.css'
|
||||
|
||||
if (!import.meta.env.DEV) throw new Error('Development checks only')
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
const results = document.querySelector<HTMLPreElement>('#results')!
|
||||
const metrics = document.querySelector<HTMLPreElement>('#metrics')!
|
||||
const host = document.querySelector<HTMLDivElement>('#fixture')!
|
||||
const root = createRoot(host)
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const originalFetch = window.fetch
|
||||
const checks: string[] = []
|
||||
const measured = { scope: 'Current fixture harness only; no baseline comparison', commits: 0, actualDurationMs: 0, requestCounts: {} as Record<string, number> }
|
||||
function reportMetrics() { metrics.textContent = JSON.stringify(measured, null, 2) }
|
||||
function check(condition: unknown, label: string) { if (!condition) throw new Error(label); checks.push(`PASS ${label}`); results.textContent = checks.join('\n') }
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 50))
|
||||
async function render(node: ReactNode) { await act(async () => { root.render(<Profiler id="f1-fixtures" onRender={(_id, _phase, actualDuration) => { measured.commits++; measured.actualDurationMs += actualDuration; reportMetrics() }}><QueryClientProvider client={qc}>{node}</QueryClientProvider></Profiler>); await flush() }) }
|
||||
async function input(id: string, value: string) { await act(async () => { const field = document.getElementById(id) as HTMLInputElement; Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(field, value); field.dispatchEvent(new Event('input', { bubbles: true })); await flush() }) }
|
||||
async function submit() { await act(async () => { document.querySelector<HTMLFormElement>('[data-slot="sheet-content"] form')!.requestSubmit(); await flush() }) }
|
||||
async function choose(id: string, label: string) { await act(async () => { document.getElementById(id)!.click(); await flush() }); await act(async () => { const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((item) => item.textContent?.trim() === label); if (!option) throw new Error(`Option not found: ${label}`); option.click(); await flush() }) }
|
||||
const explain: ExplainResponse = { agent_id: 'fixture', query: { ip: '203.0.113.10', port: 443, protocol: 'tcp', chain: 'input', dnat: false, connection_state: 'new' }, desired: { revision: 8, hash: 'desired', decision: null, stage: 'external', match: null }, applied: { revision: null, hash: null, same_as_desired: null, status: 'unsupported' }, matches: [], matches_total: 0, truncated: false, limits: ['desired_policy_not_kernel_observation', 'external_firewall_may_override_accept'], external_firewall: { observed_at: null, rule_count: 0, complete: false } }
|
||||
let explainGets = 0
|
||||
let overridePosts: Record<string, unknown>[] = []
|
||||
let failOverride = true
|
||||
window.fetch = async (request, init) => {
|
||||
const url = String(request)
|
||||
const path = url.split('?')[0]
|
||||
const key = `${init?.method ?? 'GET'} ${path}`
|
||||
measured.requestCounts[key] = (measured.requestCounts[key] ?? 0) + 1
|
||||
reportMetrics()
|
||||
if (path.endsWith('/api/v1/agents')) return Response.json({ items: [{ id: 'fixture-office', name: 'Office network', hostname: 'office.example' }, { id: 'fixture-edge', name: 'Edge gateway', hostname: 'edge.example' }] })
|
||||
if (path.endsWith('/explain')) { explainGets++; return Response.json(explain) }
|
||||
if (path.endsWith('/overrides')) {
|
||||
if (init?.method === 'POST') { overridePosts.push(JSON.parse(String(init.body))); return failOverride ? Response.json({ error: { message: 'Fixture write rejected' } }, { status: 503 }) : Response.json({ ok: true }) }
|
||||
return Response.json({ items: [{ id: 'expired', agent_id: 'fixture', cidr: '198.51.100.0/24', action: 'deny', created_at: '2026-10-23T00:00:00Z', created_by_name: 'Fixture author', reason: 'Fixture incident', expires_at: '2026-10-23T01:00:00Z', expired: true, expiration_enforcement: 'next_policy_sync' }] })
|
||||
}
|
||||
throw new Error(`Unexpected fixture request: ${key}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = { items: [{ value: 'fixture-list', label: 'Office ranges' }], loading: false, ready: true, error: null, retry: () => {} }
|
||||
const initial = { action: 'open' as const, protocol: 'tcp' as const, port_start: '', port_end: '', src_kind: 'all' as const, src_cidr: '', list_id: '', enabled: true, comment: '' }
|
||||
let saves = 0
|
||||
let savedPort: number | null = null
|
||||
const rule = (list = catalog) => <AgentPortRuleForm open onOpenChange={() => {}} title="Fixture rule" description="Fixture only" canWrite pending={false} lists={list} initialValues={initial} onSubmit={(values) => { saves++; savedPort = portAclDraftSchema.parse(values).port_start }} />
|
||||
await render(rule())
|
||||
await input('acl-port_start', '1e3'); await submit()
|
||||
check(saves === 0 && document.getElementById('acl-port_start')?.getAttribute('aria-invalid') === 'true', 'RHF Port ACL blocks exponent notation before save')
|
||||
await input('acl-port_start', '443'); await input('acl-port_end', '80'); await submit()
|
||||
check(saves === 0 && document.getElementById('acl-port_end')?.getAttribute('aria-invalid') === 'true', 'RHF Port ACL rejects reversed range with an inline error')
|
||||
await input('acl-port_end', '')
|
||||
const portNode = document.getElementById('acl-port_start')
|
||||
await render(rule({ ...catalog, items: [...catalog.items] }))
|
||||
check(document.getElementById('acl-port_start') === portNode && (portNode as HTMLInputElement).value === '443', 'catalog refresh preserves the mounted dirty Port ACL draft')
|
||||
await submit()
|
||||
check(saves === 1 && savedPort === 443, 'form submit delivers one validated Port ACL save')
|
||||
|
||||
let imports = 0
|
||||
let imported = ''
|
||||
await render(<AgentPortImportForm open onOpenChange={() => {}} canWrite pending={false} lists={catalog} sets={catalog} onSubmit={(values) => { imports++; imported = `${values.listId}:${values.ports}` }} />)
|
||||
await input('acl-import-ports', '80-'); await submit()
|
||||
check(imports === 0 && document.getElementById('acl-import-ports')?.getAttribute('aria-invalid') === 'true', 'RHF import rejects malformed ports and missing source')
|
||||
await choose('acl-import-source', 'Office ranges'); await input('acl-import-ports', '22,8000-8001'); await submit()
|
||||
check(imports === 1 && imported === 'fixture-list:22,8000-8001', 'RHF import submits its selected source and valid ranges once')
|
||||
|
||||
let selection: string[] = []
|
||||
function Selection() { const [value, setValue] = useState<string[]>([]); return <StatsAgentSelect value={value} onChange={(next) => { selection = next; setValue(next) }} /> }
|
||||
await render(<Selection />)
|
||||
await act(async () => { const field = document.getElementById('stats-agents')!; field.focus(); field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); await flush(); await flush() })
|
||||
check(document.getElementById('stats-agents')?.getAttribute('aria-expanded') === 'true', 'agent combobox opens through keyboard interaction')
|
||||
await act(async () => { const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find((item) => item.textContent?.includes('Office network')); if (!option) throw new Error('Office network option not found'); option.click(); await flush() })
|
||||
check(selection[0] === 'fixture-office' && host.textContent?.includes('Office network') && !host.textContent?.includes('fixture-office'), 'agent filtering selects names without exposing raw identifiers')
|
||||
|
||||
await render(<AgentExplainSheet agentId="fixture" open onOpenChange={() => {}} />)
|
||||
await input('explain-ip', '203.0.113.10'); await input('explain-port', '443'); await submit()
|
||||
check(explainGets === 0 && document.getElementById('explain-port')?.getAttribute('aria-invalid') === 'true', 'Explain blocks an unpaired transport input without a request')
|
||||
await choose('explain-protocol', 'TCP'); await submit(); await act(async () => { await flush() })
|
||||
check(explainGets === 1 && document.body.textContent?.includes('Решение не определено') && document.body.textContent?.includes('Совпадение неизвестно'), 'Explain preserves external and unconfirmed decisions after a valid read')
|
||||
await input('explain-ip', '203.0.113.11')
|
||||
check(explainGets === 1 && document.body.textContent?.includes('Форма изменена — запустите проверку снова'), 'editing Explain retains a labeled previous result without issuing another query')
|
||||
|
||||
await render(<AgentOverrideSheet agentId="fixture" open onOpenChange={() => {}} />)
|
||||
await act(async () => { await flush() })
|
||||
check(document.body.textContent?.includes('Fixture author') && document.body.textContent?.includes('Истёк'), 'override history shows server-owned author and expired state')
|
||||
await input('ov-cidr', '203.0.113.10'); await choose('ov-expiry-mode', 'На время'); await input('ov-ttl', '0'); await submit()
|
||||
check(overridePosts.length === 0 && document.getElementById('ov-ttl')?.getAttribute('aria-invalid') === 'true', 'override expiry validation blocks zero TTL')
|
||||
await input('ov-ttl', '15')
|
||||
await act(async () => { const reason = document.getElementById('ov-reason') as HTMLTextAreaElement; Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(reason, 'Fixture reason'); reason.dispatchEvent(new Event('input', { bubbles: true })); await flush() })
|
||||
await submit(); await act(async () => { await flush() })
|
||||
check(overridePosts.length === 1 && (document.getElementById('ov-cidr') as HTMLInputElement).value === '203.0.113.10', 'failed override write preserves the dirty draft')
|
||||
failOverride = false
|
||||
await submit(); await act(async () => { await flush() })
|
||||
check(overridePosts.length === 2 && overridePosts[1].ttl_seconds === 900 && overridePosts[1].reason === 'Fixture reason' && !('expires_at' in overridePosts[1]) && !('created_by_name' in overridePosts[1]), 'temporary override sends validated TTL and reason without author spoofing')
|
||||
check((document.getElementById('ov-cidr') as HTMLInputElement).value === '' && document.body.textContent?.includes('следующей связи'), 'successful override resets the draft and retains next-sync expiry limits')
|
||||
results.dataset.status = 'passed'
|
||||
} catch (error) {
|
||||
results.textContent = `${checks.join('\n')}\nFAIL ${error instanceof Error ? error.stack : String(error)}`
|
||||
results.dataset.status = 'failed'
|
||||
} finally {
|
||||
await act(async () => root.unmount())
|
||||
qc.clear()
|
||||
window.fetch = originalFetch
|
||||
reportMetrics()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><title>EvoFirewall F2/F3 interaction checks</title></head><body><h1>Isolated F2/F3 interaction checks</h1><pre id="results">Running…</pre><pre id="metrics"></pre><div id="fixture"></div><script type="module" src="/test/f2.tsx"></script></body></html>
|
||||
@@ -0,0 +1,515 @@
|
||||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import type { PolicyRollout } from '@evofw/shared'
|
||||
import { WebhooksSheet } from '@/components/webhooks-sheet'
|
||||
import { AgentLabelsSheet } from '@/components/agents/agent-labels-sheet'
|
||||
import { PolicyRolloutsSheet } from '@/components/rules/policy-rollouts-sheet'
|
||||
import { AgentCredentialsSheet } from '@/components/agents/agent-credentials-sheet'
|
||||
import { ensureAuthConfig, setToken, getToken } from '@/lib/auth'
|
||||
import '@evofw/ui/globals.css'
|
||||
|
||||
if (!import.meta.env.DEV) throw new Error('Development checks only')
|
||||
;(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true
|
||||
const results = document.querySelector<HTMLPreElement>('#results')!
|
||||
const metrics = document.querySelector<HTMLPreElement>('#metrics')!
|
||||
const root = createRoot(document.querySelector<HTMLDivElement>('#fixture')!)
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
})
|
||||
const originalFetch = window.fetch
|
||||
const originalToken = getToken()
|
||||
const checks: string[] = []
|
||||
const calls: { method: string; path: string; body: any }[] = []
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 75))
|
||||
function check(value: unknown, label: string) {
|
||||
if (!value) throw new Error(label)
|
||||
checks.push(`PASS ${label}`)
|
||||
results.textContent = checks.join('\n')
|
||||
}
|
||||
async function render(node: ReactNode) {
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={qc}>{node}</QueryClientProvider>)
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
async function click(text: string) {
|
||||
await act(async () => {
|
||||
const button = [
|
||||
...document.querySelectorAll<HTMLButtonElement>('button'),
|
||||
].find((item) => item.textContent?.trim() === text && !item.disabled)
|
||||
if (!button) throw new Error(`Button missing or disabled: ${text}`)
|
||||
button.click()
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
function button(text: string) {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
(item) => item.textContent?.trim() === text,
|
||||
)
|
||||
}
|
||||
async function input(id: string, value: string) {
|
||||
await act(async () => {
|
||||
const field = document.getElementById(id) as HTMLInputElement
|
||||
Object.getOwnPropertyDescriptor(
|
||||
HTMLInputElement.prototype,
|
||||
'value',
|
||||
)!.set!.call(field, value)
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
async function submit() {
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelector<HTMLFormElement>('[data-slot="sheet-content"] form')!
|
||||
.requestSubmit()
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
async function choose(id: string, label: string) {
|
||||
await act(async () => {
|
||||
const field = document.getElementById(id)!
|
||||
field.focus()
|
||||
field.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
|
||||
await flush()
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
].find((item) => item.textContent?.trim() === label)
|
||||
if (!option) throw new Error(`Option missing: ${label}`)
|
||||
option.click()
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
document
|
||||
.getElementById(id)
|
||||
?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
)
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
async function chooseSelect(index: number, label: string) {
|
||||
await act(async () => {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>('[data-slot="select-trigger"]')
|
||||
[index].click()
|
||||
await flush()
|
||||
})
|
||||
await act(async () => {
|
||||
const option = [
|
||||
...document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
].find((item) => item.textContent?.trim() === label)
|
||||
if (!option) throw new Error(`Select option missing: ${label}`)
|
||||
option.click()
|
||||
await flush()
|
||||
})
|
||||
}
|
||||
const writes = (path: string) =>
|
||||
calls.filter((call) => call.method !== 'GET' && call.path === path)
|
||||
const fixtureToken = (permissions: string[]) =>
|
||||
`fixture.${btoa(JSON.stringify({ sub: 'fixture', permissions, apps: ['fw'] }))}.unsigned`
|
||||
let configured = true,
|
||||
failHook = true,
|
||||
failLabels = true,
|
||||
pendingRotation = false
|
||||
const endpoint = {
|
||||
id: 'hook',
|
||||
name: 'Existing endpoint',
|
||||
url: 'https://fixture.invalid/hook',
|
||||
events: ['agent.offline'],
|
||||
enabled: false,
|
||||
secret_configured: true,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
}
|
||||
let rollout: PolicyRollout = {
|
||||
id: 'rollout',
|
||||
snapshot_id: 'snapshot',
|
||||
state: 'canary',
|
||||
version: 9,
|
||||
created_at: '2026-10-24',
|
||||
updated_at: '',
|
||||
targets: [
|
||||
{
|
||||
agent_id: 'target',
|
||||
is_canary: true,
|
||||
phase: 'candidate',
|
||||
desired_revision: 8,
|
||||
desired_hash: 'new',
|
||||
applied_revision: null,
|
||||
applied_hash: null,
|
||||
acknowledged: false,
|
||||
previous_desired_snapshot_id: 'previous',
|
||||
},
|
||||
],
|
||||
}
|
||||
window.fetch = async (request, init) => {
|
||||
const url = String(request),
|
||||
path = new URL(url, location.origin).pathname,
|
||||
method = init?.method ?? 'GET'
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : null
|
||||
calls.push({ method, path, body })
|
||||
metrics.textContent = JSON.stringify(
|
||||
calls.map(({ method, path }) => ({ method, path })),
|
||||
null,
|
||||
2,
|
||||
)
|
||||
if (path === '/api/auth/config') return Response.json({ required: true })
|
||||
if (path === '/api/v1/agents')
|
||||
return Response.json({
|
||||
items: [
|
||||
{ id: 'target', name: 'Fixture target', hostname: 'fixture.invalid' },
|
||||
],
|
||||
})
|
||||
if (path === '/api/v1/webhooks') {
|
||||
if (method === 'POST')
|
||||
return failHook
|
||||
? Response.json(
|
||||
{ error: { message: 'Fixture webhook failure' } },
|
||||
{ status: 503 },
|
||||
)
|
||||
: Response.json(endpoint)
|
||||
return Response.json({
|
||||
items: [endpoint],
|
||||
configured,
|
||||
limits: { endpoints: 10, pending: 100 },
|
||||
queue: { pending: 1, dead: 2, dropped: 3 },
|
||||
})
|
||||
}
|
||||
if (path === '/api/v1/webhooks/hook') return Response.json(endpoint)
|
||||
if (path.endsWith('/deliveries'))
|
||||
return Response.json({ items: [], total: 0 })
|
||||
if (path === '/api/v1/agent-labels')
|
||||
return Response.json({
|
||||
items: [
|
||||
{ id: 'label-a', name: 'Fixture label A', color: null, created_at: '' },
|
||||
{ id: 'label-b', name: 'Fixture label B', color: null, created_at: '' },
|
||||
],
|
||||
total: 2,
|
||||
})
|
||||
if (path.endsWith('/labels')) {
|
||||
if (method === 'PUT' && failLabels)
|
||||
return Response.json(
|
||||
{ error: { message: 'Fixture label write rejected' } },
|
||||
{ status: 409 },
|
||||
)
|
||||
return Response.json({ agent_id: 'target', label_ids: ['label-a'] })
|
||||
}
|
||||
if (path === '/api/v1/policy-sets')
|
||||
return Response.json({ items: [{ id: 'set', name: 'Fixture set' }] })
|
||||
if (path.endsWith('/preview-assignment'))
|
||||
return Response.json({
|
||||
mode: body.mode,
|
||||
items: [
|
||||
{
|
||||
agent_id: 'target',
|
||||
generation: 7,
|
||||
previous_set_ids: [],
|
||||
set_ids: ['set'],
|
||||
changed: true,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
if (path.endsWith('/assign-policy-sets'))
|
||||
return Response.json({ items: [], affected_agent_ids: ['target'] })
|
||||
if (path === '/api/v1/policies/snapshots')
|
||||
return Response.json({
|
||||
items: [{ id: 'snapshot', name: 'Fixture snapshot' }],
|
||||
})
|
||||
if (path.endsWith('/diff'))
|
||||
return Response.json({
|
||||
snapshot_id: 'snapshot',
|
||||
items: [
|
||||
{
|
||||
agent_id: 'target',
|
||||
expected_generation: 7,
|
||||
before_hash: 'old',
|
||||
candidate_hash: 'new',
|
||||
added: 1,
|
||||
removed: 0,
|
||||
examples: [],
|
||||
truncated: false,
|
||||
default_action_changed: false,
|
||||
port_rules_changed: false,
|
||||
conflict: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
if (path === '/api/v1/policies/rollouts')
|
||||
return method === 'POST'
|
||||
? Response.json(rollout)
|
||||
: Response.json({ items: [rollout] })
|
||||
if (path === '/api/v1/policies/rollouts/rollout')
|
||||
return Response.json(rollout)
|
||||
if (
|
||||
path.endsWith('/confirm') ||
|
||||
path.endsWith('/rollback') ||
|
||||
path.endsWith('/release')
|
||||
)
|
||||
return Response.json({ ok: true })
|
||||
if (path.endsWith('/token-rotation')) {
|
||||
if (method === 'POST') {
|
||||
pendingRotation = true
|
||||
return Response.json(
|
||||
{
|
||||
request_id: '00000000-0000-4000-8000-000000000001',
|
||||
expires_at: '2099-01-01T00:00:00Z',
|
||||
},
|
||||
{ status: 202 },
|
||||
)
|
||||
}
|
||||
return Response.json({
|
||||
agent_id: 'target',
|
||||
supported: true,
|
||||
client_version: '1.0.0',
|
||||
agent_status: 'approved',
|
||||
rotation: pendingRotation
|
||||
? {
|
||||
request_id: '00000000-0000-4000-8000-000000000001',
|
||||
expires_at: '2099-01-01T00:00:00Z',
|
||||
}
|
||||
: null,
|
||||
completed_rotation: null,
|
||||
})
|
||||
}
|
||||
if (path === '/api/v1/install-links')
|
||||
return Response.json({
|
||||
items: [
|
||||
{
|
||||
id: 'install',
|
||||
slug: 'not-rendered',
|
||||
client_name: 'Fixture target',
|
||||
platform: 'linux',
|
||||
agent_id: 'target',
|
||||
created_at: '2026-10-24',
|
||||
expires_at: '2026-10-25',
|
||||
consumed_at: '2026-10-24',
|
||||
revoked_at: null,
|
||||
use_count: 1,
|
||||
urls: { by_id: 'not-rendered', by_slug: 'not-rendered' },
|
||||
curl: {
|
||||
by_id: 'credential-must-not-render',
|
||||
by_slug: 'credential-must-not-render',
|
||||
},
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})
|
||||
throw new Error(`Unexpected fixture request ${method} ${path}`)
|
||||
}
|
||||
try {
|
||||
await ensureAuthConfig()
|
||||
setToken(
|
||||
fixtureToken(['fw:agents:write', 'fw:policies:write', 'fw:settings:admin']),
|
||||
)
|
||||
await render(<WebhooksSheet open onOpenChange={() => {}} />)
|
||||
check(
|
||||
document.body.textContent?.includes('1 ожидают') &&
|
||||
document.body.textContent.includes('2 исчерпали'),
|
||||
'webhook health keeps pending/dead/dropped distinct',
|
||||
)
|
||||
await input('webhook-name', 'Draft hook')
|
||||
await input('webhook-url', 'https://fixture.invalid/new')
|
||||
await input('webhook-secret', 'short')
|
||||
await submit()
|
||||
check(
|
||||
writes('/api/v1/webhooks').length === 0 &&
|
||||
document
|
||||
.getElementById('webhook-secret')
|
||||
?.getAttribute('aria-invalid') === 'true',
|
||||
'short webhook secret blocked inline before POST',
|
||||
)
|
||||
await input('webhook-secret', 'fixture-only-never-delivered-0000000000')
|
||||
await submit()
|
||||
check(
|
||||
writes('/api/v1/webhooks').length === 1 &&
|
||||
(document.getElementById('webhook-name') as HTMLInputElement).value ===
|
||||
'Draft hook',
|
||||
'failed webhook write preserves dirty draft',
|
||||
)
|
||||
failHook = false
|
||||
await submit()
|
||||
check(
|
||||
(document.getElementById('webhook-secret') as HTMLInputElement).value ===
|
||||
'' && writes('/api/v1/webhooks')[1].body.enabled === false,
|
||||
'successful create clears write-only secret and defaults disabled',
|
||||
)
|
||||
await click('Изменить')
|
||||
await input('webhook-name', 'Renamed hook')
|
||||
await submit()
|
||||
check(
|
||||
writes('/api/v1/webhooks/hook')[0]?.body.name === 'Renamed hook' &&
|
||||
!('secret' in writes('/api/v1/webhooks/hook')[0].body),
|
||||
'edit omits blank secret instead of clearing existing secret',
|
||||
)
|
||||
configured = false
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['webhooks'] })
|
||||
await flush()
|
||||
})
|
||||
check(
|
||||
button('Добавить webhook')?.disabled &&
|
||||
document.body.textContent?.includes('Шифрование webhook не настроено'),
|
||||
'missing encryption key disables writes with explanation',
|
||||
)
|
||||
await render(null)
|
||||
qc.clear()
|
||||
setToken(fixtureToken(['fw:agents:read']))
|
||||
const previousCalls = calls.length
|
||||
await render(<WebhooksSheet key="denied" open onOpenChange={() => {}} />)
|
||||
check(
|
||||
calls.length === previousCalls &&
|
||||
document.body.textContent?.includes('Требуются права администратора'),
|
||||
'non-admin webhook panel fetches and writes nothing',
|
||||
)
|
||||
|
||||
await render(null)
|
||||
qc.clear()
|
||||
setToken(fixtureToken(['fw:agents:write', 'fw:policies:write']))
|
||||
await render(
|
||||
<AgentLabelsSheet agentId="target" open onOpenChange={() => {}} />,
|
||||
)
|
||||
await choose('agent-label-ids', 'Fixture label B')
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['agents', 'target', 'labels'] })
|
||||
await flush()
|
||||
})
|
||||
check(
|
||||
document.body.textContent?.includes('Fixture label B'),
|
||||
'agent labels retain dirty selection across refetch',
|
||||
)
|
||||
await click('Сохранить метки агента')
|
||||
check(
|
||||
writes('/api/v1/agents/target/labels')[0]?.body.label_ids.includes(
|
||||
'label-b',
|
||||
) && !button('Сохранить метки агента')?.disabled,
|
||||
'rejected labels write preserves editable selection',
|
||||
)
|
||||
await render(null)
|
||||
qc.clear()
|
||||
await render(<AgentLabelsSheet open onOpenChange={() => {}} />)
|
||||
await choose('group-label-selector', 'Fixture label A')
|
||||
await choose('group-sets', 'Fixture set')
|
||||
await click('Предпросмотр назначения')
|
||||
check(
|
||||
writes('/api/v1/agent-labels/assign-policy-sets').length === 0 &&
|
||||
document.body.textContent?.includes('Fixture target'),
|
||||
'group preview resolves named agents without auto-assignment',
|
||||
)
|
||||
await click('Применить показанное назначение')
|
||||
check(
|
||||
writes('/api/v1/agent-labels/assign-policy-sets').length === 0,
|
||||
'group write requires explicit confirmation',
|
||||
)
|
||||
await click('Подтвердить')
|
||||
check(
|
||||
JSON.stringify(
|
||||
writes('/api/v1/agent-labels/assign-policy-sets')[0]?.body.expected,
|
||||
) === JSON.stringify([{ agent_id: 'target', generation: 7 }]),
|
||||
'group assignment sends exact preview CAS generations',
|
||||
)
|
||||
|
||||
await render(null)
|
||||
qc.clear()
|
||||
await render(<PolicyRolloutsSheet open onOpenChange={() => {}} />)
|
||||
await chooseSelect(0, 'Fixture snapshot')
|
||||
await choose('rollout-targets', 'Fixture targetfixture.invalid')
|
||||
check(
|
||||
button('Запустить canary')?.disabled,
|
||||
'canary start gated on prior diff',
|
||||
)
|
||||
await click('Предпросмотр diff')
|
||||
await choose('rollout-canaries', 'Fixture targetfixture.invalid')
|
||||
await click('Запустить canary')
|
||||
check(
|
||||
writes('/api/v1/policies/rollouts').length === 0,
|
||||
'canary publication requires separate confirmation',
|
||||
)
|
||||
await click('Подтвердить')
|
||||
check(
|
||||
writes('/api/v1/policies/rollouts')[0]?.body.targets[0]
|
||||
.expected_generation === 7 &&
|
||||
button('Подтвердить после ACK')?.disabled &&
|
||||
document.body.textContent?.includes('Ожидается ACK'),
|
||||
'canary uses diff generation and pending ACK never enables confirmation',
|
||||
)
|
||||
rollout = {
|
||||
...rollout,
|
||||
targets: [
|
||||
{
|
||||
...rollout.targets[0],
|
||||
acknowledged: true,
|
||||
applied_revision: 8,
|
||||
applied_hash: 'new',
|
||||
},
|
||||
],
|
||||
}
|
||||
await act(async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['policy-rollouts'] })
|
||||
await flush()
|
||||
})
|
||||
check(
|
||||
!button('Подтвердить после ACK')?.disabled &&
|
||||
writes('/api/v1/policies/rollouts/rollout/confirm').length === 0,
|
||||
'fresh canary ACK enables but does not auto-confirm',
|
||||
)
|
||||
await click('Подтвердить после ACK')
|
||||
await click('Подтвердить')
|
||||
check(
|
||||
writes('/api/v1/policies/rollouts/rollout/confirm')[0]?.body
|
||||
.expected_version === 9,
|
||||
'rollout transition sends exact displayed version',
|
||||
)
|
||||
await click('Снять pin')
|
||||
check(
|
||||
document.body.textContent?.includes('живая политика будет проверена') &&
|
||||
writes('/api/v1/agents/target/policy-pin/release').length === 0,
|
||||
'pin release warns about validated live-policy resume before write',
|
||||
)
|
||||
await click('Отмена')
|
||||
|
||||
await render(null)
|
||||
qc.clear()
|
||||
await render(
|
||||
<AgentCredentialsSheet agentId="target" open onOpenChange={() => {}} />,
|
||||
)
|
||||
check(
|
||||
document.body.textContent?.includes('Использована') &&
|
||||
!document.body.textContent.includes('credential-must-not-render'),
|
||||
'credential panel displays link lifecycle without installation credentials',
|
||||
)
|
||||
await click('Запросить ротацию')
|
||||
check(
|
||||
writes('/api/v1/agents/target/token-rotation').length === 0,
|
||||
'rotation requires explicit request confirmation',
|
||||
)
|
||||
await click('Запросить')
|
||||
check(
|
||||
pendingRotation &&
|
||||
document.body.textContent?.includes('Ожидается обмен ключа агентом') &&
|
||||
button('Запросить ротацию')?.disabled,
|
||||
'rotation request remains pending until agent exchange',
|
||||
)
|
||||
results.dataset.status = 'passed'
|
||||
} catch (error) {
|
||||
results.textContent = `${checks.join('\n')}\nFAIL ${error instanceof Error ? error.stack : String(error)}`
|
||||
results.dataset.status = 'failed'
|
||||
} finally {
|
||||
if (results.dataset.status === 'passed') {
|
||||
await act(async () => root.unmount())
|
||||
qc.clear()
|
||||
window.fetch = originalFetch
|
||||
}
|
||||
if (originalToken) setToken(originalToken)
|
||||
else localStorage.removeItem('fw_auth_token')
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><title>EvoFirewall isolated interaction checks</title></head>
|
||||
<body>
|
||||
<h1>Isolated interaction checks</h1>
|
||||
<pre id="results">Running…</pre>
|
||||
<div id="fixture"></div>
|
||||
<script type="module" src="/test/interaction.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,192 @@
|
||||
// Browser-only mounted regression checks. This page is outside the production
|
||||
// entry graph and replaces fetch with fixtures before mounting application UI.
|
||||
import { act, useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
|
||||
import { AgentDiagnosticsPanel } from '@/components/agents/agent-diagnostics-panel'
|
||||
import { TrafficTimeseriesChart } from '@/components/stats/traffic-timeseries-chart'
|
||||
import { PortalShell } from '@/components/layout/portal-shell'
|
||||
import { createRootRoute, createRouter, createMemoryHistory, RouterProvider } from '@tanstack/react-router'
|
||||
import { ensureAuthConfig } from '@/lib/auth'
|
||||
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
|
||||
import type { AgentDiagnostics, TimeseriesPoint } from '@evofw/shared'
|
||||
import '@evofw/ui/globals.css'
|
||||
|
||||
if (!import.meta.env.DEV) throw new Error('Development checks only')
|
||||
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
const results = document.querySelector<HTMLPreElement>('#results')!
|
||||
const host = document.querySelector<HTMLDivElement>('#fixture')!
|
||||
const root = createRoot(host)
|
||||
const checks: string[] = []
|
||||
function check(condition: unknown, label: string) {
|
||||
if (!condition) throw new Error(label)
|
||||
checks.push(`PASS ${label}`)
|
||||
results.textContent = checks.join('\n')
|
||||
}
|
||||
|
||||
let mounts = 0
|
||||
let unmounts = 0
|
||||
function Editor() {
|
||||
const [value, setValue] = useState('initial')
|
||||
useEffect(() => { mounts++; return () => { unmounts++ } }, [])
|
||||
return <input aria-label="Operator edit" value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
}
|
||||
|
||||
try {
|
||||
const view = (failed: boolean) => (
|
||||
<QueryState hasData isError={failed} error={new Error('Fixture offline')}>
|
||||
<Editor />
|
||||
</QueryState>
|
||||
)
|
||||
await act(async () => root.render(view(false)))
|
||||
const input = host.querySelector<HTMLInputElement>('input')!
|
||||
input.focus()
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, 'unsaved')
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
await act(async () => root.render(view(true)))
|
||||
check(host.querySelector('input') === input, 'background failure preserves the input DOM node')
|
||||
check(document.activeElement === input, 'background failure preserves focus')
|
||||
check(input.value === 'unsaved', 'background failure preserves local edit state')
|
||||
check(mounts === 1 && unmounts === 0, 'background failure does not remount the editor')
|
||||
await act(async () => root.render(view(false)))
|
||||
check(host.querySelector('input') === input && input.value === 'unsaved', 'refresh recovery preserves the edit')
|
||||
|
||||
let assignmentPuts = 0
|
||||
const originalFetch = window.fetch
|
||||
window.fetch = async (request, init) => {
|
||||
const path = String(request)
|
||||
if (init?.method === 'PUT') assignmentPuts++
|
||||
if (path.endsWith('/agents/fixture/policy-sets')) {
|
||||
return Response.json({ error: { message: 'Assignment fixture unavailable' } }, { status: 503 })
|
||||
}
|
||||
if (path.endsWith('/policy-sets')) {
|
||||
return Response.json({ items: [{ id: 'catalog-set', name: 'Fixture set', enabled: true }] })
|
||||
}
|
||||
throw new Error(`Unexpected fixture request: ${path}`)
|
||||
}
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
await act(async () => {
|
||||
root.render(<QueryClientProvider client={qc}><AgentPolicySetsSortable agentId="fixture" /></QueryClientProvider>)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
})
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)) })
|
||||
const add = [...host.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === 'Добавить')
|
||||
check(host.textContent?.includes('Assignment fixture unavailable'), 'assignment GET failure displays its error')
|
||||
check(add?.disabled, 'assignment GET failure disables assignment writes')
|
||||
add?.click()
|
||||
check(assignmentPuts === 0, 'assignment GET failure cannot produce a replacement PUT')
|
||||
await act(async () => root.unmount())
|
||||
qc.clear()
|
||||
|
||||
// Import the router plugin's development split component so the test mounts
|
||||
// the real settings page without adding a production export or route.
|
||||
const splitUrl = '/src/routes/_auth/settings.tsx?tsr-split=component'
|
||||
const { component: SettingsPage } = await import(/* @vite-ignore */ splitUrl)
|
||||
let storedSettings = { enroll_seed: 'fixture-seed', evobgp_api_url: '', evobgp_api_token: '', agent_sync_interval_sec: '60', show_quick_actions: 'true' }
|
||||
let settingsOffline = false
|
||||
let saves = 0
|
||||
window.fetch = async (request, init) => {
|
||||
if (!String(request).endsWith('/api/v1/settings')) throw new Error('Unexpected settings fixture request')
|
||||
if (init?.method === 'PUT') {
|
||||
storedSettings = JSON.parse(String(init.body))
|
||||
saves++
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
return settingsOffline
|
||||
? Response.json({ error: { message: 'Settings fixture offline' } }, { status: 503 })
|
||||
: Response.json(storedSettings)
|
||||
}
|
||||
const settingsClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const settingsRoot = createRoot(host)
|
||||
await act(async () => {
|
||||
settingsRoot.render(<QueryClientProvider client={settingsClient}><SettingsPage /></QueryClientProvider>)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
})
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)) })
|
||||
const seed = host.querySelector<HTMLInputElement>('#enroll_seed')!
|
||||
check(seed?.value === 'fixture-seed', 'real settings page hydrates the fixture')
|
||||
seed.focus()
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(seed, 'operator-unsaved-seed')
|
||||
seed.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
check(host.textContent?.includes('Есть несохранённые изменения'), 'editing settings records dirty state')
|
||||
await act(async () => { settingsClient.setQueryData(['settings'], { ...storedSettings, enroll_seed: 'remote-refresh' }) })
|
||||
check(seed.value === 'operator-unsaved-seed', 'settings background data cannot reset dirty fields')
|
||||
settingsOffline = true
|
||||
await act(async () => { await settingsClient.refetchQueries({ queryKey: ['settings'] }); await new Promise((resolve) => setTimeout(resolve, 30)) })
|
||||
check(host.querySelector('#enroll_seed') === seed && seed.value === 'operator-unsaved-seed', 'settings refresh error preserves mounted dirty fields')
|
||||
check(document.activeElement === seed, 'settings refresh error preserves input focus')
|
||||
settingsOffline = false
|
||||
await act(async () => { await settingsClient.refetchQueries({ queryKey: ['settings'] }); await new Promise((resolve) => setTimeout(resolve, 30)) })
|
||||
await act(async () => {
|
||||
host.querySelector<HTMLFormElement>('form')!.requestSubmit()
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
})
|
||||
check(saves === 1 && storedSettings.enroll_seed === 'operator-unsaved-seed', 'settings save sends the operator edit once')
|
||||
check(seed.value === 'operator-unsaved-seed' && !host.textContent?.includes('Есть несохранённые изменения'), 'accepted settings become clean without reverting to stale cache')
|
||||
await act(async () => settingsRoot.unmount())
|
||||
settingsClient.clear()
|
||||
|
||||
const diagnostics: AgentDiagnostics = {
|
||||
desired_revision: 8, desired_hash: 'sha256:desired-fixture', applied_revision: 7, applied_hash: 'sha256:applied-fixture',
|
||||
agent_version: 'fixture-v2', capabilities: ['traffic-v2'], last_contact_at: '2026-10-24T00:05:00Z',
|
||||
last_telemetry_at: null, last_apply_at: null, apply_error: null, status: 'drift', telemetry_version: 2,
|
||||
quality: { partial: true, truncated: true, unsupported: ['accepted'], lost_reports: 2 },
|
||||
}
|
||||
const diagnosticRoot = createRoot(host)
|
||||
await act(async () => diagnosticRoot.render(<AgentDiagnosticsPanel diagnostics={diagnostics} />))
|
||||
check(host.textContent?.includes('Расхождение политики') && !host.textContent?.includes('Синхронизирован'), 'diagnostics displays authoritative drift instead of inferring synchronization from contact')
|
||||
check(host.textContent?.includes('Не получена') && host.textContent?.includes('Не подтверждено'), 'diagnostics distinguishes missing telemetry from missing apply acknowledgment')
|
||||
check(host.textContent?.includes('Неполный') && host.textContent?.includes('Обрезан') && host.textContent?.includes('Потеряно отчётов: 2'), 'diagnostics exposes partial, truncated and lost-report quality')
|
||||
const nullPoint: TimeseriesPoint = { timestamp: '2026-10-24T00:00:00.000Z', packets_dropped: null, packets_accepted: null, pps: null, coverage: 0, estimated: false, quality: { partial: false, truncated: false, unsupported: ['packets'], lost_reports: 0 } }
|
||||
await act(async () => diagnosticRoot.render(<TrafficTimeseriesChart items={[nullPoint]} />))
|
||||
check(host.textContent?.includes('Пропуски не означают нулевой трафик') && !host.querySelector('.recharts-surface'), 'missing packet measurements render an explicit gap state instead of a zero traffic chart')
|
||||
check(nullPoint.packets_dropped === null && nullPoint.packets_accepted === null, 'chart rendering leaves missing packet values null')
|
||||
await act(async () => diagnosticRoot.unmount())
|
||||
|
||||
let fullCatalogRequests = 0
|
||||
let summaryRequests = 0
|
||||
const previousToken = localStorage.getItem('fw_auth_token')
|
||||
localStorage.setItem('fw_auth_token', `fixture.${btoa(JSON.stringify({ sub: 'fixture', name: 'Fixture reader', apps: ['fw'], permissions: ['fw:agents:read'] }))}.fixture`)
|
||||
window.fetch = async (request) => {
|
||||
const path = String(request)
|
||||
if (path.endsWith('/api/auth/config')) return Response.json({ required: true, portal_url: 'https://fixture.invalid' })
|
||||
if (path.endsWith('/api/v1/app-switcher')) return Response.json(DEFAULT_APP_SWITCHER_CONFIG)
|
||||
if (path.endsWith('/api/v1/ui/summary')) {
|
||||
summaryRequests++
|
||||
return Response.json({ updated_at: '2026-10-24T00:00:00.000Z', agents: { approved: 0, online: 0, pending: 0, offline: 0 } })
|
||||
}
|
||||
if (/\/api\/v1\/(agents|lists|policy-sets)$/.test(path)) fullCatalogRequests++
|
||||
throw new Error(`Unexpected shell fixture request: ${path}`)
|
||||
}
|
||||
await ensureAuthConfig()
|
||||
const shellClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const fixtureRoute = createRootRoute({ component: () => <QueryClientProvider client={shellClient}><PortalShell><p>Read-only summary fixture</p></PortalShell></QueryClientProvider> })
|
||||
const fixtureRouter = createRouter({ routeTree: fixtureRoute, history: createMemoryHistory({ initialEntries: ['/'] }) })
|
||||
const shellRoot = createRoot(host)
|
||||
await act(async () => {
|
||||
shellRoot.render(<RouterProvider router={fixtureRouter} />)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
})
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 100)) })
|
||||
check(summaryRequests === 1 && fullCatalogRequests === 0, 'closed shell reads one summary without fetching full agent/list/policy catalogs')
|
||||
const monitor = [...host.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.getAttribute('aria-label')?.startsWith('Состояние системы:'))!
|
||||
await act(async () => { monitor.click(); await new Promise((resolve) => setTimeout(resolve, 50)) })
|
||||
check(document.body.textContent?.includes('Нет одобренных') && !document.body.textContent?.includes('Списки IP'), 'permission-omitted summary sections stay hidden instead of showing fabricated zero metrics')
|
||||
check(!host.textContent?.includes('Списки') && !host.textContent?.includes('Наборы правил'), 'read-only agent permission hides unrelated navigation sections')
|
||||
await act(async () => shellRoot.unmount())
|
||||
shellClient.clear()
|
||||
if (previousToken === null) localStorage.removeItem('fw_auth_token')
|
||||
else localStorage.setItem('fw_auth_token', previousToken)
|
||||
window.fetch = originalFetch
|
||||
results.dataset.status = 'passed'
|
||||
} catch (error) {
|
||||
results.textContent = `${checks.join('\n')}\nFAIL ${error instanceof Error ? error.stack : String(error)}`
|
||||
results.dataset.status = 'failed'
|
||||
}
|
||||
Reference in new issue
Block a user