feat(web): live-данные — polling, онлайн-индикаторы, относительное время, actionable-очередь ошибок apply, часовая агрегация статистики
This commit is contained in:
@@ -21,10 +21,8 @@ import {
|
|||||||
} from '@/components/reui/alert'
|
} from '@/components/reui/alert'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import {
|
import { AgentPlatformIcon, platformLabel } from '@/components/agents/agent-platform-icon'
|
||||||
AgentPlatformIcon,
|
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||||
platformLabel,
|
|
||||||
} from '@/components/agents/agent-platform-icon'
|
|
||||||
import {
|
import {
|
||||||
agentTrafficAccepted,
|
agentTrafficAccepted,
|
||||||
agentTrafficDropped,
|
agentTrafficDropped,
|
||||||
@@ -48,6 +46,7 @@ import {
|
|||||||
agentQueryOptions,
|
agentQueryOptions,
|
||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import { formatDateTime, formatRelativeTime } from '@/lib/format'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
@@ -138,8 +137,19 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<CircleAlertIcon />
|
<CircleAlertIcon />
|
||||||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
<span>
|
||||||
|
{agentQ.error?.message ?? 'Не удалось загрузить агента'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-fit"
|
||||||
|
onClick={() => void agentQ.refetch()}
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,8 +159,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
const headerDesc = [
|
const headerDesc = [
|
||||||
a.hostname,
|
a.hostname,
|
||||||
platformLabel(a.platform),
|
platformLabel(a.platform),
|
||||||
`gen ${a.policy_generation}`,
|
`поколение ${a.policy_generation}`,
|
||||||
a.default_action === 'drop' ? 'default Drop' : 'default Accept',
|
a.default_action === 'drop' ? 'по умолчанию: блокировать' : 'по умолчанию: пропускать',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' · ')
|
.join(' · ')
|
||||||
@@ -165,7 +175,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<AgentPlatformIcon platform={a.platform} />
|
<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
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
a.default_action === 'drop'
|
a.default_action === 'drop'
|
||||||
@@ -304,7 +317,10 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
|||||||
icon: <ClockIcon aria-hidden />,
|
icon: <ClockIcon aria-hidden />,
|
||||||
iconClassName: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
label: 'Last apply',
|
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,
|
agentTrafficAccepted,
|
||||||
agentTrafficDropped,
|
agentTrafficDropped,
|
||||||
} from '@/components/agents/agent-traffic'
|
} 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 { Button } from '@evofw/ui/components/button'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@evofw/ui/components/tooltip'
|
} from '@evofw/ui/components/tooltip'
|
||||||
import { formatPackets, formatShortDateTime } from '@/lib/format'
|
import { formatPackets } from '@/lib/format'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fleet triage DataGrid — firewall ops density.
|
* Fleet triage DataGrid — firewall ops density.
|
||||||
@@ -237,25 +239,27 @@ export function AgentFleetDataGrid({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'last_seen_at',
|
accessorKey: 'last_seen_at',
|
||||||
size: 140,
|
size: 150,
|
||||||
minSize: 120,
|
minSize: 130,
|
||||||
maxSize: 180,
|
maxSize: 190,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataGridColumnHeader column={column} title="Seen" />
|
<DataGridColumnHeader column={column} title="Активность" />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const a = row.original
|
const a = row.original
|
||||||
const short = formatShortDateTime(a.last_seen_at)
|
if (!a.last_seen_at) {
|
||||||
if (!a.last_seen_at || short === '—') {
|
|
||||||
return <DataGridMutedCell>—</DataGridMutedCell>
|
return <DataGridMutedCell>—</DataGridMutedCell>
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<DataGridPrimaryCell
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
accent="default"
|
<AgentOnlineDot agent={a} />
|
||||||
title={short}
|
<DataGridPrimaryCell
|
||||||
subtitle={a.last_seen_ip ?? undefined}
|
accent="default"
|
||||||
className="[&>span:first-child]:text-xs [&>span:first-child]:font-normal [&>span:first-child]:tabular-nums"
|
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
|
onRetry?: () => void
|
||||||
primaryAction?: ReactNode
|
primaryAction?: ReactNode
|
||||||
emptyState?: { title: string; description?: string; action?: ReactNode }
|
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||||
|
/** Текст для пустого результата после фильтров/поиска (по вкладкам). */
|
||||||
|
filteredEmptyMessage?: string
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
enableRowSelection?: boolean
|
enableRowSelection?: boolean
|
||||||
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
|
/** Controlled selection — page owns state, so bulk-действия видят выбор. */
|
||||||
@@ -142,6 +144,7 @@ export function ResourcePage<T extends object>({
|
|||||||
onRetry,
|
onRetry,
|
||||||
primaryAction,
|
primaryAction,
|
||||||
emptyState,
|
emptyState,
|
||||||
|
filteredEmptyMessage,
|
||||||
pageSize = 10,
|
pageSize = 10,
|
||||||
enableRowSelection = false,
|
enableRowSelection = false,
|
||||||
rowSelection: rowSelectionProp,
|
rowSelection: rowSelectionProp,
|
||||||
@@ -385,7 +388,8 @@ export function ResourcePage<T extends object>({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
const emptyMessage =
|
||||||
|
filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Agent } from '@evofw/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Порог «на связи» — тот же, что в API /dashboard: агент считается
|
||||||
|
* онлайн, если last_seen_at свежее 5 минут.
|
||||||
|
*/
|
||||||
|
export const AGENT_ONLINE_WINDOW_MS = 5 * 60_000
|
||||||
|
|
||||||
|
export function isAgentOnline(
|
||||||
|
agent: Pick<Agent, 'last_seen_at'> | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!agent?.last_seen_at) return false
|
||||||
|
const t = Date.parse(agent.last_seen_at)
|
||||||
|
if (Number.isNaN(t)) return false
|
||||||
|
return Date.now() - t < AGENT_ONLINE_WINDOW_MS
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ const timeFmt = new Intl.DateTimeFormat('ru-RU', {
|
|||||||
second: '2-digit',
|
second: '2-digit',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const relativeFmt = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'auto' })
|
||||||
|
|
||||||
/** Compact packet counter: 1,2K / 3,4M */
|
/** Compact packet counter: 1,2K / 3,4M */
|
||||||
export function formatPackets(n: number | undefined | null): string {
|
export function formatPackets(n: number | undefined | null): string {
|
||||||
if (n === undefined || n === null) return '—'
|
if (n === undefined || n === null) return '—'
|
||||||
@@ -72,3 +74,20 @@ export function formatStampDateTime(iso: string | null | undefined): string {
|
|||||||
export function formatTime(d: Date): string {
|
export function formatTime(d: Date): string {
|
||||||
return timeFmt.format(d)
|
return timeFmt.format(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** «2 минуты назад» / «5 часов назад»; старше 7 дней — полная дата. */
|
||||||
|
export function formatRelativeTime(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '—'
|
||||||
|
const t = Date.parse(iso)
|
||||||
|
if (Number.isNaN(t)) return '—'
|
||||||
|
const diffMs = t - Date.now()
|
||||||
|
const absMin = Math.round(Math.abs(diffMs) / 60_000)
|
||||||
|
if (absMin < 1) return 'только что'
|
||||||
|
const sign = diffMs < 0 ? -1 : 1
|
||||||
|
if (absMin < 60) return relativeFmt.format(sign * absMin, 'minute')
|
||||||
|
const absHours = Math.round(absMin / 60)
|
||||||
|
if (absHours < 24) return relativeFmt.format(sign * absHours, 'hour')
|
||||||
|
const absDays = Math.round(absHours / 24)
|
||||||
|
if (absDays < 7) return relativeFmt.format(sign * absDays, 'day')
|
||||||
|
return formatDateTime(iso)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ export const dashboardQueryOptions = () =>
|
|||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['dashboard'],
|
queryKey: ['dashboard'],
|
||||||
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
|
queryFn: () => apiFetch<DashboardStats>('/api/v1/dashboard'),
|
||||||
|
refetchInterval: 30_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const agentsQueryOptions = () =>
|
export const agentsQueryOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['agents'],
|
queryKey: ['agents'],
|
||||||
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
|
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
|
||||||
|
refetchInterval: 60_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const agentQueryOptions = (id: string) =>
|
export const agentQueryOptions = (id: string) =>
|
||||||
@@ -272,4 +274,5 @@ export const recentStatsQueryOptions = () =>
|
|||||||
recorded_at: string
|
recorded_at: string
|
||||||
}[]
|
}[]
|
||||||
}>('/api/v1/stats/recent'),
|
}>('/api/v1/stats/recent'),
|
||||||
|
refetchInterval: 60_000,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,14 +29,10 @@ import {
|
|||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
FrameHeader,
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
AlertDescription,
|
|
||||||
AlertTitle,
|
|
||||||
} from '@/components/reui/alert'
|
|
||||||
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||||
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
|
||||||
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
|
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
|
||||||
@@ -48,8 +44,11 @@ import {
|
|||||||
} from '@/components/agents/agents-fleet-kpis'
|
} from '@/components/agents/agents-fleet-kpis'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||||
|
import { AgentOnlineDot } from '@/components/agents/agent-online-dot'
|
||||||
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
|
import { agentsQueryOptions, settingsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import { isAgentOnline } from '@/lib/agent-online'
|
||||||
import { useCan } from '@/lib/permissions'
|
import { useCan } from '@/lib/permissions'
|
||||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
@@ -332,7 +331,12 @@ function AgentsPage() {
|
|||||||
if (card.id === 'pending') setActiveTab('pending')
|
if (card.id === 'pending') setActiveTab('pending')
|
||||||
else if (card.id === 'invited') setActiveTab('invited')
|
else if (card.id === 'invited') setActiveTab('invited')
|
||||||
else if (card.id === 'approved') setActiveTab('approved')
|
else if (card.id === 'approved') setActiveTab('approved')
|
||||||
else if (card.id === 'stale') setActiveTab('approved')
|
else if (card.id === 'stale') {
|
||||||
|
setActiveTab('all')
|
||||||
|
setFilters([
|
||||||
|
{ id: 'stale', field: 'online', operator: 'is', values: ['offline'] },
|
||||||
|
])
|
||||||
|
}
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
[counts],
|
[counts],
|
||||||
@@ -351,10 +355,10 @@ function AgentsPage() {
|
|||||||
label: 'Статус',
|
label: 'Статус',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'invited', label: 'invited' },
|
{ value: 'invited', label: 'Приглашён' },
|
||||||
{ value: 'approved', label: 'approved' },
|
{ value: 'pending', label: 'Ожидает одобрения' },
|
||||||
{ value: 'pending', label: 'pending' },
|
{ value: 'approved', label: 'Одобрен' },
|
||||||
{ value: 'revoked', label: 'revoked' },
|
{ value: 'revoked', label: 'Отозван' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -362,8 +366,17 @@ function AgentsPage() {
|
|||||||
label: 'Платформа',
|
label: 'Платформа',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ value: 'linux', label: 'linux' },
|
{ value: 'linux', label: 'Linux' },
|
||||||
{ value: 'mikrotik', label: 'mikrotik' },
|
{ value: 'mikrotik', label: 'MikroTik' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'online',
|
||||||
|
label: 'На связи',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ value: 'online', label: 'На связи' },
|
||||||
|
{ value: 'offline', label: 'Не на связи' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -374,6 +387,7 @@ function AgentsPage() {
|
|||||||
if (field === 'name') return item.name
|
if (field === 'name') return item.name
|
||||||
if (field === 'status') return item.status
|
if (field === 'status') return item.status
|
||||||
if (field === 'platform') return item.platform
|
if (field === 'platform') return item.platform
|
||||||
|
if (field === 'online') return isAgentOnline(item) ? 'online' : 'offline'
|
||||||
return undefined
|
return undefined
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -480,6 +494,26 @@ function AgentsPage() {
|
|||||||
[counts.pending],
|
[counts.pending],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const applyErrorAgents = useMemo(
|
||||||
|
() => items.filter((a) => a.last_apply_error),
|
||||||
|
[items],
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredEmptyMessage = useMemo(() => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'pending':
|
||||||
|
return 'Нет агентов на одобрении — всё обработано.'
|
||||||
|
case 'invited':
|
||||||
|
return 'Нет приглашённых агентов.'
|
||||||
|
case 'approved':
|
||||||
|
return 'Нет одобренных агентов.'
|
||||||
|
case 'revoked':
|
||||||
|
return 'Нет отозванных агентов.'
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [activeTab])
|
||||||
|
|
||||||
const handleSelectAgent = useCallback(
|
const handleSelectAgent = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
setSearch({ agent: id })
|
setSearch({ agent: id })
|
||||||
@@ -545,7 +579,12 @@ function AgentsPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Агенты"
|
title="Агенты"
|
||||||
description="Ops console: invite, install, approve"
|
description="Ops console: invite, install, approve"
|
||||||
actions={addButton}
|
actions={
|
||||||
|
<>
|
||||||
|
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
|
||||||
|
{addButton}
|
||||||
|
</>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
|
||||||
@@ -586,15 +625,33 @@ function AgentsPage() {
|
|||||||
</Frame>
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{counts.applyErrors > 0 ? (
|
{applyErrorAgents.length > 0 ? (
|
||||||
<Alert variant="warning">
|
<Frame dense spacing="sm">
|
||||||
<CircleAlertIcon />
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
<AlertTitle>Ошибки apply</AlertTitle>
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
<AlertDescription>
|
<FrameTitle>Ошибки применения политики</FrameTitle>
|
||||||
У {counts.applyErrors} агент(ов) есть last_apply_error — откройте
|
<FrameDescription>
|
||||||
карточку для деталей.
|
У {applyErrorAgents.length} агент(ов) последняя попытка apply
|
||||||
</AlertDescription>
|
завершилась ошибкой — откройте карточку для деталей.
|
||||||
</Alert>
|
</FrameDescription>
|
||||||
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-wrap gap-2">
|
||||||
|
{applyErrorAgents.map((a) => (
|
||||||
|
<Button
|
||||||
|
key={a.id}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-destructive"
|
||||||
|
title={a.last_apply_error ?? undefined}
|
||||||
|
onClick={() => setSearch({ agent: a.id })}
|
||||||
|
>
|
||||||
|
<CircleAlertIcon className="size-3.5" aria-hidden />
|
||||||
|
{a.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{view === 'table' ? (
|
{view === 'table' ? (
|
||||||
@@ -617,6 +674,7 @@ function AgentsPage() {
|
|||||||
error={agentsQ.error}
|
error={agentsQ.error}
|
||||||
onRetry={() => void agentsQ.refetch()}
|
onRetry={() => void agentsQ.refetch()}
|
||||||
emptyAction={addButton}
|
emptyAction={addButton}
|
||||||
|
filteredEmptyMessage={filteredEmptyMessage}
|
||||||
toolbarExtra={viewToggle}
|
toolbarExtra={viewToggle}
|
||||||
enableRowSelection={canWrite}
|
enableRowSelection={canWrite}
|
||||||
rowSelection={rowSelection}
|
rowSelection={rowSelection}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
|
||||||
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
|
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
|
||||||
|
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||||
import type { Agent } from '@evofw/shared'
|
import type { Agent } from '@evofw/shared'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/')({
|
export const Route = createFileRoute('/_auth/')({
|
||||||
@@ -139,6 +140,7 @@ function DashboardPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Панель управления"
|
title="Панель управления"
|
||||||
description="Обзор агентов и пакетной статистики"
|
description="Обзор агентов и пакетной статистики"
|
||||||
|
actions={<UpdatedAtLabel updatedAt={dash.dataUpdatedAt} />}
|
||||||
/>
|
/>
|
||||||
<OpsDashboard
|
<OpsDashboard
|
||||||
isLoading={dash.isLoading}
|
isLoading={dash.isLoading}
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
FramePanel,
|
FramePanel,
|
||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
|
import { UpdatedAtLabel } from '@/components/updated-at-label'
|
||||||
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
import { dashboardQueryOptions, recentStatsQueryOptions } from '@/queries'
|
||||||
|
import { formatNumber, formatStampDateTime } from '@/lib/format'
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import {
|
import {
|
||||||
ChartContainer,
|
ChartContainer,
|
||||||
ChartTooltip,
|
ChartTooltip,
|
||||||
@@ -43,8 +46,8 @@ export const Route = createFileRoute('/_auth/stats')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
dropped: { label: 'Dropped', color: 'var(--chart-1)' },
|
dropped: { label: 'Заблокировано', color: 'var(--chart-1)' },
|
||||||
accepted: { label: 'Accepted', color: 'var(--chart-2)' },
|
accepted: { label: 'Пропущено', color: 'var(--chart-2)' },
|
||||||
} satisfies ChartConfig
|
} satisfies ChartConfig
|
||||||
|
|
||||||
function StatsPage() {
|
function StatsPage() {
|
||||||
@@ -52,14 +55,38 @@ function StatsPage() {
|
|||||||
const stats = useQuery(recentStatsQueryOptions())
|
const stats = useQuery(recentStatsQueryOptions())
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
|
|
||||||
const series = [...(stats.data?.items ?? [])]
|
/**
|
||||||
.reverse()
|
* Часовая агрегация: пик счётчиков за час. Счётчики агентов смешанной
|
||||||
.slice(-40)
|
* семантики (absolute/presence), поэтому суммирование сэмплов дало бы
|
||||||
.map((s) => ({
|
* двойной счёт — берём максимум наблюдавшегося уровня за час.
|
||||||
t: s.recorded_at.slice(11, 19),
|
*/
|
||||||
dropped: s.packets_dropped,
|
const series = useMemo(() => {
|
||||||
accepted: s.packets_accepted,
|
const byHour = new Map<
|
||||||
}))
|
string,
|
||||||
|
{ t: string; label: string; dropped: number; accepted: number }
|
||||||
|
>()
|
||||||
|
for (const s of stats.data?.items ?? []) {
|
||||||
|
const d = new Date(s.recorded_at)
|
||||||
|
if (Number.isNaN(d.getTime())) continue
|
||||||
|
const key = `${s.recorded_at.slice(0, 13)}:00:00`
|
||||||
|
const entry = byHour.get(key) ?? {
|
||||||
|
t: key,
|
||||||
|
label: d.toLocaleTimeString('ru-RU', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
dropped: 0,
|
||||||
|
accepted: 0,
|
||||||
|
}
|
||||||
|
entry.dropped = Math.max(entry.dropped, s.packets_dropped)
|
||||||
|
entry.accepted = Math.max(entry.accepted, s.packets_accepted)
|
||||||
|
byHour.set(key, entry)
|
||||||
|
}
|
||||||
|
return [...byHour.values()]
|
||||||
|
.sort((a, b) => a.t.localeCompare(b.t))
|
||||||
|
.slice(-48)
|
||||||
|
.map(({ t: _t, ...rest }) => rest)
|
||||||
|
}, [stats.data])
|
||||||
|
|
||||||
const rows: StatRow[] = useMemo(
|
const rows: StatRow[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -76,7 +103,7 @@ function StatsPage() {
|
|||||||
const kpiCards: KpiStatCard[] = [
|
const kpiCards: KpiStatCard[] = [
|
||||||
{
|
{
|
||||||
id: 'd',
|
id: 'd',
|
||||||
label: 'Dropped (sum)',
|
label: 'Заблокировано (сумма)',
|
||||||
value: dash.data?.packets_dropped ?? 0,
|
value: dash.data?.packets_dropped ?? 0,
|
||||||
icon: <BanIcon aria-hidden />,
|
icon: <BanIcon aria-hidden />,
|
||||||
iconClassName: 'text-warning',
|
iconClassName: 'text-warning',
|
||||||
@@ -84,14 +111,14 @@ function StatsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'a',
|
id: 'a',
|
||||||
label: 'Accepted (sum)',
|
label: 'Пропущено (сумма)',
|
||||||
value: dash.data?.packets_accepted ?? 0,
|
value: dash.data?.packets_accepted ?? 0,
|
||||||
icon: <CheckCircle2Icon aria-hidden />,
|
icon: <CheckCircle2Icon aria-hidden />,
|
||||||
iconClassName: 'text-success',
|
iconClassName: 'text-success',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'o',
|
id: 'o',
|
||||||
label: 'Online agents',
|
label: 'Агентов онлайн',
|
||||||
value: dash.data?.agents_online ?? 0,
|
value: dash.data?.agents_online ?? 0,
|
||||||
icon: <ServerIcon aria-hidden />,
|
icon: <ServerIcon aria-hidden />,
|
||||||
iconClassName: 'text-info',
|
iconClassName: 'text-info',
|
||||||
@@ -102,7 +129,7 @@ function StatsPage() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
key: 'agent_id',
|
key: 'agent_id',
|
||||||
label: 'Agent',
|
label: 'Агент',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
placeholder: 'agent id…',
|
placeholder: 'agent id…',
|
||||||
},
|
},
|
||||||
@@ -119,7 +146,9 @@ function StatsPage() {
|
|||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'agent_id',
|
accessorKey: 'agent_id',
|
||||||
header: 'Agent',
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Агент" />
|
||||||
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs">
|
<span className="font-mono text-xs">
|
||||||
{row.original.agent_id.slice(0, 8)}
|
{row.original.agent_id.slice(0, 8)}
|
||||||
@@ -128,23 +157,35 @@ function StatsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'recorded_at',
|
accessorKey: 'recorded_at',
|
||||||
header: 'Time',
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Время" />
|
||||||
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="text-xs">{row.original.recorded_at}</span>
|
<span className="text-xs tabular-nums">
|
||||||
|
{formatStampDateTime(row.original.recorded_at)}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'packets_dropped',
|
accessorKey: 'packets_dropped',
|
||||||
header: 'Drop',
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Заблокировано" />
|
||||||
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="tabular-nums">{row.original.packets_dropped}</span>
|
<span className="text-warning tabular-nums">
|
||||||
|
{formatNumber(row.original.packets_dropped)}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'packets_accepted',
|
accessorKey: 'packets_accepted',
|
||||||
header: 'Accept',
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Пропущено" />
|
||||||
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="tabular-nums">{row.original.packets_accepted}</span>
|
<span className="text-success tabular-nums">
|
||||||
|
{formatNumber(row.original.packets_accepted)}
|
||||||
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -156,12 +197,13 @@ function StatsPage() {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Статистика"
|
title="Статистика"
|
||||||
description="История apply-report counters"
|
description="История apply-report counters"
|
||||||
|
actions={<UpdatedAtLabel updatedAt={stats.dataUpdatedAt} />}
|
||||||
/>
|
/>
|
||||||
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
<KpiStatGrid cards={kpiCards} isLoading={dash.isLoading} />
|
||||||
|
|
||||||
<Frame dense spacing="sm">
|
<Frame dense spacing="sm">
|
||||||
<FrameHeader>
|
<FrameHeader>
|
||||||
<FrameTitle>Тренд (последние samples)</FrameTitle>
|
<FrameTitle>Активность по часам (пик за час)</FrameTitle>
|
||||||
</FrameHeader>
|
</FrameHeader>
|
||||||
<FramePanel>
|
<FramePanel>
|
||||||
{series.length === 0 ? (
|
{series.length === 0 ? (
|
||||||
@@ -207,8 +249,9 @@ function StatsPage() {
|
|||||||
onClearFilters={() => setFilters([])}
|
onClearFilters={() => setFilters([])}
|
||||||
getFilterFieldValue={getFilterFieldValue}
|
getFilterFieldValue={getFilterFieldValue}
|
||||||
isLoading={stats.isLoading}
|
isLoading={stats.isLoading}
|
||||||
|
pageSize={20}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет samples',
|
title: 'Нет данных',
|
||||||
description: 'Агенты ещё не отправили apply-report.',
|
description: 'Агенты ещё не отправили apply-report.',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user