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
+80 -22
View File
@@ -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() {
<PageHeader
title="Агенты"
description="Ops console: invite, install, approve"
actions={addButton}
actions={
<>
<UpdatedAtLabel updatedAt={agentsQ.dataUpdatedAt} />
{addButton}
</>
}
/>
<KpiStatGrid cards={kpiCards} isLoading={agentsQ.isLoading} />
@@ -586,15 +625,33 @@ function AgentsPage() {
</Frame>
) : null}
{counts.applyErrors > 0 ? (
<Alert variant="warning">
<CircleAlertIcon />
<AlertTitle>Ошибки apply</AlertTitle>
<AlertDescription>
У {counts.applyErrors} агент(ов) есть last_apply_error — откройте
карточку для деталей.
</AlertDescription>
</Alert>
{applyErrorAgents.length > 0 ? (
<Frame dense spacing="sm">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle>Ошибки применения политики</FrameTitle>
<FrameDescription>
У {applyErrorAgents.length} агент(ов) последняя попытка apply
завершилась ошибкой — откройте карточку для деталей.
</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}
{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}