feat(web): Agents ops console по ReUI Solutions Agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m15s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

KPI/Chart/Attention на списке, Stepper wizard, Timeline и Install на detail; breadcrumb loader.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 03:35:10 +07:00
co-authored by Cursor
parent 41e4ca1410
commit 0653313a57
10 changed files with 1683 additions and 102 deletions
+158 -4
View File
@@ -6,8 +6,9 @@ import type { ColumnDef } from '@tanstack/react-table'
import {
BanIcon,
CheckCircle2Icon,
CpuIcon,
ClockIcon,
Copy,
CpuIcon,
} from 'lucide-react'
import { PageShell, DetailPanel } from '@/components/reui-kit'
import {
@@ -17,12 +18,18 @@ import {
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge'
import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
import { AgentLifecycleTimeline } from '@/components/agents/agent-lifecycle-timeline'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
@@ -35,6 +42,7 @@ import {
policySetsQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import { Checkbox } from '@evofw/ui/components/checkbox'
import { Input } from '@evofw/ui/components/input'
@@ -48,14 +56,27 @@ import {
} from '@evofw/ui/components/select'
import { Skeleton } from '@evofw/ui/components/skeleton'
import type { PolicySet } from '@evofw/shared'
import { CircleAlertIcon } from 'lucide-react'
/**
* Agent detail — Solutions Agents DNA.
* Preview: https://reui.io/preview/base/solution-agents-3 · stats-12 · settings-14
*/
export const Route = createFileRoute('/_auth/agents/$id')({
loader: async ({ context: { queryClient }, params }) => {
const agent = await queryClient.ensureQueryData(
agentQueryOptions(params.id),
)
return { breadcrumb: agent.name }
},
component: AgentDetailPage,
})
function AgentDetailPage() {
const { id } = Route.useParams()
const qc = useQueryClient()
const { copyToClipboard } = useCopyToClipboard()
const agentQ = useQuery(agentQueryOptions(id))
const setsQ = useQuery(policySetsQueryOptions())
const assignedQ = useQuery(agentPolicySetsQueryOptions(id))
@@ -82,6 +103,16 @@ function AgentDetailPage() {
onError: (e: Error) => toast.error(e.message),
})
const approve = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
onSuccess: () => {
toast.success('Агент одобрен')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const addOverride = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/agents/${id}/overrides`, {
@@ -200,21 +231,41 @@ function AgentDetailPage() {
if (agentQ.isLoading || !a) {
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header title="Агент" description="Загрузка…" />
</DetailPanel>
<Skeleton className="h-40 w-full rounded-xl" />
</PageShell>
)
}
const headerDesc = [
a.hostname,
platformLabel(a.platform),
`gen ${a.policy_generation}`,
]
.filter(Boolean)
.join(' · ')
return (
<PageShell>
<DetailPanel>
<DetailPanel.Header
title={a.name}
description={`${platformLabel(a.platform)} · gen ${a.policy_generation}`}
description={headerDesc}
actions={
<div className="flex items-center gap-2">
<>
<AgentPlatformIcon platform={a.platform} />
<StatusBadge status={a.status} />
{a.status === 'pending' ? (
<Button
size="sm"
onClick={() => approve.mutate()}
disabled={approve.isPending}
>
Approve
</Button>
) : null}
{a.status === 'approved' ? (
<Button
variant="outline"
@@ -225,6 +276,19 @@ function AgentDetailPage() {
Revoke
</Button>
) : null}
{a.install_curl ? (
<Button
variant="outline"
size="sm"
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Install
</Button>
) : null}
<Button
variant="outline"
size="sm"
@@ -232,9 +296,36 @@ function AgentDetailPage() {
>
К списку
</Button>
</div>
</>
}
/>
{a.last_apply_error ? (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка apply</AlertTitle>
<AlertDescription>{a.last_apply_error}</AlertDescription>
</Alert>
) : null}
{a.status === 'pending' ? (
<Alert variant="warning">
<CircleAlertIcon />
<AlertTitle>Ожидает approve</AlertTitle>
<AlertDescription>
Агент записался, но политика не выдаётся до одобрения.
</AlertDescription>
</Alert>
) : null}
{a.status === 'invited' ? (
<Alert variant="info">
<CircleAlertIcon />
<AlertTitle>Invited</AlertTitle>
<AlertDescription>
Скопируйте install-команду и выполните на хосте.
</AlertDescription>
</Alert>
) : null}
<DetailPanel.Metrics
cards={[
{
@@ -273,6 +364,69 @@ function AgentDetailPage() {
<DetailPanel.Section>
<div className="grid gap-4 lg:grid-cols-2">
<AgentLifecycleTimeline agent={a} />
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Install / identity</FrameTitle>
<FrameDescription>
Copy one-liner · hostname · token
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-3">
{a.install_curl ? (
<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">
{a.install_curl}
</pre>
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => {
copyToClipboard(a.install_curl!)
toast.success('Скопировано')
}}
>
<Copy data-icon="inline-start" />
Копировать
</Button>
</div>
) : (
<p className="text-muted-foreground text-sm">
Install curl недоступен
</p>
)}
<div className="text-muted-foreground grid gap-1 text-sm">
<div>
Hostname:{' '}
<span className="text-foreground">
{a.hostname ?? '—'}
</span>
</div>
<div>
Last seen IP:{' '}
<span className="text-foreground">
{a.last_seen_ip ?? '—'}
</span>
</div>
<div>
Client:{' '}
<span className="text-foreground">
{a.client_version ?? '—'}
</span>
</div>
<div>
Token prefix:{' '}
<span className="text-foreground font-mono">
{a.token_prefix}
</span>
</div>
</div>
</FramePanel>
</Frame>
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Режим фильтра</FrameTitle>
+148 -7
View File
@@ -1,15 +1,40 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { Check, Copy, Pencil, Plus, Trash2 } from 'lucide-react'
import {
Check,
CheckCircle2,
CircleAlertIcon,
Copy,
Inbox,
Pencil,
Plus,
Trash2,
UserPlus,
WifiOff,
} from 'lucide-react'
import { useCallback, useMemo, useState, type MouseEvent } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
KpiStatGrid,
PageHeader,
PageShell,
ResourcePage,
} from '@/components/reui-kit'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import {
DataGridMutedCell,
@@ -18,10 +43,15 @@ import {
import { StatusBadge } from '@/components/status-badge'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
import {
computeFleetCounts,
fleetKpiCards,
} from '@/components/agents/agents-fleet-kpis'
import { agentsQueryOptions } from '@/queries'
import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
@@ -34,11 +64,11 @@ import {
import type { Agent } from '@evofw/shared'
/**
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Row icon + copyable install: https://reui.io/preview/base/settings-14 · stats-12
* Empty: https://reui.io/preview/base/empty-state-7
* Agents ops console — Solutions Agents DNA.
* Preview: https://reui.io/preview/base/solution-agents-1 · stats-12 · data-grid-filtering-2
* Pending inbox DNA: https://reui.io/preview/base/solution-agents-5 (Frame adapt)
*/
export const Route = createFileRoute('/_auth/agents/')({
component: AgentsPage,
})
@@ -63,6 +93,21 @@ function AgentsPage() {
onError: (e: Error) => toast.error(e.message),
})
const approveAllPending = useMutation({
mutationFn: async (ids: string[]) => {
await Promise.all(
ids.map((id) =>
apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
),
)
},
onSuccess: () => {
toast.success('Все pending одобрены')
void qc.invalidateQueries({ queryKey: ['agents'] })
},
onError: (e: Error) => toast.error(e.message),
})
const remove = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
@@ -75,6 +120,30 @@ function AgentsPage() {
})
const items = agentsQ.data?.items ?? []
const counts = useMemo(() => computeFleetCounts(items), [items])
const pendingIds = useMemo(
() => items.filter((a) => a.status === 'pending').map((a) => a.id),
[items],
)
const kpiCards = useMemo(
() =>
fleetKpiCards(counts, {
pending: <Inbox aria-hidden />,
invited: <UserPlus aria-hidden />,
approved: <CheckCircle2 aria-hidden />,
stale: <WifiOff aria-hidden />,
}).map((card) => ({
...card,
onSelect: () => {
if (card.id === 'pending') setActiveTab('pending')
else if (card.id === 'invited') setActiveTab('invited')
else if (card.id === 'approved') setActiveTab('approved')
else if (card.id === 'stale') setActiveTab('approved')
},
})),
[counts],
)
const filterFields: FilterFieldConfig[] = useMemo(
() => [
@@ -158,6 +227,31 @@ function AgentsPage() {
),
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'apply',
accessorFn: (row) => row.last_apply_status ?? '',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Apply" />
),
cell: ({ row }) => {
const a = row.original
if (a.last_apply_error) {
return (
<Badge variant="destructive-light" size="sm">
error
</Badge>
)
}
if (!a.last_apply_status && !a.last_apply_at) {
return <DataGridMutedCell></DataGridMutedCell>
}
return (
<Badge variant="secondary" size="sm">
{a.last_apply_status ?? 'ok'}
</Badge>
)
},
},
{
id: 'install',
enableSorting: false,
@@ -269,12 +363,59 @@ function AgentsPage() {
<PageShell>
<PageHeader
title="Агенты"
description="Linux / MikroTik — invite, install, approve"
description="Ops console: invite, install, approve, apply throughput"
actions={addButton}
/>
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
{counts.pending > 0 ? (
<Frame dense spacing="sm">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Attention pending approve</FrameTitle>
<FrameDescription>
{counts.pending} агент(ов) ждут одобрения (DNA approval inbox)
</FrameDescription>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setActiveTab('pending')}
>
Показать
</Button>
<Button
size="sm"
disabled={
approveAllPending.isPending || pendingIds.length === 0
}
onClick={() => approveAllPending.mutate(pendingIds)}
>
<Check data-icon="inline-start" />
Approve all
</Button>
</div>
</FrameHeader>
</Frame>
) : null}
{counts.applyErrors > 0 ? (
<Alert variant="warning">
<CircleAlertIcon />
<AlertTitle>Ошибки apply</AlertTitle>
<AlertDescription>
У {counts.applyErrors} агент(ов) есть last_apply_error откройте
карточку для деталей.
</AlertDescription>
</Alert>
) : null}
<AgentsFleetChart />
<ResourcePage
title="Клиенты"
title="Очередь агентов"
hideHeader
data={items}
columns={columns}