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: ( +