fix(ui): адаптировать длинные данные и пустые списки трафика

This commit is contained in:
Denozordec committed 2026-09-24 09:53:10 +07:00
1 parent cb16c03e01
commit 094c812c37
3 files changed
+86 -12

No files matched your search

+17 -12
View File
@@ -323,7 +323,7 @@ function StatChip({ icon, label, value }: { icon: ReactNode; label: string; valu
return (
<div className="flex items-center gap-2">
<div className="flex items-center justify-center size-7 rounded-md bg-muted shrink-0">{icon}</div>
<div className="leading-tight">
<div className="leading-tight min-w-0 break-words">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-semibold tabular-nums">{value}</p>
</div>
@@ -475,13 +475,13 @@ function IfaceCard({ c, selected, onClick }: { c: BoundIfaceTraffic; selected: b
function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; showServer?: boolean }) {
return (
<div className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg border bg-card hover:bg-muted/30 transition-colors",
"flex flex-wrap items-center gap-3 px-3 py-2.5 rounded-lg border bg-card hover:bg-muted/30 transition-colors",
c.status === "offline" && "opacity-55",
)}>
<StatusDot status={c.status} />
<div className="flex-1 min-w-0">
<div className="min-w-0 basis-full sm:basis-0 sm:flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono font-semibold">{boundIfaceLabel(c)}</span>
<span className="text-xs font-mono font-semibold break-all">{boundIfaceLabel(c)}</span>
<Badge variant={TYPE_VARIANT[c.interfaceType]} size="sm">{IFACE_TYPE_LABEL[c.interfaceType]}</Badge>
{showServer && (
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
@@ -558,7 +558,7 @@ function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
rxTotal: number; txTotal: number; rxSeries: number[]; txSeries: number[]
}) {
return (
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4">
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4 [&>div]:min-w-0 [&_svg]:max-w-full">
<div>
<p className="text-xs text-muted-foreground mb-1">Получено за период</p>
<p className="text-lg font-semibold tabular-nums">
@@ -595,7 +595,7 @@ function ServerDetail({
<>
<DetailHeader range={range} setRange={setRange}>
<StatusDot status={sel.status} />
<h2 className="text-base font-semibold">{sel.name}</h2>
<h2 className="text-base font-semibold break-all">{sel.name}</h2>
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded flex items-center gap-1">
<Flag code={sel.country} />{sel.site}
</span>
@@ -662,8 +662,8 @@ function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range;
<UsersIcon className="size-4 text-muted-foreground" />
</div>
<div className="leading-tight">
<h2 className="text-base font-semibold">{sel.login}</h2>
<p className="text-xs text-muted-foreground">{sel.displayName} · {sel.role}</p>
<h2 className="text-base font-semibold break-all">{sel.login}</h2>
<p className="text-xs text-muted-foreground break-all">{sel.displayName} · {sel.role}</p>
</div>
</DetailHeader>
{!hasClients ? (
@@ -698,7 +698,7 @@ function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range:
<DetailHeader range={range} setRange={setRange}>
<StatusDot status={sel.status} />
<div className="leading-tight min-w-0">
<h2 className="text-base font-mono font-semibold">{boundIfaceLabel(sel)}</h2>
<h2 className="text-base font-mono font-semibold break-all">{boundIfaceLabel(sel)}</h2>
<p className="text-xs text-muted-foreground truncate">{sel.comment || sel.userLogin}</p>
</div>
<Badge variant={TYPE_VARIANT[sel.interfaceType]} size="sm">{IFACE_TYPE_LABEL[sel.interfaceType]}</Badge>
@@ -717,7 +717,7 @@ function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range:
<div className="mt-4 pt-4 border-t grid grid-cols-2 sm:grid-cols-3 gap-4">
<div>
<p className="text-xs text-muted-foreground mb-0.5">Сервер</p>
<p className="text-sm font-mono">{sel.serverName}</p>
<p className="text-sm font-mono break-all">{sel.serverName}</p>
</div>
<div>
<p className="text-xs text-muted-foreground mb-0.5">Тип</p>
@@ -725,7 +725,7 @@ function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range:
</div>
<div>
<p className="text-xs text-muted-foreground mb-0.5">Пользователь</p>
<p className="text-sm font-mono">{sel.userLogin}</p>
<p className="text-sm font-mono break-all">{sel.userLogin}</p>
</div>
</div>
</>
@@ -1178,7 +1178,7 @@ export default function TrafficPage() {
{/* ── mode tab bar ── */}
<div className="border-b bg-background shrink-0">
<div className="flex items-center overflow-x-auto px-3 sm:px-6">
<div role="group" aria-label="Разделы трафика" className="flex items-center overflow-x-auto px-3 sm:px-6">
{GROUP_MODES.map(({ mode, icon, label }) => (
<button key={mode} onClick={() => handleModeChange(mode)}
className={cn(
@@ -1360,6 +1360,11 @@ export default function TrafficPage() {
{isLive && trafficLoading && <p role="status" className="text-sm text-muted-foreground">Загрузка списка трафика…</p>}
{trafficError && <Alert variant="destructive" role="alert"><AlertTitle>Список трафика недоступен</AlertTitle><AlertDescription>{trafficError}<Button type="button" size="sm" variant="outline" onClick={() => setTrafficRetry(value => value + 1)}>Повторить загрузку списка</Button></AlertDescription></Alert>}
{(!isLive || trafficReady) && (effectiveMode === "servers" ? sortedServers.length : effectiveMode === "users" ? sortedUsers.length : sortedIfaces.length) === 0 && (
<p role="status" className="text-sm text-muted-foreground">
{search.trim() ? "По вашему запросу ничего не найдено" : effectiveMode === "servers" ? "Нет серверов с данными трафика" : effectiveMode === "users" ? "Нет клиентов с данными трафика" : "Нет привязанных интерфейсов с данными трафика"}
</p>
)}
{/* entity list */}
<div className="flex flex-col gap-2">
{effectiveMode === "servers" && sortedServers.map(s => (
+8
View File
@@ -29,6 +29,14 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 8.4Б — клиенты, интерфейсы и пустые списки (2026-09-24)
- Длинные идентификаторы переносятся в деталях и строках привязанных интерфейсов; строки адаптированы под телефон. Мини-графики итогов ограничены шириной ячейки: устранено переполнение на 320 px.
- Пустой успешный список показывает объяснение для серверов/клиентов/интерфейсов; поиск без совпадений имеет отдельную подпись. Сообщения не показываются вместо загрузки или ошибки API. Выбранные детали при поиске сохраняются.
- 10 E2E прошли: четыре сценария деталей плюс клиенты/интерфейсы с длинными значениями на 320/390 px, пустой список клиентов и очистка поиска. Тесты проверяют выход элементов за край экрана, исключая намеренно прокручиваемую навигацию. Скриншот клиентов 320 px просмотрен; снимки сохранены в игнорируемом test-results.
- Production build/TypeScript прошли (34 страницы). Lint нового теста чистый; на странице остаётся прежняя ошибка синхронного сброса flowIface. diff-check чистый. Использовано 25% пятичасового и 66% недельного лимита.
- Далее: единицы/неизвестные значения, пустые состояния деталей и flow; оставшиеся проверки backend/сервера/SSE и строгая валидация sample. Этап 8 остаётся в работе. API-тесты с моками не подтверждают реальную телеметрию RouterOS/IPFIX.
## Блок 8.4А2 — мобильный период серверного трафика (2026-09-24)
- Добавлены E2E ширин 320/390 px: кнопка 24ч помещается в экран, меняет период, показывает подтверждённые детали; документ не переполняется по горизонтали. Всего 4 теста деталей прошли, lint теста чистый. Изменений production-кода в этом блоке нет.
+61
View File
@@ -0,0 +1,61 @@
import { test, expect, type Page } from '@playwright/test'
const longName = 'customer-with-a-very-long-unbroken-identifier-12345678901234567890'
async function setup(page: Page, empty = false) {
const metrics = { rxNow: 2, txNow: 3, rxPeak: 4, txPeak: 5, rxTotal: 100, txTotal: 200, rxSeries: [1, 2], txSeries: [2, 3] }
const iface = { ...metrics, id: 'i1', bindingId: 'b1', userId: 'u1', userLogin: longName, userName: longName, interfaceName: longName, interfaceType: 'gre', comment: longName, serverId: '1', serverName: longName, serverSite: 'TEST', serverCountry: 'RU', status: 'online' }
const user = { ...metrics, id: 'u1', login: longName, displayName: longName, role: 'viewer', interfaces: [iface] }
await page.route('**/health', r => r.fulfill({ json: { ok: true } }))
await page.route('**/api/**', route => {
const path = new URL(route.request().url()).pathname
const json = path === '/api/auth/config' ? { required: false }
: path === '/api/traffic/servers' ? { servers: [] }
: path === '/api/traffic/users' ? { users: empty ? [] : [user] }
: path === '/api/traffic/bound-interfaces' ? { interfaces: empty ? [] : [iface] }
: path.endsWith('/interfaces') ? { interfaces: [] }
: path === '/api/servers' ? [] : {}
return route.fulfill({ json })
})
await page.goto('/traffic')
}
for (const width of [320, 390]) {
for (const mode of ['Клиенты', 'Интерфейсы']) {
test(`Трафик: ${mode}, длинные значения, ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 })
await setup(page)
await page.getByRole('button', { name: mode, exact: true }).click()
const heading = page.getByRole('heading', { name: longName, exact: true })
await expect(heading).toBeVisible()
const box = await heading.boundingBox()
expect(box!.x + box!.width).toBeLessThanOrEqual(width + 1)
await page.getByRole('button', { name: '24ч', exact: true }).click()
await expect(heading).toBeVisible()
const overflow = await page.evaluate(() => [...document.querySelectorAll('main *')].filter(el => {
const r = el.getBoundingClientRect()
return r.width > 0 && r.right > innerWidth + 1 && getComputedStyle(el).position !== 'fixed' && !el.closest('[aria-label="Разделы трафика"]')
}).map(el => el.tagName + '.' + el.className))
expect(overflow).toEqual([])
await page.screenshot({ path: `test-results/traffic-${mode}-${width}.png`, fullPage: true })
})
}
}
test('Трафик: пустой список клиентов имеет объяснение', async ({ page }) => {
await setup(page, true)
await page.getByRole('button', { name: 'Клиенты', exact: true }).click()
await expect(page.getByText('Нет клиентов с данными трафика', { exact: true })).toBeVisible()
})
test('Трафик: поиск без совпадений сохраняет данные при очистке', async ({ page }) => {
await setup(page)
await page.getByRole('button', { name: 'Клиенты', exact: true }).click()
await expect(page.getByRole('heading', { name: longName, exact: true })).toBeVisible()
await page.getByPlaceholder('Поиск…').fill('отсутствующий-клиент')
await expect(page.getByText('По вашему запросу ничего не найдено', { exact: true })).toBeVisible()
await expect(page.getByText('Нет клиентов с данными трафика', { exact: true })).toHaveCount(0)
await page.getByPlaceholder('Поиск…').fill('')
await expect(page.getByText('По вашему запросу ничего не найдено', { exact: true })).toHaveCount(0)
await expect(page.getByRole('button').filter({ hasText: longName })).toHaveCount(1)
})