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:
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user