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
+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}