Files
EvoFirewall/apps/web/src/components/agents/agent-settings-sheets.tsx
T

323 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Controller, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { parseListEntry } from '@evofw/shared'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
agentOverridesQueryOptions,
agentsQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { Badge } from '@/components/reui/badge'
import { LoadingButton } from '@/components/loading-button'
import { Button } from '@evofw/ui/components/button'
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
import { Input } from '@evofw/ui/components/input'
import { ScrollArea } from '@evofw/ui/components/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evofw/ui/components/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
/**
* Agent settings sheets — override IP + clone sets.
* Preview: https://reui.io/preview/base/sheet-8 · sheet-1
* Docs: https://ui.shadcn.com/docs/components/base/sheet
*/
type OverrideSheetProps = {
agentId: string
open: boolean
onOpenChange: (open: boolean) => void
}
const OVERRIDE_ACTION_ITEMS = [
{ value: 'deny', label: 'deny — блокировать' },
{ value: 'allow', label: 'allow — пропускать' },
] as const
const overrideSchema = z.object({
cidr: z
.string()
.trim()
.min(1, 'Укажите CIDR или IP')
.refine((v) => {
try {
const kind = parseListEntry(v).kind
return kind === 'cidr' || kind === 'ip'
} catch {
return false
}
}, 'Ожидается IP или CIDR, например 1.2.3.4/32'),
action: z.enum(['deny', 'allow']),
})
type OverrideValues = z.infer<typeof overrideSchema>
export function AgentOverrideSheet({
agentId,
open,
onOpenChange,
}: OverrideSheetProps) {
const qc = useQueryClient()
const overridesQ = useQuery({
...agentOverridesQueryOptions(agentId),
enabled: open,
})
const form = useForm<OverrideValues>({
resolver: zodResolver(overrideSchema),
defaultValues: { cidr: '', action: 'deny' },
mode: 'onBlur',
})
const add = useMutation({
mutationFn: (values: OverrideValues) =>
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
method: 'POST',
body: JSON.stringify(values),
}),
onSuccess: () => {
toast.success('Override добавлен — подхватится на следующей итерации sync')
form.reset({ cidr: '', action: 'deny' })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
},
onError: (e: Error) => toast.error(e.message),
})
const remove = useMutation({
mutationFn: (overrideId: string) =>
apiFetch(`/api/v1/agents/${agentId}/overrides/${overrideId}`, {
method: 'DELETE',
}),
onSuccess: () => {
toast.success('Override удалён')
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
},
onError: (e: Error) => toast.error(e.message),
})
const items = overridesQ.data?.items ?? []
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>IP override</SheetTitle>
<SheetDescription>
Мгновенные allow/deny поверх политики. Обновятся на агенте на
следующей итерации sync (~1 мин).
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="flex flex-col gap-4 py-2 pb-4">
<form
onSubmit={form.handleSubmit((values) => add.mutateAsync(values))}
className="grid gap-3"
>
<Field
data-invalid={Boolean(form.formState.errors.cidr) || undefined}
>
<FieldLabel htmlFor="ov-cidr">CIDR / IP</FieldLabel>
<Input
id="ov-cidr"
placeholder="1.2.3.4/32"
{...form.register('cidr')}
aria-invalid={Boolean(form.formState.errors.cidr) || undefined}
/>
<FieldError errors={[form.formState.errors.cidr]} />
</Field>
<Field>
<FieldLabel>Действие</FieldLabel>
<Controller
control={form.control}
name="action"
render={({ field }) => (
<Select
items={[...OVERRIDE_ACTION_ITEMS]}
value={field.value}
onValueChange={(v) => {
if (v === 'allow' || v === 'deny') field.onChange(v)
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OVERRIDE_ACTION_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</Field>
<LoadingButton
type="submit"
size="sm"
isLoading={add.isPending}
className="self-start"
>
Добавить
</LoadingButton>
</form>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">
Активные{' '}
<Badge variant="secondary" size="sm">
{items.length}
</Badge>
</p>
{items.length === 0 ? (
<p className="text-muted-foreground text-sm">Пока нет overrides</p>
) : (
<ul className="flex flex-col gap-1">
{items.map((o) => (
<li
key={o.id}
className="border-border flex items-center gap-2 rounded-lg border px-3 py-2"
>
<span className="min-w-0 flex-1 truncate font-mono text-sm">
{o.cidr}
</span>
<Badge
variant={
o.action === 'deny'
? 'destructive-light'
: 'success-light'
}
size="xs"
>
{o.action}
</Badge>
<Button
size="icon-sm"
variant="ghost"
className="text-destructive"
aria-label="Удалить"
disabled={remove.isPending}
onClick={() => remove.mutate(o.id)}
>
<Trash2 className="size-3.5" />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
</ScrollArea>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Закрыть
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
type CloneSheetProps = {
agentId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function AgentCloneSetsSheet({
agentId,
open,
onOpenChange,
}: CloneSheetProps) {
const qc = useQueryClient()
const agentsQ = useQuery({
...agentsQueryOptions(),
enabled: open,
})
const [cloneFrom, setCloneFrom] = useState<string | null>(null)
const clone = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${agentId}/clone-from/${cloneFrom}`, {
method: 'POST',
body: JSON.stringify({ include_overrides: true }),
}),
onSuccess: () => {
toast.success('Наборы скопированы')
setCloneFrom(null)
onOpenChange(false)
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void qc.invalidateQueries({ queryKey: ['policy-sets'] })
},
onError: (e: Error) => toast.error(e.message),
})
const sources = (agentsQ.data?.items ?? []).filter((x) => x.id !== agentId)
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>Копировать наборы</SheetTitle>
<SheetDescription>
Копирует назначения наборов и overrides с другого агента.
</SheetDescription>
</SheetHeader>
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4 py-2">
<Field>
<FieldLabel>Источник</FieldLabel>
<Select
value={cloneFrom}
onValueChange={(v) => setCloneFrom(v)}
items={sources.map((x) => ({ value: x.id, label: x.name }))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Выберите агента" />
</SelectTrigger>
<SelectContent>
{sources.map((x) => (
<SelectItem key={x.id} value={x.id}>
{x.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={!cloneFrom || clone.isPending}
onClick={() => clone.mutate()}
>
Клонировать
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}