Files
EvoFirewall/apps/web/src/components/agents/add-agent-sheet.tsx
T
Denozordec 9fa6269174
quality / changes (push) Successful in 7s
quality / commitlint (push) Skipped
quality / openapi (push) Successful in 30s
quality / web (push) Successful in 1m3s
quality / api (push) Successful in 1m15s
quality / linux-agent-smoke (push) Failing after 27s
quality / docker-check (push) Failing after 2m28s
CD / quality (push) Failing after 5m52s
CD / publish (push) Skipped
feat: harden firewall policies telemetry and operations
Implement reliable agent apply, telemetry v2, analytics retention, guarded rollouts, enrollment, labels and webhooks. Improve ReUI frontend data safety and bounded list loading, and add Docker smoke, recovery and resource checks. Document local verification and pending native acceptance.
2026-10-26 13:10:58 +07:00

232 lines
7.8 KiB
TypeScript

import { invalidateAfterMutation } from '@/queries/invalidation'
import { useEffect, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Copy } from 'lucide-react'
import { FormSheet } from '@/components/form-sheet'
import { LoadingButton } from '@/components/loading-button'
import { apiFetch } from '@/lib/api'
import type { InstallLink } from '@evofw/shared'
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'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Controller } from 'react-hook-form'
/**
* Create agent install invite — FormSheet (create) + Sheet (install curl).
* Preview: https://reui.io/preview/base/sheet-8 · form-7
* Docs: https://ui.shadcn.com/docs/components/base/sheet
*/
const PLATFORM_ITEMS = [
{ value: 'linux', label: 'Linux' },
{ value: 'mikrotik', label: 'MikroTik' },
] as const
const createAgentSchema = z.object({
name: z.string().trim().min(1, 'Укажите имя'),
platform: z.enum(['linux', 'mikrotik']),
})
type CreateAgentValues = z.infer<typeof createAgentSchema>
interface AddAgentSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const [created, setCreated] = useState<InstallLink | null>(null)
const form = useForm<CreateAgentValues>({
resolver: zodResolver(createAgentSchema),
defaultValues: { name: 'web-01', platform: 'linux' },
})
useEffect(() => {
if (!open) {
setCreated(null)
form.reset({ name: 'web-01', platform: 'linux' })
}
}, [open, form])
const create = useMutation({
mutationFn: (values: CreateAgentValues) =>
apiFetch<InstallLink>('/api/v1/install-links', {
method: 'POST',
body: JSON.stringify({
name: values.name.trim(),
platform: values.platform,
}),
}),
onSuccess: (link) => {
setCreated(link)
toast.success('Агент создан')
void invalidateAfterMutation(qc, 'agent')
},
onError: (e: Error) => toast.error(e.message),
})
function handleCopy(text: string) {
if (!text) return
copyToClipboard(text)
toast.success('Скопировано')
}
if (created) {
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>
Агент уже в списке (Invited). Ссылка одноразовая, действует 24 часа и
погашается при регистрации. Выполните команду на целевом хосте.
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="grid auto-rows-min gap-4 py-2 pb-4">
<p className="text-muted-foreground text-sm">Истекает: {created.expires_at ? new Date(created.expires_at).toLocaleString() : 'неизвестно'}. Повторная установка по использованной ссылке недоступна.</p>
<Field>
<FieldLabel>По id</FieldLabel>
<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">
{created.curl?.by_id}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => handleCopy(created.curl?.by_id ?? '')}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
</Field>
<Field>
<FieldLabel>Короткий slug</FieldLabel>
<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">
{created.curl?.by_slug}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => handleCopy(created.curl?.by_slug ?? '')}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
</Field>
</div>
</ScrollArea>
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
<Button
variant="outline"
onClick={() => {
setCreated(null)
form.reset({ name: 'web-01', platform: 'linux' })
}}
>
Ещё агент
</Button>
<Button onClick={() => onOpenChange(false)}>Готово</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
return (
<FormSheet
open={open}
onOpenChange={onOpenChange}
title="Добавить агента"
description="Создайте агента и одноразовую install-ссылку на 24 часа."
form={form}
onSubmit={async (values) => {
await create.mutateAsync(values)
}}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Отмена
</Button>
<LoadingButton type="submit" isLoading={create.isPending}>
Создать
</LoadingButton>
</>
}
>
<Field data-invalid={Boolean(form.formState.errors.name) || undefined}>
<FieldLabel htmlFor="agent-name">Имя клиента</FieldLabel>
<Input
id="agent-name"
placeholder="web-01"
aria-invalid={Boolean(form.formState.errors.name) || undefined}
{...form.register('name')}
/>
<FieldError errors={[form.formState.errors.name]} />
</Field>
<Field>
<FieldLabel>Платформа</FieldLabel>
<Controller
control={form.control}
name="platform"
render={({ field }) => (
<Select
items={[...PLATFORM_ITEMS]}
value={field.value}
onValueChange={(v) => {
if (v === 'linux' || v === 'mikrotik') field.onChange(v)
}}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLATFORM_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</Field>
</FormSheet>
)
}