fix(ui): открывать выбранный сервер из очереди проблем
This commit is contained in:
1 parent
4ec32765df
commit
668c7c6823
5 files changed
+106
-14
No files matched your search
@@ -1,6 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react"
|
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
import { serverCreateSchema } from "@mmapp/contracts/servers"
|
import { serverCreateSchema } from "@mmapp/contracts/servers"
|
||||||
import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations"
|
import { OperationGate, pollServers, serverOperationError, type PollResult } from "@/lib/server-operations"
|
||||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||||
@@ -218,12 +220,17 @@ type TypeFilter = "all" | ServerType
|
|||||||
type SheetMode = "add" | "edit"
|
type SheetMode = "add" | "edit"
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════════════
|
||||||
export default function ServersPage() {
|
function ServersPageContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const selectedServerId = searchParams.get("server")
|
||||||
|
const clearParams = new URLSearchParams(searchParams.toString())
|
||||||
|
clearParams.delete("server")
|
||||||
|
const allServersHref = "/servers" + (clearParams.size ? "?" + clearParams : "")
|
||||||
const { mode, backendUrl } = useDataSource()
|
const { mode, backendUrl } = useDataSource()
|
||||||
const isLive = mode === "live"
|
const isLive = mode === "live"
|
||||||
|
|
||||||
const [serverList, setServerList] = useState<Server[]>([])
|
const [serverList, setServerList] = useState<Server[]>([])
|
||||||
const [_backendOk, setBackendOk] = useState(false)
|
const [listState, setListState] = useState<"loading" | "ready" | "error">("loading")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
|
||||||
const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery())
|
const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery())
|
||||||
@@ -283,25 +290,26 @@ export default function ServersPage() {
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
formVersion.current++; testVersion.current++
|
formVersion.current++; testVersion.current++
|
||||||
setOpen(false); setDeleteTarget(null); setOperationError(""); setOperationMessage(""); setPollResults([])
|
setOpen(false); setDeleteTarget(null); setOperationError(""); setOperationMessage(""); setPollResults([])
|
||||||
|
setListState("loading")
|
||||||
})
|
})
|
||||||
if (mode !== "live") {
|
if (mode !== "live") {
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setServerList(initialServers)
|
setServerList(initialServers)
|
||||||
setBackendOk(false)
|
setListState("ready")
|
||||||
})
|
})
|
||||||
return invalidate
|
return invalidate
|
||||||
}
|
}
|
||||||
listServers(backendUrl)
|
listServers(backendUrl)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (cancelled || version !== contextVersion.current) return
|
if (cancelled || version !== contextVersion.current) return
|
||||||
setBackendOk(true)
|
setListState("ready")
|
||||||
setServerList(data.map(toFrontendServer))
|
setServerList(data.map(toFrontendServer))
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled || version !== contextVersion.current) return
|
if (cancelled || version !== contextVersion.current) return
|
||||||
setOperationError(serverOperationError(error))
|
setOperationError(serverOperationError(error))
|
||||||
setBackendOk(false)
|
setListState("error")
|
||||||
setServerList([])
|
setServerList([])
|
||||||
})
|
})
|
||||||
return invalidate
|
return invalidate
|
||||||
@@ -550,15 +558,18 @@ export default function ServersPage() {
|
|||||||
|
|
||||||
// ── derived ──────────────────────────────────────────────────────────────
|
// ── derived ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const selectedServer = serverList.find(server => server.id === selectedServerId)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const base = serverList.filter(sv => {
|
const base = serverList.filter(sv => {
|
||||||
|
if (selectedServerId !== null && sv.id !== selectedServerId) return false
|
||||||
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
if (typeFilter !== "all" && sv.type !== typeFilter) return false
|
||||||
if (!search) return true
|
if (!search) return true
|
||||||
const q = search.toLowerCase()
|
const q = search.toLowerCase()
|
||||||
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
|
||||||
})
|
})
|
||||||
return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS)
|
||||||
}, [serverList, search, typeFilter, advancedFilters])
|
}, [serverList, search, typeFilter, advancedFilters, selectedServerId])
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
all: serverList.length,
|
all: serverList.length,
|
||||||
@@ -585,8 +596,8 @@ export default function ServersPage() {
|
|||||||
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
crumbs={[{ label: "Управление" }, { label: "Серверы" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={handlePollAll} disabled={!isLive || controlsBusy || serverList.length === 0}>
|
<Button variant="outline" size="sm" onClick={() => selectedServerId !== null ? selectedServer && void runPoll([selectedServer.id]) : void handlePollAll()} disabled={!isLive || controlsBusy || (selectedServerId !== null ? !selectedServer : serverList.length === 0)}>
|
||||||
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />Проверить все
|
<RefreshCwIcon className={cn("size-4", pollAllBusy && "animate-spin")} />{selectedServerId !== null ? "Проверить сервер" : "Проверить все"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||||||
<Button size="sm" onClick={openAdd} disabled={controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
|
<Button size="sm" onClick={openAdd} disabled={controlsBusy}><PlusIcon className="size-4" />Добавить сервер</Button>
|
||||||
@@ -609,6 +620,19 @@ export default function ServersPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedServerId !== null && (
|
||||||
|
<Alert variant={listState === "error" || (listState === "ready" && !selectedServer) ? "warning" : "info"} role="status" aria-label="Сервер по ссылке">
|
||||||
|
<AlertTitle className="line-clamp-none wrap-anywhere">{selectedServer ? "Выбран сервер: " + selectedServer.name : "Сервер по ссылке"}</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<p>{listState === "loading" ? "Загрузка сервера…" : listState === "error" ? "Не удалось загрузить сервер. Проверьте сообщение об ошибке выше." : selectedServer ? selectedServer.host + " · Список ограничен выбранным сервером. Сводка относится ко всем серверам." : "Сервер не найден или недоступен. Возможно, он удалён или у вас нет доступа."}</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Link href={allServersHref} className="underline underline-offset-2">Все серверы</Link>
|
||||||
|
<Link href="/dashboard" className="underline underline-offset-2">К дашборду</Link>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<KpiStatGrid
|
<KpiStatGrid
|
||||||
aria-label="Сводка серверов"
|
aria-label="Сводка серверов"
|
||||||
items={[
|
items={[
|
||||||
@@ -934,3 +958,7 @@ export default function ServersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ServersPage() {
|
||||||
|
return <Suspense fallback={<p role="status" className="p-6">Загрузка серверов…</p>}><ServersPageContent /></Suspense>
|
||||||
|
}
|
||||||
@@ -411,6 +411,18 @@ useDashboardLive больше не обрезает массивы пробле
|
|||||||
|
|
||||||
Границы: текущий mapper дашборда по-прежнему трактует null server.status как offline; неизвестные состояния устройств требуют отдельного исправления и проверки. Квота при завершении общей регрессии и запуске build: использовано 65% пятичасовой и 26% недельной. Сохранить блок локальным коммитом без push; изменения skills/AGENTS/.codex не включать.
|
Границы: текущий mapper дашборда по-прежнему трактует null server.status как offline; неизвестные состояния устройств требуют отдельного исправления и проверки. Квота при завершении общей регрессии и запуске build: использовано 65% пятичасовой и 26% недельной. Сохранить блок локальным коммитом без push; изменения skills/AGENTS/.codex не включать.
|
||||||
|
|
||||||
|
## Этап 6 — блок 4: переход к серверу (2026-09-20)
|
||||||
|
|
||||||
|
Очередь серверов дашборда формирует `/servers?server=<encoded id>`. Страница серверов читает параметр в Suspense, ограничивает таблицу точным ID и показывает контекст перед общей сводкой. «Все серверы» снимает только параметр server, сохраняя остальные query-параметры; «К дашборду» и браузерный возврат доступны. Прямой URL и перезагрузка сохраняют выбор. При отсутствующем ID или 403/500 другой сервер не подставляется. Состояния загрузки, ошибки получения и отсутствующего объекта разделены.
|
||||||
|
|
||||||
|
При выбранном сервере верхняя кнопка «Проверить сервер» опрашивает только его через существующую очередь операций. Без параметра сохраняется прежний массовый опрос. API, права и правила мутаций не менялись. Контекст использует установленный ReUI Alert; состав общей сводки явно указан. Полная синхронизация поиска/Filters/форм с URL остаётся этапу 7.
|
||||||
|
|
||||||
|
Проверки: 27 профильных сценариев servers/dashboard passed; после переноса контекста над сводкой повторно пройден сценарий выбора/опроса/возврата и просмотрен мобильный экран 390. ESLint изменённых файлов успешен. Production build прошёл: контракты, TypeScript, 34 страницы. HTTP-фикстура дашборда дополнена и проверяется serverReadSchema: переход на сервер использует строгий API-контракт. Реальный RouterOS не проверялся.
|
||||||
|
|
||||||
|
Окружение: предыдущий запуск остановился из-за исчерпанной квоты автоматической проверки разрешений. После сброса обнаружен dev-кеш Next.js, возвращавший 404 для существующего маршрута; .next/dev сохранён как .next/dev-before-server-links, чистый кеш устранил ошибку, пять URL-сценариев и вся профильная регрессия прошли. Это игнорируемые сборочные артефакты, не исходники.
|
||||||
|
|
||||||
|
Квота нового окна: 1% использовано на старте, 26% при запуске итоговой сборки. Блок сохранить отдельным локальным коммитом без push. Этап 6 открыт: далее `/uptime?probe=<id>` и обработка выбранной проверки, затем туннели с идентификатором устройства и объекта.
|
||||||
|
|
||||||
## Карта миграции (исходная инвентаризация этапа 0)
|
## Карта миграции (исходная инвентаризация этапа 0)
|
||||||
|
|
||||||
Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация.
|
Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация.
|
||||||
|
|||||||
@@ -634,7 +634,7 @@ export function useDashboardLive() {
|
|||||||
id: s.id,
|
id: s.id,
|
||||||
title: s.name,
|
title: s.name,
|
||||||
hint: s.status === "degraded" ? "Ухудшение" : "Не в сети",
|
hint: s.status === "degraded" ? "Ухудшение" : "Не в сети",
|
||||||
href: "/servers",
|
href: "/servers?server=" + encodeURIComponent(s.id),
|
||||||
tone: s.status === "degraded" ? ("warning" as const) : ("destructive" as const),
|
tone: s.status === "degraded" ? ("warning" as const) : ("destructive" as const),
|
||||||
}))
|
}))
|
||||||
}, [servers])
|
}, [servers])
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { test, expect, type Page } from "@playwright/test"
|
import { test, expect, type Page } from "@playwright/test"
|
||||||
|
import { serverReadSchema } from "../../packages/contracts/src/servers"
|
||||||
|
|
||||||
async function setup(page: Page, initiallyFailed = false, serverCount = 1) {
|
async function setup(page: Page, initiallyFailed = false, serverCount = 1) {
|
||||||
const control = { failed: initiallyFailed, partial: false, prefixes: 321, hold: false, holdPath: "/api/bgp/sessions", fallback: false, failFallback: false, pending: 0, release: () => {} }
|
const control = { failed: initiallyFailed, partial: false, prefixes: 321, hold: false, holdPath: "/api/bgp/sessions", fallback: false, failFallback: false, pending: 0, release: () => {} }
|
||||||
let release!: () => void
|
let release!: () => void
|
||||||
const gate = new Promise<void>(resolve => { release = resolve })
|
const gate = new Promise<void>(resolve => { release = resolve })
|
||||||
control.release = release
|
control.release = release
|
||||||
const servers = Array.from({ length: serverCount }, (_, index) => ({ id: index + 1, name: serverCount === 1 ? "Узел" : `Узел ${index + 1}`, host: `192.0.2.${index + 1}`, site: "TEST", country: "RU", asn: "64512", type: "exit-node", enabled: true, status: serverCount === 1 ? "online" : "offline", latency: 10, os: "7", model: "CHR" }))
|
const servers = Array.from({ length: serverCount }, (_, index) => ({ id: index + 1, name: serverCount === 1 ? "Узел" : `Узел ${index + 1}`, host: `192.0.2.${index + 1}`, site: "TEST", country: "RU", asn: "64512", type: "exit-node", enabled: true, status: serverCount === 1 ? "online" : "offline", latency: 10, os: "7", model: "CHR" })).map(row => serverReadSchema.parse({
|
||||||
|
port: 443, useSsl: true, verifySsl: true, username: "test", password: "fixture-only", comment: "", lanSubnet: "", wanUplinks: [],
|
||||||
|
createdAt: "2026-09-20T00:00:00Z", updatedAt: "2026-09-20T00:00:00Z", uptime: null, cpuLoad: null, freeMemory: null, totalMemory: null, identityName: null, sessions: 0, polledAt: null, ...row,
|
||||||
|
}))
|
||||||
const snapshot = { sampledAt: "2026-09-20T00:00:00Z", servers, greTunnels: [], filtersRulesets: [], speedProbes: [], routeLookupByServerId: {}, wanRuntimeByHomeId: {} }
|
const snapshot = { sampledAt: "2026-09-20T00:00:00Z", servers, greTunnels: [], filtersRulesets: [], speedProbes: [], routeLookupByServerId: {}, wanRuntimeByHomeId: {} }
|
||||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||||
await page.route("**/api/**", async route => {
|
await page.route("**/api/**", async route => {
|
||||||
@@ -62,6 +66,12 @@ test("Дашборд: очередь над измерениями, полный
|
|||||||
await page.getByRole("navigation", { name: "Путь страницы" }).scrollIntoViewIfNeeded()
|
await page.getByRole("navigation", { name: "Путь страницы" }).scrollIntoViewIfNeeded()
|
||||||
await page.screenshot({ path: "test-results/dashboard-priority-mobile.png", animations: "disabled" })
|
await page.screenshot({ path: "test-results/dashboard-priority-mobile.png", animations: "disabled" })
|
||||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||||
|
await serverLinks.first().click()
|
||||||
|
await expect(page).toHaveURL(/\/servers\?server=1$/)
|
||||||
|
await expect(page.getByRole("status", { name: "Сервер по ссылке" })).toContainText("Выбран сервер: Узел 1")
|
||||||
|
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||||
|
await page.goBack()
|
||||||
|
await expect(queue).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Дашборд: первоначальная ошибка не подменяется нулями и исправным состоянием", async ({ page }) => {
|
test("Дашборд: первоначальная ошибка не подменяется нулями и исправным состоянием", async ({ page }) => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function server(id: number): ServerRead {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setup(page: Page, intercept?: (route: Route, path: string) => Promise<boolean>, count = 2) {
|
async function setup(page: Page, intercept?: (route: Route, path: string) => Promise<boolean>, count = 2, url = "/servers") {
|
||||||
const rows = Array.from({ length: count }, (_, i) => server(i + 1))
|
const rows = Array.from({ length: count }, (_, i) => server(i + 1))
|
||||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||||
await page.route("**/api/**", async route => {
|
await page.route("**/api/**", async route => {
|
||||||
@@ -36,8 +36,8 @@ async function setup(page: Page, intercept?: (route: Route, path: string) => Pro
|
|||||||
}
|
}
|
||||||
await route.fulfill({ json: body })
|
await route.fulfill({ json: body })
|
||||||
})
|
})
|
||||||
await page.goto("/servers")
|
await page.goto(url)
|
||||||
await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible()
|
if (url === "/servers") await expect(page.getByText("Проверка 1", { exact: true })).toBeVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function edit(page: Page, id = 1) {
|
async function edit(page: Page, id = 1) {
|
||||||
@@ -45,6 +45,48 @@ async function edit(page: Page, id = 1) {
|
|||||||
await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click()
|
await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("ссылка на сервер: выбор, опрос одного устройства, сброс и возврат", async ({ page }) => {
|
||||||
|
const calls: string[] = []
|
||||||
|
await setup(page, async (route, path) => {
|
||||||
|
if (!path.endsWith("/poll")) return false
|
||||||
|
calls.push(path)
|
||||||
|
await route.fulfill({ json: {} })
|
||||||
|
return true
|
||||||
|
}, 2, "/servers?server=2&keep=1")
|
||||||
|
const context = page.getByRole("status", { name: "Сервер по ссылке" })
|
||||||
|
await expect(context).toContainText("Выбран сервер: Проверка 2")
|
||||||
|
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||||
|
await page.getByRole("button", { name: "Проверить сервер", exact: true }).click()
|
||||||
|
await expect(page.getByText("Опрос: успешно 1 из 1", { exact: true })).toBeVisible()
|
||||||
|
expect(calls).toEqual(["/api/servers/2/poll"])
|
||||||
|
await context.getByRole("link", { name: "Все серверы", exact: true }).click()
|
||||||
|
await expect(page).toHaveURL(/\/servers\?keep=1$/)
|
||||||
|
await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
|
||||||
|
await page.goBack()
|
||||||
|
await expect(context).toContainText("Выбран сервер: Проверка 2")
|
||||||
|
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||||
|
await page.reload()
|
||||||
|
await expect(context).toContainText("Выбран сервер: Проверка 2")
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 })
|
||||||
|
await context.scrollIntoViewIfNeeded()
|
||||||
|
await page.screenshot({ path: "test-results/server-link-mobile.png", animations: "disabled" })
|
||||||
|
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const apiStatus of [200, 403, 500]) {
|
||||||
|
test(`ссылка на отсутствующий сервер: ответ ${apiStatus} не выбирает другое устройство`, async ({ page }) => {
|
||||||
|
await setup(page, async (route, path) => {
|
||||||
|
if (path !== "/api/servers" || apiStatus === 200) return false
|
||||||
|
await route.fulfill({ status: apiStatus, json: { error: "Список недоступен" } })
|
||||||
|
return true
|
||||||
|
}, 2, "/servers?server=999")
|
||||||
|
const context = page.getByRole("status", { name: "Сервер по ссылке" })
|
||||||
|
await expect(context).toContainText(apiStatus === 200 ? "Сервер не найден или недоступен" : "Не удалось загрузить сервер")
|
||||||
|
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
|
||||||
|
await expect(page.getByRole("button", { name: "Проверить сервер", exact: true })).toBeDisabled()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
test("таблица v9 сохраняет все строки, сортировку и раскрытие деталей", async ({ page }) => {
|
test("таблица v9 сохраняет все строки, сортировку и раскрытие деталей", async ({ page }) => {
|
||||||
await setup(page, undefined, 12)
|
await setup(page, undefined, 12)
|
||||||
const rows = page.locator('tr[data-row-id]')
|
const rows = page.locator('tr[data-row-id]')
|
||||||
|
|||||||
Reference in new issue
Block a user