feat(api, web): implement short install link functionality for agents
- 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Copy, Check, Trash2 } from 'lucide-react'
|
||||
import { Check, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||
@@ -10,13 +10,6 @@ import {
|
||||
PageShell,
|
||||
ResourcePage,
|
||||
} from '@/components/reui-kit'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import {
|
||||
DataGridMutedCell,
|
||||
@@ -24,13 +17,19 @@ import {
|
||||
} from '@/components/data-grid-cell'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { agentsQueryOptions, installContextQueryOptions } from '@/queries'
|
||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||
import { agentsQueryOptions } from '@/queries'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Input } from '@evofw/ui/components/input'
|
||||
import { Label } from '@evofw/ui/components/label'
|
||||
import type { Agent } from '@evofw/shared'
|
||||
|
||||
/**
|
||||
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* Empty: https://reui.io/preview/base/empty-state-7
|
||||
* Create Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
|
||||
* Docs: https://reui.io/blocks · https://reui.io/components/sheet
|
||||
*/
|
||||
export const Route = createFileRoute('/_auth/agents/')({
|
||||
component: AgentsPage,
|
||||
})
|
||||
@@ -38,8 +37,7 @@ export const Route = createFileRoute('/_auth/agents/')({
|
||||
function AgentsPage() {
|
||||
const qc = useQueryClient()
|
||||
const agentsQ = useQuery(agentsQueryOptions())
|
||||
const installQ = useQuery(installContextQueryOptions())
|
||||
const [name, setName] = useState('web-01')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
@@ -61,6 +59,7 @@ function AgentsPage() {
|
||||
toast.success('Агент отозван')
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
@@ -71,16 +70,10 @@ function AgentsPage() {
|
||||
setDeleteId(null)
|
||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const installCmd = useMemo(() => {
|
||||
const cp = installQ.data?.suggested_cp_url ?? 'https://fw.example.com'
|
||||
const seed = installQ.data?.enroll_seed ?? '<seed>'
|
||||
return `curl -fsSL ${cp}/v1/agent/install.sh | \\\n EVOFW_CP_URL=${cp} \\\n EVOFW_SEED=${seed} \\\n EVOFW_CLIENT_NAME="${name}" \\\n bash`
|
||||
}, [installQ.data, name])
|
||||
|
||||
const items = agentsQ.data?.items ?? []
|
||||
const pending = items.filter((a) => a.status === 'pending')
|
||||
|
||||
const filterFields: FilterFieldConfig[] = useMemo(
|
||||
() => [
|
||||
@@ -184,6 +177,16 @@ function AgentsPage() {
|
||||
const a = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-1">
|
||||
{a.status === 'pending' ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approve.mutate(a.id)}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Approve
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -214,105 +217,24 @@ function AgentsPage() {
|
||||
},
|
||||
},
|
||||
],
|
||||
[revoke],
|
||||
[approve, revoke],
|
||||
)
|
||||
|
||||
const addButton = (
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus data-icon="inline-start" />
|
||||
Добавить агента
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Агенты"
|
||||
description="Linux / MikroTik — enroll, approve, policy mode"
|
||||
description="Linux / MikroTik — short install, approve, policy mode"
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<div>
|
||||
<FrameTitle>Установка Linux</FrameTitle>
|
||||
<FrameDescription>
|
||||
One-liner. После enroll одобрите агента ниже.
|
||||
</FrameDescription>
|
||||
</div>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="mb-3 flex max-w-sm flex-col gap-2">
|
||||
<Label htmlFor="cname">Имя клиента</Label>
|
||||
<Input
|
||||
id="cname"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs">
|
||||
{installCmd}
|
||||
</pre>
|
||||
<Button
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
installCmd.replace(/\\\n\s*/g, ' '),
|
||||
)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<Copy data-icon="inline-start" />
|
||||
Копировать
|
||||
</Button>
|
||||
{installQ.data?.mikrotik_url ? (
|
||||
<p className="text-muted-foreground mt-3 text-sm">
|
||||
MikroTik:{' '}
|
||||
<a className="underline" href={installQ.data.mikrotik_url}>
|
||||
mikrotik-install.rsc
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
{pending.length > 0 ? (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Запросы ({pending.length})</FrameTitle>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<div className="flex flex-col gap-2">
|
||||
{pending.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-0"
|
||||
>
|
||||
<div>
|
||||
<DataGridPrimaryCell
|
||||
accent="primary"
|
||||
title={a.name}
|
||||
subtitle={`${a.platform} · ${a.hostname ?? '—'} · ${a.token_prefix}…`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => approve.mutate(a.id)}
|
||||
disabled={approve.isPending}
|
||||
>
|
||||
<Check data-icon="inline-start" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteId(a.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
) : null}
|
||||
|
||||
<ResourcePage
|
||||
title="Клиенты"
|
||||
hideHeader
|
||||
@@ -339,10 +261,14 @@ function AgentsPage() {
|
||||
onRetry={() => void agentsQ.refetch()}
|
||||
emptyState={{
|
||||
title: 'Нет агентов',
|
||||
description: 'Установите agent на сервер и одобрите запрос.',
|
||||
description:
|
||||
'Создайте install-ссылку, выполните curl на хосте и одобрите запрос.',
|
||||
action: addButton,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteId !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
Reference in New Issue
Block a user