feat(web): live-данные — polling, онлайн-индикаторы, относительное время, actionable-очередь ошибок apply, часовая агрегация статистики

This commit is contained in:
Denozordec
2026-09-25 01:17:33 +07:00
parent 98e4dcdefb
commit 0b426fa7ae
11 changed files with 272 additions and 69 deletions
@@ -21,10 +21,8 @@ import {
} from '@/components/reui/alert'
import { Badge } from '@/components/reui/badge'
import { StatusBadge } from '@/components/status-badge'
import {
AgentPlatformIcon,
platformLabel,
} from '@/components/agents/agent-platform-icon'
import { AgentPlatformIcon, platformLabel } from '@/components/agents/agent-platform-icon'
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
import {
agentTrafficAccepted,
agentTrafficDropped,
@@ -48,6 +46,7 @@ import {
agentQueryOptions,
} from '@/queries'
import { apiFetch } from '@/lib/api'
import { formatDateTime, formatRelativeTime } from '@/lib/format'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton'
@@ -138,8 +137,19 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription>
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
<AlertDescription className="flex flex-col gap-2">
<span>
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
</span>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
onClick={() => void agentQ.refetch()}
>
Повторить
</Button>
</AlertDescription>
</Alert>
</div>
@@ -149,8 +159,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const headerDesc = [
a.hostname,
platformLabel(a.platform),
`gen ${a.policy_generation}`,
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
`поколение ${a.policy_generation}`,
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
]
.filter(Boolean)
.join(' · ')
@@ -165,7 +175,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
actions={
<>
<AgentPlatformIcon platform={a.platform} />
<StatusBadge status={a.status} />
<span className="flex items-center gap-1.5">
<AgentOnlineDot agent={a} />
<StatusBadge status={a.status} />
</span>
<Badge
variant={
a.default_action === 'drop'
@@ -304,7 +317,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
icon: <ClockIcon aria-hidden />,
iconClassName: 'text-primary',
label: 'Last apply',
description: a.last_apply_at ?? '—',
description: formatDateTime(a.last_apply_at),
hint: a.last_apply_at
? formatRelativeTime(a.last_apply_at)
: undefined,
},
]}
/>
@@ -20,13 +20,15 @@ import {
agentTrafficAccepted,
agentTrafficDropped,
} from '@/components/agents/agent-traffic'
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
import { formatRelativeTime } from '@/lib/format'
import { Button } from '@evofw/ui/components/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@evofw/ui/components/tooltip'
import { formatPackets, formatShortDateTime } from '@/lib/format'
import { formatPackets } from '@/lib/format'
/**
* Fleet triage DataGrid — firewall ops density.
@@ -237,25 +239,27 @@ export function AgentFleetDataGrid({
},
{
accessorKey: 'last_seen_at',
size: 140,
minSize: 120,
maxSize: 180,
size: 150,
minSize: 130,
maxSize: 190,
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Seen" />
<DataGridColumnHeader column={column} title="Активность" />
),
cell: ({ row }) => {
const a = row.original
const short = formatShortDateTime(a.last_seen_at)
if (!a.last_seen_at || short === '—') {
if (!a.last_seen_at) {
return <DataGridMutedCell>—</DataGridMutedCell>
}
return (
<DataGridPrimaryCell
accent="default"
title={short}
subtitle={a.last_seen_ip ?? undefined}
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums"
/>
<div className="flex min-w-0 items-center gap-2">
<AgentOnlineDot agent={a} />
<DataGridPrimaryCell
accent="default"
title={formatRelativeTime(a.last_seen_at)}
subtitle={a.last_seen_ip ?? undefined}
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal"
/>
</div>
)
},
},
@@ -0,0 +1,27 @@
import type { Agent } from '@evofw/shared'
import { isAgentOnline } from '@/lib/agent-online'
import { cn } from '@evofw/ui/lib/utils'
/** Индикатор «на связи» (last_seen < 5 мин) — тот же порог, что в API. */
export function AgentOnlineDot({
agent,
className,
}: {
agent: Pick<Agent, 'last_seen_at'> | null | undefined
className?: string
}) {
const online = isAgentOnline(agent)
const label = online ? 'На связи' : 'Не на связи'
return (
<span
role="img"
aria-label={label}
title={label}
className={cn(
'inline-block size-2 shrink-0 rounded-full',
online ? 'bg-success' : 'bg-muted-foreground/30',
className,
)}
/>
)
}
@@ -78,6 +78,8 @@ export interface ResourcePageProps<T extends object> {
onRetry?: () => void
primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode }
/** Текст для пустого результата после фильтров/поиска (по вкладкам). */
filteredEmptyMessage?: string
pageSize?: number
enableRowSelection?: boolean
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
@@ -142,6 +144,7 @@ export function ResourcePage<T extends object>({
onRetry,
primaryAction,
emptyState,
filteredEmptyMessage,
pageSize = 10,
enableRowSelection = false,
rowSelection: rowSelectionProp,
@@ -385,7 +388,8 @@ export function ResourcePage<T extends object>({
)
}
const emptyMessage = 'Нет записей по выбранным фильтрам.'
const emptyMessage =
filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
return (
<div className="w-full">
@@ -0,0 +1,11 @@
import { formatTime } from '@/lib/format'
/** «Обновлено в HH:mm:ss» — подпись свежести данных для polling-страниц. */
export function UpdatedAtLabel({ updatedAt }: { updatedAt?: number }) {
if (!updatedAt) return null
return (
<span className="text-muted-foreground text-xs tabular-nums">
Обновлено в {formatTime(new Date(updatedAt))}
</span>
)
}