feat(web): live-данные — polling, онлайн-индикаторы, относительное время, actionable-очередь ошибок apply, часовая агрегация статистики
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user