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:
@@ -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>
|
||||
|
||||
@@ -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