diff --git a/apps/web/src/components/agents/agent-card.tsx b/apps/web/src/components/agents/agent-card.tsx
new file mode 100644
index 0000000..9d202d8
--- /dev/null
+++ b/apps/web/src/components/agents/agent-card.tsx
@@ -0,0 +1,174 @@
+import type { Agent } from '@evofw/shared'
+import {
+ AgentPlatformIcon,
+ platformLabel,
+} from '@/components/agents/agent-platform-icon'
+import { StatusBadge } from '@/components/status-badge'
+import { Badge } from '@/components/reui/badge'
+import { Frame, FramePanel } from '@/components/reui/frame'
+import {
+ Item,
+ ItemContent,
+ ItemDescription,
+ ItemTitle,
+} from '@evofw/ui/components/item'
+import { Separator } from '@evofw/ui/components/separator'
+import { cn } from '@evofw/ui/lib/utils'
+
+/**
+ * Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
+ * Preview: https://reui.io/preview/base/card-3 · https://reui.io/preview/base/stats-12
+ * Reference: apps/web/src/components/blocks/card-3/components/investor-card.tsx
+ */
+
+const packetFmt = new Intl.NumberFormat('ru-RU', {
+ notation: 'compact',
+ maximumFractionDigits: 1,
+})
+
+const seenFmt = new Intl.DateTimeFormat('ru-RU', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+})
+
+function formatPackets(n: number | undefined, hasApply: boolean): string {
+ if (!hasApply || n === undefined) return '—'
+ return packetFmt.format(n)
+}
+
+function formatShort(iso: string | null | undefined): string {
+ if (!iso) return '—'
+ const t = Date.parse(iso)
+ if (Number.isNaN(t)) return '—'
+ return seenFmt.format(t)
+}
+
+type AgentCardProps = {
+ agent: Agent
+ selected?: boolean
+ onSelect: (id: string) => void
+}
+
+export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
+ const hasApply = Boolean(agent.last_apply_at || agent.last_apply_status)
+ const dropped = formatPackets(agent.last_apply_packets_dropped, hasApply)
+ const accepted = formatPackets(agent.last_apply_packets_accepted, hasApply)
+ const seen = formatShort(agent.last_seen_at ?? agent.last_apply_at)
+ const defaultAction =
+ agent.default_action === 'drop' ? 'Drop' : 'Accept'
+ const subtitle = [
+ agent.hostname,
+ platformLabel(agent.platform),
+ `gen ${agent.policy_generation}`,
+ ]
+ .filter(Boolean)
+ .join(' · ')
+
+ const stats = [
+ {
+ label: 'Dropped',
+ value: dropped,
+ valueClass: dropped === '—' ? undefined : 'text-warning',
+ },
+ {
+ label: 'Accepted',
+ value: accepted,
+ valueClass: accepted === '—' ? undefined : 'text-success',
+ },
+ {
+ label: 'Seen',
+ value: seen,
+ valueClass: 'text-muted-foreground',
+ },
+ ] as const
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/agents/agent-cards-grid.tsx b/apps/web/src/components/agents/agent-cards-grid.tsx
new file mode 100644
index 0000000..d9ec073
--- /dev/null
+++ b/apps/web/src/components/agents/agent-cards-grid.tsx
@@ -0,0 +1,65 @@
+import type { Agent } from '@evofw/shared'
+import { AgentCard } from '@/components/agents/agent-card'
+import { EmptyState } from '@/components/empty-state'
+import { Skeleton } from '@evofw/ui/components/skeleton'
+import type { ReactNode } from 'react'
+
+/**
+ * Agent catalog grid.
+ * Preview DNA: https://reui.io/preview/base/card-3 · solution-agents-1
+ */
+
+type AgentCardsGridProps = {
+ agents: Agent[]
+ selectedId?: string | null
+ onSelect: (id: string) => void
+ isLoading?: boolean
+ emptyTitle?: string
+ emptyDescription?: string
+ emptyAction?: ReactNode
+}
+
+export function AgentCardsGrid({
+ agents,
+ selectedId,
+ onSelect,
+ isLoading,
+ emptyTitle = 'Нет агентов',
+ emptyDescription,
+ emptyAction,
+}: AgentCardsGridProps) {
+ if (isLoading) {
+ return (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ )
+ }
+
+ if (agents.length === 0) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {agents.map((agent) => (
+
+ ))}
+
+ )
+}
diff --git a/apps/web/src/components/agents/agent-detail-sheet.tsx b/apps/web/src/components/agents/agent-detail-sheet.tsx
new file mode 100644
index 0000000..7f3bc3a
--- /dev/null
+++ b/apps/web/src/components/agents/agent-detail-sheet.tsx
@@ -0,0 +1,323 @@
+import { Link } from '@tanstack/react-router'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { toast } from 'sonner'
+import { Check, CircleAlertIcon, Copy, ShieldOff } from 'lucide-react'
+import type { Agent } from '@evofw/shared'
+import {
+ agentPreviewQueryOptions,
+ agentsQueryOptions,
+} from '@/queries'
+import { apiFetch } from '@/lib/api'
+import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
+import {
+ AgentPlatformIcon,
+ platformLabel,
+} from '@/components/agents/agent-platform-icon'
+import { StatusBadge } from '@/components/status-badge'
+import { DetailPanel } from '@/components/reui-kit'
+import { Badge } from '@/components/reui/badge'
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+} from '@/components/reui/alert'
+import { Button } from '@evofw/ui/components/button'
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from '@evofw/ui/components/sheet'
+import { Skeleton } from '@evofw/ui/components/skeleton'
+
+/**
+ * Agent preview sheet — VPS topology detail DNA, wider for policy summary.
+ * Preview: https://reui.io/preview/base/components/c-sheet-1
+ * DNA: vps-tracker VpsDetailSheet
+ */
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
+
+const packetFmt = new Intl.NumberFormat('ru-RU')
+
+function formatWhen(iso?: string | null): string {
+ if (!iso) return '—'
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return iso
+ return d.toLocaleString('ru-RU')
+}
+
+type AgentDetailSheetProps = {
+ agentId: string | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+export function AgentDetailSheet({
+ agentId,
+ open,
+ onOpenChange,
+}: AgentDetailSheetProps) {
+ const qc = useQueryClient()
+ const { copyToClipboard } = useCopyToClipboard()
+ const agentsQ = useQuery({
+ ...agentsQueryOptions(),
+ enabled: open,
+ })
+ const agent = (agentsQ.data?.items ?? []).find((a) => a.id === agentId) as
+ | Agent
+ | undefined
+
+ const previewQ = useQuery({
+ ...agentPreviewQueryOptions(agentId ?? ''),
+ enabled: open && Boolean(agentId),
+ })
+
+ const approve = useMutation({
+ mutationFn: () =>
+ apiFetch(`/api/v1/agents/${agentId}/approve`, { method: 'POST' }),
+ onSuccess: () => {
+ toast.success('Агент одобрен')
+ void qc.invalidateQueries({ queryKey: ['agents'] })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const revoke = useMutation({
+ mutationFn: () =>
+ apiFetch(`/api/v1/agents/${agentId}/revoke`, { method: 'POST' }),
+ onSuccess: () => {
+ toast.success('Агент отозван')
+ void qc.invalidateQueries({ queryKey: ['agents'] })
+ },
+ onError: (e: Error) => toast.error(e.message),
+ })
+
+ const subtitle = agent
+ ? [
+ agent.hostname,
+ platformLabel(agent.platform),
+ `gen ${agent.policy_generation}`,
+ ]
+ .filter(Boolean)
+ .join(' · ')
+ : 'Агент не найден'
+
+ return (
+
+
+
+
+ {agent ? (
+
+ ) : null}
+
+
+ {agent?.name ?? 'Агент'}
+
+ {subtitle}
+
+
+
+
+ {!agentId || agentsQ.isLoading ? (
+
+
+
+
+ ) : !agent ? (
+
+ Агент удалён или недоступен.
+
+ ) : (
+
+
+
+
+ {agent.default_action === 'drop' ? 'Drop' : 'Accept'}
+
+
+
+ {agent.last_apply_error ? (
+
+
+ Ошибка apply
+ {agent.last_apply_error}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
|
+ {previewQ.isLoading ? (
+
+
+
+ ) : previewQ.data ? (
+ <>
+
|
+
|
+
|
+
|
+
|
+ >
+ ) : (
+
|
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {agent.status === 'pending' ? (
+
+ ) : null}
+ {agent.status === 'approved' ? (
+
+ ) : null}
+ {agent.install_curl ? (
+
+ ) : null}
+
+
+ }
+ >
+ Открыть карточку агента
+
+
+
+ )}
+
+
+ )
+}
diff --git a/apps/web/src/components/blocks/card-3/components/data.tsx b/apps/web/src/components/blocks/card-3/components/data.tsx
new file mode 100644
index 0000000..5d4fb16
--- /dev/null
+++ b/apps/web/src/components/blocks/card-3/components/data.tsx
@@ -0,0 +1,64 @@
+import { type ReactNode } from "react"
+import { BarChart3Icon, WalletIcon, CircleDollarSignIcon } from "lucide-react"
+
+export interface StatItem {
+ value: string
+ label: string
+}
+
+export interface FundingSource {
+ name: string
+ amount: string
+ icon: ReactNode
+ tileClassName: string
+}
+
+export const PROFILE = {
+ name: "Mara Alves",
+ status: "Open to Proposals",
+ email: "malves@reui-capital.io",
+ avatarSrc:
+ "https://images.unsplash.com/photo-1584308972272-9e4e7685e80f?w=160&h=160&dpr=2&q=80",
+}
+
+export const STATS: StatItem[] = [
+ {
+ value: "87",
+ label: "Deals",
+ },
+ {
+ value: "$7.2M",
+ label: "Avg. Ticket",
+ },
+ {
+ value: "$415M",
+ label: "Total Fund",
+ },
+]
+
+export const FUNDING_SOURCES: FundingSource[] = [
+ {
+ name: "Northline Ventures",
+ amount: "$7,840,000",
+ tileClassName: "bg-invert text-invert-foreground",
+ icon: (
+
+ ),
+ },
+ {
+ name: "Bluepeak Capital",
+ amount: "$3,260,000",
+ tileClassName: "bg-[oklch(0.54_0.25_292)] text-[oklch(0.98_0.01_292)]",
+ icon: (
+
+ ),
+ },
+ {
+ name: "Everfield Equity",
+ amount: "$1,180,000",
+ tileClassName: "bg-[oklch(0.78_0.18_79)] text-[oklch(0.99_0.01_88)]",
+ icon: (
+
+ ),
+ },
+]
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/card-3/components/investor-card.tsx b/apps/web/src/components/blocks/card-3/components/investor-card.tsx
new file mode 100644
index 0000000..a39f782
--- /dev/null
+++ b/apps/web/src/components/blocks/card-3/components/investor-card.tsx
@@ -0,0 +1,150 @@
+import { type ReactNode } from "react"
+import { Badge } from "@/components/reui/badge"
+import { Frame, FramePanel } from "@/components/reui/frame"
+
+import { cn } from "@evofw/ui/lib/utils"
+import { AspectRatio } from "@evofw/ui/components/aspect-ratio"
+import { Button } from "@evofw/ui/components/button"
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemGroup,
+ ItemMedia,
+ ItemSeparator,
+ ItemTitle,
+} from "@evofw/ui/components/item"
+import { Separator } from "@evofw/ui/components/separator"
+import { FUNDING_SOURCES, PROFILE, STATS } from "./data"
+import { CircleCheckIcon, MessageSquareIcon } from "lucide-react"
+
+function FundingIcon({
+ className,
+ children,
+}: {
+ className: string
+ children: ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+export function InvestorCard() {
+ return (
+
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+ {PROFILE.name}
+
+
+
+ {PROFILE.status}
+
+
+ {PROFILE.email}
+
+
+
+
+
+ {/* Stats */}
+
+ {STATS.map((stat, index) => (
+
+ -
+
+
+ {stat.value}
+
+
+ {stat.label}
+
+
+
+ {index < STATS.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
+
+ {/* Funding Sources */}
+
+ {FUNDING_SOURCES.map((source, index) => (
+
+ -
+
+
+ {source.icon}
+
+
+
+
+
+
+ {source.name}
+
+
+
+
+
+
+ {source.amount}
+
+
+
+ {index < FUNDING_SOURCES.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
+
+ {/* Action */}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/card-3/page.tsx b/apps/web/src/components/blocks/card-3/page.tsx
new file mode 100644
index 0000000..73966b2
--- /dev/null
+++ b/apps/web/src/components/blocks/card-3/page.tsx
@@ -0,0 +1,9 @@
+import { InvestorCard } from "./components/investor-card"
+
+export function Page() {
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/reui/frame.tsx b/apps/web/src/components/reui/frame.tsx
index 9a2055a..86485c3 100644
--- a/apps/web/src/components/reui/frame.tsx
+++ b/apps/web/src/components/reui/frame.tsx
@@ -34,12 +34,20 @@ const frameVariants = cva(
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
ghost: "",
},
+ // Header/footer vertical rhythm is tighter than the panel body's, and
+ // the gap widens as the frame grows: the bars read as chrome rather than
+ // as another content block. py ladder is 0.5 / 1.5 / 2 / 2.5 against a
+ // body py of 2 / 3.5 / 4 / 5. These vars are style-agnostic - no
+ // style-*.css overrides them - so this single ladder drives all shadcn
+ // styles. `px` is deliberately left level with the body so header,
+ // content and footer stay left-aligned. `xs` holds at 0.5 (2px): it is
+ // the practical floor, since anything lower stops reading as padding.
spacing: {
- xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
- sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
+ xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
+ sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
default:
- "[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
- lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
+ "[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
+ lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
},
stacked: {
true: [
diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx
index 50d4f80..62fd6c3 100644
--- a/apps/web/src/routes/_auth/agents/index.tsx
+++ b/apps/web/src/routes/_auth/agents/index.tsx
@@ -1,29 +1,28 @@
-import { createFileRoute, useNavigate } from '@tanstack/react-router'
+import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
Check,
CheckCircle2,
CircleAlertIcon,
- Copy,
+ FilterIcon,
Inbox,
ListIcon,
- Pencil,
Plus,
+ SearchIcon,
ShieldIcon,
- Trash2,
UserPlus,
WifiOff,
} from 'lucide-react'
-import { useCallback, useMemo, useState, type MouseEvent } from 'react'
-import type { ColumnDef } from '@tanstack/react-table'
+import { useCallback, useMemo, useState } from 'react'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
+import { Filters } from '@/components/reui/filters'
import {
+ applyFiltersToData,
KpiStatGrid,
PageHeader,
PageShell,
QuickActionGrid,
- ResourcePage,
} from '@/components/reui-kit'
import {
Frame,
@@ -32,94 +31,56 @@ import {
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,
- DataGridPrimaryCell,
-} from '@/components/data-grid-cell'
-import { StatusBadge } from '@/components/status-badge'
-import { ConfirmDialog } from '@/components/confirm-dialog'
+import { CountedLineTabs } from '@/components/counted-line-tabs'
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 { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
+import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
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'
import { Button } from '@evofw/ui/components/button'
import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from '@evofw/ui/components/tooltip'
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from '@evofw/ui/components/input-group'
+import { Separator } from '@evofw/ui/components/separator'
import type { Agent } from '@evofw/shared'
-const packetFmt = new Intl.NumberFormat('ru-RU', {
- notation: 'compact',
- maximumFractionDigits: 1,
-})
-
-function formatPackets(n: number | undefined, hasApply: boolean): string {
- if (!hasApply || n === undefined) return '—'
- return packetFmt.format(n)
-}
-
-const seenFmt = new Intl.DateTimeFormat('ru-RU', {
- day: '2-digit',
- month: '2-digit',
- hour: '2-digit',
- minute: '2-digit',
-})
-
-function formatAgentSeen(iso: string | null | undefined): string {
- if (!iso) return '—'
- const t = Date.parse(iso)
- if (Number.isNaN(t)) return '—'
- return seenFmt.format(t)
-}
-
/**
- * 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)
+ * Agents ops console — card catalog + detail Sheet.
+ * Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12 · c-sheet-1
*/
export const Route = createFileRoute('/_auth/agents/')({
component: AgentsPage,
})
+const AGENT_TABS = [
+ { id: 'all', label: 'Все' },
+ { id: 'invited', label: 'Invited' },
+ { id: 'pending', label: 'Pending' },
+ { id: 'approved', label: 'Approved' },
+ { id: 'revoked', label: 'Revoked' },
+] as const
+
function AgentsPage() {
- const navigate = useNavigate()
const qc = useQueryClient()
const agentsQ = useQuery(agentsQueryOptions())
- const { copyToClipboard } = useCopyToClipboard()
const [createOpen, setCreateOpen] = useState(false)
const [filters, setFilters] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('all')
- const [deleteId, setDeleteId] = useState(null)
-
- const approve = useMutation({
- mutationFn: (id: string) =>
- apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }),
- onSuccess: () => {
- toast.success('Агент одобрен')
- void qc.invalidateQueries({ queryKey: ['agents'] })
- },
- onError: (e: Error) => toast.error(e.message),
- })
+ const [detailAgentId, setDetailAgentId] = useState(null)
+ const [detailOpen, setDetailOpen] = useState(false)
const approveAllPending = useMutation({
mutationFn: async (ids: string[]) => {
@@ -136,17 +97,6 @@ function AgentsPage() {
onError: (e: Error) => toast.error(e.message),
})
- const remove = useMutation({
- mutationFn: (id: string) =>
- apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }),
- onSuccess: () => {
- toast.success('Удалён')
- setDeleteId(null)
- void qc.invalidateQueries({ queryKey: ['agents'] })
- },
- onError: (e: Error) => toast.error(e.message),
- })
-
const items = agentsQ.data?.items ?? []
const counts = useMemo(() => computeFleetCounts(items), [items])
const pendingIds = useMemo(
@@ -212,12 +162,19 @@ function AgentsPage() {
return undefined
}, [])
- const getSearchText = useCallback(
- (item: Agent) =>
- [item.name, item.hostname ?? '', item.last_seen_ip ?? '']
- .filter(Boolean)
- .join(' '),
- [],
+ const applySearch = useCallback(
+ (data: Agent[]) => {
+ const q = searchQuery.trim().toLowerCase()
+ if (!q) return data
+ return data.filter((item) =>
+ [item.name, item.hostname ?? '', item.last_seen_ip ?? '']
+ .filter(Boolean)
+ .join(' ')
+ .toLowerCase()
+ .includes(q),
+ )
+ },
+ [searchQuery],
)
const tabFilter = useCallback((item: Agent, tabId: string) => {
@@ -225,6 +182,41 @@ function AgentsPage() {
return item.status === tabId
}, [])
+ const filteredItems = useMemo(() => {
+ let result = applySearch(items)
+ result = applyFiltersToData(result, filters, getFilterFieldValue)
+ if (activeTab !== 'all') {
+ result = result.filter((item) => tabFilter(item, activeTab))
+ }
+ return result
+ }, [items, applySearch, filters, getFilterFieldValue, activeTab, tabFilter])
+
+ const tabCounts = useMemo(() => {
+ const base = applyFiltersToData(
+ applySearch(items),
+ filters,
+ getFilterFieldValue,
+ )
+ const next: Record = {}
+ for (const tab of AGENT_TABS) {
+ next[tab.id] =
+ tab.id === 'all'
+ ? base.length
+ : base.filter((item) => tabFilter(item, tab.id)).length
+ }
+ return next
+ }, [items, applySearch, filters, getFilterFieldValue, tabFilter])
+
+ const countedTabs = useMemo(
+ () =>
+ AGENT_TABS.map((tab) => ({
+ id: tab.id,
+ label: tab.label,
+ count: tabCounts[tab.id] ?? 0,
+ })),
+ [tabCounts],
+ )
+
const quickActions = useMemo(
() => [
{
@@ -265,267 +257,15 @@ function AgentsPage() {
[counts.pending],
)
- const handleCopyCurl = useCallback(
- (curl: string, e?: MouseEvent) => {
- e?.stopPropagation()
- if (!curl) return
- copyToClipboard(curl)
- toast.success('Скопировано')
- },
- [copyToClipboard],
- )
+ const handleSelectAgent = useCallback((id: string) => {
+ setDetailAgentId(id)
+ setDetailOpen(true)
+ }, [])
- const columns: ColumnDef[] = useMemo(
- () => [
- {
- accessorKey: 'name',
- size: 280,
- minSize: 180,
- maxSize: 480,
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {
- const a = row.original
- return (
-
- )
- },
- },
- {
- accessorKey: 'status',
- size: 110,
- minSize: 100,
- maxSize: 130,
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => ,
- },
- {
- id: 'apply',
- size: 90,
- minSize: 80,
- maxSize: 110,
- accessorFn: (row) => row.last_apply_status ?? '',
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {
- const a = row.original
- if (a.last_apply_error) {
- return (
-
- error
-
- )
- }
- if (!a.last_apply_status && !a.last_apply_at) {
- return —
- }
- return (
-
- {a.last_apply_status ?? 'ok'}
-
- )
- },
- },
- {
- id: 'dropped',
- size: 90,
- minSize: 80,
- maxSize: 110,
- accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {
- const a = row.original
- const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
- const text = formatPackets(a.last_apply_packets_dropped, hasApply)
- if (text === '—') {
- return —
- }
- return (
-
-
- }
- >
- {text}
-
-
- Dropped с последнего apply
- {a.last_apply_at ? ` · ${a.last_apply_at}` : ''}
-
-
- )
- },
- },
- {
- id: 'accepted',
- size: 90,
- minSize: 80,
- maxSize: 110,
- accessorFn: (row) => row.last_apply_packets_accepted ?? -1,
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {
- const a = row.original
- const hasApply = Boolean(a.last_apply_at || a.last_apply_status)
- const text = formatPackets(a.last_apply_packets_accepted, hasApply)
- if (text === '—') {
- return —
- }
- return (
-
-
- }
- >
- {text}
-
-
- Accepted с последнего apply
- {a.last_apply_at ? ` · ${a.last_apply_at}` : ''}
-
-
- )
- },
- },
- {
- id: 'install',
- size: 100,
- minSize: 90,
- maxSize: 120,
- enableSorting: false,
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {
- const curl = row.original.install_curl
- if (!curl) {
- return —
- }
- return (
-
- handleCopyCurl(curl, e)}
- />
- }
- >
-
- curl
-
-
- {curl}
-
-
- )
- },
- },
- {
- accessorKey: 'last_seen_at',
- size: 130,
- minSize: 110,
- maxSize: 160,
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => {
- const iso = row.original.last_seen_at
- const short = formatAgentSeen(iso)
- if (!iso || short === '—') {
- return —
- }
- return (
-
-
- }
- >
- {short}
-
- {iso}
-
- )
- },
- },
- {
- id: 'actions',
- size: 108,
- minSize: 108,
- maxSize: 108,
- enableSorting: false,
- enableResizing: false,
- header: () => Действия,
- cell: ({ row }) => {
- const a = row.original
- return (
-
- {a.status === 'pending' ? (
-
- ) : null}
-
-
-
- )
- },
- },
- ],
- [approve, handleCopyCurl, navigate],
- )
+ const handleClearFilters = useCallback(() => {
+ setFilters([])
+ setSearchQuery('')
+ }, [])
const addButton = (