feat(api): unify policy handling with default action updates
- Updated `evofw-firewall.sh` and related scripts to replace `policy_mode` with `default_action`, enhancing clarity and consistency in policy management. - Adjusted agent routes and evaluation logic to accommodate the new default action structure, ensuring backward compatibility with legacy modes. - Enhanced tests to validate the new default action behavior and its integration within the agent policy framework. - Refactored related components in the web interface to align with the updated policy handling, improving user experience and reducing confusion around policy modes.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import type { AgentPolicyPreview } from '@evofw/shared'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from '@evofw/ui/components/tabs'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
/**
|
||||
* Effective CIDR bags — tabs Блок / Accept.
|
||||
* Preview: https://reui.io/preview/base/components/c-tabs-2
|
||||
* · https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
|
||||
type AgentEffectiveCidrsProps = {
|
||||
preview?: AgentPolicyPreview
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
function CidrList({
|
||||
items,
|
||||
total,
|
||||
emptyTitle,
|
||||
}: {
|
||||
items: string[]
|
||||
total: number
|
||||
emptyTitle: string
|
||||
}) {
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={emptyTitle}
|
||||
description={total === 0 ? undefined : `Всего ${total} (обрезано)`}
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ul className="flex max-h-64 flex-col gap-1 overflow-y-auto font-mono text-xs">
|
||||
{items.map((c) => (
|
||||
<li key={c} className="bg-muted/40 rounded px-2 py-1">
|
||||
{c}
|
||||
</li>
|
||||
))}
|
||||
{total > items.length ? (
|
||||
<li className="text-muted-foreground px-2 py-1">
|
||||
… и ещё {total - items.length}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentEffectiveCidrs({
|
||||
preview,
|
||||
isLoading,
|
||||
}: AgentEffectiveCidrsProps) {
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-48 w-full rounded-xl" />
|
||||
}
|
||||
|
||||
const deny = preview?.deny_cidrs ?? []
|
||||
const allow = preview?.allow_cidrs ?? []
|
||||
const denyTotal = preview?.deny_cidrs_total ?? deny.length
|
||||
const allowTotal = preview?.allow_cidrs_total ?? allow.length
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FrameTitle>Effective CIDR</FrameTitle>
|
||||
<Badge variant="secondary" size="sm">
|
||||
apply v{preview?.apply_version ?? 2}
|
||||
</Badge>
|
||||
</div>
|
||||
<FrameDescription>
|
||||
После deny-wins · hash {preview?.hash?.slice(0, 18) ?? '—'}…
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<Tabs defaultValue="deny">
|
||||
<TabsList>
|
||||
<TabsTrigger value="deny">Блок ({denyTotal})</TabsTrigger>
|
||||
<TabsTrigger value="allow">Accept ({allowTotal})</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="deny" className="mt-3">
|
||||
<CidrList
|
||||
items={deny}
|
||||
total={denyTotal}
|
||||
emptyTitle="Нет deny CIDR"
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="allow" className="mt-3">
|
||||
<CidrList
|
||||
items={allow}
|
||||
total={allowTotal}
|
||||
emptyTitle="Нет allow CIDR"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import type { Agent, DefaultAction } from '@evofw/shared'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import { Separator } from '@evofw/ui/components/separator'
|
||||
|
||||
/**
|
||||
* Agent facts panel — SA3 RunFacts DNA (editable default_action).
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/settings-3
|
||||
*/
|
||||
|
||||
type AgentFactsPanelProps = {
|
||||
agent: Agent
|
||||
}
|
||||
|
||||
function formatWhen(iso?: string | null): string {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString('ru-RU')
|
||||
}
|
||||
|
||||
export function AgentFactsPanel({ agent }: AgentFactsPanelProps) {
|
||||
const qc = useQueryClient()
|
||||
const defaultAction: DefaultAction =
|
||||
agent.default_action === 'drop' ? 'drop' : 'accept'
|
||||
|
||||
const patch = useMutation({
|
||||
mutationFn: (default_action: DefaultAction) =>
|
||||
apiFetch<Agent>(`/api/v1/agents/${agent.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ default_action }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('Default action обновлён')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agent.id, 'preview'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Параметры</FrameTitle>
|
||||
<FrameDescription>Default + identity</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Если не совпало</FieldLabel>
|
||||
<Select
|
||||
value={defaultAction}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'accept' || v === 'drop') patch.mutate(v)
|
||||
}}
|
||||
disabled={patch.isPending}
|
||||
items={[
|
||||
{ value: 'accept', label: 'Accept' },
|
||||
{ value: 'drop', label: 'Drop' },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="accept">Accept</SelectItem>
|
||||
<SelectItem value="drop">Drop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Пакет вне deny/allow → {defaultAction === 'drop' ? 'DROP' : 'ACCEPT'}
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<Separator />
|
||||
|
||||
<dl className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Hostname</dt>
|
||||
<dd className="truncate font-medium">{agent.hostname ?? '—'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Token</dt>
|
||||
<dd className="font-mono text-xs">{agent.token_prefix}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Client</dt>
|
||||
<dd>{agent.client_version ?? '—'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Last seen IP</dt>
|
||||
<dd>{agent.last_seen_ip ?? '—'}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Created</dt>
|
||||
<dd className="text-right text-xs">{formatWhen(agent.created_at)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Approved</dt>
|
||||
<dd className="text-right text-xs">
|
||||
{formatWhen(agent.approved_at)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3">
|
||||
<dt className="text-muted-foreground">Generation</dt>
|
||||
<dd className="tabular-nums">{agent.policy_generation}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import type { PolicySet } from '@evofw/shared'
|
||||
import {
|
||||
Sortable,
|
||||
SortableItem,
|
||||
@@ -42,7 +41,6 @@ import {
|
||||
/**
|
||||
* Agent-assigned policy sets — ReUI Sortable (c-sortable-5 DNA).
|
||||
* Preview: https://reui.io/preview/base/components/c-sortable-5
|
||||
* · https://reui.io/preview/base/settings-8
|
||||
* Docs: https://reui.io/docs/components/base/sortable
|
||||
*/
|
||||
|
||||
@@ -52,7 +50,6 @@ export type AgentPolicySetRow = {
|
||||
name: string
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
policy_mode: 'blacklist' | 'whitelist'
|
||||
}
|
||||
|
||||
type AgentPolicySetsSortableProps = {
|
||||
@@ -72,24 +69,10 @@ export function AgentPolicySetsSortable({
|
||||
setItems(assignedQ.data?.items ?? [])
|
||||
}, [assignedQ.data])
|
||||
|
||||
const assignedMode = items[0]?.policy_mode
|
||||
|
||||
const availableSets = useMemo(() => {
|
||||
const assigned = new Set(items.map((i) => i.set_id))
|
||||
return (catalogQ.data?.items ?? []).filter((s) => {
|
||||
if (assigned.has(s.id)) return false
|
||||
if (assignedMode && s.policy_mode !== assignedMode) return false
|
||||
return true
|
||||
})
|
||||
}, [catalogQ.data?.items, items, assignedMode])
|
||||
|
||||
const conflictSets = useMemo(() => {
|
||||
if (!assignedMode) return [] as PolicySet[]
|
||||
const assigned = new Set(items.map((i) => i.set_id))
|
||||
return (catalogQ.data?.items ?? []).filter(
|
||||
(s) => !assigned.has(s.id) && s.policy_mode !== assignedMode,
|
||||
)
|
||||
}, [catalogQ.data?.items, items, assignedMode])
|
||||
return (catalogQ.data?.items ?? []).filter((s) => !assigned.has(s.id))
|
||||
}, [catalogQ.data?.items, items])
|
||||
|
||||
const persist = useMutation({
|
||||
mutationFn: (set_ids: string[]) =>
|
||||
@@ -103,6 +86,7 @@ export function AgentPolicySetsSortable({
|
||||
onSuccess: (res) => {
|
||||
setItems(res.items)
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'preview'] })
|
||||
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
@@ -127,12 +111,6 @@ export function AgentPolicySetsSortable({
|
||||
if (!addId) return
|
||||
const set = (catalogQ.data?.items ?? []).find((s) => s.id === addId)
|
||||
if (!set) return
|
||||
if (assignedMode && set.policy_mode !== assignedMode) {
|
||||
toast.error(
|
||||
`Режим набора (${set.policy_mode}) не совпадает с текущим (${assignedMode})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
const next: AgentPolicySetRow[] = [
|
||||
...items,
|
||||
{
|
||||
@@ -141,7 +119,6 @@ export function AgentPolicySetsSortable({
|
||||
name: set.name,
|
||||
description: set.description,
|
||||
enabled: set.enabled,
|
||||
policy_mode: set.policy_mode,
|
||||
},
|
||||
]
|
||||
const prev = items
|
||||
@@ -168,7 +145,7 @@ export function AgentPolicySetsSortable({
|
||||
</Badge>
|
||||
</div>
|
||||
<FrameDescription>
|
||||
Перетащите для приоритета · один режим на агента
|
||||
Порядок = приоритет merge · deny → allow → default
|
||||
</FrameDescription>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
@@ -191,9 +168,7 @@ export function AgentPolicySetsSortable({
|
||||
))}
|
||||
{availableSets.length === 0 ? (
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs">
|
||||
{conflictSets.length > 0
|
||||
? 'Нет совместимых наборов'
|
||||
: 'Все наборы уже назначены'}
|
||||
Все наборы уже назначены
|
||||
</div>
|
||||
) : null}
|
||||
</SelectContent>
|
||||
@@ -242,23 +217,13 @@ export function AgentPolicySetsSortable({
|
||||
<GripVerticalIcon className="size-4" />
|
||||
</SortableItemHandle>
|
||||
|
||||
<PolicySetIcon mode={row.policy_mode} className="size-9" />
|
||||
<PolicySetIcon className="size-9" />
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{row.name}
|
||||
</span>
|
||||
<Badge
|
||||
variant={
|
||||
row.policy_mode === 'whitelist'
|
||||
? 'warning-light'
|
||||
: 'secondary'
|
||||
}
|
||||
size="xs"
|
||||
>
|
||||
{row.policy_mode}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
status={row.enabled ? 'enabled' : 'disabled'}
|
||||
/>
|
||||
@@ -300,13 +265,6 @@ export function AgentPolicySetsSortable({
|
||||
</FramePanel>
|
||||
)}
|
||||
</Frame>
|
||||
|
||||
{conflictSets.length > 0 && items.length > 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{conflictSets.length} набор(ов) скрыты из‑за другого режима (
|
||||
{assignedMode}).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { BanIcon, ShieldCheckIcon } from 'lucide-react'
|
||||
import type { AgentPolicyPreview } from '@evofw/shared'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from '@/components/reui/timeline'
|
||||
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 { EmptyState } from '@/components/empty-state'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
/**
|
||||
* Policy chain trace — SA3 Timeline DNA.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/components/c-timeline-6
|
||||
* Docs: https://reui.io/docs/components/base/timeline
|
||||
*/
|
||||
|
||||
type AgentPolicyTraceProps = {
|
||||
preview?: AgentPolicyPreview
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function AgentPolicyTrace({
|
||||
preview,
|
||||
isLoading,
|
||||
}: AgentPolicyTraceProps) {
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-64 w-full rounded-xl" />
|
||||
}
|
||||
|
||||
const chain = preview?.chain ?? []
|
||||
const conflicts = preview?.summary.conflicts_dropped ?? 0
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Цепочка политики</FrameTitle>
|
||||
<FrameDescription>
|
||||
deny → allow → default (
|
||||
{preview?.default_action === 'drop' ? 'Drop' : 'Accept'})
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
{conflicts > 0 ? (
|
||||
<Alert variant="warning">
|
||||
<BanIcon />
|
||||
<AlertTitle>Конфликты</AlertTitle>
|
||||
<AlertDescription>
|
||||
{conflicts} CIDR исключены из allow (deny wins, exact match)
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{chain.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет правил"
|
||||
description="Назначьте наборы или добавьте overrides."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<Timeline defaultValue={chain.length} className="px-1">
|
||||
{chain.map((step, i) => {
|
||||
const isDeny = step.action === 'deny'
|
||||
return (
|
||||
<TimelineItem key={`${step.rule_id ?? 'ov'}-${i}`} step={i + 1}>
|
||||
<TimelineHeader>
|
||||
<TimelineSeparator />
|
||||
<TimelineIndicator />
|
||||
<TimelineTitle className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{isDeny ? (
|
||||
<BanIcon className="text-destructive size-3.5" />
|
||||
) : (
|
||||
<ShieldCheckIcon className="text-success size-3.5" />
|
||||
)}
|
||||
<Badge
|
||||
variant={isDeny ? 'destructive-light' : 'success-light'}
|
||||
size="xs"
|
||||
>
|
||||
{isDeny ? 'Блок' : 'Accept'}
|
||||
</Badge>
|
||||
<span className="font-medium">{step.source_label}</span>
|
||||
</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineContent className="text-muted-foreground text-xs">
|
||||
{[
|
||||
step.set_name,
|
||||
step.source_kind,
|
||||
`${step.cidr_count} CIDR`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
|
||||
{preview ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Блок: {preview.deny_cidrs_total} · Accept:{' '}
|
||||
{preview.allow_cidrs_total} · gen {preview.generation}
|
||||
</p>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
"use client"
|
||||
|
||||
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import { ScrollArea } from "@evofw/ui/components/scroll-area"
|
||||
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||
|
||||
const inputVariants = cva(
|
||||
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
|
||||
default:
|
||||
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
|
||||
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Autocomplete = AutocompletePrimitive.Root
|
||||
|
||||
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteInput({
|
||||
className,
|
||||
size = "default",
|
||||
showClear = false,
|
||||
showTrigger = false,
|
||||
...props
|
||||
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
|
||||
VariantProps<typeof inputVariants> & {
|
||||
showClear?: boolean
|
||||
showTrigger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<AutocompletePrimitive.Input
|
||||
data-slot="autocomplete-input"
|
||||
data-size={size}
|
||||
className={cn(inputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
{showTrigger && <AutocompleteTrigger />}
|
||||
{showClear && <AutocompleteClear />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteStatus({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Status.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Status
|
||||
data-slot="autocomplete-status"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteBackdrop({
|
||||
...props
|
||||
}: AutocompletePrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Backdrop
|
||||
data-slot="autocomplete-backdrop"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePositioner({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Positioner.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Positioner
|
||||
data-slot="autocomplete-positioner"
|
||||
className={cn("z-50 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteList({
|
||||
className,
|
||||
scrollAreaClassName,
|
||||
...props
|
||||
}: AutocompletePrimitive.List.Props & {
|
||||
scrollAreaClassName?: string
|
||||
scrollFade?: boolean
|
||||
scrollbarGutter?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
|
||||
scrollAreaClassName
|
||||
)}
|
||||
>
|
||||
<AutocompletePrimitive.List
|
||||
data-slot="autocomplete-list"
|
||||
className={cn(
|
||||
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteCollection({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Collection
|
||||
data-slot="autocomplete-collection"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteRow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Row
|
||||
data-slot="autocomplete-row"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Item
|
||||
data-slot="autocomplete-item"
|
||||
className={cn(
|
||||
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5",
|
||||
"rounded-md",
|
||||
"data-highlighted:before:rounded-md",
|
||||
"px-1.5 py-1 text-sm ([class*='size-'])]:size-4 ([class*='size-'])]:size-4 [&_svg:not([class*='size-'])]:size-4 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 ([class*='size-'])]:size-4 ([class*='size-'])]:size-3.5 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AutocompleteContentProps extends React.ComponentProps<
|
||||
typeof AutocompletePrimitive.Popup
|
||||
> {
|
||||
align?: AutocompletePrimitive.Positioner.Props["align"]
|
||||
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
|
||||
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
|
||||
side?: AutocompletePrimitive.Positioner.Props["side"]
|
||||
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
|
||||
showBackdrop?: boolean
|
||||
}
|
||||
|
||||
function AutocompleteContent({
|
||||
className,
|
||||
children,
|
||||
showBackdrop = false,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
anchor,
|
||||
...props
|
||||
}: AutocompleteContentProps) {
|
||||
return (
|
||||
<AutocompletePortal>
|
||||
{showBackdrop && <AutocompleteBackdrop />}
|
||||
<AutocompletePositioner
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
anchor={anchor}
|
||||
>
|
||||
<div className="relative flex max-h-full">
|
||||
<AutocompletePrimitive.Popup
|
||||
data-slot="autocomplete-popup"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</AutocompletePrimitive.Popup>
|
||||
</div>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
|
||||
return (
|
||||
<AutocompletePrimitive.GroupLabel
|
||||
data-slot="autocomplete-group-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Empty
|
||||
data-slot="autocomplete-empty"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteClear({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Clear
|
||||
data-slot="autocomplete-clear"
|
||||
className={cn(
|
||||
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</AutocompletePrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Trigger
|
||||
data-slot="autocomplete-trigger"
|
||||
className={cn(
|
||||
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4 opacity-70" />
|
||||
</AutocompletePrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteArrow({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Separator
|
||||
data-slot="autocomplete-separator"
|
||||
className={cn(
|
||||
"bg-border my-1.5 h-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Autocomplete,
|
||||
AutocompleteValue,
|
||||
AutocompleteTrigger,
|
||||
AutocompleteInput,
|
||||
AutocompleteStatus,
|
||||
AutocompletePortal,
|
||||
AutocompleteBackdrop,
|
||||
AutocompletePositioner,
|
||||
AutocompleteContent,
|
||||
AutocompleteList,
|
||||
AutocompleteCollection,
|
||||
AutocompleteRow,
|
||||
AutocompleteItem,
|
||||
AutocompleteGroup,
|
||||
AutocompleteGroupLabel,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteClear,
|
||||
AutocompleteArrow,
|
||||
AutocompleteSeparator,
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@evofw/ui/components/toggle-group'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
|
||||
type PolicyMode = 'blacklist' | 'whitelist'
|
||||
|
||||
type PolicyModeToggleProps = {
|
||||
value: PolicyMode
|
||||
onChange: (mode: PolicyMode) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter mode — settings-3 ToggleGroup pattern.
|
||||
* Preview: https://reui.io/preview/base/settings-3
|
||||
* Docs: https://ui.shadcn.com/docs/components/base/toggle-group
|
||||
*/
|
||||
export function PolicyModeToggle({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
className,
|
||||
}: PolicyModeToggleProps) {
|
||||
return (
|
||||
<Frame dense spacing="sm" className={cn(className)}>
|
||||
<FrameHeader>
|
||||
<FrameTitle>Режим фильтра</FrameTitle>
|
||||
<FrameDescription>
|
||||
Чёрный список: блокировать deny. Белый список: пропускать только
|
||||
allow, остальное (forward) — DROP.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={[value]}
|
||||
onValueChange={(next) => {
|
||||
const mode = next[0]
|
||||
if (mode === 'blacklist' || mode === 'whitelist') {
|
||||
onChange(mode)
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
aria-label="Режим фильтра"
|
||||
className="flex flex-wrap justify-start gap-1"
|
||||
>
|
||||
<ToggleGroupItem value="blacklist" aria-label="Чёрный список">
|
||||
Чёрный список
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="whitelist" aria-label="Белый список">
|
||||
Белый список
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -58,7 +58,6 @@ function ruleSubtitle(r: PolicyRule): string | null {
|
||||
type PolicyRulesSortableProps = {
|
||||
setId: string
|
||||
rules: PolicyRule[]
|
||||
policyMode: 'blacklist' | 'whitelist'
|
||||
onDelete: (id: string) => void
|
||||
onAdd?: () => void
|
||||
}
|
||||
@@ -66,7 +65,6 @@ type PolicyRulesSortableProps = {
|
||||
export function PolicyRulesSortable({
|
||||
setId,
|
||||
rules: rulesProp,
|
||||
policyMode,
|
||||
onDelete,
|
||||
onAdd,
|
||||
}: PolicyRulesSortableProps) {
|
||||
@@ -105,22 +103,16 @@ export function PolicyRulesSortable({
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const isWl = policyMode === 'whitelist'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Frame dense spacing="sm">
|
||||
<FramePanel className="flex items-center gap-3 py-3">
|
||||
<Badge
|
||||
variant={isWl ? 'destructive-light' : 'success-light'}
|
||||
size="sm"
|
||||
>
|
||||
{isWl ? 'DROP' : 'ACCEPT'}
|
||||
<Badge variant="secondary" size="sm">
|
||||
deny → allow
|
||||
</Badge>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{isWl
|
||||
? 'По умолчанию DROP — ниже только allow-правила пропускают трафик'
|
||||
: 'По умолчанию ACCEPT — ниже deny-правила блокируют адреса'}
|
||||
Правила с action deny блокируют, allow — пропускают; default задаётся
|
||||
на агенте
|
||||
</p>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { cn } from '@evofw/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
|
||||
type PolicySetIconProps = {
|
||||
mode?: 'blacklist' | 'whitelist' | string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -11,16 +10,14 @@ type PolicySetIconProps = {
|
||||
* KPI-style tile for policy set rows.
|
||||
* Preview DNA: https://reui.io/preview/base/stats-12
|
||||
*/
|
||||
export function PolicySetIcon({ mode, className }: PolicySetIconProps) {
|
||||
const isWhitelist = mode === 'whitelist'
|
||||
export function PolicySetIcon({ className }: PolicySetIconProps) {
|
||||
return (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
isWhitelist ? 'text-warning' : 'text-muted-foreground',
|
||||
'border-background bg-muted text-muted-foreground flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
className,
|
||||
)}
|
||||
aria-label={isWhitelist ? 'Whitelist' : 'Blacklist'}
|
||||
aria-label="Набор правил"
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<Shield aria-hidden />
|
||||
|
||||
@@ -93,11 +93,30 @@ export const agentPolicySetsQueryOptions = (agentId: string) =>
|
||||
name: string
|
||||
description?: string | null
|
||||
enabled: boolean
|
||||
policy_mode: 'blacklist' | 'whitelist'
|
||||
}[]
|
||||
}>(`/api/v1/agents/${agentId}/policy-sets`),
|
||||
})
|
||||
|
||||
export const agentPreviewQueryOptions = (agentId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', agentId, 'preview'],
|
||||
queryFn: () =>
|
||||
apiFetch<import('@evofw/shared').AgentPolicyPreview>(
|
||||
`/api/v1/agents/${agentId}/preview?limit_cidrs=100`,
|
||||
),
|
||||
})
|
||||
|
||||
export const evobgpCommunitiesQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['integrations', 'evobgp', 'communities'],
|
||||
queryFn: () =>
|
||||
apiFetch<{
|
||||
items: import('@evofw/shared').EvobgpCommunity[]
|
||||
}>('/api/v1/integrations/evobgp/communities'),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
export const agentOverridesQueryOptions = (agentId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', agentId, 'overrides'],
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import {
|
||||
BanIcon,
|
||||
CheckCircle2Icon,
|
||||
CircleAlertIcon,
|
||||
ClockIcon,
|
||||
Copy,
|
||||
CpuIcon,
|
||||
CopyPlusIcon,
|
||||
ShieldOffIcon,
|
||||
CpuIcon,
|
||||
MoreHorizontalIcon,
|
||||
ShieldPlusIcon,
|
||||
TerminalIcon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DetailPanel,
|
||||
PageShell,
|
||||
QuickActionGrid,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { DetailPanel, PageShell } from '@/components/reui-kit'
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
@@ -36,21 +25,34 @@ import {
|
||||
AgentPlatformIcon,
|
||||
platformLabel,
|
||||
} from '@/components/agents/agent-platform-icon'
|
||||
import { AgentLifecycleTimeline } from '@/components/agents/agent-lifecycle-timeline'
|
||||
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 { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
} from '@/components/agents/agent-settings-sheets'
|
||||
import { agentQueryOptions } from '@/queries'
|
||||
import {
|
||||
agentPreviewQueryOptions,
|
||||
agentQueryOptions,
|
||||
} from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@evofw/ui/components/dropdown-menu'
|
||||
|
||||
/**
|
||||
* Agent detail — Solutions Agents DNA.
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3 · stats-12 · sheet-8 · c-sortable-5
|
||||
* Agent detail — SA3 layout DNA (Header + Trace 2/3 + Facts 1/3).
|
||||
* Preview: https://reui.io/preview/base/solution-agents-3
|
||||
* · https://reui.io/preview/base/stats-12
|
||||
* Docs: https://reui.io/blocks/solutions/agents
|
||||
*/
|
||||
|
||||
export const Route = createFileRoute('/_auth/agents/$id')({
|
||||
@@ -58,6 +60,7 @@ export const Route = createFileRoute('/_auth/agents/$id')({
|
||||
const agent = await queryClient.ensureQueryData(
|
||||
agentQueryOptions(params.id),
|
||||
)
|
||||
void queryClient.ensureQueryData(agentPreviewQueryOptions(params.id))
|
||||
return { breadcrumb: agent.name }
|
||||
},
|
||||
component: AgentDetailPage,
|
||||
@@ -68,6 +71,7 @@ function AgentDetailPage() {
|
||||
const qc = useQueryClient()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const agentQ = useQuery(agentQueryOptions(id))
|
||||
const previewQ = useQuery(agentPreviewQueryOptions(id))
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
@@ -94,68 +98,6 @@ function AgentDetailPage() {
|
||||
|
||||
const a = agentQ.data
|
||||
|
||||
const quickActions = useMemo(() => {
|
||||
if (!a) return []
|
||||
const actions = [
|
||||
{
|
||||
id: 'override',
|
||||
title: 'IP override',
|
||||
description: 'Allow/deny поверх политики',
|
||||
icon: <ShieldPlusIcon aria-hidden />,
|
||||
iconClassName: 'text-warning [&_svg]:text-current',
|
||||
badgeLabel: 'Открыть',
|
||||
onSelect: () => setOverrideOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'clone',
|
||||
title: 'Копировать наборы',
|
||||
description: 'С другого агента + overrides',
|
||||
icon: <CopyPlusIcon aria-hidden />,
|
||||
iconClassName: 'text-info [&_svg]:text-current',
|
||||
badgeLabel: 'Открыть',
|
||||
onSelect: () => setCloneOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'install',
|
||||
title: 'Install curl',
|
||||
description: a.install_curl ? 'Скопировать one-liner' : 'Недоступен',
|
||||
icon: <TerminalIcon aria-hidden />,
|
||||
iconClassName: 'text-primary [&_svg]:text-current',
|
||||
badgeLabel: 'Копировать',
|
||||
onSelect: () => {
|
||||
if (a.install_curl) {
|
||||
copyToClipboard(a.install_curl)
|
||||
toast.success('Скопировано')
|
||||
}
|
||||
installRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
},
|
||||
},
|
||||
]
|
||||
if (a.status === 'pending') {
|
||||
actions.push({
|
||||
id: 'approve',
|
||||
title: 'Approve',
|
||||
description: 'Выдать политику агенту',
|
||||
icon: <CheckCircle2Icon aria-hidden />,
|
||||
iconClassName: 'text-success [&_svg]:text-current',
|
||||
badgeLabel: 'Выполнить',
|
||||
onSelect: () => approve.mutate(),
|
||||
})
|
||||
}
|
||||
if (a.status === 'approved') {
|
||||
actions.push({
|
||||
id: 'revoke',
|
||||
title: 'Revoke',
|
||||
description: 'Отозвать доступ агента',
|
||||
icon: <ShieldOffIcon aria-hidden />,
|
||||
iconClassName: 'text-destructive [&_svg]:text-current',
|
||||
badgeLabel: 'Выполнить',
|
||||
onSelect: () => revoke.mutate(),
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}, [a, approve, copyToClipboard, revoke])
|
||||
|
||||
if (agentQ.isLoading || !a) {
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -171,6 +113,7 @@ function AgentDetailPage() {
|
||||
a.hostname,
|
||||
platformLabel(a.platform),
|
||||
`gen ${a.policy_generation}`,
|
||||
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
@@ -217,13 +160,44 @@ function AgentDetailPage() {
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
render={<Link to="/agents" />}
|
||||
>
|
||||
К списку
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="outline" size="icon-sm" aria-label="Ещё" />
|
||||
}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setOverrideOpen(true)}>
|
||||
<ShieldPlusIcon className="size-4" />
|
||||
IP override
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setCloneOpen(true)}>
|
||||
<CopyPlusIcon className="size-4" />
|
||||
Копировать наборы
|
||||
</DropdownMenuItem>
|
||||
{a.install_curl ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
installRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
Install curl
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
render={<Link to="/agents" />}
|
||||
>
|
||||
К списку
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
@@ -272,78 +246,26 @@ function AgentDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<QuickActionGrid actions={quickActions} />
|
||||
|
||||
<DetailPanel.Section>
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<AgentLifecycleTimeline agent={a} />
|
||||
<div className="@container flex flex-col gap-4">
|
||||
<div className="grid gap-4 @4xl:grid-cols-3">
|
||||
<div className="@4xl:col-span-2">
|
||||
<AgentPolicyTrace
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
</div>
|
||||
<AgentFactsPanel agent={a} />
|
||||
</div>
|
||||
|
||||
<div ref={installRef}>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Install / identity</FrameTitle>
|
||||
<FrameDescription>
|
||||
Copy one-liner · hostname · token
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3">
|
||||
{a.install_curl ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs break-all whitespace-pre-wrap">
|
||||
{a.install_curl}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start"
|
||||
onClick={() => {
|
||||
copyToClipboard(a.install_curl!)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Копировать
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Install curl недоступен
|
||||
</p>
|
||||
)}
|
||||
<div className="text-muted-foreground grid gap-1 text-sm">
|
||||
<div>
|
||||
Hostname:{' '}
|
||||
<span className="text-foreground">
|
||||
{a.hostname ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Last seen IP:{' '}
|
||||
<span className="text-foreground">
|
||||
{a.last_seen_ip ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Client:{' '}
|
||||
<span className="text-foreground">
|
||||
{a.client_version ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Token prefix:{' '}
|
||||
<span className="text-foreground font-mono">
|
||||
{a.token_prefix}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<AgentPolicySetsSortable agentId={id} />
|
||||
</div>
|
||||
|
||||
<AgentEffectiveCidrs
|
||||
preview={previewQ.data}
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
@@ -13,8 +13,16 @@ import {
|
||||
listTabFilter,
|
||||
} from '@/components/lists/lists-columns'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { listsQueryOptions } from '@/queries'
|
||||
import { listsQueryOptions, evobgpCommunitiesQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteContent,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteInput,
|
||||
AutocompleteItem,
|
||||
AutocompleteList,
|
||||
} from '@/components/reui/autocomplete'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
@@ -58,8 +66,6 @@ const CREATE_SOURCE_ITEMS = [
|
||||
function ListsPage() {
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [source, setSource] = useState<CreateSource>('static')
|
||||
@@ -69,6 +75,21 @@ function ListsPage() {
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
||||
|
||||
const listsQ = useQuery(listsQueryOptions())
|
||||
const communitiesQ = useQuery({
|
||||
...evobgpCommunitiesQueryOptions(),
|
||||
enabled: createOpen && source === 'evobgp_community',
|
||||
})
|
||||
|
||||
const communityItems = useMemo(
|
||||
() =>
|
||||
(communitiesQ.data?.items ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: c.title ? `${c.community} · ${c.title}` : c.community,
|
||||
})),
|
||||
[communitiesQ.data?.items],
|
||||
)
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async () => {
|
||||
const config: Record<string, unknown> = {}
|
||||
@@ -273,12 +294,35 @@ function ListsPage() {
|
||||
) : null}
|
||||
{source === 'evobgp_community' ? (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="list-comm">Community ID</FieldLabel>
|
||||
<Input
|
||||
id="list-comm"
|
||||
<FieldLabel>BGP community</FieldLabel>
|
||||
<Autocomplete
|
||||
items={communityItems}
|
||||
value={extra}
|
||||
onChange={(e) => setExtra(e.target.value)}
|
||||
/>
|
||||
onValueChange={setExtra}
|
||||
>
|
||||
<AutocompleteInput
|
||||
placeholder={
|
||||
communitiesQ.isError
|
||||
? 'ID вручную (EvoBGP недоступен)'
|
||||
: 'Поиск community…'
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteEmpty>
|
||||
{communitiesQ.isLoading
|
||||
? 'Загрузка…'
|
||||
: 'Нет совпадений'}
|
||||
</AutocompleteEmpty>
|
||||
<AutocompleteList>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.value} value={item}>
|
||||
{item.label}
|
||||
</AutocompleteItem>
|
||||
)}
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</Field>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,6 @@ 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 { PolicyModeToggle } from '@/components/rules/policy-mode-toggle'
|
||||
import {
|
||||
agentsQueryOptions,
|
||||
listsQueryOptions,
|
||||
@@ -103,11 +102,7 @@ function PolicySetDetailPage() {
|
||||
}, [assignedIds])
|
||||
|
||||
const patchSet = useMutation({
|
||||
mutationFn: (body: {
|
||||
enabled?: boolean
|
||||
name?: string
|
||||
policy_mode?: 'blacklist' | 'whitelist'
|
||||
}) =>
|
||||
mutationFn: (body: { enabled?: boolean; name?: string }) =>
|
||||
apiFetch(`/api/v1/policy-sets/${setId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
@@ -277,8 +272,6 @@ function PolicySetDetailPage() {
|
||||
}
|
||||
|
||||
const set = setQ.data
|
||||
const policyMode =
|
||||
set.policy_mode === 'whitelist' ? 'whitelist' : 'blacklist'
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -339,19 +332,10 @@ function PolicySetDetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<PolicyModeToggle
|
||||
value={policyMode}
|
||||
disabled={patchSet.isPending}
|
||||
onChange={(mode) => patchSet.mutate({ policy_mode: mode })}
|
||||
/>
|
||||
</DetailPanel.Section>
|
||||
|
||||
<DetailPanel.Section>
|
||||
<PolicyRulesSortable
|
||||
setId={setId}
|
||||
rules={rules}
|
||||
policyMode={policyMode}
|
||||
onDelete={(id) => setDeleteRuleId(id)}
|
||||
onAdd={() => setRuleOpen(true)}
|
||||
/>
|
||||
@@ -359,7 +343,7 @@ function PolicySetDetailPage() {
|
||||
|
||||
<DetailPanel.Section
|
||||
title="Назначено агентам"
|
||||
description="Все наборы агента должны иметь один режим фильтра."
|
||||
description="Агенты, которым применён этот набор."
|
||||
>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 { PolicySetIcon } from '@/components/rules/policy-set-icon'
|
||||
import { policySetsQueryOptions } from '@/queries'
|
||||
@@ -120,7 +119,7 @@ function PolicySetsPage() {
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<PolicySetIcon mode={row.original.policy_mode} />
|
||||
<PolicySetIcon />
|
||||
<DataGridPrimaryCell
|
||||
accent="primary"
|
||||
title={row.original.name}
|
||||
@@ -140,26 +139,6 @@ 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