323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
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>
|
||
)
|
||
}
|