feat(api, web): implement short install link functionality for agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m37s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Added new API endpoints for creating, retrieving, and revoking install links for agents.
- Enhanced the agent installation process with short links accessible via `/agent-install/:id` and `/:slug`.
- Updated the README and documentation to reflect the new installation method and usage instructions.
- Refactored relevant components in the web application to support the new install link feature.
- Improved error handling and validation for install link operations.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 01:25:05 +07:00
co-authored by Cursor
parent 14f516d5cc
commit ef56da4d91
13 changed files with 677 additions and 117 deletions
@@ -0,0 +1,206 @@
import { useEffect, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Copy } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import type { InstallLink } from '@evofw/shared'
import { Button } from '@evofw/ui/components/button'
import { Field, 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'
/**
* Create agent install invite — Sheet.
* Preview: https://reui.io/preview/base/sheet-1 · https://reui.io/preview/base/sheet-8
* Hub: https://reui.io/components/sheet · https://reui.io/preview/base/components/c-sheet-1
* Copy pattern: https://reui.io/preview/base/settings-14
* Primitive API: https://ui.shadcn.com/docs/components/base/sheet
*/
type Platform = 'linux' | 'mikrotik'
const PLATFORM_ITEMS = [
{ value: 'linux', label: 'Linux' },
{ value: 'mikrotik', label: 'MikroTik' },
] as const
interface AddAgentSheetProps {
open: boolean
onOpenChange: (open: boolean) => void
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text)
toast.success('Скопировано')
}
export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
const [name, setName] = useState('web-01')
const [platform, setPlatform] = useState<Platform>('linux')
const [created, setCreated] = useState<InstallLink | null>(null)
useEffect(() => {
if (!open) {
setCreated(null)
setName('web-01')
setPlatform('linux')
}
}, [open])
const create = useMutation({
mutationFn: () =>
apiFetch<InstallLink>('/api/v1/install-links', {
method: 'POST',
body: JSON.stringify({ name: name.trim(), platform }),
}),
onSuccess: (link) => {
setCreated(link)
toast.success('Ссылка создана')
},
onError: (e: Error) => toast.error(e.message),
})
const canCreate = Boolean(name.trim()) && !create.isPending
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
<SheetHeader className="shrink-0">
<SheetTitle>
{created ? 'Команда установки' : 'Добавить агента'}
</SheetTitle>
<SheetDescription>
{created
? 'Скопируйте one-liner и выполните на хосте. Затем одобрите агента в списке.'
: 'Создайте короткую install-ссылку с именем клиента.'}
</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4">
<div className="grid auto-rows-min gap-4 py-2 pb-4">
{!created ? (
<>
<Field>
<FieldLabel htmlFor="agent-name">Имя клиента</FieldLabel>
<Input
id="agent-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="web-01"
/>
</Field>
<Field>
<FieldLabel>Платформа</FieldLabel>
<Select
items={[...PLATFORM_ITEMS]}
value={platform}
onValueChange={(v) => {
if (v === 'linux' || v === 'mikrotik') setPlatform(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>
</>
) : (
<>
<Field>
<FieldLabel>По id</FieldLabel>
<div className="flex flex-col gap-2">
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs whitespace-pre-wrap break-all">
{created.curl?.by_id}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() =>
void copyText(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 whitespace-pre-wrap break-all">
{created.curl?.by_slug}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() =>
void copyText(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">
{created ? (
<>
<Button
variant="outline"
onClick={() => {
setCreated(null)
}}
>
Ещё ссылка
</Button>
<Button onClick={() => onOpenChange(false)}>Готово</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={!canCreate}
onClick={() => create.mutate()}
>
Создать ссылку
</Button>
</>
)}
</SheetFooter>
</SheetContent>
</Sheet>
)
}