Files
Denozordec 5efd0339fb
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 4m35s
Docker images / frontend-image (push) Successful in 3m29s
Docker images / updater-image (push) Successful in 41s
Docker images / netflow-agent-image (push) Successful in 34s
Docker images / backend-image (push) Successful in 1m44s
Docker images / publish-release (push) Successful in 10s
Docker images / notify-webhook (push) Successful in 2s
feat(logs): добавить раздел логов с историей и живым потоком
2026-10-16 22:13:48 +07:00

156 lines
12 KiB
TypeScript

import { test, expect, type Page } from "@playwright/test"
const headers = { "access-control-allow-origin": "http://127.0.0.1:3101", "access-control-allow-credentials": "true", "access-control-allow-methods": "GET,PUT,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization" }
const levels = { critical: 1, error: 0, warning: 1, info: 1, debug: 0, unknown: 0 }
const settings = { enabled: true, intervalSec: 10, retentionDays: 14 }
const servers = [1, 2].map(id => ({ id, name: `MikroTik-${id}`, host: `192.0.2.${id}`, enabled: true, country: "RU", type: "home-router", status: "online" }))
const row = (id: number, serverId = 1) => ({ id: String(id), serverId, serverName: `MikroTik-${serverId}`, host: `192.0.2.${serverId}`, routerId: `*${id}`, rawTime: "12:00:00", eventAt: null, receivedAt: new Date().toISOString(), effectiveAt: new Date().toISOString(), timeKind: "receipt", topics: ["system", id === 2 ? "warning" : id === 3 ? "error" : "info"], level: id === 2 ? "warning" : id === 3 ? "critical" : "info", message: `Событие ${id} сервера ${serverId} — interface ether1`, generation: 0 })
const state = (id: number) => ({ serverId: id, serverName: `MikroTik-${id}`, host: `192.0.2.${id}`, generation: 0, lastCollectedAt: new Date().toISOString(), lastError: null, gapAt: null, nextPollAt: null, failures: 0 })
async function setup(page: Page, options: { count?: number; historyError?: boolean } = {}) {
const calls: URL[] = []
await page.route("**/health", route => route.fulfill({ headers, json: { status: "ok" } }))
await page.route("**/api/**", route => {
const url = new URL(route.request().url()), path = url.pathname
calls.push(url)
if (path === "/api/auth/config") return route.fulfill({ headers, json: { required: false } })
if (path === "/api/servers") return route.fulfill({ headers, json: servers })
if (path === "/api/logs/settings") return route.fulfill({ headers, json: route.request().method() === "PUT" ? { ok: true, settings: { ...settings, ...route.request().postDataJSON() } } : settings })
if (path === "/api/logs/summary") return route.fulfill({ headers, json: { settings, total: options.count ?? 3, levels, states: [state(1), state(2)], buckets: [{ at: new Date(Date.now() - 60000).toISOString(), ...levels }] } })
if (path === "/api/logs") return route.fulfill(options.historyError ? { headers, status: 503, json: { error: "История временно недоступна" } } : { headers, json: { entries: Array.from({ length: options.count ?? 3 }, (_, i) => row((options.count ?? 3) - i, Number(url.searchParams.get("serverId") ?? 1))), nextCursor: null, watermark: String(options.count ?? 3) } })
if (path === "/api/logs/live") return route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: `event: batch\ndata: ${JSON.stringify({ entries: [], cursor: url.searchParams.get("cursor"), state: state(Number(url.searchParams.get("serverId"))), enabled: true })}\n\n` })
if (/\/api\/logs\/\d+\/context$/.test(path)) { const id = Number(path.split("/").at(-2)); return route.fulfill({ headers, json: { entry: row(id), before: [row(1)], after: [row(4)] } }) }
if (path === "/api/logs/export") return route.fulfill({ headers: { ...headers, "content-type": "text/csv", "content-disposition": 'attachment; filename="router-logs.csv"' }, body: "time,message\nnow,test" })
return route.fulfill({ headers, json: {} })
})
return calls
}
test("Логи: общий выбор серверов, история и детали", async ({ page }) => {
const errors: string[] = []; page.on("pageerror", error => errors.push(error.message))
const calls = await setup(page)
await page.goto("/logs")
await expect(page).toHaveURL(/server=1/)
await expect(page.getByRole("heading", { name: "Логи MikroTik" })).toBeVisible()
await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toBeVisible()
await page.getByRole("button", { name: "Открыть событие 3", exact: true }).click()
await expect(page.getByRole("dialog")).toBeVisible()
await expect(page.getByRole("heading", { name: "Событие RouterOS" })).toBeVisible()
await page.getByRole("tab", { name: "Исходные поля" }).click()
await expect(page.getByRole("tabpanel")).toContainText('".id": "*3"')
await page.getByRole("tab", { name: "Контекст", exact: true }).click()
await expect(page.getByRole("tabpanel")).toContainText("Событие 4")
await page.keyboard.press("Escape")
await page.getByRole("option", { name: /MikroTik-2/ }).click()
await expect(page).toHaveURL(/server=2/)
await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toContainText("сервера 2")
await expect.poll(() => calls.some(url => url.pathname === "/api/logs/live" && url.searchParams.get("serverId") === "2")).toBe(true)
await page.getByRole("option", { name: /Все серверы/ }).click()
await expect(page).toHaveURL(/server=all/)
await expect(page.getByText("Все серверы · сохранённая история")).toBeVisible()
expect(errors).toEqual([])
})
test("Логи: поиск в URL, представления и экспорт используют фильтры", async ({ page }) => {
const calls = await setup(page)
await page.goto("/logs?server=1&live=0")
await page.getByRole("textbox", { name: "Поиск по сообщениям" }).fill("ether1")
await page.getByRole("button", { name: "Найти события" }).click()
await expect(page).toHaveURL(/q=ether1/)
await expect.poll(() => calls.some(url => url.pathname === "/api/logs" && url.searchParams.get("q") === "ether1")).toBe(true)
await expect(page.locator("mark").first()).toHaveText("ether1")
await page.getByRole("textbox", { name: "Название представления" }).fill("Мой WAN")
await page.getByRole("button", { name: "Сохранить", exact: true }).click()
await expect(page.getByRole("button", { name: "Мой WAN", exact: true })).toBeVisible()
const download = page.waitForEvent("download")
await page.getByRole("button", { name: "CSV", exact: true }).click()
await download
expect(calls.find(url => url.pathname === "/api/logs/export")?.searchParams.get("q")).toBe("ether1")
await page.reload()
await expect(page.getByRole("button", { name: "Мой WAN", exact: true })).toBeVisible()
})
test("Логи: пауза буферизует новые события", async ({ page }) => {
await setup(page)
let release!: () => void, started = false
const gate = new Promise<void>(resolve => { release = resolve })
await page.route("**/api/logs/live?**", async route => {
if (started) return route.fallback()
started = true; await gate
await route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: `event: batch\ndata: ${JSON.stringify({ entries: [row(999)], cursor: "999", state: state(1), enabled: true })}\n\n` }).catch(() => {})
})
await page.goto("/logs?server=1")
await expect.poll(() => started).toBe(true)
await page.getByRole("button", { name: "Пауза", exact: true }).click()
release()
await expect(page.getByRole("button", { name: "Новых событий: 1", exact: true })).toBeVisible()
await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toHaveCount(0)
await page.getByRole("button", { name: "Новых событий: 1", exact: true }).click()
await expect(page.getByRole("button", { name: "Открыть событие 999", exact: true })).toBeVisible()
})
test("Логи: поздний ответ прежнего устройства не меняет новый журнал", async ({ page }) => {
await setup(page)
let release!: () => void, started = false
const gate = new Promise<void>(resolve => { release = resolve })
await page.route("**/api/logs?**", async route => {
if (new URL(route.request().url()).searchParams.get("serverId") !== "1") return route.fallback()
started = true; await gate
await route.fulfill({ headers, json: { entries: [{ ...row(888), message: "Поздний ответ первого устройства" }], watermark: "888", nextCursor: null } }).catch(() => {})
})
await page.goto("/logs?server=1")
await expect.poll(() => started).toBe(true)
await page.getByRole("option", { name: /MikroTik-2/ }).click()
await expect(page.getByRole("button", { name: "Открыть событие 3", exact: true })).toContainText("сервера 2")
release()
await expect(page.getByText("Поздний ответ первого устройства")).toHaveCount(0)
})
for (const width of [1440, 390]) for (const theme of ["light", "dark"]) test(`Логи: ${width}px ${theme}, виртуализация 5000 строк`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 })
await page.addInitScript(value => localStorage.setItem("rl-theme", value), theme)
await setup(page, { count: 5000 })
await page.goto("/logs?server=1&live=0")
await expect(page.getByRole("heading", { name: "Логи MikroTik" })).toBeVisible({ timeout: 15000 })
await expect(page.getByText("Показаны последние 5 000 загруженных событий.", { exact: false })).toBeVisible()
expect(await page.locator("tbody tr").count()).toBeLessThan(60)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
if (width === 390) {
await page.getByRole("button", { name: "Серверы", exact: true }).click()
await expect(page.getByRole("option", { name: /MikroTik-2/ })).toBeVisible()
await page.keyboard.press("Escape")
}
await page.screenshot({ path: `test-results/logs-${width}-${theme}.png`, fullPage: true })
})
test("Логи: ошибка истории отображается с повтором", async ({ page }) => {
await setup(page, { historyError: true })
await page.goto("/logs?server=1&live=0")
await expect(page.getByText("История временно недоступна")).toBeVisible()
await expect(page.getByRole("button", { name: "Повторить", exact: true })).toBeVisible()
})
test("Логи: настройки сборщика сохраняют подтверждённые параметры", async ({ page }) => {
await setup(page)
const writes: unknown[] = []
await page.route("**/api/logs/settings", async route => {
if (route.request().method() !== "PUT") return route.fallback()
const payload = route.request().postDataJSON(); writes.push(payload)
await route.fulfill({ headers, json: { ok: true, settings: { ...settings, ...payload } } })
})
await page.goto("/data-collection#router-logs")
await expect(page.getByRole("textbox", { name: "Интервал сбора логов", exact: true })).toBeEnabled({ timeout: 15000 })
await page.getByRole("textbox", { name: "Интервал сбора логов", exact: true }).fill("20")
await page.getByRole("textbox", { name: "Срок хранения логов", exact: true }).fill("7")
await page.getByRole("button", { name: "Сохранить настройки логов", exact: true }).click()
await expect(page.getByText("Настройки сборщика логов сохранены", { exact: true })).toBeVisible()
expect(writes).toEqual([{ enabled: true, intervalSec: 20, retentionDays: 7 }])
await page.getByRole("textbox", { name: "Срок хранения логов", exact: true }).fill("0")
await page.getByRole("button", { name: "Сохранить настройки логов", exact: true }).click()
await expect(page.getByText("Интервал должен быть от 5 до 3600 секунд, срок хранения — от 1 до 365 дней")).toBeVisible()
expect(writes).toHaveLength(1)
})
test("Логи: обслуживание БД требует нового снимка истории", async ({ page }) => {
await setup(page)
let connections = 0
await page.route("**/api/logs/live?**", route => {
connections++
return route.fulfill({ headers: { ...headers, "content-type": "text/event-stream" }, body: 'event: reset\ndata: {"reason":"database-maintenance"}\n\n' })
})
await page.goto("/logs?server=1")
await expect(page.getByText("Поток остановлен для обслуживания БД.", { exact: false })).toBeVisible()
expect(connections).toBe(1)
await expect(page.getByRole("button", { name: "Повторить", exact: true })).toBeVisible()
})