feat(api, web): enhance agent installation process with invited status and policy support
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m48s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Updated the agent enrollment process to include an 'invited' status, allowing for better tracking of agent states.
- Implemented support for install links that can now include an `install_link_id`, facilitating the transition from invited to pending status upon enrollment.
- Enhanced the MikroTik installation script to include the `EvofwInstallLinkId` for better tracking and management.
- Added new API endpoints for fetching agent policies and serving MikroTik-specific installation scripts.
- Improved the web UI to reflect the new agent statuses and provide copyable installation commands for agents.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 01:45:38 +07:00
co-authored by Cursor
parent ef56da4d91
commit d5784b9f35
20 changed files with 793 additions and 104 deletions
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Copy } from 'lucide-react'
import { apiFetch } from '@/lib/api'
@@ -23,6 +23,7 @@ import {
SheetHeader,
SheetTitle,
} from '@evofw/ui/components/sheet'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
/**
* Create agent install invite — Sheet.
@@ -44,12 +45,9 @@ interface AddAgentSheetProps {
onOpenChange: (open: boolean) => void
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text)
toast.success('Скопировано')
}
export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const [name, setName] = useState('web-01')
const [platform, setPlatform] = useState<Platform>('linux')
const [created, setCreated] = useState<InstallLink | null>(null)
@@ -70,13 +68,20 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
}),
onSuccess: (link) => {
setCreated(link)
toast.success('Ссылка создана')
toast.success('Агент создан')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const canCreate = Boolean(name.trim()) && !create.isPending
function handleCopy(text: string) {
if (!text) return
copyToClipboard(text)
toast.success('Скопировано')
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
@@ -86,8 +91,8 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
</SheetTitle>
<SheetDescription>
{created
? 'Скопируйте one-liner и выполните на хосте. Затем одобрите агента в списке.'
: 'Создайте короткую install-ссылку с именем клиента.'}
? 'Агент уже в списке (Invited). Скопируйте one-liner и выполните на хосте.'
: 'Создайте агента и короткую install-ссылку.'}
</SheetDescription>
</SheetHeader>
@@ -139,9 +144,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
variant="outline"
size="sm"
className="self-start"
onClick={() =>
void copyText(created.curl?.by_id ?? '')
}
onClick={() => handleCopy(created.curl?.by_id ?? '')}
>
<Copy data-icon="inline-start" />
Копировать
@@ -159,9 +162,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
variant="outline"
size="sm"
className="self-start"
onClick={() =>
void copyText(created.curl?.by_slug ?? '')
}
onClick={() => handleCopy(created.curl?.by_slug ?? '')}
>
<Copy data-icon="inline-start" />
Копировать
@@ -182,7 +183,7 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
setCreated(null)
}}
>
Ещё ссылка
Ещё агент
</Button>
<Button onClick={() => onOpenChange(false)}>Готово</Button>
</>
@@ -191,11 +192,8 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
<Button variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</Button>
<Button
disabled={!canCreate}
onClick={() => create.mutate()}
>
Создать ссылку
<Button disabled={!canCreate} onClick={() => create.mutate()}>
Создать
</Button>
</>
)}
+2
View File
@@ -16,6 +16,7 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
allow: 'success-light',
pending_push: 'secondary',
pending: 'warning-light',
invited: 'info-light',
warning: 'warning-light',
degraded: 'warning-light',
conflict: 'destructive-light',
@@ -54,6 +55,7 @@ const STATUS_LABELS: Record<string, string> = {
synced: 'Синхронизировано',
pending_push: 'Ожидает отправки',
pending: 'Pending',
invited: 'Invited',
approved: 'Approved',
revoked: 'Revoked',
enabled: 'Включён',
@@ -0,0 +1,35 @@
import { useState } from "react"
export function useCopyToClipboard({
timeout = 2000,
onCopy,
}: {
timeout?: number
onCopy?: () => void
} = {}) {
const [isCopied, setIsCopied] = useState(false)
const copyToClipboard = (value: string) => {
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
return
}
if (!value) return
navigator.clipboard.writeText(value).then(() => {
setIsCopied(true)
if (onCopy) {
onCopy()
}
if (timeout !== 0) {
setTimeout(() => {
setIsCopied(false)
}, timeout)
}
}, console.error)
}
return { isCopied, copyToClipboard }
}
+58 -7
View File
@@ -1,7 +1,7 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Check, Plus, Trash2 } from 'lucide-react'
import { Check, Copy, 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'
@@ -20,15 +20,21 @@ import { ConfirmDialog } from '@/components/confirm-dialog'
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
import { agentsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@evofw/ui/components/tooltip'
import type { Agent } from '@evofw/shared'
/**
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Copyable install: https://reui.io/preview/base/settings-14
* 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,
@@ -37,6 +43,7 @@ export const Route = createFileRoute('/_auth/agents/')({
function AgentsPage() {
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
const { copyToClipboard } = useCopyToClipboard()
const [createOpen, setCreateOpen] = useState(false)
const [filters, setFilters] = useState<Filter[]>([])
const [activeTab, setActiveTab] = useState('all')
@@ -88,6 +95,7 @@ function AgentsPage() {
label: 'Статус',
type: 'select',
options: [
{ value: 'invited', label: 'invited' },
{ value: 'approved', label: 'approved' },
{ value: 'pending', label: 'pending' },
{ value: 'revoked', label: 'revoked' },
@@ -118,6 +126,15 @@ function AgentsPage() {
return item.status === tabId
}, [])
const handleCopyCurl = useCallback(
(curl: string) => {
if (!curl) return
copyToClipboard(curl)
toast.success('Скопировано')
},
[copyToClipboard],
)
const columns: ColumnDef<Agent>[] = useMemo(
() => [
{
@@ -152,6 +169,39 @@ function AgentsPage() {
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'install',
enableSorting: false,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Install" />
),
cell: ({ row }) => {
const curl = row.original.install_curl
if (!curl) {
return <DataGridMutedCell></DataGridMutedCell>
}
return (
<Tooltip>
<TooltipTrigger
render={
<Button
size="sm"
variant="outline"
className="max-w-[14rem] font-mono text-xs"
onClick={() => handleCopyCurl(curl)}
/>
}
>
<Copy data-icon="inline-start" className="size-3.5" />
<span className="truncate">{curl}</span>
</TooltipTrigger>
<TooltipContent className="max-w-sm break-all font-mono text-xs">
{curl}
</TooltipContent>
</Tooltip>
)
},
},
{
accessorKey: 'policy_mode',
header: ({ column }) => (
@@ -217,7 +267,7 @@ function AgentsPage() {
},
},
],
[approve, revoke],
[approve, revoke, handleCopyCurl],
)
const addButton = (
@@ -231,7 +281,7 @@ function AgentsPage() {
<PageShell>
<PageHeader
title="Агенты"
description="Linux / MikroTik — short install, approve, policy mode"
description="Linux / MikroTik — invite, install, approve"
actions={addButton}
/>
@@ -248,8 +298,9 @@ function AgentsPage() {
getFilterFieldValue={getFilterFieldValue}
tabs={[
{ id: 'all', label: 'Все' },
{ id: 'approved', label: 'Approved' },
{ id: 'invited', label: 'Invited' },
{ id: 'pending', label: 'Pending' },
{ id: 'approved', label: 'Approved' },
{ id: 'revoked', label: 'Revoked' },
]}
activeTab={activeTab}
@@ -262,7 +313,7 @@ function AgentsPage() {
emptyState={{
title: 'Нет агентов',
description:
'Создайте install-ссылку, выполните curl на хосте и одобрите запрос.',
'Создайте агента — он появится в списке как Invited с командой установки.',
action: addButton,
}}
/>
@@ -275,7 +326,7 @@ function AgentsPage() {
if (!open) setDeleteId(null)
}}
title="Удалить агента?"
description="Агент и связанные назначения будут удалены."
description="Агент, install-ссылка и связанные назначения будут удалены."
onConfirm={() => {
if (deleteId) remove.mutate(deleteId)
}}