feat(policy): именованные наборы правил с DNS и M:N привязкой к агентам
Правила живут в policy_sets; evaluate мержит назначенные наборы; источник list|CIDR|hostname с кэшем DNS. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,8 +1,7 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
@@ -21,10 +20,12 @@ import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
agentQueryOptions,
|
||||
agentsQueryOptions,
|
||||
rulesQueryOptions,
|
||||
agentPolicySetsQueryOptions,
|
||||
policySetsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Checkbox } from '@evofw/ui/components/checkbox'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Label } from '@evofw/ui/components/label'
|
||||
import {
|
||||
@@ -34,10 +35,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import type { PolicyRule } from '@evofw/shared'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||
@@ -48,11 +45,20 @@ function AgentDetailPage() {
|
||||
const { id } = Route.useParams()
|
||||
const qc = useQueryClient()
|
||||
const agentQ = useQuery(agentQueryOptions(id))
|
||||
const rulesQ = useQuery(rulesQueryOptions(id))
|
||||
const setsQ = useQuery(policySetsQueryOptions())
|
||||
const assignedQ = useQuery(agentPolicySetsQueryOptions(id))
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const [cidr, setCidr] = useState('')
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const [cloneFrom, setCloneFrom] = useState('')
|
||||
const [selectedSets, setSelectedSets] = useState<string[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSets(null)
|
||||
}, [id, assignedQ.data])
|
||||
|
||||
const assignedIds =
|
||||
selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? []
|
||||
|
||||
const patchMode = useMutation({
|
||||
mutationFn: (policy_mode: 'blacklist' | 'whitelist') =>
|
||||
@@ -80,6 +86,21 @@ function AgentDetailPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const saveSets = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${id}/policy-sets`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ set_ids: assignedIds }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Наборы сохранены')
|
||||
setSelectedSets(null)
|
||||
void qc.invalidateQueries({ queryKey: ['agents', id] })
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const clone = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${id}/clone-from/${cloneFrom}`, {
|
||||
@@ -87,52 +108,13 @@ function AgentDetailPage() {
|
||||
body: JSON.stringify({ include_overrides: true }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Правила скопированы')
|
||||
void qc.invalidateQueries({ queryKey: ['rules'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
toast.success('Наборы скопированы')
|
||||
void qc.invalidateQueries({ queryKey: ['agents', id] })
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const ruleColumns: ColumnDef<PolicyRule>[] = useMemo(
|
||||
() => [
|
||||
{ accessorKey: 'priority', header: 'Prio' },
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.action === 'deny'
|
||||
? 'destructive-light'
|
||||
: 'success-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'Source',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.cidr ?? row.original.list_id ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const rulesTable = useReactTable({
|
||||
data: rulesQ.data?.items ?? [],
|
||||
columns: ruleColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r) => r.id,
|
||||
})
|
||||
|
||||
const a = agentQ.data
|
||||
if (agentQ.isLoading || !a) {
|
||||
return (
|
||||
@@ -231,6 +213,59 @@ function AgentDetailPage() {
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Наборы правил</FrameTitle>
|
||||
<FrameDescription>
|
||||
Можно назначить несколько — мержатся при sync
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(setsQ.data?.items ?? []).map((s) => {
|
||||
const checked = assignedIds.includes(s.id)
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
setSelectedSets(
|
||||
v
|
||||
? [...assignedIds, s.id]
|
||||
: assignedIds.filter((x) => x !== s.id),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<Link
|
||||
to="/rules/$setId"
|
||||
params={{ setId: s.id }}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{s.name}
|
||||
</Link>
|
||||
{!s.enabled ? (
|
||||
<Badge variant="secondary" size="xs">
|
||||
off
|
||||
</Badge>
|
||||
) : null}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={saveSets.isPending || selectedSets === null}
|
||||
onClick={() => saveSets.mutate()}
|
||||
>
|
||||
Сохранить наборы
|
||||
</Button>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Мгновенный IP override</FrameTitle>
|
||||
@@ -277,7 +312,10 @@ function AgentDetailPage() {
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Копировать правила</FrameTitle>
|
||||
<FrameTitle>Копировать наборы</FrameTitle>
|
||||
<FrameDescription>
|
||||
Копирует назначения наборов (+ overrides) с другого агента
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -308,20 +346,6 @@ function AgentDetailPage() {
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Правила агента</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
<DataGrid
|
||||
table={rulesTable}
|
||||
recordCount={rulesQ.data?.items?.length ?? 0}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import {
|
||||
PageHeader,
|
||||
PageShell,
|
||||
DetailPanel,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
policySetQueryOptions,
|
||||
policySetRulesQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Checkbox } from '@evofw/ui/components/checkbox'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import type { PolicyRule } from '@evofw/shared'
|
||||
import { listsQueryOptions } from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_auth/rules/$setId')({
|
||||
component: PolicySetDetailPage,
|
||||
})
|
||||
|
||||
type SourceKind = 'list' | 'cidr' | 'hostname'
|
||||
|
||||
/**
|
||||
* Policy set detail — Frame + DataGrid + sheet-8 create rule.
|
||||
* Preview: https://reui.io/preview/base/sheet-8 · https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
function PolicySetDetailPage() {
|
||||
const { setId } = Route.useParams()
|
||||
const qc = useQueryClient()
|
||||
const setQ = useQuery(policySetQueryOptions(setId))
|
||||
const rulesQ = useQuery(policySetRulesQueryOptions(setId))
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
|
||||
const [ruleOpen, setRuleOpen] = useState(false)
|
||||
const [priority, setPriority] = useState('100')
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const [source, setSource] = useState<SourceKind>('cidr')
|
||||
const [listId, setListId] = useState('')
|
||||
const [cidr, setCidr] = useState('')
|
||||
const [hostname, setHostname] = useState('')
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null)
|
||||
|
||||
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
|
||||
|
||||
const patchSet = useMutation({
|
||||
mutationFn: (body: { enabled?: boolean; name?: string }) =>
|
||||
apiFetch(`/api/v1/policy-sets/${setId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Набор обновлён')
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const saveAgents = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Assign this set to selected agents: merge with their other sets
|
||||
const allAgents = agentsQ.data?.items ?? []
|
||||
await Promise.all(
|
||||
allAgents.map(async (a) => {
|
||||
const current = await apiFetch<{
|
||||
items: { set_id: string }[]
|
||||
}>(`/api/v1/agents/${a.id}/policy-sets`)
|
||||
const others = current.items
|
||||
.map((i) => i.set_id)
|
||||
.filter((id) => id !== setId)
|
||||
const next = assignedIds.includes(a.id)
|
||||
? [...others, setId]
|
||||
: others
|
||||
await apiFetch(`/api/v1/agents/${a.id}/policy-sets`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ set_ids: next }),
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Назначение агентов сохранено')
|
||||
setSelectedAgents(null)
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const createRule = useMutation({
|
||||
mutationFn: () => {
|
||||
const body: Record<string, unknown> = {
|
||||
set_id: setId,
|
||||
priority: Number(priority),
|
||||
action,
|
||||
}
|
||||
if (source === 'list') body.list_id = listId
|
||||
if (source === 'cidr') body.cidr = cidr.trim()
|
||||
if (source === 'hostname') body.hostname = hostname.trim()
|
||||
return apiFetch('/api/v1/rules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Правило создано')
|
||||
setRuleOpen(false)
|
||||
setCidr('')
|
||||
setHostname('')
|
||||
setListId('')
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const removeRule = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/rules/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Удалено')
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||
},
|
||||
})
|
||||
|
||||
const rules = rulesQ.data?.items ?? []
|
||||
|
||||
const columns: ColumnDef<PolicyRule>[] = useMemo(
|
||||
() => [
|
||||
{ accessorKey: 'priority', header: 'Prio' },
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.action === 'deny'
|
||||
? 'destructive-light'
|
||||
: 'success-light'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'Источник',
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
if (r.hostname) {
|
||||
return (
|
||||
<span className="text-xs">
|
||||
DNS <span className="font-mono">{r.hostname}</span>
|
||||
{typeof r.resolved_count === 'number'
|
||||
? ` (${r.resolved_count} IP)`
|
||||
: ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (r.cidr) {
|
||||
return <span className="font-mono text-xs">{r.cidr}</span>
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
list:{r.list_id?.slice(0, 8)}…
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeRule.mutate(row.original.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[removeRule],
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: rules,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r) => r.id,
|
||||
})
|
||||
|
||||
const canCreate =
|
||||
source === 'list'
|
||||
? Boolean(listId)
|
||||
: source === 'cidr'
|
||||
? Boolean(cidr.trim())
|
||||
: Boolean(hostname.trim())
|
||||
|
||||
if (setQ.isLoading) {
|
||||
return (
|
||||
<PageShell>
|
||||
<Skeleton className="h-10 w-64" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!setQ.data) {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Набор не найден" />
|
||||
<Button render={<Link to="/rules" />}>К списку</Button>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
const set = setQ.data
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={set.name}
|
||||
description={set.description ?? 'Набор правил политики'}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={set.enabled}
|
||||
onCheckedChange={(v) => patchSet.mutate({ enabled: v })}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{set.enabled ? 'Включён' : 'Выключен'}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="outline" render={<Link to="/rules" />}>
|
||||
Назад
|
||||
</Button>
|
||||
<Button onClick={() => setRuleOpen(true)}>Правило</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<DetailPanel>
|
||||
<DetailPanel.Section
|
||||
title="Правила"
|
||||
description="Список IP, CIDR или DNS-имя (резолвится в A/AAAA)."
|
||||
>
|
||||
<Frame dense spacing="sm">
|
||||
<FramePanel className="p-0">
|
||||
<DataGrid table={table} recordCount={rules.length}>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DetailPanel.Section>
|
||||
|
||||
<DetailPanel.Section
|
||||
title="Назначено агентам"
|
||||
description="Агент может иметь несколько наборов — они мержатся при sync."
|
||||
>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Агенты</FrameTitle>
|
||||
<FrameDescription>
|
||||
Отметьте, каким агентам применять этот набор
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(agentsQ.data?.items ?? [])
|
||||
.filter((a) => a.status === 'approved')
|
||||
.map((a) => {
|
||||
const checked = assignedIds.includes(a.id)
|
||||
return (
|
||||
<label
|
||||
key={a.id}
|
||||
className="flex cursor-pointer items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => {
|
||||
const base = assignedIds
|
||||
setSelectedAgents(
|
||||
v
|
||||
? [...base, a.id]
|
||||
: base.filter((id) => id !== a.id),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium">{a.name}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{a.hostname ?? a.platform}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{(agentsQ.data?.items ?? []).filter((a) => a.status === 'approved')
|
||||
.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет approved-агентов
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
className="mt-3"
|
||||
disabled={saveAgents.isPending || selectedAgents === null}
|
||||
onClick={() => saveAgents.mutate()}
|
||||
>
|
||||
Сохранить назначение
|
||||
</Button>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
<Sheet open={ruleOpen} onOpenChange={setRuleOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новое правило</SheetTitle>
|
||||
<SheetDescription>
|
||||
Один источник: список, CIDR или DNS-имя
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="prio">Priority</FieldLabel>
|
||||
<Input
|
||||
id="prio"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Select
|
||||
value={source}
|
||||
onValueChange={(v) => setSource(v as SourceKind)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cidr">CIDR / IP</SelectItem>
|
||||
<SelectItem value="hostname">DNS-имя</SelectItem>
|
||||
<SelectItem value="list">IP-список</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{source === 'list' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
<Select
|
||||
value={listId || null}
|
||||
onValueChange={(v) => setListId(v ?? '')}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Выберите список" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(listsQ.data?.items ?? []).map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'cidr' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
|
||||
<Input
|
||||
id="cidr"
|
||||
value={cidr}
|
||||
onChange={(e) => setCidr(e.target.value)}
|
||||
placeholder="203.0.113.0/24"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'hostname' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
|
||||
<Input
|
||||
id="host"
|
||||
value={hostname}
|
||||
onChange={(e) => setHostname(e.target.value)}
|
||||
placeholder="bad.example.com"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
<Button variant="outline" onClick={() => setRuleOpen(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canCreate || createRule.isPending}
|
||||
onClick={() => createRule.mutate()}
|
||||
>
|
||||
Создать
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
+106
-136
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
@@ -6,22 +6,12 @@ import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
rulesQueryOptions,
|
||||
listsQueryOptions,
|
||||
agentsQueryOptions,
|
||||
} from '@/queries'
|
||||
import { policySetsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import { Textarea } from '@evofw/ui/components/textarea'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -30,117 +20,139 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@evofw/ui/components/sheet'
|
||||
import type { PolicyRule } from '@evofw/shared'
|
||||
import type { PolicySet } from '@evofw/shared'
|
||||
|
||||
export const Route = createFileRoute('/_auth/rules')({
|
||||
component: RulesPage,
|
||||
component: PolicySetsPage,
|
||||
})
|
||||
|
||||
function RulesPage() {
|
||||
/**
|
||||
* Policy sets list — ReUI ResourcePage.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Empty: https://reui.io/preview/base/empty-state-5
|
||||
* Create sheet: https://reui.io/preview/base/sheet-8
|
||||
*/
|
||||
function PolicySetsPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const rulesQ = useQuery(rulesQueryOptions())
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const setsQ = useQuery(policySetsQueryOptions())
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [priority, setPriority] = useState('100')
|
||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
||||
const [listId, setListId] = useState('')
|
||||
const [agentId, setAgentId] = useState('tenant')
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch('/api/v1/rules', {
|
||||
apiFetch<PolicySet>('/api/v1/policy-sets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
priority: Number(priority),
|
||||
action,
|
||||
list_id: listId || null,
|
||||
agent_id: agentId === 'tenant' ? null : agentId,
|
||||
name,
|
||||
description: description.trim() || null,
|
||||
enabled: true,
|
||||
}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Правило создано')
|
||||
onSuccess: (row) => {
|
||||
toast.success('Набор создан')
|
||||
setName('')
|
||||
setDescription('')
|
||||
setSheetOpen(false)
|
||||
void qc.invalidateQueries({ queryKey: ['rules'] })
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void navigate({ to: '/rules/$setId', params: { setId: row.id } })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/rules/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: ['rules'] }),
|
||||
apiFetch(`/api/v1/policy-sets/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Набор удалён')
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const items = rulesQ.data?.items ?? []
|
||||
const items = setsQ.data?.items ?? []
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{ key: 'name', label: 'Имя', type: 'text', placeholder: 'Поиск…' },
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
key: 'enabled',
|
||||
label: 'Статус',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'deny', label: 'deny' },
|
||||
{ value: 'allow', label: 'allow' },
|
||||
{ value: 'true', label: 'Включён' },
|
||||
{ value: 'false', label: 'Выключен' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const getFilterFieldValue = useCallback((item: PolicyRule, field: string) => {
|
||||
if (field === 'action') return item.action
|
||||
const getFilterFieldValue = useCallback((item: PolicySet, field: string) => {
|
||||
if (field === 'name') return item.name
|
||||
if (field === 'enabled') return String(item.enabled)
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
const columns: ColumnDef<PolicyRule>[] = useMemo(
|
||||
const columns: ColumnDef<PolicySet>[] = useMemo(
|
||||
() => [
|
||||
{ accessorKey: 'priority', header: 'Prio' },
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Action',
|
||||
accessorKey: 'name',
|
||||
header: 'Набор',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to="/rules/$setId"
|
||||
params={{ setId: row.original.id }}
|
||||
className="font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{row.original.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'enabled',
|
||||
header: 'Статус',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.action === 'deny'
|
||||
? 'destructive-light'
|
||||
: 'success-light'
|
||||
}
|
||||
variant={row.original.enabled ? 'success-light' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.action}
|
||||
{row.original.enabled ? 'Включён' : 'Выключен'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'agent_id',
|
||||
header: 'Agent',
|
||||
accessorKey: 'rules_count',
|
||||
header: 'Правила',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.agent_id ?? 'tenant'}</span>
|
||||
<span className="tabular-nums">{row.original.rules_count ?? 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: 'List / CIDR',
|
||||
accessorKey: 'agents_count',
|
||||
header: 'Агенты',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.cidr ?? row.original.list_id ?? '—'}
|
||||
</span>
|
||||
<span className="tabular-nums">{row.original.agents_count ?? 0}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => remove.mutate(row.original.id)}
|
||||
>
|
||||
Удалить
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" variant="outline" render={<Link to="/rules/$setId" params={{ setId: row.original.id }} />}>
|
||||
Открыть
|
||||
</Button>
|
||||
{row.original.id !== 'set-shared-default' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => remove.mutate(row.original.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -151,15 +163,15 @@ function RulesPage() {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Правила"
|
||||
description="Упорядоченные allow/deny по списку или CIDR"
|
||||
title="Наборы правил"
|
||||
description="Именованные наборы назначаются агентам (можно несколько). Источник правила: список, CIDR или DNS."
|
||||
actions={
|
||||
<Button onClick={() => setSheetOpen(true)}>Новое правило</Button>
|
||||
<Button onClick={() => setSheetOpen(true)}>Новый набор</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourcePage
|
||||
title="Правила"
|
||||
title="Наборы"
|
||||
hideHeader
|
||||
data={items}
|
||||
columns={columns}
|
||||
@@ -169,15 +181,15 @@ function RulesPage() {
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
isLoading={rulesQ.isLoading}
|
||||
isError={rulesQ.isError}
|
||||
error={rulesQ.error}
|
||||
onRetry={() => void rulesQ.refetch()}
|
||||
isLoading={setsQ.isLoading}
|
||||
isError={setsQ.isError}
|
||||
error={setsQ.error}
|
||||
onRetry={() => void setsQ.refetch()}
|
||||
emptyState={{
|
||||
title: 'Нет правил',
|
||||
description: 'Создайте первое правило политики.',
|
||||
title: 'Нет наборов',
|
||||
description: 'Создайте первый набор правил политики.',
|
||||
action: (
|
||||
<Button onClick={() => setSheetOpen(true)}>Новое правило</Button>
|
||||
<Button onClick={() => setSheetOpen(true)}>Новый набор</Button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
@@ -185,71 +197,29 @@ function RulesPage() {
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новое правило</SheetTitle>
|
||||
<SheetDescription>Priority + action + list scope</SheetDescription>
|
||||
<SheetTitle>Новый набор</SheetTitle>
|
||||
<SheetDescription>
|
||||
После создания добавьте правила и назначьте набор агентам.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4 sm:grid-cols-2">
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rule-priority">Priority</FieldLabel>
|
||||
<FieldLabel htmlFor="set-name">Имя</FieldLabel>
|
||||
<Input
|
||||
id="rule-priority"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
id="set-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Web deny"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => setAction(v as 'allow' | 'deny')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">deny</SelectItem>
|
||||
<SelectItem value="allow">allow</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
<Select
|
||||
value={listId || null}
|
||||
onValueChange={(v) => setListId(v ?? '')}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="IP list" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(listsQ.data?.items ?? []).map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel>Scope</FieldLabel>
|
||||
<Select
|
||||
value={agentId}
|
||||
onValueChange={(v) => {
|
||||
if (v) setAgentId(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tenant">Tenant default</SelectItem>
|
||||
{(agentsQ.data?.items ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldLabel htmlFor="set-desc">Описание</FieldLabel>
|
||||
<Textarea
|
||||
id="set-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||
@@ -257,7 +227,7 @@ function RulesPage() {
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!listId || create.isPending}
|
||||
disabled={!name.trim() || create.isPending}
|
||||
onClick={() => create.mutate()}
|
||||
>
|
||||
Создать
|
||||
|
||||
Reference in New Issue
Block a user