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
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.
232 lines
7.8 KiB
TypeScript
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>
|
|
)
|
|
}
|