fix(ui): показывать временные отметки трафика серверов
This commit is contained in:
1 parent
65241c94e9
commit
c6d146d38f
7 files changed
+56
-27
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Ближайший оставшийся объём:
|
||||
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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'))
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user