feat(web): Agents ops console по ReUI Solutions Agents
KPI/Chart/Attention на списке, Stepper wizard, Timeline и Install на detail; breadcrumb loader. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user