feat(web): refine spacing and layout in frame component and agents page
- Enhanced vertical rhythm in the frame component by adjusting padding values for header, content, and footer, ensuring consistent alignment and improved readability. - Updated the agents page to incorporate a new tab structure for better organization of agent statuses, along with a refined search and filter functionality to enhance user experience. - Removed unused imports and streamlined the code for better maintainability and performance. These changes contribute to a more cohesive and user-friendly interface across the application.
This commit is contained in:
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(agent.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left outline-none',
|
||||||
|
'focus-visible:ring-ring rounded-[calc(var(--frame-radius)+2px)] focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Frame
|
||||||
|
spacing="xs"
|
||||||
|
className={cn(
|
||||||
|
'bg-muted/50 dark:bg-muted/10 w-full transition-shadow',
|
||||||
|
selected
|
||||||
|
? 'ring-primary/30 ring-2'
|
||||||
|
: 'hover:ring-border hover:ring-1',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FramePanel className="text-card-foreground isolate flex flex-col gap-4 px-4 py-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AgentPlatformIcon platform={agent.platform} />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="truncate text-sm leading-tight font-semibold">
|
||||||
|
{agent.name}
|
||||||
|
</h3>
|
||||||
|
<StatusBadge status={agent.status} />
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
agent.default_action === 'drop'
|
||||||
|
? 'warning-light'
|
||||||
|
: 'success-light'
|
||||||
|
}
|
||||||
|
size="xs"
|
||||||
|
radius="full"
|
||||||
|
>
|
||||||
|
{defaultAction}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/60 grid grid-cols-[1fr_auto_1fr_auto_1fr] overflow-hidden rounded-lg border">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<div key={stat.label} className="contents">
|
||||||
|
<Item
|
||||||
|
variant="muted"
|
||||||
|
size="sm"
|
||||||
|
className="justify-center bg-transparent px-2 py-3"
|
||||||
|
>
|
||||||
|
<ItemContent className="items-center gap-1">
|
||||||
|
<ItemTitle
|
||||||
|
className={cn(
|
||||||
|
'text-sm leading-none font-medium tabular-nums',
|
||||||
|
stat.valueClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{stat.value}
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className="line-clamp-1 text-xs leading-tight">
|
||||||
|
{stat.label}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
{index < stats.length - 1 ? (
|
||||||
|
<Separator orientation="vertical" className="my-2.5" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agent.last_apply_error ? (
|
||||||
|
<p className="text-destructive truncate text-xs">
|
||||||
|
{agent.last_apply_error}
|
||||||
|
</p>
|
||||||
|
) : agent.last_apply_kernel_method ? (
|
||||||
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
|
kernel · {agent.last_apply_kernel_method}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-44 w-full rounded-xl" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agents.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={emptyTitle}
|
||||||
|
description={emptyDescription}
|
||||||
|
action={emptyAction}
|
||||||
|
centered={false}
|
||||||
|
className="py-12"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<AgentCard
|
||||||
|
key={agent.id}
|
||||||
|
agent={agent}
|
||||||
|
selected={selectedId === agent.id}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="border-border/60 flex items-baseline justify-between gap-3 border-b py-2 last:border-0">
|
||||||
|
<span className="text-muted-foreground text-xs">{label}</span>
|
||||||
|
<span className="text-right text-sm break-all">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-2xl"
|
||||||
|
>
|
||||||
|
<SheetHeader className="border-border shrink-0 border-b px-4 py-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{agent ? (
|
||||||
|
<AgentPlatformIcon platform={agent.platform} />
|
||||||
|
) : null}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
|
<SheetTitle className="truncate">
|
||||||
|
{agent?.name ?? 'Агент'}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>{subtitle}</SheetDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{!agentId || agentsQ.isLoading ? (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<Skeleton className="h-8 w-40" />
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</div>
|
||||||
|
) : !agent ? (
|
||||||
|
<p className="text-muted-foreground p-4 text-sm">
|
||||||
|
Агент удалён или недоступен.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<StatusBadge status={agent.status} />
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
agent.default_action === 'drop'
|
||||||
|
? 'warning-light'
|
||||||
|
: 'success-light'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
radius="full"
|
||||||
|
>
|
||||||
|
{agent.default_action === 'drop' ? 'Drop' : 'Accept'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agent.last_apply_error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlertIcon />
|
||||||
|
<AlertTitle>Ошибка apply</AlertTitle>
|
||||||
|
<AlertDescription>{agent.last_apply_error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DetailPanel>
|
||||||
|
<DetailPanel.Section title="Apply">
|
||||||
|
<div className="border-border rounded-lg border px-3">
|
||||||
|
<Row
|
||||||
|
label="Last apply"
|
||||||
|
value={formatWhen(agent.last_apply_at)}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Status"
|
||||||
|
value={agent.last_apply_status ?? '—'}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Kernel"
|
||||||
|
value={agent.last_apply_kernel_method ?? '—'}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Dropped"
|
||||||
|
value={
|
||||||
|
agent.last_apply_at
|
||||||
|
? packetFmt.format(
|
||||||
|
agent.last_apply_packets_dropped ?? 0,
|
||||||
|
)
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Accepted"
|
||||||
|
value={
|
||||||
|
agent.last_apply_at
|
||||||
|
? packetFmt.format(
|
||||||
|
agent.last_apply_packets_accepted ?? 0,
|
||||||
|
)
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DetailPanel.Section>
|
||||||
|
|
||||||
|
<DetailPanel.Section title="Политика">
|
||||||
|
<div className="border-border rounded-lg border px-3">
|
||||||
|
<Row
|
||||||
|
label="Если не совпало"
|
||||||
|
value={
|
||||||
|
agent.default_action === 'drop' ? 'Drop' : 'Accept'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{previewQ.isLoading ? (
|
||||||
|
<div className="py-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</div>
|
||||||
|
) : previewQ.data ? (
|
||||||
|
<>
|
||||||
|
<Row
|
||||||
|
label="Наборы"
|
||||||
|
value={String(previewQ.data.summary.sets)}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="CIDR блок"
|
||||||
|
value={String(previewQ.data.summary.cidrs_deny)}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="CIDR accept"
|
||||||
|
value={String(previewQ.data.summary.cidrs_allow)}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Конфликты"
|
||||||
|
value={String(
|
||||||
|
previewQ.data.summary.conflicts_dropped,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Apply version"
|
||||||
|
value={String(previewQ.data.apply_version)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Row label="Preview" value="недоступен" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DetailPanel.Section>
|
||||||
|
|
||||||
|
<DetailPanel.Section title="Identity">
|
||||||
|
<div className="border-border rounded-lg border px-3">
|
||||||
|
<Row label="Hostname" value={agent.hostname ?? '—'} />
|
||||||
|
<Row label="Last seen IP" value={agent.last_seen_ip ?? '—'} />
|
||||||
|
<Row
|
||||||
|
label="Last seen"
|
||||||
|
value={formatWhen(agent.last_seen_at)}
|
||||||
|
/>
|
||||||
|
<Row
|
||||||
|
label="Client"
|
||||||
|
value={agent.client_version ?? '—'}
|
||||||
|
/>
|
||||||
|
<Row label="Token" value={agent.token_prefix} />
|
||||||
|
<Row label="Created" value={formatWhen(agent.created_at)} />
|
||||||
|
<Row
|
||||||
|
label="Approved"
|
||||||
|
value={formatWhen(agent.approved_at)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DetailPanel.Section>
|
||||||
|
</DetailPanel>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{agent.status === 'pending' ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={approve.isPending}
|
||||||
|
onClick={() => approve.mutate()}
|
||||||
|
>
|
||||||
|
<Check data-icon="inline-start" />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{agent.status === 'approved' ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={revoke.isPending}
|
||||||
|
onClick={() => revoke.mutate()}
|
||||||
|
>
|
||||||
|
<ShieldOff data-icon="inline-start" />
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{agent.install_curl ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
copyToClipboard(agent.install_curl!)
|
||||||
|
toast.success('Скопировано')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy data-icon="inline-start" />
|
||||||
|
Install
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="w-full"
|
||||||
|
render={
|
||||||
|
<Link to="/agents/$id" params={{ id: agent.id }} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Открыть карточку агента
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: "[email protected]",
|
||||||
|
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: (
|
||||||
|
<BarChart3Icon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bluepeak Capital",
|
||||||
|
amount: "$3,260,000",
|
||||||
|
tileClassName: "bg-[oklch(0.54_0.25_292)] text-[oklch(0.98_0.01_292)]",
|
||||||
|
icon: (
|
||||||
|
<WalletIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Everfield Equity",
|
||||||
|
amount: "$1,180,000",
|
||||||
|
tileClassName: "bg-[oklch(0.78_0.18_79)] text-[oklch(0.99_0.01_88)]",
|
||||||
|
icon: (
|
||||||
|
<CircleDollarSignIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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 (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"border-background flex size-8 shrink-0 items-center justify-center rounded-lg border-2 shadow-[0_1px_3px_0_oklch(0.2_0.01_260/0.16)] [&_svg]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InvestorCard() {
|
||||||
|
return (
|
||||||
|
<Frame
|
||||||
|
spacing="xs"
|
||||||
|
className="bg-muted/50 dark:bg-muted/10 w-full max-w-[408px]"
|
||||||
|
>
|
||||||
|
<FramePanel className="text-card-foreground isolate px-4 py-4 sm:px-5">
|
||||||
|
<div className="relative z-10 flex flex-col gap-5">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="relative isolate overflow-visible">
|
||||||
|
<div className="relative z-10 flex items-center gap-5">
|
||||||
|
<AspectRatio
|
||||||
|
ratio={1}
|
||||||
|
className="border-background bg-muted/60 size-20 shrink-0 overflow-hidden rounded-lg border-4 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={PROFILE.avatarSrc}
|
||||||
|
alt={PROFILE.name}
|
||||||
|
className="size-full rounded-lg object-cover object-top"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
|
<h2 className="truncate text-sm leading-tight font-semibold">
|
||||||
|
{PROFILE.name}
|
||||||
|
</h2>
|
||||||
|
<Badge variant="success-light" radius="full">
|
||||||
|
<CircleCheckIcon aria-hidden="true" />
|
||||||
|
{PROFILE.status}
|
||||||
|
</Badge>
|
||||||
|
<p className="text-muted-foreground truncate text-xs">
|
||||||
|
{PROFILE.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="bg-muted/60 grid grid-cols-[1fr_auto_1fr_auto_1fr] overflow-hidden rounded-lg border">
|
||||||
|
{STATS.map((stat, index) => (
|
||||||
|
<div key={stat.label} className="contents">
|
||||||
|
<Item
|
||||||
|
variant="muted"
|
||||||
|
size="sm"
|
||||||
|
className="justify-center bg-transparent px-2 py-3"
|
||||||
|
>
|
||||||
|
<ItemContent className="items-center gap-1">
|
||||||
|
<ItemTitle className="text-sm leading-none font-medium tabular-nums">
|
||||||
|
{stat.value}
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className="line-clamp-1 text-xs leading-tight">
|
||||||
|
{stat.label}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
{index < STATS.length - 1 ? (
|
||||||
|
<Separator orientation="vertical" className="my-2.5" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Funding Sources */}
|
||||||
|
<ItemGroup className="gap-0!">
|
||||||
|
{FUNDING_SOURCES.map((source, index) => (
|
||||||
|
<div key={source.name} className="contents">
|
||||||
|
<Item size="sm" className="flex-nowrap px-0 py-0">
|
||||||
|
<ItemMedia>
|
||||||
|
<FundingIcon className={source.tileClassName}>
|
||||||
|
{source.icon}
|
||||||
|
</FundingIcon>
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
|
<ItemContent className="min-w-0">
|
||||||
|
<ItemTitle className="w-full min-w-0">
|
||||||
|
<span className="truncate text-sm leading-5 font-medium">
|
||||||
|
{source.name}
|
||||||
|
</span>
|
||||||
|
</ItemTitle>
|
||||||
|
</ItemContent>
|
||||||
|
|
||||||
|
<ItemActions className="shrink-0">
|
||||||
|
<span className="text-sm leading-5 font-medium tabular-nums">
|
||||||
|
{source.amount}
|
||||||
|
</span>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
{index < FUNDING_SOURCES.length - 1 ? (
|
||||||
|
<ItemSeparator className="my-2.5 border-t border-dashed bg-transparent" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="h-10! w-full"
|
||||||
|
>
|
||||||
|
<MessageSquareIcon aria-hidden="true" />
|
||||||
|
Send Funding Proposal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { InvestorCard } from "./components/investor-card"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh w-full items-center justify-center p-6">
|
||||||
|
<InvestorCard />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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",
|
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
||||||
ghost: "",
|
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: {
|
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)]",
|
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(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
|
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:
|
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)]",
|
"[--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(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (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(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
|
||||||
},
|
},
|
||||||
stacked: {
|
stacked: {
|
||||||
true: [
|
true: [
|
||||||
|
|||||||
@@ -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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CircleAlertIcon,
|
CircleAlertIcon,
|
||||||
Copy,
|
FilterIcon,
|
||||||
Inbox,
|
Inbox,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
Pencil,
|
|
||||||
Plus,
|
Plus,
|
||||||
|
SearchIcon,
|
||||||
ShieldIcon,
|
ShieldIcon,
|
||||||
Trash2,
|
|
||||||
UserPlus,
|
UserPlus,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useCallback, useMemo, useState, type MouseEvent } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
|
import { Filters } from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
|
applyFiltersToData,
|
||||||
KpiStatGrid,
|
KpiStatGrid,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageShell,
|
PageShell,
|
||||||
QuickActionGrid,
|
QuickActionGrid,
|
||||||
ResourcePage,
|
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
@@ -32,94 +31,56 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
AlertDescription,
|
AlertDescription,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
} from '@/components/reui/alert'
|
} from '@/components/reui/alert'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
import {
|
|
||||||
DataGridMutedCell,
|
|
||||||
DataGridPrimaryCell,
|
|
||||||
} from '@/components/data-grid-cell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||||
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
||||||
import {
|
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
|
||||||
AgentPlatformIcon,
|
|
||||||
platformLabel,
|
|
||||||
} from '@/components/agents/agent-platform-icon'
|
|
||||||
import {
|
import {
|
||||||
computeFleetCounts,
|
computeFleetCounts,
|
||||||
fleetKpiCards,
|
fleetKpiCards,
|
||||||
} from '@/components/agents/agents-fleet-kpis'
|
} from '@/components/agents/agents-fleet-kpis'
|
||||||
import { agentsQueryOptions } from '@/queries'
|
import { agentsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
InputGroup,
|
||||||
TooltipContent,
|
InputGroupAddon,
|
||||||
TooltipTrigger,
|
InputGroupInput,
|
||||||
} from '@evofw/ui/components/tooltip'
|
} from '@evofw/ui/components/input-group'
|
||||||
|
import { Separator } from '@evofw/ui/components/separator'
|
||||||
import type { Agent } from '@evofw/shared'
|
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.
|
* Agents ops console — card catalog + detail Sheet.
|
||||||
* Preview: https://reui.io/preview/base/solution-agents-1 · stats-12 · data-grid-filtering-2
|
* Preview: https://reui.io/preview/base/solution-agents-1 · card-3 · stats-12 · c-sheet-1
|
||||||
* Pending inbox DNA: https://reui.io/preview/base/solution-agents-5 (Frame adapt)
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/agents/')({
|
export const Route = createFileRoute('/_auth/agents/')({
|
||||||
component: AgentsPage,
|
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() {
|
function AgentsPage() {
|
||||||
const navigate = useNavigate()
|
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const agentsQ = useQuery(agentsQueryOptions())
|
const agentsQ = useQuery(agentsQueryOptions())
|
||||||
const { copyToClipboard } = useCopyToClipboard()
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [detailAgentId, setDetailAgentId] = useState<string | null>(null)
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
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 approveAllPending = useMutation({
|
const approveAllPending = useMutation({
|
||||||
mutationFn: async (ids: string[]) => {
|
mutationFn: async (ids: string[]) => {
|
||||||
@@ -136,17 +97,6 @@ function AgentsPage() {
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
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 items = agentsQ.data?.items ?? []
|
||||||
const counts = useMemo(() => computeFleetCounts(items), [items])
|
const counts = useMemo(() => computeFleetCounts(items), [items])
|
||||||
const pendingIds = useMemo(
|
const pendingIds = useMemo(
|
||||||
@@ -212,12 +162,19 @@ function AgentsPage() {
|
|||||||
return undefined
|
return undefined
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const getSearchText = useCallback(
|
const applySearch = useCallback(
|
||||||
(item: Agent) =>
|
(data: Agent[]) => {
|
||||||
[item.name, item.hostname ?? '', item.last_seen_ip ?? '']
|
const q = searchQuery.trim().toLowerCase()
|
||||||
.filter(Boolean)
|
if (!q) return data
|
||||||
.join(' '),
|
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) => {
|
const tabFilter = useCallback((item: Agent, tabId: string) => {
|
||||||
@@ -225,6 +182,41 @@ function AgentsPage() {
|
|||||||
return item.status === tabId
|
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<string, number> = {}
|
||||||
|
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(
|
const quickActions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -265,267 +257,15 @@ function AgentsPage() {
|
|||||||
[counts.pending],
|
[counts.pending],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleCopyCurl = useCallback(
|
const handleSelectAgent = useCallback((id: string) => {
|
||||||
(curl: string, e?: MouseEvent) => {
|
setDetailAgentId(id)
|
||||||
e?.stopPropagation()
|
setDetailOpen(true)
|
||||||
if (!curl) return
|
}, [])
|
||||||
copyToClipboard(curl)
|
|
||||||
toast.success('Скопировано')
|
|
||||||
},
|
|
||||||
[copyToClipboard],
|
|
||||||
)
|
|
||||||
|
|
||||||
const columns: ColumnDef<Agent>[] = useMemo(
|
const handleClearFilters = useCallback(() => {
|
||||||
() => [
|
setFilters([])
|
||||||
{
|
setSearchQuery('')
|
||||||
accessorKey: 'name',
|
}, [])
|
||||||
size: 280,
|
|
||||||
minSize: 180,
|
|
||||||
maxSize: 480,
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Имя" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const a = row.original
|
|
||||||
return (
|
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
|
||||||
<AgentPlatformIcon platform={a.platform} />
|
|
||||||
<DataGridPrimaryCell
|
|
||||||
accent="primary"
|
|
||||||
title={a.name}
|
|
||||||
subtitle={a.hostname ?? platformLabel(a.platform)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'status',
|
|
||||||
size: 110,
|
|
||||||
minSize: 100,
|
|
||||||
maxSize: 130,
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Статус" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apply',
|
|
||||||
size: 90,
|
|
||||||
minSize: 80,
|
|
||||||
maxSize: 110,
|
|
||||||
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: 'dropped',
|
|
||||||
size: 90,
|
|
||||||
minSize: 80,
|
|
||||||
maxSize: 110,
|
|
||||||
accessorFn: (row) => row.last_apply_packets_dropped ?? -1,
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Dropped" />
|
|
||||||
),
|
|
||||||
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 <DataGridMutedCell>—</DataGridMutedCell>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<span className="text-warning cursor-default tabular-nums text-sm font-medium" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
Dropped с последнего apply
|
|
||||||
{a.last_apply_at ? ` · ${a.last_apply_at}` : ''}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'accepted',
|
|
||||||
size: 90,
|
|
||||||
minSize: 80,
|
|
||||||
maxSize: 110,
|
|
||||||
accessorFn: (row) => row.last_apply_packets_accepted ?? -1,
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Accepted" />
|
|
||||||
),
|
|
||||||
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 <DataGridMutedCell>—</DataGridMutedCell>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<span className="text-success cursor-default tabular-nums text-sm font-medium" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
Accepted с последнего apply
|
|
||||||
{a.last_apply_at ? ` · ${a.last_apply_at}` : ''}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'install',
|
|
||||||
size: 100,
|
|
||||||
minSize: 90,
|
|
||||||
maxSize: 120,
|
|
||||||
enableSorting: false,
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Install" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const curl = row.original.install_curl
|
|
||||||
if (!curl) {
|
|
||||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="font-mono text-xs"
|
|
||||||
onClick={(e) => handleCopyCurl(curl, e)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Copy data-icon="inline-start" className="size-3.5" />
|
|
||||||
curl
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-sm break-all font-mono text-xs">
|
|
||||||
{curl}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'last_seen_at',
|
|
||||||
size: 130,
|
|
||||||
minSize: 110,
|
|
||||||
maxSize: 160,
|
|
||||||
header: ({ column }) => (
|
|
||||||
<DataGridColumnHeader column={column} title="Seen" />
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const iso = row.original.last_seen_at
|
|
||||||
const short = formatAgentSeen(iso)
|
|
||||||
if (!iso || short === '—') {
|
|
||||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<span className="text-muted-foreground cursor-default text-xs tabular-nums" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{short}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="font-mono text-xs">{iso}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
size: 108,
|
|
||||||
minSize: 108,
|
|
||||||
maxSize: 108,
|
|
||||||
enableSorting: false,
|
|
||||||
enableResizing: false,
|
|
||||||
header: () => <span className="sr-only">Действия</span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const a = row.original
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-1">
|
|
||||||
{a.status === 'pending' ? (
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="Approve"
|
|
||||||
disabled={approve.isPending}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
approve.mutate(a.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
aria-label="Открыть"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
void navigate({
|
|
||||||
to: '/agents/$id',
|
|
||||||
params: { id: a.id },
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Pencil className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon-sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="text-destructive"
|
|
||||||
aria-label="Удалить"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setDeleteId(a.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[approve, handleCopyCurl, navigate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const addButton = (
|
const addButton = (
|
||||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
@@ -538,7 +278,7 @@ function AgentsPage() {
|
|||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Агенты"
|
title="Агенты"
|
||||||
description="Ops console: invite, install, approve, apply throughput"
|
description="Ops console: invite, install, approve"
|
||||||
actions={addButton}
|
actions={addButton}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -589,62 +329,100 @@ function AgentsPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<AgentsFleetChart />
|
{agentsQ.isError ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
<ResourcePage
|
<CircleAlertIcon />
|
||||||
title="Очередь агентов"
|
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||||
hideHeader
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
data={items}
|
<span>{agentsQ.error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||||
columns={columns}
|
<Button
|
||||||
getRowId={(r) => r.id}
|
type="button"
|
||||||
filterFields={filterFields}
|
variant="outline"
|
||||||
filters={filters}
|
size="sm"
|
||||||
onFiltersChange={setFilters}
|
onClick={() => void agentsQ.refetch()}
|
||||||
onClearFilters={() => setFilters([])}
|
>
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
Повторить
|
||||||
searchQuery={searchQuery}
|
</Button>
|
||||||
onSearchChange={setSearchQuery}
|
</AlertDescription>
|
||||||
searchPlaceholder="Поиск агентов…"
|
</Alert>
|
||||||
getSearchText={getSearchText}
|
) : (
|
||||||
tableLayout={{ width: 'fixed', columnsResizable: true }}
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
onRowClick={(row) =>
|
<FramePanel className="p-0 shadow-none!">
|
||||||
void navigate({ to: '/agents/$id', params: { id: row.id } })
|
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||||
}
|
<CountedLineTabs
|
||||||
tabs={[
|
tabs={countedTabs}
|
||||||
{ id: 'all', label: 'Все' },
|
value={activeTab}
|
||||||
{ id: 'invited', label: 'Invited' },
|
onValueChange={setActiveTab}
|
||||||
{ id: 'pending', label: 'Pending' },
|
/>
|
||||||
{ id: 'approved', label: 'Approved' },
|
</div>
|
||||||
{ id: 'revoked', label: 'Revoked' },
|
<Separator />
|
||||||
]}
|
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||||
activeTab={activeTab}
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
onTabChange={setActiveTab}
|
<Filters
|
||||||
tabFilter={tabFilter}
|
filters={filters}
|
||||||
isLoading={agentsQ.isLoading}
|
fields={filterFields}
|
||||||
isError={agentsQ.isError}
|
onChange={setFilters}
|
||||||
error={agentsQ.error}
|
size="default"
|
||||||
onRetry={() => void agentsQ.refetch()}
|
trigger={
|
||||||
emptyState={{
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
title: 'Нет агентов',
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
description:
|
Фильтры
|
||||||
'Создайте агента — он появится в списке как Invited с командой установки.',
|
</Button>
|
||||||
action: addButton,
|
}
|
||||||
}}
|
/>
|
||||||
/>
|
<InputGroup className="h-8 max-w-xs min-w-[12rem] flex-1">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<SearchIcon aria-hidden="true" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Поиск агентов…"
|
||||||
|
aria-label="Поиск агентов…"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
{filters.length > 0 || searchQuery ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleClearFilters}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="px-(--frame-panel-header-px) pb-(--frame-panel-header-py)">
|
||||||
|
<AgentCardsGrid
|
||||||
|
agents={filteredItems}
|
||||||
|
selectedId={detailOpen ? detailAgentId : null}
|
||||||
|
onSelect={handleSelectAgent}
|
||||||
|
isLoading={agentsQ.isLoading}
|
||||||
|
emptyTitle={
|
||||||
|
items.length === 0 ? 'Нет агентов' : 'Нет записей по фильтрам'
|
||||||
|
}
|
||||||
|
emptyDescription={
|
||||||
|
items.length === 0
|
||||||
|
? 'Создайте агента — он появится в каталоге как Invited с командой установки.'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
emptyAction={items.length === 0 ? addButton : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)}
|
||||||
|
|
||||||
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
|
|
||||||
<ConfirmDialog
|
<AgentDetailSheet
|
||||||
open={deleteId !== null}
|
agentId={detailAgentId}
|
||||||
|
open={detailOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setDeleteId(null)
|
setDetailOpen(open)
|
||||||
|
if (!open) setDetailAgentId(null)
|
||||||
}}
|
}}
|
||||||
title="Удалить агента?"
|
|
||||||
description="Агент, install-ссылка и связанные назначения будут удалены."
|
|
||||||
onConfirm={() => {
|
|
||||||
if (deleteId) remove.mutate(deleteId)
|
|
||||||
}}
|
|
||||||
disabled={remove.isPending}
|
|
||||||
/>
|
/>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { cn } from "@evofw/ui/lib/utils"
|
||||||
|
|
||||||
|
function AspectRatio({
|
||||||
|
ratio,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { ratio: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="aspect-ratio"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--ratio": ratio,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn("relative aspect-(--ratio)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
Reference in New Issue
Block a user