diff --git a/apps/web/src/components/agents/agent-detail-view.tsx b/apps/web/src/components/agents/agent-detail-view.tsx
index 7402976..c2c07f9 100644
--- a/apps/web/src/components/agents/agent-detail-view.tsx
+++ b/apps/web/src/components/agents/agent-detail-view.tsx
@@ -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) {
Ошибка загрузки
-
- {agentQ.error?.message ?? 'Не удалось загрузить агента'}
+
+
+ {agentQ.error?.message ?? 'Не удалось загрузить агента'}
+
+ void agentQ.refetch()}
+ >
+ Повторить
+
@@ -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={
<>
-
+
+
+
+
,
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,
},
]}
/>
diff --git a/apps/web/src/components/agents/agent-fleet-data-grid.tsx b/apps/web/src/components/agents/agent-fleet-data-grid.tsx
index f011dc9..1788097 100644
--- a/apps/web/src/components/agents/agent-fleet-data-grid.tsx
+++ b/apps/web/src/components/agents/agent-fleet-data-grid.tsx
@@ -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 }) => (
-
+
),
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 —
}
return (
-
+
)
},
},
diff --git a/apps/web/src/components/agents/agent-online-dot.tsx b/apps/web/src/components/agents/agent-online-dot.tsx
new file mode 100644
index 0000000..2ca1214
--- /dev/null
+++ b/apps/web/src/components/agents/agent-online-dot.tsx
@@ -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 | null | undefined
+ className?: string
+}) {
+ const online = isAgentOnline(agent)
+ const label = online ? 'На связи' : 'Не на связи'
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/reui-kit/resource-page.tsx b/apps/web/src/components/reui-kit/resource-page.tsx
index 578ce9c..349bce7 100644
--- a/apps/web/src/components/reui-kit/resource-page.tsx
+++ b/apps/web/src/components/reui-kit/resource-page.tsx
@@ -78,6 +78,8 @@ export interface ResourcePageProps {
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({
onRetry,
primaryAction,
emptyState,
+ filteredEmptyMessage,
pageSize = 10,
enableRowSelection = false,
rowSelection: rowSelectionProp,
@@ -385,7 +388,8 @@ export function ResourcePage({
)
}
- const emptyMessage = 'Нет записей по выбранным фильтрам.'
+ const emptyMessage =
+ filteredEmptyMessage ?? 'Нет записей по выбранным фильтрам.'
return (
diff --git a/apps/web/src/components/updated-at-label.tsx b/apps/web/src/components/updated-at-label.tsx
new file mode 100644
index 0000000..595f150
--- /dev/null
+++ b/apps/web/src/components/updated-at-label.tsx
@@ -0,0 +1,11 @@
+import { formatTime } from '@/lib/format'
+
+/** «Обновлено в HH:mm:ss» — подпись свежести данных для polling-страниц. */
+export function UpdatedAtLabel({ updatedAt }: { updatedAt?: number }) {
+ if (!updatedAt) return null
+ return (
+
+ Обновлено в {formatTime(new Date(updatedAt))}
+
+ )
+}
diff --git a/apps/web/src/lib/agent-online.ts b/apps/web/src/lib/agent-online.ts
new file mode 100644
index 0000000..0f1b880
--- /dev/null
+++ b/apps/web/src/lib/agent-online.ts
@@ -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
| 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
+}
diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts
index e1bac8d..e1a9ff1 100644
--- a/apps/web/src/lib/format.ts
+++ b/apps/web/src/lib/format.ts
@@ -34,6 +34,8 @@ const timeFmt = new Intl.DateTimeFormat('ru-RU', {
second: '2-digit',
})
+const relativeFmt = new Intl.RelativeTimeFormat('ru-RU', { numeric: 'auto' })
+
/** Compact packet counter: 1,2K / 3,4M */
export function formatPackets(n: number | undefined | null): string {
if (n === undefined || n === null) return '—'
@@ -72,3 +74,20 @@ export function formatStampDateTime(iso: string | null | undefined): string {
export function formatTime(d: Date): string {
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)
+}
diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts
index 4b1ab20..869bc82 100644
--- a/apps/web/src/queries/index.ts
+++ b/apps/web/src/queries/index.ts
@@ -12,12 +12,14 @@ export const dashboardQueryOptions = () =>
queryOptions({
queryKey: ['dashboard'],
queryFn: () => apiFetch('/api/v1/dashboard'),
+ refetchInterval: 30_000,
})
export const agentsQueryOptions = () =>
queryOptions({
queryKey: ['agents'],
queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'),
+ refetchInterval: 60_000,
})
export const agentQueryOptions = (id: string) =>
@@ -272,4 +274,5 @@ export const recentStatsQueryOptions = () =>
recorded_at: string
}[]
}>('/api/v1/stats/recent'),
+ refetchInterval: 60_000,
})
diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx
index ea4a8f1..d41d990 100644
--- a/apps/web/src/routes/_auth/agents/index.tsx
+++ b/apps/web/src/routes/_auth/agents/index.tsx
@@ -29,14 +29,10 @@ import {
Frame,
FrameDescription,
FrameHeader,
+ FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
-import {
- Alert,
- AlertDescription,
- AlertTitle,
-} from '@/components/reui/alert'
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
import { AgentCardsGrid } from '@/components/agents/agent-cards-grid'
import { AgentDetailSheet } from '@/components/agents/agent-detail-sheet'
@@ -48,8 +44,11 @@ import {
} from '@/components/agents/agents-fleet-kpis'
import { ConfirmDialog } from '@/components/confirm-dialog'
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 { apiFetch } from '@/lib/api'
+import { isAgentOnline } from '@/lib/agent-online'
import { useCan } from '@/lib/permissions'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button'
@@ -332,7 +331,12 @@ function AgentsPage() {
if (card.id === 'pending') setActiveTab('pending')
else if (card.id === 'invited') setActiveTab('invited')
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],
@@ -351,10 +355,10 @@ function AgentsPage() {
label: 'Статус',
type: 'select',
options: [
- { value: 'invited', label: 'invited' },
- { value: 'approved', label: 'approved' },
- { value: 'pending', label: 'pending' },
- { value: 'revoked', label: 'revoked' },
+ { value: 'invited', label: 'Приглашён' },
+ { value: 'pending', label: 'Ожидает одобрения' },
+ { value: 'approved', label: 'Одобрен' },
+ { value: 'revoked', label: 'Отозван' },
],
},
{
@@ -362,8 +366,17 @@ function AgentsPage() {
label: 'Платформа',
type: 'select',
options: [
- { value: 'linux', label: 'linux' },
- { value: 'mikrotik', label: 'mikrotik' },
+ { value: 'linux', label: 'Linux' },
+ { 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 === 'status') return item.status
if (field === 'platform') return item.platform
+ if (field === 'online') return isAgentOnline(item) ? 'online' : 'offline'
return undefined
}, [])
@@ -480,6 +494,26 @@ function AgentsPage() {
[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(
(id: string) => {
setSearch({ agent: id })
@@ -545,7 +579,12 @@ function AgentsPage() {
+
+ {addButton}
+ >
+ }
/>
@@ -586,15 +625,33 @@ function AgentsPage() {
) : null}
- {counts.applyErrors > 0 ? (
-
-
- Ошибки apply
-
- У {counts.applyErrors} агент(ов) есть last_apply_error — откройте
- карточку для деталей.
-
-
+ {applyErrorAgents.length > 0 ? (
+
+
+
+ Ошибки применения политики
+
+ У {applyErrorAgents.length} агент(ов) последняя попытка apply
+ завершилась ошибкой — откройте карточку для деталей.
+
+
+
+
+ {applyErrorAgents.map((a) => (
+ setSearch({ agent: a.id })}
+ >
+
+ {a.name}
+
+ ))}
+
+
) : null}
{view === 'table' ? (
@@ -617,6 +674,7 @@ function AgentsPage() {
error={agentsQ.error}
onRetry={() => void agentsQ.refetch()}
emptyAction={addButton}
+ filteredEmptyMessage={filteredEmptyMessage}
toolbarExtra={viewToggle}
enableRowSelection={canWrite}
rowSelection={rowSelection}
diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx
index 46db007..6dcfb24 100644
--- a/apps/web/src/routes/_auth/index.tsx
+++ b/apps/web/src/routes/_auth/index.tsx
@@ -35,6 +35,7 @@ import {
} from '@/queries'
import { AgentsFleetChart } from '@/components/agents/agents-fleet-chart'
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
+import { UpdatedAtLabel } from '@/components/updated-at-label'
import type { Agent } from '@evofw/shared'
export const Route = createFileRoute('/_auth/')({
@@ -139,6 +140,7 @@ function DashboardPage() {
}
/>
([])
- const series = [...(stats.data?.items ?? [])]
- .reverse()
- .slice(-40)
- .map((s) => ({
- t: s.recorded_at.slice(11, 19),
- dropped: s.packets_dropped,
- accepted: s.packets_accepted,
- }))
+ /**
+ * Часовая агрегация: пик счётчиков за час. Счётчики агентов смешанной
+ * семантики (absolute/presence), поэтому суммирование сэмплов дало бы
+ * двойной счёт — берём максимум наблюдавшегося уровня за час.
+ */
+ const series = useMemo(() => {
+ 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(
() =>
@@ -76,7 +103,7 @@ function StatsPage() {
const kpiCards: KpiStatCard[] = [
{
id: 'd',
- label: 'Dropped (sum)',
+ label: 'Заблокировано (сумма)',
value: dash.data?.packets_dropped ?? 0,
icon: ,
iconClassName: 'text-warning',
@@ -84,14 +111,14 @@ function StatsPage() {
},
{
id: 'a',
- label: 'Accepted (sum)',
+ label: 'Пропущено (сумма)',
value: dash.data?.packets_accepted ?? 0,
icon: ,
iconClassName: 'text-success',
},
{
id: 'o',
- label: 'Online agents',
+ label: 'Агентов онлайн',
value: dash.data?.agents_online ?? 0,
icon: ,
iconClassName: 'text-info',
@@ -102,7 +129,7 @@ function StatsPage() {
() => [
{
key: 'agent_id',
- label: 'Agent',
+ label: 'Агент',
type: 'text',
placeholder: 'agent id…',
},
@@ -119,7 +146,9 @@ function StatsPage() {
() => [
{
accessorKey: 'agent_id',
- header: 'Agent',
+ header: ({ column }) => (
+
+ ),
cell: ({ row }) => (
{row.original.agent_id.slice(0, 8)}
@@ -128,23 +157,35 @@ function StatsPage() {
},
{
accessorKey: 'recorded_at',
- header: 'Time',
+ header: ({ column }) => (
+
+ ),
cell: ({ row }) => (
- {row.original.recorded_at}
+
+ {formatStampDateTime(row.original.recorded_at)}
+
),
},
{
accessorKey: 'packets_dropped',
- header: 'Drop',
+ header: ({ column }) => (
+
+ ),
cell: ({ row }) => (
- {row.original.packets_dropped}
+
+ {formatNumber(row.original.packets_dropped)}
+
),
},
{
accessorKey: 'packets_accepted',
- header: 'Accept',
+ header: ({ column }) => (
+
+ ),
cell: ({ row }) => (
- {row.original.packets_accepted}
+
+ {formatNumber(row.original.packets_accepted)}
+
),
},
],
@@ -156,12 +197,13 @@ function StatsPage() {
}
/>
- Тренд (последние samples)
+ Активность по часам (пик за час)
{series.length === 0 ? (
@@ -207,8 +249,9 @@ function StatsPage() {
onClearFilters={() => setFilters([])}
getFilterFieldValue={getFilterFieldValue}
isLoading={stats.isLoading}
+ pageSize={20}
emptyState={{
- title: 'Нет samples',
+ title: 'Нет данных',
description: 'Агенты ещё не отправили apply-report.',
}}
/>