fix(ui): показывать временные отметки трафика серверов

This commit is contained in:
Denozordec committed 2026-09-28 11:27:20 +07:00
1 parent 65241c94e9
commit c6d146d38f
7 files changed
+56 -27

No files matched your search

+10 -5
View File
@@ -782,7 +782,7 @@ export default function TrafficPage() {
const [search, setSearch] = useState("")
const [trafficRetry, setTrafficRetry] = useState(0)
const trafficKey = JSON.stringify([isLive, backendUrl, range, groupMode, trafficRetry])
const [trafficState, setTrafficState] = useState<{ key: string; status: "ready" | "error"; error?: string } | null>(null)
const [trafficState, setTrafficState] = useState<{ key: string; status: "ready" | "error"; receivedAt?: number; error?: string } | null>(null)
const trafficReady = trafficState?.key === trafficKey && trafficState.status === "ready"
const trafficLoading = trafficState?.key !== trafficKey
const trafficError = trafficState?.key === trafficKey ? trafficState.error : undefined
@@ -791,7 +791,7 @@ export default function TrafficPage() {
const [selectedIface, setSelectedIface] = useState("__all__")
const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true)
const [detailRetry, setDetailRetry] = useState(0)
const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; error: string | null } | null>(null)
const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; receivedAt?: number; error: string | null } | null>(null)
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
const [flowScope, setFlowScope] = useState<FlowScope>("servers")
@@ -819,7 +819,7 @@ export default function TrafficPage() {
const detailKey = JSON.stringify([isLive, backendUrl, effectiveMode, selectedId, range, selectedIface, detailRetry])
const currentDetail = detailResult?.key === detailKey ? detailResult : null
const detailBusy = isLive && effectiveMode === "servers" && Boolean(selectedId) && currentDetail === null
const { sample: liveSample, error: liveStreamError } = useTrafficLive({
const { sample: liveSample, error: liveStreamError, receivedAt: liveReceivedAt } = useTrafficLive({
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
backendUrl,
serverId: selectedId,
@@ -860,7 +860,7 @@ export default function TrafficPage() {
setLiveServers(mapped)
setLiveUsers(mappedUsers)
setLiveBoundIfaces(liveIfaces)
setTrafficState({ key, status: "ready" })
setTrafficState({ key, status: "ready", receivedAt: Date.now() })
setSelectedId((prev) => {
if (groupMode === "users") return mappedUsers.some((x) => x.id === prev) ? prev : (mappedUsers[0]?.id ?? "")
if (groupMode === "ifaces") return liveIfaces.some((x) => x.id === prev) ? prev : (liveIfaces[0]?.id ?? "")
@@ -952,7 +952,7 @@ export default function TrafficPage() {
apiFetch<{ server: LiveTrafficServer }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}?range=${encodeURIComponent(range)}&iface=${encodeURIComponent(selectedIface)}`)
.then((res) => {
if (cancelled) return
setDetailResult({ key: detailKey, error: null, server: {
setDetailResult({ key: detailKey, receivedAt: Date.now(), error: null, server: {
...toLiveServer(res.server),
boundIfaces: liveBoundIfaces.filter((i) => i.serverId === selectedId),
} })
@@ -1183,6 +1183,7 @@ export default function TrafficPage() {
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
/>}
{isLive && effectiveMode !== "flows" && trafficReady && <TrafficDataTime label="Получение списка трафика" receivedAt={trafficState.receivedAt} source="API" />}
{effectiveMode === "flows" ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
@@ -1420,6 +1421,10 @@ export default function TrafficPage() {
{detailBusy && <p role="status" className="text-sm text-muted-foreground">Загрузка выбранного периода и интерфейса…</p>}
{currentDetail?.error && <Alert variant="destructive" role="alert"><AlertTitle>Детали трафика недоступны</AlertTitle><AlertDescription>{currentDetail.error}<Button type="button" size="sm" variant="outline" onClick={() => setDetailRetry(n => n + 1)}>Повторить загрузку деталей</Button></AlertDescription></Alert>}
</>}
{isLive && effectiveMode === "servers" && detailServer && <>
<TrafficDataTime label="Получение истории сервера" receivedAt={currentDetail?.receivedAt} source="API" />
<TrafficDataTime label="Последнее измерение сервера" receivedAt={liveReceivedAt} source="live-поток" serverAt={liveSample?.at} />
</>}
{effectiveMode === "servers" && detailServer && (
<ServerDetail
sel={detailServer}
+5 -2
View File
@@ -1,15 +1,18 @@
/** Receipt time is not the timestamp of a network measurement. */
export function TrafficDataTime({ label, receivedAt, source }: {
export function TrafficDataTime({ label, receivedAt, source, serverAt }: {
label: string
receivedAt?: number | null
source: "API" | "live-поток"
serverAt?: string
}) {
if (receivedAt == null || !Number.isFinite(receivedAt)) return null
const date = new Date(receivedAt)
return (
<p aria-label={label} className="text-xs text-muted-foreground mb-3 break-words">
Получено ({source}): <time dateTime={date.toISOString()}>{date.toLocaleString("ru-RU")}</time>
{" · время измерения не предоставлено"}
{serverAt && Number.isFinite(Date.parse(serverAt))
? <> · метка сервера: <time dateTime={serverAt}>{new Date(serverAt).toLocaleString("ru-RU")}</time></>
: " · время измерения не предоставлено"}
</p>
)
}
+1 -1
View File
@@ -84,7 +84,7 @@ flowchart LR
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8](ux-ui-stage-8.md).
Для этапа 8 уже выполнены блоки периодов/URL статистики, ошибок и гонок трафика, месячного отчёта, адаптивности, неизвестных метрик, пустой истории и разрывов графиков. Проверены смена flow-сервера, отмена SSE до ответа, позднее чтение открытого SSE на управляемом ReadableStream и смена backend через настройки. Точный состав тестов и оговорки — в журнале, блоки 8.1–8.4О. В flow добавлены отметки получения API/SSE; свежесть остальных видов трафика ещё требует реализации.
Для этапа 8 уже выполнены блоки периодов/URL статистики, ошибок и гонок трафика, месячного отчёта, адаптивности, неизвестных метрик, пустой истории и разрывов графиков. Проверены смена flow-сервера, отмена SSE до ответа, позднее чтение открытого SSE на управляемом ReadableStream и смена backend через настройки. Точный состав тестов и оговорки — в журнале, блоки 8.1–8.4П. В flow и остальных видах трафика добавлены отметки получения API/SSE; для серверного SSE отдельно показана метка обработки ответа сервером. Далее — сводная приёмка этапа 8.
Ближайший оставшийся объём:
+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.4О; далее свежесть остальных видов трафика и приёмка |
| 8 | Трафик и статистика | В работе: выполнены блоки 8.1–8.4П; далее сводная приёмка этапа 8 |
| 9 | VPN, Firewall и Filters | Не начат |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 8.4П — временные отметки остальных видов трафика (2026-09-28)
- Общий REST-список серверов/клиентов/интерфейсов и история выбранного сервера показывают время успешного получения. При загрузке другого контекста или ошибке скрытые данные не оставляют прежнюю отметку.
- Серверный SSE хранит sample, receivedAt и ошибку вместе с ключом backend/сервера/интерфейса/режима. Удалён синхронный сброс через effect; hook проходит lint. Ошибка сохраняет время последнего валидного сообщения, смена интерфейса исключает чужое измерение.
- Поле at проверено в backend/src/services/traffic-rate.ts: parseMonitorTraffic создаёт его при обработке ответа. Поэтому подпись — «метка сервера», а не точное время измерения устройства. Время получения браузером отображается отдельно. Серверный контракт не изменён.
- Прошли 20 профильных E2E (детали, списки, SSE, мобильные клиенты/интерфейсы), затем 4 проверки SSE/flow после расширения сценария смены интерфейса. Финальная подпись проверена ещё 3 SSE-тестами и TypeScript. ESLint и diff-check чистые. Production build прошёл (34 страницы) до уточнения текста/имени prop; после уточнения выполнен tsc --noEmit --incremental false. Адаптивность 320/390 проверена тестами.
- Далее: сводная приёмка этапа 8 по журналу и stage-8: инвентаризация оставшихся проблем (неработающий экспорт, месячные ограничения, подписи текущих/исторических значений, молчащий SSE), затем согласованный набор регрессионных проверок. Таймаут SSE и достоверность времени устройства не реализованы этим блоком. Реальные RouterOS/IPFIX не проверены. На контрольной точке использовано 46% пятичасового и 7% недельного лимита. Локальный коммит без push.
## Блок 8.4О — время получения flow-данных (2026-09-28)
- Список потоков и выбранная аналитика/месячный отчёт показывают отдельное время успешного получения. Источник обозначен как API или live-поток. Подпись явно сообщает, что время измерения не предоставлено: локальные часы браузера не выдаются за метку сетевой телеметрии.
+10 -16
View File
@@ -24,20 +24,16 @@ export function useTrafficLive(opts: {
backendUrl: string
serverId: string
iface: string
}): { sample: TrafficLiveSample | null; error: string | null } {
const [sample, setSample] = useState<TrafficLiveSample | null>(null)
const [error, setError] = useState<string | null>(null)
}): { sample: TrafficLiveSample | null; error: string | null; receivedAt: number | null } {
const key = JSON.stringify([opts.enabled, opts.backendUrl, opts.serverId, opts.iface])
const [result, setResult] = useState<{ key: string; sample: TrafficLiveSample | null; receivedAt: number | null; error: string | null } | null>(null)
useEffect(() => {
if (!opts.enabled || !opts.serverId) {
setSample(null)
setError(null)
return
}
if (!opts.enabled || !opts.serverId) return
const ac = new AbortController()
setSample(null)
setError(null)
const setError = (error: string) => {
if (!ac.signal.aborted) setResult(previous => ({ key, sample: previous?.key === key ? previous.sample : null, receivedAt: previous?.key === key ? previous.receivedAt : null, error }))
}
const ifaceQ =
opts.iface && opts.iface !== "__all__"
? `?iface=${encodeURIComponent(opts.iface)}`
@@ -46,7 +42,6 @@ export function useTrafficLive(opts: {
const url = resolveApiUrl(opts.backendUrl, path)
let buf = ""
setError(null)
void (async () => {
try {
@@ -80,8 +75,7 @@ export function useTrafficLive(opts: {
if (!parsed || !Number.isFinite(parsed.rxMbps) || !Number.isFinite(parsed.txMbps) || parsed.rxMbps < 0 || parsed.txMbps < 0 || typeof parsed.at !== "string" || !Number.isFinite(Date.parse(parsed.at))) {
throw new Error("Некорректные данные live-потока")
}
setSample(parsed)
setError(null)
setResult({ key, sample: parsed, receivedAt: Date.now(), error: null })
} else if (ev.event === "error" && ev.data) {
const parsed = JSON.parse(ev.data) as { error?: string }
setError(parsed.error ?? "live error")
@@ -95,7 +89,7 @@ export function useTrafficLive(opts: {
})()
return () => ac.abort()
}, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface])
}, [opts.enabled, opts.backendUrl, opts.serverId, opts.iface, key])
return { sample, error }
return result?.key === key ? { sample: result.sample, error: result.error, receivedAt: result.receivedAt } : { sample: null, error: null, receivedAt: null }
}
+1
View File
@@ -35,6 +35,7 @@ test('Детали трафика: поздний период не заменя
await started
await expect(page.getByText('Загрузка выбранного периода и интерфейса…', { exact: true })).toBeVisible()
await expect(page.getByLabel('Скорость выбранного сервера')).toHaveCount(0)
await expect(page.getByLabel('Получение истории сервера')).toHaveCount(0)
await page.getByRole('button', { name: '15м', exact: true }).click()
await expect(page.getByRole('heading', { name: 'Детали 15m __all__', exact: true })).toBeVisible()
const late = page.waitForResponse(r => new URL(r.url()).pathname === '/api/traffic/servers/1' && r.url().includes('range=24h'))
+20 -2
View File
@@ -5,9 +5,10 @@ for (const variant of ['closed', 'invalid', 'http-error'] as const) {
const server = { id: '1', name: 'Тестовый сервер', site: 'TEST', country: 'RU', status: 'online', rxNow: 2, txNow: 3, rxPeak: 5, txPeak: 6, rxTotal: 100, txTotal: 200, sessions: 1, rxSeries: [1, 2], txSeries: [2, 3] }
await page.route('**/health', route => route.fulfill({ json: { ok: true } }))
await page.route('**/api/**', route => {
const path = new URL(route.request().url()).pathname
const url = new URL(route.request().url())
const path = url.pathname
if (path.endsWith('/live')) {
if (variant === 'http-error') return route.fulfill({ status: 503, json: { error: 'Поток недоступен' } })
if (variant === 'http-error' || url.searchParams.get('iface') === 'ether2') return route.fulfill({ status: 503, json: { error: 'Поток недоступен' } })
const sample = { rxMbps: 17, txMbps: 19, at: '2026-09-23T12:00:00Z' }
const body = 'event: sample\ndata: ' + JSON.stringify(sample) + '\n\n' + (variant === 'invalid' ? 'event: sample\ndata: {"rxMbps":null,"txMbps":-1}\n\n' : '')
return route.fulfill({ contentType: 'text/event-stream', body })
@@ -15,20 +16,37 @@ for (const variant of ['closed', 'invalid', 'http-error'] as const) {
const json = path === '/api/auth/config' ? { required: false }
: path === '/api/traffic/servers' ? { servers: [server] }
: path === '/api/traffic/servers/1' ? { server }
: path === '/api/traffic/servers/1/interfaces' ? { interfaces: [{ name: 'ether2', running: true, disabled: false, rxNow: 0, txNow: 0 }] }
: path.endsWith('/interfaces') || path === '/api/traffic/bound-interfaces' ? { interfaces: [] }
: path === '/api/traffic/users' ? { users: [] }
: path === '/api/servers' ? [] : {}
return route.fulfill({ json })
})
const received = new Date('2026-09-28T06:00:00Z')
await page.clock.setFixedTime(received)
await page.goto('/traffic')
const summary = page.getByLabel('Скорость выбранного сервера')
await expect(summary).toBeVisible()
const hint = variant === 'http-error' ? 'История · live недоступен' : 'Последнее live-измерение · поток недоступен'
await expect(summary.getByText(hint, { exact: true }).filter({ visible: true })).toHaveCount(2)
await expect(summary.getByText('live', { exact: true })).toHaveCount(0)
await expect(page.getByLabel('Получение истории сервера').locator('time')).toHaveAttribute('datetime', received.toISOString())
const measurement = page.getByLabel('Последнее измерение сервера')
if (variant === 'http-error') await expect(measurement).toHaveCount(0)
if (variant !== 'http-error') {
await expect(measurement.locator('time').nth(0)).toHaveAttribute('datetime', received.toISOString())
await expect(measurement).toContainText('метка сервера:')
await expect(measurement.locator('time').nth(1)).toHaveAttribute('datetime', '2026-09-23T12:00:00Z')
await page.clock.setFixedTime(new Date('2026-09-28T06:05:00Z'))
await expect(measurement.locator('time').nth(0)).toHaveAttribute('datetime', received.toISOString())
await expect(summary).toContainText('17.0')
await expect(summary).toContainText('19.0')
if (variant === 'closed') {
await page.getByRole('button', { name: 'ether2', exact: true }).click()
await expect(summary.getByText('История · live недоступен', { exact: true }).filter({ visible: true })).toHaveCount(2)
await expect(measurement).toHaveCount(0)
await expect(summary).not.toContainText('17.0')
}
}
})
}