feat(api, web): implement policy mode management for agents and rules
- Added support for policy modes ('blacklist' and 'whitelist') in agent and policy set management.
- Updated API endpoints to handle policy mode during agent assignment and rule operations.
- Enhanced the web UI to display and manage policy modes for agents and rules, ensuring all assigned sets share a consistent mode.
- Introduced new validation to enforce single policy mode across assigned sets for agents.
- Improved error handling for policy mode conflicts and updated documentation accordingly.
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -18,6 +18,11 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
@@ -67,16 +72,14 @@ function AgentDetailPage() {
|
||||
const assignedIds =
|
||||
selectedSets ?? assignedQ.data?.items.map((i) => i.set_id) ?? []
|
||||
|
||||
const patchMode = useMutation({
|
||||
mutationFn: (policy_mode: 'blacklist' | 'whitelist') =>
|
||||
apiFetch(`/api/v1/agents/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ policy_mode }),
|
||||
}),
|
||||
const revoke = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Режим обновлён')
|
||||
toast.success('Агент отозван')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const addOverride = useMutation({
|
||||
@@ -207,10 +210,21 @@ function AgentDetailPage() {
|
||||
<DetailPanel>
|
||||
<DetailPanel.Header
|
||||
title={a.name}
|
||||
description={`${a.platform} · gen ${a.policy_generation}`}
|
||||
description={`${platformLabel(a.platform)} · gen ${a.policy_generation}`}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<StatusBadge status={a.status} />
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revoke.mutate()}
|
||||
disabled={revoke.isPending}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -261,30 +275,32 @@ function AgentDetailPage() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Политика</FrameTitle>
|
||||
<FrameTitle>Режим фильтра</FrameTitle>
|
||||
<FrameDescription>
|
||||
blacklist = deny set; whitelist = allow set + default drop
|
||||
Задаётся наборами правил (не на агенте). Все назначенные
|
||||
наборы должны иметь один режим.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={
|
||||
a.policy_mode === 'blacklist' ? 'default' : 'outline'
|
||||
}
|
||||
onClick={() => patchMode.mutate('blacklist')}
|
||||
>
|
||||
Blacklist
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
a.policy_mode === 'whitelist' ? 'default' : 'outline'
|
||||
}
|
||||
onClick={() => patchMode.mutate('whitelist')}
|
||||
>
|
||||
Whitelist
|
||||
</Button>
|
||||
</div>
|
||||
<FramePanel className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant={
|
||||
a.policy_mode === 'whitelist'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{a.policy_mode === 'whitelist'
|
||||
? 'Белый список'
|
||||
: 'Чёрный список'}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
render={<Link to="/rules" />}
|
||||
>
|
||||
Открыть правила
|
||||
</Button>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
@@ -292,7 +308,7 @@ function AgentDetailPage() {
|
||||
<FrameHeader>
|
||||
<FrameTitle>Наборы правил</FrameTitle>
|
||||
<FrameDescription>
|
||||
Можно назначить несколько — мержатся при sync
|
||||
Можно назначить несколько — мержатся при sync (один режим)
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Check, Copy, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Check, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState, type MouseEvent } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import {
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||
import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { agentsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
@@ -32,15 +36,15 @@ import type { Agent } from '@evofw/shared'
|
||||
/**
|
||||
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Copyable install: https://reui.io/preview/base/settings-14
|
||||
* Row icon + copyable install: https://reui.io/preview/base/settings-14 · stats-12
|
||||
* Empty: https://reui.io/preview/base/empty-state-7
|
||||
* Create Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
|
||||
*/
|
||||
export const Route = createFileRoute('/_auth/agents/')({
|
||||
component: AgentsPage,
|
||||
})
|
||||
|
||||
function AgentsPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -59,16 +63,6 @@ function AgentsPage() {
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
toast.success('Агент отозван')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
|
||||
@@ -127,7 +121,8 @@ function AgentsPage() {
|
||||
}, [])
|
||||
|
||||
const handleCopyCurl = useCallback(
|
||||
(curl: string) => {
|
||||
(curl: string, e?: MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
if (!curl) return
|
||||
copyToClipboard(curl)
|
||||
toast.success('Скопировано')
|
||||
@@ -142,25 +137,19 @@ function AgentsPage() {
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Имя" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to="/agents/$id"
|
||||
params={{ id: row.original.id }}
|
||||
className="min-w-0"
|
||||
>
|
||||
<DataGridPrimaryCell
|
||||
accent="primary"
|
||||
title={row.original.name}
|
||||
subtitle={row.original.hostname ?? undefined}
|
||||
/>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'platform',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Платформа" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const a = row.original
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<AgentPlatformIcon platform={a.platform} />
|
||||
<DataGridPrimaryCell
|
||||
accent="primary"
|
||||
title={a.name}
|
||||
subtitle={a.hostname ?? platformLabel(a.platform)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
@@ -188,7 +177,7 @@ function AgentsPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="max-w-[14rem] font-mono text-xs"
|
||||
onClick={() => handleCopyCurl(curl)}
|
||||
onClick={(e) => handleCopyCurl(curl, e)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -202,12 +191,6 @@ function AgentsPage() {
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'policy_mode',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Режим" />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
header: ({ column }) => (
|
||||
@@ -229,36 +212,41 @@ function AgentsPage() {
|
||||
<div className="flex justify-end gap-1">
|
||||
{a.status === 'pending' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approve.mutate(a.id)}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Approve"
|
||||
disabled={approve.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
approve.mutate(a.id)
|
||||
}}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
render={<Link to="/agents/$id" params={{ id: a.id }} />}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
{a.status === 'approved' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => revoke.mutate(a.id)}
|
||||
>
|
||||
Revoke
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="Открыть"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void navigate({
|
||||
to: '/agents/$id',
|
||||
params: { id: a.id },
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить"
|
||||
onClick={() => setDeleteId(a.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteId(a.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
@@ -267,7 +255,7 @@ function AgentsPage() {
|
||||
},
|
||||
},
|
||||
],
|
||||
[approve, revoke, handleCopyCurl],
|
||||
[approve, handleCopyCurl, navigate],
|
||||
)
|
||||
|
||||
const addButton = (
|
||||
@@ -296,6 +284,9 @@ function AgentsPage() {
|
||||
onFiltersChange={setFilters}
|
||||
onClearFilters={() => setFilters([])}
|
||||
getFilterFieldValue={getFilterFieldValue}
|
||||
onRowClick={(row) =>
|
||||
void navigate({ to: '/agents/$id', params: { id: row.id } })
|
||||
}
|
||||
tabs={[
|
||||
{ id: 'all', label: 'Все' },
|
||||
{ id: 'invited', label: 'Invited' },
|
||||
|
||||
@@ -5,16 +5,16 @@ import {
|
||||
ListIcon,
|
||||
ShieldIcon,
|
||||
UsersIcon,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { PageShell, DetailPanel, ResourcePage } from '@/components/reui-kit'
|
||||
import { PageShell, DetailPanel } from '@/components/reui-kit'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
listsQueryOptions,
|
||||
@@ -44,11 +44,8 @@ import {
|
||||
} 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 { Agent, PolicyRule } from '@evofw/shared'
|
||||
import { getCoreRowModel, useReactTable } from '@tanstack/react-table'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import {
|
||||
Frame,
|
||||
@@ -65,8 +62,9 @@ export const Route = createFileRoute('/_auth/rules/$setId')({
|
||||
type SourceKind = 'list' | 'cidr' | 'hostname'
|
||||
|
||||
/**
|
||||
* Policy set detail — DetailPanel + ResourcePage rules + agents DataGrid.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2 · stats-12 · sheet-8
|
||||
* Policy set detail — Sortable rules (ReUI PRO) + agents.
|
||||
* Preview: https://reui.io/preview/base/components/c-sortable-5 · settings-8 · settings-3
|
||||
* Docs: https://reui.io/docs/components/base/sortable
|
||||
*/
|
||||
function PolicySetDetailPage() {
|
||||
const { setId } = Route.useParams()
|
||||
@@ -77,14 +75,12 @@ function PolicySetDetailPage() {
|
||||
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 [ruleFilters, setRuleFilters] = useState<Filter[]>([])
|
||||
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null)
|
||||
|
||||
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
|
||||
@@ -101,7 +97,11 @@ function PolicySetDetailPage() {
|
||||
}, [assignedIds])
|
||||
|
||||
const patchSet = useMutation({
|
||||
mutationFn: (body: { enabled?: boolean; name?: string }) =>
|
||||
mutationFn: (body: {
|
||||
enabled?: boolean
|
||||
name?: string
|
||||
policy_mode?: 'blacklist' | 'whitelist'
|
||||
}) =>
|
||||
apiFetch(`/api/v1/policy-sets/${setId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
@@ -109,6 +109,7 @@ function PolicySetDetailPage() {
|
||||
onSuccess: () => {
|
||||
toast.success('Набор обновлён')
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
@@ -145,7 +146,6 @@ function PolicySetDetailPage() {
|
||||
mutationFn: () => {
|
||||
const body: Record<string, unknown> = {
|
||||
set_id: setId,
|
||||
priority: Number(priority),
|
||||
action,
|
||||
}
|
||||
if (source === 'list') body.list_id = listId
|
||||
@@ -179,112 +179,6 @@ function PolicySetDetailPage() {
|
||||
|
||||
const rules = rulesQ.data?.items ?? []
|
||||
|
||||
const ruleFilterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'deny', label: 'deny' },
|
||||
{ value: 'allow', label: 'allow' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: 'Источник',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: 'list', label: 'list' },
|
||||
{ value: 'cidr', label: 'CIDR' },
|
||||
{ value: 'hostname', label: 'DNS' },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const getRuleFilterValue = useCallback((item: PolicyRule, field: string) => {
|
||||
if (field === 'action') return item.action
|
||||
if (field === 'source') {
|
||||
if (item.hostname) return 'hostname'
|
||||
if (item.cidr) return 'cidr'
|
||||
return 'list'
|
||||
}
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
const ruleColumns: ColumnDef<PolicyRule>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Prio" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">{row.original.priority}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Action" />
|
||||
),
|
||||
cell: ({ row }) => <StatusBadge status={row.original.action} />,
|
||||
},
|
||||
{
|
||||
id: 'source',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Источник" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
if (r.hostname) {
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
accent="mono"
|
||||
title={r.hostname}
|
||||
subtitle={
|
||||
typeof r.resolved_count === 'number'
|
||||
? `${r.resolved_count} IP`
|
||||
: 'DNS'
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (r.cidr) {
|
||||
return <DataGridPrimaryCell accent="mono" title={r.cidr} />
|
||||
}
|
||||
return (
|
||||
<DataGridPrimaryCell
|
||||
accent="mono"
|
||||
title={`list:${r.list_id?.slice(0, 8) ?? '—'}…`}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
aria-label="Удалить"
|
||||
onClick={() => setDeleteRuleId(row.original.id)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const agentColumns: ColumnDef<Agent>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -377,6 +271,8 @@ function PolicySetDetailPage() {
|
||||
}
|
||||
|
||||
const set = setQ.data
|
||||
const policyMode =
|
||||
set.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist'
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -425,7 +321,9 @@ function PolicySetDetailPage() {
|
||||
{
|
||||
id: 'status',
|
||||
icon: <ListIcon aria-hidden />,
|
||||
iconClassName: set.enabled ? 'text-success' : 'text-muted-foreground',
|
||||
iconClassName: set.enabled
|
||||
? 'text-success'
|
||||
: 'text-muted-foreground',
|
||||
label: 'Статус',
|
||||
description: set.enabled ? 'Включён' : 'Выключен',
|
||||
hint: set.enabled ? 'active' : 'disabled',
|
||||
@@ -433,37 +331,69 @@ function PolicySetDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Режим фильтра</FrameTitle>
|
||||
<FrameDescription>
|
||||
Blacklist: блокировать deny. Whitelist: пропускать только
|
||||
allow, остальное (forward) — DROP.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={
|
||||
policyMode === 'blacklist' ? 'default' : 'outline'
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
patchSet.mutate({ policy_mode: 'blacklist' })
|
||||
}
|
||||
>
|
||||
Чёрный список
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
policyMode === 'whitelist' ? 'default' : 'outline'
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
patchSet.mutate({ policy_mode: 'whitelist' })
|
||||
}
|
||||
>
|
||||
Белый список
|
||||
</Button>
|
||||
<Badge
|
||||
variant={
|
||||
policyMode === 'whitelist'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{policyMode}
|
||||
</Badge>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</DetailPanel.Section>
|
||||
|
||||
<DetailPanel.Section
|
||||
title="Правила"
|
||||
description="Список IP, CIDR или DNS-имя (резолвится в A/AAAA)."
|
||||
description="DnD порядок · Switch вкл/выкл. Preview: c-sortable-5 · settings-8"
|
||||
>
|
||||
<ResourcePage
|
||||
title="Правила"
|
||||
hideHeader
|
||||
data={rules}
|
||||
columns={ruleColumns}
|
||||
getRowId={(r) => r.id}
|
||||
filterFields={ruleFilterFields}
|
||||
filters={ruleFilters}
|
||||
onFiltersChange={setRuleFilters}
|
||||
onClearFilters={() => setRuleFilters([])}
|
||||
getFilterFieldValue={getRuleFilterValue}
|
||||
isLoading={rulesQ.isLoading}
|
||||
emptyState={{
|
||||
title: 'Нет правил',
|
||||
description: 'Добавьте CIDR, DNS или IP-список.',
|
||||
action: (
|
||||
<Button size="sm" onClick={() => setRuleOpen(true)}>
|
||||
Правило
|
||||
</Button>
|
||||
),
|
||||
}}
|
||||
<PolicyRulesSortable
|
||||
setId={setId}
|
||||
rules={rules}
|
||||
policyMode={policyMode}
|
||||
onDelete={(id) => setDeleteRuleId(id)}
|
||||
/>
|
||||
</DetailPanel.Section>
|
||||
|
||||
<DetailPanel.Section
|
||||
title="Назначено агентам"
|
||||
description="Агент может иметь несколько наборов — они мержатся при sync."
|
||||
description="Все наборы агента должны иметь один режим фильтра."
|
||||
>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
@@ -518,18 +448,10 @@ function PolicySetDetailPage() {
|
||||
<SheetHeader className="shrink-0">
|
||||
<SheetTitle>Новое правило</SheetTitle>
|
||||
<SheetDescription>
|
||||
Один источник: список, CIDR или DNS-имя
|
||||
Один источник: список, CIDR или DNS-имя (priority — в конец)
|
||||
</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>
|
||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
||||
<Field>
|
||||
<FieldLabel>Action</FieldLabel>
|
||||
<Select
|
||||
@@ -538,7 +460,7 @@ function PolicySetDetailPage() {
|
||||
if (v) setAction(v as 'allow' | 'deny')
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -547,7 +469,7 @@ function PolicySetDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="sm:col-span-2">
|
||||
<Field>
|
||||
<FieldLabel>Источник</FieldLabel>
|
||||
<Select
|
||||
value={source}
|
||||
@@ -566,7 +488,7 @@ function PolicySetDetailPage() {
|
||||
</Select>
|
||||
</Field>
|
||||
{source === 'list' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<Field>
|
||||
<FieldLabel>Список</FieldLabel>
|
||||
<Select
|
||||
value={listId || null}
|
||||
@@ -586,7 +508,7 @@ function PolicySetDetailPage() {
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'cidr' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
|
||||
<Input
|
||||
id="cidr"
|
||||
@@ -597,7 +519,7 @@ function PolicySetDetailPage() {
|
||||
</Field>
|
||||
) : null}
|
||||
{source === 'hostname' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
|
||||
<Input
|
||||
id="host"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { policySetsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
@@ -142,6 +143,26 @@ function PolicySetsPage() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'policy_mode',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Режим" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant={
|
||||
row.original.policy_mode === 'whitelist'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{row.original.policy_mode === 'whitelist'
|
||||
? 'whitelist'
|
||||
: 'blacklist'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'rules_count',
|
||||
header: ({ column }) => (
|
||||
|
||||
Reference in New Issue
Block a user