fix(ui): выбирать неактивные серверы для месячного отчёта

This commit is contained in:
Denozordec committed 2026-09-28 16:59:40 +07:00
1 parent 7dd377d7c3
commit 460cf67313
6 files changed
+76 -33

No files matched your search

+27 -12
View File
@@ -797,7 +797,7 @@ export default function TrafficPage() {
const [flowScope, setFlowScope] = useState<FlowScope>("servers")
const [flowRetry, setFlowRetry] = useState(0)
const flowKey = JSON.stringify([isLive, groupMode, backendUrl, range, flowScope, flowRetry])
const [flowResult, setFlowResult] = useState<{ key: string; receivedAt?: number; stats?: FlowStatsDto; exporters?: FlowEntityCard[]; clients?: FlowEntityCard[]; error?: string } | null>(null)
const [flowResult, setFlowResult] = useState<{ key: string; receivedAt?: number; monthlyServers?: ServerRead[]; stats?: FlowStatsDto; exporters?: FlowEntityCard[]; clients?: FlowEntityCard[]; error?: string } | null>(null)
const currentFlows = flowResult?.key === flowKey ? flowResult : null
const liveBusy = isLive && groupMode === "flows" && !currentFlows
const liveError = currentFlows?.error
@@ -879,10 +879,20 @@ export default function TrafficPage() {
useEffect(() => {
if (!isLive || effectiveMode !== "flows") return
let cancelled = false
if (range === "30d") {
void listServers(backendUrl).then(servers => {
if (cancelled) return
setFlowResult({ key: flowKey, receivedAt: Date.now(), monthlyServers: servers })
setSelectedId(prev => servers.some(server => String(server.id) === prev) ? prev : String(servers[0]?.id ?? ""))
}).catch(error => {
if (!cancelled) setFlowResult({ key: flowKey, error: error instanceof Error ? error.message : "Не удалось загрузить каталог серверов" })
})
return () => { cancelled = true }
}
void Promise.all([
getTrafficFlows(backendUrl, range === "30d" ? "24h" : range),
getFlowExporters(backendUrl, range === "30d" ? "24h" : range),
getFlowClients(backendUrl, range === "30d" ? "24h" : range),
getTrafficFlows(backendUrl, range),
getFlowExporters(backendUrl, range),
getFlowClients(backendUrl, range),
]).then(([stats, exporters, clients]) => {
if (cancelled) return
setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients })
@@ -1049,7 +1059,12 @@ export default function TrafficPage() {
return sortDir === "desc" ? -v : v
})
}, [flowCards, q, sortField, sortDir])
const selFlowCard = sortedFlowCards.find((c) => c.id === selectedId) ?? sortedFlowCards[0] ?? null
const monthlyServers = currentFlows?.monthlyServers ?? []
const visibleMonthlyServers = monthlyServers.filter(server => !q || server.name.toLowerCase().includes(q)).sort((a, b) => a.name.localeCompare(b.name))
const monthlyServer = monthlyServers.find(server => String(server.id) === selectedId)
const selFlowCard = range === "30d"
? (monthlyServer ? { id: String(monthlyServer.id), name: monthlyServer.name, country: monthlyServer.country, site: monthlyServer.site } : null)
: flowCards.find((c) => c.id === selectedId) ?? null
const displayedFlow = currentFlows && !liveError ? (flowLiveSample ?? currentAnalytics?.data ?? null) : null
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
@@ -1242,7 +1257,7 @@ export default function TrafficPage() {
className="w-full pl-8 pr-3 h-8 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
/>
</div>
<div className="flex gap-1 flex-wrap">
<div className={cn("flex gap-1 flex-wrap", range === "30d" && "hidden")}>
<span className="text-[10px] text-muted-foreground self-center mr-0.5">Сортировка:</span>
{visibleSortFields.map(({ field, label }) => (
<button
@@ -1260,9 +1275,9 @@ export default function TrafficPage() {
</button>
))}
</div>
{range === "30d" && <p className="text-xs text-muted-foreground">Выбор сервера: активность за последние 24 часа. Месячный отчёт — в деталях сервера.</p>}
{range === "30d" && <p className="text-xs text-muted-foreground">Серверы из каталога, включая неактивные. Удалённые серверы в список не входят.</p>}
<div className="flex flex-col gap-2">
{sortedFlowCards.map((card) => (
{range === "30d" ? visibleMonthlyServers.map(server => <Button key={server.id} variant="outline" className="justify-start whitespace-normal break-all h-auto py-2" aria-pressed={String(server.id) === selectedId} onClick={() => setSelectedId(String(server.id))}>{server.name}</Button>) : sortedFlowCards.map((card) => (
<FlowEntityCardView
key={card.id}
card={card}
@@ -1270,9 +1285,9 @@ export default function TrafficPage() {
onClick={() => { setFlowIface("__all__"); setSelectedId(card.id) }}
/>
))}
{!liveBusy && !liveError && sortedFlowCards.length === 0 ? (
{!liveBusy && !liveError && (range === "30d" ? visibleMonthlyServers.length : sortedFlowCards.length) === 0 ? (
<p className="text-xs text-muted-foreground">
{flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
{range === "30d" ? (q ? "Нет серверов по этому запросу" : "В каталоге нет серверов") : flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
</p>
) : null}
</div>
@@ -1281,11 +1296,11 @@ export default function TrafficPage() {
<FramePanel className="flex-1 min-w-0 p-3 sm:p-5">
{analyticsLoading && <p role="status">Загрузка аналитики…</p>}
{currentAnalytics?.error && <Alert variant="destructive" role="alert"><AlertTitle>Аналитика недоступна</AlertTitle><AlertDescription>{currentAnalytics.error}<Button size="sm" variant="outline" onClick={() => setAnalyticsRetry(value => value + 1)}>Повторить аналитику</Button></AlertDescription></Alert>}
{isLive && (displayedFlow || currentAnalytics?.monthly) && <TrafficDataTime label="Получение аналитики потоков" receivedAt={flowLiveSample ? flowLiveReceivedAt : currentAnalytics?.receivedAt} source={flowLiveSample ? "live-поток" : "API"} />}
{isLive && (displayedFlow || (monthlyServer && currentAnalytics?.monthly)) && <TrafficDataTime label="Получение аналитики потоков" receivedAt={flowLiveSample ? flowLiveReceivedAt : currentAnalytics?.receivedAt} source={flowLiveSample ? "live-поток" : "API"} />}
<FlowAnalyticsDetail
card={selFlowCard}
analytics={displayedFlow}
monthly={currentAnalytics?.monthly}
monthly={monthlyServer ? currentAnalytics?.monthly : undefined}
monthlyAvailable={flowScope === "servers"}
month={flowMonth}
onMonth={setFlowMonth}
+8 -16
View File
@@ -322,7 +322,7 @@ export function FlowAnalyticsDetail({
liveHint,
emptyHint,
}: {
card: FlowEntityCard | null
card: (Pick<FlowEntityCard, "id" | "name" | "country" | "site"> & Partial<FlowEntityCard>) | null
analytics: FlowAnalyticsDto | null
monthly?: FlowMonthlyDto
monthlyAvailable?: boolean
@@ -355,23 +355,15 @@ export function FlowAnalyticsDetail({
setSlice("sessions")
}
if (!card) {
return (
<p className="text-sm text-muted-foreground py-8 text-center">
Выберите сервер или клиента слева
</p>
)
}
return (
<>
<div className="flex flex-wrap items-start justify-between mb-3 gap-3">
<div className="flex items-center gap-2 flex-wrap min-w-0">
<StatusDot status={card.status} />
<h2 className="text-base font-semibold truncate">{card.name}</h2>
{card?.status && <StatusDot status={card.status} />}
<h2 className="text-base font-semibold truncate">{card?.name ?? "Выберите сервер или клиента"}</h2>
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded flex items-center gap-1">
{card.country !== "UN" ? <Flag code={card.country} /> : null}
{card.site}
{card?.country && card.country !== "UN" ? <Flag code={card.country} /> : null}
{card?.site}
</span>
</div>
<div className="flex flex-wrap items-center gap-3 min-w-0">
@@ -482,8 +474,8 @@ export function FlowAnalyticsDetail({
{analytics && <>
<TrafficRxTxChart
rx={analytics?.rxSeries ?? card.rxSeries}
tx={analytics?.txSeries ?? card.txSeries}
rx={analytics?.rxSeries ?? card?.rxSeries ?? []}
tx={analytics?.txSeries ?? card?.txSeries ?? []}
range={range}
/>
@@ -536,7 +528,7 @@ export function FlowAnalyticsDetail({
{
id: "flows",
label: "Сессии",
value: String(analytics?.conversations ?? card.sessions),
value: String(analytics?.conversations ?? card?.sessions ?? 0),
hint: analytics?.conversationsRaw != null && analytics.conversationsRaw !== analytics.conversations
? `до дедупа ${analytics.conversationsRaw}`
: undefined,
+2 -2
View File
@@ -88,8 +88,8 @@ flowchart LR
Ближайший оставшийся объём:
1. Молчащий SSE и CRLF/heartbeat проверены в блоках 8.6–8.7. Одиночные точки графика исправлены в 8.8; ожидание первого sample проверено в 8.9. Выбор месяца реализован в 8.10; следующий шаг — архивные серверы и доступность.
2. Итоговая приёмка этапа 8: выбор серверов без активности за 24 часа, ReUI/accessibility checklist. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md).
1. Молчащий SSE и CRLF/heartbeat проверены в блоках 8.6–8.7. Одиночные точки графика исправлены в 8.8; ожидание первого sample проверено в 8.9. Выбор месяца реализован в 8.10, каталог неактивных серверов — в 8.11. Следующий шаг — доступность и итоговая приёмка.
2. Итоговая приёмка этапа 8: ReUI/accessibility checklist и регрессия; удалённые серверы остаются ограничением. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md).
3. После фиксации результата этапа 8 — этап 9.
## Порядок продолжения и журналирования
+9 -1
View File
@@ -24,13 +24,21 @@
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.10; выбор месяца готов, далее архивные серверы и доступность |
| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.11; каталог месячного отчёта готов, далее доступность и приёмка |
| 9 | VPN, Firewall и Filters | Не начат |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 8.11 — каталог серверов месячного отчёта (2026-09-28)
- Для месячного режима список загружается через существующий listServers, независимо от активности IPFIX за 24 часа. Выбор доступен даже при полном отсутствии активных экспортёров. В списке только имена; скорости, графики и статус без подтверждения не подставляются. Сортировка по live-метрикам скрыта, поиск работает по имени.
- Ответ каталога привязан к flowKey и защищён флагом отмены; ошибка имеет повтор и скрывает недоступный отчёт. Выбор сохраняется при успешном повторе, если сервер остаётся в каталоге. Детали используют выбранный ID, а не первую карточку отфильтрованного поиска.
- Все 25 flow-E2E прошли. Новый сценарий проверяет сервер без потоков, правильный serverId, отсутствие мини-графиков, 403 каталога и повтор; после расширения отдельно проверен вход при полностью пустом списке экспортёров. Адаптивные 320/390/1440 входят в набор. Итоговые lint, diff-check и production build/TypeScript прошли (34 страницы).
- Граница: серверы, удалённые из каталога, не перечисляются; это явно указано в интерфейсе. API архивного каталога не добавлялся. Реальные RouterOS/IPFIX/JWT не проверены.
- Далее: ReUI/accessibility checklist для трафика/статистики и итоговая регрессия этапа 8; удалённые серверы и экспорт зафиксировать как оставшиеся ограничения. Использовано 77% пятичасового и 27% недельного лимита на контрольной точке. Локальный коммит без push.
## Блок 8.10 — выбор месяца flow-отчёта (2026-09-28)
- Месячный отчёт использует выбранный календарный месяц UTC через существующий GET /api/traffic/flow/monthly. Доступны текущий и прошлые месяцы; пустые, некорректные и будущие значения блокируют запрос и показывают связанную с полем ошибку. Текущий месяц остаётся начальным значением.
+2
View File
@@ -52,3 +52,5 @@
Обновление 8.9 (2026-09-28): проверено ожидание первого sample в открытом SSE для flow и сервера, heartbeat без sample, таймаут и переход с истории на live. 28 профильных E2E, lint/TypeScript прошли; production-код не менялся. Далее — решение по месячному отчёту и доступность.
Обновление 8.10 (2026-09-28): добавлен выбор прошлого/текущего месяца UTC; валидация не отправляет пустые/будущие значения, ключ запроса защищает от позднего отчёта. 23 прежних и 1 новый flow-E2E прошли, lint/build чистые. Ограничение списка серверов по активности 24 часа остаётся следующим шагом; полный аудит доступности ещё не завершён.
Обновление 8.11 (2026-09-28): месячный режим выбирает сервер из каталога независимо от активности за 24 часа. 25 flow-E2E и дополнительная проверка пустого списка экспортёров прошли, как и lint/build. Удалённые из каталога серверы не перечисляются; далее доступность и итоговая регрессия этапа 8.
+28 -2
View File
@@ -2,13 +2,14 @@ import { installOpenLiveStream, resumeLiveStream, sendLiveHeartbeat } from "./li
import { test, expect, type Page } from '@playwright/test'
async function setup(page: Page) {
const control = { hold: '', holdIface: '', fail: false, failList: false, invalidSample: undefined as unknown, mergeSample: false, metrics: {} as Record<string, unknown>, release: () => {} }
const control = { emptyExporters: false, failCatalog: false, hold: '', holdIface: '', fail: false, failList: false, invalidSample: undefined as unknown, mergeSample: false, metrics: {} as Record<string, unknown>, release: () => {} }
const gate = new Promise<void>(resolve => { control.release = resolve })
const card = { id: '1', name: 'Экспортёр тест', subtitle: '', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3], bytes: 200 }
const data = { bpsNow: 1000000, bytes: 200, packets: 2, conversations: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', rxSeries: [1], txSeries: [2], applications: [], protocols: [], sources: [], destinations: [], interfaces: [], conversationsList: [], ifaces: [], live: false }
await page.route('**/health', r => r.fulfill({ json: { ok: true } }))
await page.route('**/api/**', async route => {
const url = new URL(route.request().url()), path = url.pathname, range = url.searchParams.get('range')
if (path === '/api/servers') return route.fulfill(control.failCatalog ? { status: 403, json: { error: 'Каталог недоступен' } } : { json: [1, 2, 3].map(id => ({ id, name: id === 3 ? 'Сервер без потоков' : id === 2 ? 'Экспортёр второй' : 'Экспортёр тест', host: '127.0.0.1', port: 8728, useSsl: false, verifySsl: false, username: 'admin', password: '', type: 'jump-host', site: 'TEST', country: 'RU', asn: '', comment: '', enabled: true, lanSubnet: '', wanUplinks: [], createdAt: '', updatedAt: '', status: null, latency: null, os: null, model: null, uptime: null, cpuLoad: null, freeMemory: null, totalMemory: null, identityName: null, sessions: 0, polledAt: null })) })
if (path === '/api/traffic/flow/monthly' && control.hold === url.searchParams.get('month')) await gate
if (path === '/api/traffic/flow/monthly') return route.fulfill({ json: { month: url.searchParams.get('month'), bytes: 1500, countries: [{ id: 'RU', label: 'Россия', bytes: 1500, packets: 1, bps: 0, percent: 100 }], services: [], asns: [] } })
if (path === '/api/traffic/flow/analytics') {
@@ -19,7 +20,7 @@ async function setup(page: Page) {
if (path === '/api/traffic/flow/live') return route.fulfill({ contentType: 'text/event-stream', body: 'event: sample\ndata: ' + JSON.stringify({ ...data, live: true }) + '\n\n' + (control.invalidSample === undefined ? '' : 'event: sample\ndata: ' + JSON.stringify(control.mergeSample ? { ...data, ...(control.invalidSample as object) } : control.invalidSample) + '\n\n') })
if (path === '/api/traffic/flow/exporters' && control.failList) return route.fulfill({ status: 403, json: { error: 'Нет доступа к экспортёрам' } })
const json = path === '/api/auth/config' ? { required: false }
: path === '/api/traffic/flow/exporters' ? { exporters: [card, { ...card, id: '2', name: 'Экспортёр второй' }] }
: path === '/api/traffic/flow/exporters' ? { exporters: control.emptyExporters ? [] : [card, { ...card, id: '2', name: 'Экспортёр второй' }] }
: path === '/api/traffic/flow/clients' ? { clients: [{ ...card, id: 'client1', name: 'Клиент тест' }] }
: path === '/api/traffic/flows' ? { exportersOnline: 1, bytesPerMin: 1, uniqueSrc: 1, uniqueDst: 1, topProto: 'TCP', talkers: [], listenerBound: true }
: path === '/api/traffic/servers' ? { servers: [] }
@@ -401,3 +402,28 @@ test('Flow: выбор месяца игнорирует поздний отчё
await expect(field).toBeFocused()
await expect(page.getByRole('button', { name: 'Месяц', exact: true })).toHaveAttribute('aria-pressed', 'true')
})
test('Flow: месячный отчёт доступен серверу без активных потоков; ошибка каталога допускает повтор', async ({ page }) => {
const control = await setup(page)
control.emptyExporters = true
await page.getByRole('button', { name: 'Обновить', exact: true }).click()
await expect(page.getByRole('heading', { name: 'Выберите сервер или клиента', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'Месяц', exact: true }).click()
const inactive = page.getByRole('button', { name: 'Сервер без потоков', exact: true })
const request = page.waitForRequest(r => r.url().includes('/flow/monthly?') && r.url().includes('serverId=3'))
await inactive.click()
await request
await expect(inactive).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByRole('heading', { name: 'Сервер без потоков', exact: true })).toBeVisible()
await expect(page.getByRole('region', { name: 'Месячный отчёт' })).toBeVisible()
await expect(page.getByLabel('Мини-график истории RX/TX')).toHaveCount(0)
control.failCatalog = true
await page.getByRole('button', { name: 'Обновить', exact: true }).click()
await expect(page.getByRole('alert').filter({ hasText: 'Потоки недоступны' })).toContainText('Каталог недоступен')
await expect(page.getByRole('region', { name: 'Месячный отчёт' })).toHaveCount(0)
control.failCatalog = false
await page.getByRole('button', { name: 'Повторить загрузку потоков', exact: true }).click()
await expect(inactive).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByRole('region', { name: 'Месячный отчёт' })).toBeVisible()
})