fix(ui): выбирать календарный месяц отчёта потоков
This commit is contained in:
1 parent
7a01e8d545
commit
7dd377d7c3
6 files changed
+76
-13
No files matched your search
@@ -804,6 +804,8 @@ export default function TrafficPage() {
|
||||
const flowStats = currentFlows?.stats ?? null
|
||||
const flowExporters = useMemo(() => currentFlows?.exporters ?? [], [currentFlows])
|
||||
const flowClients = useMemo(() => currentFlows?.clients ?? [], [currentFlows])
|
||||
const [flowMonth, setFlowMonth] = useState(() => new Date().toISOString().slice(0, 7))
|
||||
const flowMonthValid = /^(?!0000)\d{4}-(0[1-9]|1[0-2])$/.test(flowMonth) && flowMonth <= new Date().toISOString().slice(0, 7)
|
||||
const [analyticsRetry, setAnalyticsRetry] = useState(0)
|
||||
const [analyticsResult, setAnalyticsResult] = useState<{ key: string; receivedAt?: number; data?: FlowAnalyticsDto; monthly?: FlowMonthlyDto; error?: string } | null>(null)
|
||||
const flowIfaceOwner = JSON.stringify([isLive, backendUrl, groupMode, flowScope, selectedId])
|
||||
@@ -894,14 +896,14 @@ export default function TrafficPage() {
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, effectiveMode, backendUrl, range, flowScope, flowKey])
|
||||
|
||||
const analyticsKey = JSON.stringify([flowKey, selectedId, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, analyticsRetry])
|
||||
const analyticsKey = JSON.stringify([flowKey, selectedId, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, analyticsRetry, range === "30d" ? flowMonth : null])
|
||||
const currentAnalytics = analyticsResult?.key === analyticsKey ? analyticsResult : null
|
||||
const analyticsLoading = isLive && effectiveMode === "flows" && Boolean(selectedId) && !currentAnalytics
|
||||
const analyticsLoading = isLive && effectiveMode === "flows" && Boolean(selectedId) && (range !== "30d" || flowMonthValid) && !currentAnalytics
|
||||
useEffect(() => {
|
||||
if (!isLive || effectiveMode !== "flows" || !selectedId) return
|
||||
if (!isLive || effectiveMode !== "flows" || !selectedId || (range === "30d" && !flowMonthValid)) return
|
||||
let cancelled = false
|
||||
const request = range === "30d"
|
||||
? getFlowMonthly(backendUrl, { month: new Date().toISOString().slice(0, 7), serverId: flowScope === "servers" ? selectedId : undefined }).then(monthly => ({ monthly }))
|
||||
? getFlowMonthly(backendUrl, { month: flowMonth, serverId: flowScope === "servers" ? selectedId : undefined }).then(monthly => ({ monthly }))
|
||||
: getFlowAnalytics(backendUrl, {
|
||||
range, serverId: flowScope === "servers" ? selectedId : undefined,
|
||||
userId: flowScope === "users" ? selectedId : undefined,
|
||||
@@ -913,7 +915,7 @@ export default function TrafficPage() {
|
||||
if (!cancelled) setAnalyticsResult({ key: analyticsKey, error: e instanceof Error ? e.message : "Не удалось загрузить аналитику" })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl, analyticsKey])
|
||||
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl, analyticsKey, flowMonth, flowMonthValid])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) return
|
||||
@@ -1285,6 +1287,9 @@ export default function TrafficPage() {
|
||||
analytics={displayedFlow}
|
||||
monthly={currentAnalytics?.monthly}
|
||||
monthlyAvailable={flowScope === "servers"}
|
||||
month={flowMonth}
|
||||
onMonth={setFlowMonth}
|
||||
monthValid={flowMonthValid}
|
||||
range={range}
|
||||
onRange={(r) => setRange(r as Range)}
|
||||
selectedIface={flowIface}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TrafficMiniChart } from "@/components/reui-kit/traffic-mini-chart"
|
||||
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
@@ -44,7 +45,7 @@ const RANGE_LABELS: Record<string, string> = {
|
||||
"1h": "1ч",
|
||||
"4h": "4ч",
|
||||
"24h": "24ч",
|
||||
"30d": "Текущий месяц",
|
||||
"30d": "Месяц",
|
||||
}
|
||||
|
||||
|
||||
@@ -305,6 +306,9 @@ export function FlowAnalyticsDetail({
|
||||
analytics,
|
||||
monthly,
|
||||
monthlyAvailable = true,
|
||||
month,
|
||||
onMonth,
|
||||
monthValid,
|
||||
range,
|
||||
onRange,
|
||||
selectedIface,
|
||||
@@ -322,6 +326,9 @@ export function FlowAnalyticsDetail({
|
||||
analytics: FlowAnalyticsDto | null
|
||||
monthly?: FlowMonthlyDto
|
||||
monthlyAvailable?: boolean
|
||||
month: string
|
||||
onMonth: (value: string) => void
|
||||
monthValid: boolean
|
||||
range: string
|
||||
onRange: (r: string) => void
|
||||
selectedIface: string
|
||||
@@ -407,6 +414,7 @@ export function FlowAnalyticsDetail({
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => onRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={cn(
|
||||
"h-7 px-2 text-xs rounded border transition-colors",
|
||||
range === r
|
||||
@@ -421,6 +429,11 @@ export function FlowAnalyticsDetail({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{range === "30d" && <div className="mb-3 flex flex-col gap-2">
|
||||
<Label htmlFor="flow-report-month">Месяц отчёта (UTC)</Label>
|
||||
<Input id="flow-report-month" type="month" value={month} max={new Date().toISOString().slice(0, 7)} onChange={event => onMonth(event.target.value)} aria-invalid={!monthValid} aria-describedby={!monthValid ? "flow-report-month-error" : undefined} />
|
||||
{!monthValid && <p id="flow-report-month-error" role="alert" className="text-sm text-destructive">Выберите существующий месяц не позднее текущего.</p>}
|
||||
</div>}
|
||||
{range === "30d" && <p className="text-sm text-muted-foreground mb-3">Календарный месяц по UTC. Фильтры интерфейса, дублей, overlay и mesh к этому отчёту не применяются.</p>}
|
||||
{monthly && range === "30d" && <section aria-label="Месячный отчёт" className="flex flex-col gap-4">
|
||||
<p className="text-sm">Период: {monthly.month} (UTC) · Объём: {formatBytes(monthly.bytes)}</p>
|
||||
|
||||
+2
-2
@@ -88,8 +88,8 @@ flowchart LR
|
||||
|
||||
Ближайший оставшийся объём:
|
||||
|
||||
1. Молчащий SSE и CRLF/heartbeat проверены в блоках 8.6–8.7. Одиночные точки графика исправлены в 8.8; ожидание первого sample проверено в 8.9. Следующий шаг — месячный отчёт и доступность.
|
||||
2. Итоговая приёмка этапа 8: решение по архивным серверам/месяцам, ReUI/accessibility checklist. Промежуточная матрица проверок сохранена в [этапе 8](ux-ui-stage-8.md).
|
||||
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).
|
||||
3. После фиксации результата этапа 8 — этап 9.
|
||||
|
||||
## Порядок продолжения и журналирования
|
||||
|
||||
@@ -24,13 +24,20 @@
|
||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.9; ожидание первого SSE проверено, далее месячный отчёт и доступность |
|
||||
| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.10; выбор месяца готов, далее архивные серверы и доступность |
|
||||
| 9 | VPN, Firewall и Filters | Не начат |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 8.10 — выбор месяца flow-отчёта (2026-09-28)
|
||||
|
||||
- Месячный отчёт использует выбранный календарный месяц UTC через существующий GET /api/traffic/flow/monthly. Доступны текущий и прошлые месяцы; пустые, некорректные и будущие значения блокируют запрос и показывают связанную с полем ошибку. Текущий месяц остаётся начальным значением.
|
||||
- Месяц входит в ключ аналитики, старый отчёт скрыт при смене/невалидном вводе; поздний ответ предыдущего месяца не применяется. API и правила сетевых операций не менялись. Кнопка периода переименована в «Месяц»; кнопки периодов получили aria-pressed, поле — Label/aria-invalid/aria-describedby через существующий Input.
|
||||
- 23 прежних flow-E2E прошли, включая адаптивность 320/390/1440. Новый сценарий смены 2026-08→2026-07, позднего ответа, пустого/будущего месяца и восстановления прошёл после уточнения селектора alert (Next.js добавляет свой announcer). Проверены программный фокус и aria-pressed; это не полный клавиатурный аудит. Итоговые lint, diff-check и production build/TypeScript прошли (34 страницы).
|
||||
- Следующий шаг: выбор серверов без активности в последние 24 часа для месячного отчёта; затем оставшийся ReUI/accessibility checklist и итоговая регрессия. Пока отчёт доступен только для серверов текущего списка экспортёров; исторические удалённые серверы отдельно не перечисляются. Реальный RouterOS/IPFIX/JWT не проверен. Использовано 52% пятичасового и 24% недельного лимита. Локальный коммит без push.
|
||||
|
||||
## Блок 8.9 — ожидание первого SSE sample (2026-09-28)
|
||||
|
||||
- Управляемый ReadableStream умеет открываться без первого sample. Добавлены flow/серверные сценарии: до первого сообщения нет фиктивной live-метки или времени live-измерения; отображается подтверждённая REST-история.
|
||||
|
||||
@@ -50,3 +50,5 @@
|
||||
Обновление 8.8 (2026-09-28): одиночные точки RX/TX, включая нули между пропусками, получили маркеры. Итоговые 10 тестов деталей и build/lint прошли; перед финальным подключением TX проверены 38 сценариев потребителей. Далее — первый SSE sample и оставшаяся приёмка.
|
||||
|
||||
Обновление 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 часа остаётся следующим шагом; полный аудит доступности ещё не завершён.
|
||||
@@ -9,6 +9,7 @@ async function setup(page: Page) {
|
||||
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/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') {
|
||||
if (control.hold === range || (control.holdIface && url.searchParams.get('iface') === control.holdIface)) await gate
|
||||
@@ -85,7 +86,7 @@ test('Flow: завершённый SSE не помечает последние
|
||||
test('Flow: месяц показывает только доступные агрегаты и не применяется к клиенту', async ({ page }) => {
|
||||
await setup(page)
|
||||
const monthlyRequest = page.waitForRequest(r => r.url().includes('/flow/monthly?'))
|
||||
await page.getByRole('button', { name: 'Текущий месяц', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Месяц', exact: true }).click()
|
||||
const url = new URL((await monthlyRequest).url())
|
||||
expect(url.searchParams.get('serverId')).toBe('1')
|
||||
expect(url.searchParams.get('month')).toMatch(/^\d{4}-\d{2}$/)
|
||||
@@ -103,7 +104,7 @@ test('Flow: месяц показывает только доступные аг
|
||||
expect(new URL((await clientRequest).url()).searchParams.get('range')).toBe('1h')
|
||||
await expect(page.getByLabel('Скорость потоков')).toBeVisible()
|
||||
await expect(report).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Текущий месяц', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole('button', { name: 'Месяц', exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole('switch', { name: 'Без дублей' })).toBeEnabled()
|
||||
})
|
||||
|
||||
@@ -113,10 +114,10 @@ for (const width of [320, 390, 1440]) {
|
||||
await setup(page)
|
||||
for (const monthly of [false, true]) {
|
||||
if (monthly) {
|
||||
await page.getByRole('button', { name: 'Текущий месяц', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Месяц', exact: true }).click()
|
||||
await expect(page.getByRole('region', { name: 'Месячный отчёт' })).toBeVisible()
|
||||
}
|
||||
const button = page.getByRole('button', { name: 'Текущий месяц', exact: true })
|
||||
const button = page.getByRole('button', { name: 'Месяц', exact: true })
|
||||
const box = await button.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0)
|
||||
@@ -365,3 +366,38 @@ test('Flow: ожидание первого sample использует исто
|
||||
await expect(receipt).toContainText('Получено (live-поток)')
|
||||
await expect(receipt.locator('time')).not.toHaveAttribute('datetime', stamp!)
|
||||
})
|
||||
|
||||
|
||||
test('Flow: выбор месяца игнорирует поздний отчёт и блокирует пустое значение', async ({ page }) => {
|
||||
const control = await setup(page)
|
||||
await page.getByRole('button', { name: 'Месяц', exact: true }).click()
|
||||
const field = page.getByLabel('Месяц отчёта (UTC)', { exact: true })
|
||||
const report = page.getByRole('region', { name: 'Месячный отчёт' })
|
||||
await expect(report).toBeVisible()
|
||||
control.hold = '2026-08'
|
||||
const old = page.waitForRequest(r => r.url().includes('/flow/monthly?') && r.url().includes('month=2026-08'))
|
||||
await field.fill('2026-08')
|
||||
await old
|
||||
await expect(report).toHaveCount(0)
|
||||
await field.fill('2026-07')
|
||||
await expect(report).toContainText('2026-07 (UTC)')
|
||||
const late = page.waitForResponse(r => r.url().includes('/flow/monthly?') && r.url().includes('month=2026-08'))
|
||||
control.release()
|
||||
await late
|
||||
await expect(report).toContainText('2026-07 (UTC)')
|
||||
const requests: string[] = []
|
||||
page.on('request', r => { if (r.url().includes('/flow/monthly?')) requests.push(r.url()) })
|
||||
await field.fill('')
|
||||
await expect(field).toHaveAttribute('aria-invalid', 'true')
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Выберите существующий месяц' })).toBeVisible()
|
||||
await expect(report).toHaveCount(0)
|
||||
await field.fill('2100-01')
|
||||
await expect(field).toHaveAttribute('aria-invalid', 'true')
|
||||
expect(requests).toHaveLength(0)
|
||||
await field.fill('2026-07')
|
||||
await expect(report).toContainText('2026-07 (UTC)')
|
||||
await expect(field).toHaveAttribute('aria-invalid', 'false')
|
||||
await field.focus()
|
||||
await expect(field).toBeFocused()
|
||||
await expect(page.getByRole('button', { name: 'Месяц', exact: true })).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
Reference in new issue
Block a user