feat(api, web): enhance agent installation process with invited status and policy support
- 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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user