fix(ui): показывать свежесть GRE и закреплять экспорт
This commit is contained in:
1 parent
ceb2113209
commit
a21252fb02
5 files changed
+94
-8
No files matched your search
+31
-7
@@ -231,6 +231,9 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
const [failures, setFailures] = useState<z.infer<typeof greResponseSchema>["failures"]>([])
|
||||
const failedServerIds = useMemo(() => failures.map(f => f.serverId), [failures])
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
const [readReceipt, setReadReceipt] = useState<Record<string, string>>({})
|
||||
const [responseReceipt, setResponseReceipt] = useState<string | null>(null)
|
||||
const [writeReceipt, setWriteReceipt] = useState<{ serverId: string; at: string } | null>(null)
|
||||
const loadEpoch = useRef(0)
|
||||
const mounted = useRef(true)
|
||||
const operationLock = useRef(false)
|
||||
@@ -264,6 +267,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||
const [poolOpen, setPoolOpen] = useState(false)
|
||||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||||
const [exportContext, setExportContext] = useState<{ servers: Record<string, Server>; at: string | null; stale: boolean; mock: boolean } | null>(null)
|
||||
|
||||
const [tForm, setTForm] = useState(defaultTunnelForm)
|
||||
const [pForm, setPForm] = useState(defaultPoolForm)
|
||||
@@ -289,6 +293,9 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
setLiveTunnels(previous => [...response.data.tunnels, ...previous.filter(t => failedIds.has(t.serverId) && enabledIds.has(t.serverId))])
|
||||
setFailures(response.data.failures)
|
||||
setHasLoaded(true)
|
||||
const at = new Date().toISOString()
|
||||
setResponseReceipt(at)
|
||||
setReadReceipt(previous => Object.fromEntries([...enabledIds].map(id => [id, failedIds.has(id) ? previous[id] : at]).filter((entry): entry is [string, string] => !!entry[1])))
|
||||
setDataError(null)
|
||||
setLiveStale(false)
|
||||
} catch (e) {
|
||||
@@ -400,6 +407,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
)
|
||||
if (!mounted.current) return
|
||||
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
|
||||
setWriteReceipt({ serverId: historyServerId, at: new Date().toISOString() })
|
||||
toast.success("Версия применена на роутер")
|
||||
await loadLive(true)
|
||||
if (mounted.current) await loadRevisions()
|
||||
@@ -464,6 +472,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
})
|
||||
if (!mounted.current) return
|
||||
if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение туннеля")
|
||||
setWriteReceipt({ serverId: tForm.serverId, at: new Date().toISOString() })
|
||||
toast.success(`Туннель ${tForm.name} обновлён`)
|
||||
} else {
|
||||
const result = await apiFetch<unknown>("/api/gre/tunnels", {
|
||||
@@ -479,6 +488,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
"enabled" in result && result.enabled === tForm.enabled)
|
||||
)
|
||||
if (!confirmed) throw new Error("Сервер не подтвердил создание туннеля")
|
||||
setWriteReceipt({ serverId: tForm.serverId, at: new Date().toISOString() })
|
||||
toast.success(`Туннель ${tForm.name} создан`)
|
||||
}
|
||||
setTunnelOpen(false)
|
||||
@@ -510,6 +520,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
})
|
||||
if (!mounted.current) return
|
||||
if (result?.ok !== true) throw new Error("Сервер не подтвердил изменение туннеля")
|
||||
setWriteReceipt({ serverId: t.serverId, at: new Date().toISOString() })
|
||||
toast.success(t.enabled ? `Выключен ${t.name}` : `Включён ${t.name}`)
|
||||
await loadLive(true)
|
||||
} catch (err) {
|
||||
@@ -533,6 +544,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
})
|
||||
if (!mounted.current) return
|
||||
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление туннеля")
|
||||
setWriteReceipt({ serverId: t.serverId, at: new Date().toISOString() })
|
||||
toast.success(`Удалён ${t.name}`)
|
||||
setPendingDelete(null)
|
||||
await loadLive(true)
|
||||
@@ -615,9 +627,16 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
{ value: "plain", label: "Без IPsec", count: scopedTunnels.length - scopedIpsecCount },
|
||||
]
|
||||
|
||||
function openCodePreview(t: GreTunnel) {
|
||||
setCodePreviewTunnel(t)
|
||||
setExportContext({ servers: serverById, at: readReceipt[t.serverId] ?? null, stale: isLive && (!hasLoaded || !!dataError || failedServerIds.includes(t.serverId)), mock: !isLive })
|
||||
}
|
||||
const scopeReceipt = selectedServerId === ALL_SERVERS_ID ? responseReceipt : readReceipt[selectedServerId]
|
||||
const formatReceipt = (at: string) => new Date(at).toLocaleString("ru-RU")
|
||||
|
||||
const greExportCode = useMemo(
|
||||
() => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, serverById) : ""),
|
||||
[codePreviewTunnel, serverById],
|
||||
() => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, exportContext?.servers ?? {}) : ""),
|
||||
[codePreviewTunnel, exportContext],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -744,6 +763,11 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
]}
|
||||
/>
|
||||
|
||||
<div role="group" aria-label="Свежесть GRE" className="flex flex-wrap gap-x-5 gap-y-1 text-xs text-muted-foreground">
|
||||
<span data-source-time="read">{isLive ? selectedServerId === ALL_SERVERS_ID ? "Ответ каталога: " : "Опрос устройства: " : "Моковые данные · "}{isLive ? scopeReceipt ? <time dateTime={scopeReceipt} title="Время получения ответа браузером; не время измерения RouterOS">{formatReceipt(scopeReceipt)}</time> : "неизвестно" : "не получены с RouterOS"}</span>
|
||||
{isLive && writeReceipt && (selectedServerId === ALL_SERVERS_ID || writeReceipt.serverId === selectedServerId) && <span data-source-time="write">API подтвердил изменение · {serverById[writeReceipt.serverId]?.name ?? writeReceipt.serverId}: <time dateTime={writeReceipt.at}>{formatReceipt(writeReceipt.at)}</time></span>}
|
||||
</div>
|
||||
|
||||
{toggleStatus && <p role="status" className="text-sm text-muted-foreground">{toggleStatus}</p>}
|
||||
{toggleError && <p role="alert" className="text-sm text-destructive">{toggleError}</p>}
|
||||
|
||||
@@ -780,7 +804,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
tunnels={filtered}
|
||||
servers={displayServers}
|
||||
pools={displayPools}
|
||||
onCodePreview={setCodePreviewTunnel}
|
||||
onCodePreview={openCodePreview}
|
||||
onEdit={openEditTunnel}
|
||||
onToggle={(t) => { void toggleTunnel(t) }}
|
||||
onDelete={setPendingDelete}
|
||||
@@ -864,7 +888,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
open={!!codePreviewTunnel}
|
||||
onClose={() => setCodePreviewTunnel(null)}
|
||||
title={codePreviewTunnel?.name ?? "GRE"}
|
||||
description="Команды RouterOS 7.20+ для создания туннеля"
|
||||
description={exportContext?.mock ? "Моковый снимок · данные не получены с RouterOS" : `Снимок на момент открытия · ${exportContext?.at ? formatReceipt(exportContext.at) : "время получения неизвестно"} · ${exportContext?.stale ? "актуальность не подтверждена" : "последний успешный опрос"}`}
|
||||
formats={[
|
||||
{
|
||||
id: "rsc",
|
||||
@@ -877,11 +901,11 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
|
||||
codePreviewTunnel ? (
|
||||
<div className="flex flex-wrap gap-3 text-xs shrink-0">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`size-1.5 rounded-full ${greStatusMeta(codePreviewTunnel.status).dot}`} />
|
||||
{greStatusMeta(codePreviewTunnel.status).label}
|
||||
<span className={`size-1.5 rounded-full ${exportContext?.stale ? "bg-muted-foreground" : greStatusMeta(codePreviewTunnel.status).dot}`} />
|
||||
{exportContext?.stale ? "Неизвестно" : greStatusMeta(codePreviewTunnel.status).label}
|
||||
</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span>{serverById[codePreviewTunnel.serverId]?.name}</span>
|
||||
<span>{exportContext?.servers[codePreviewTunnel.serverId]?.name}</span>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="font-mono">
|
||||
{codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress}
|
||||
|
||||
@@ -25,12 +25,19 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.59 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.60 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.60 — свежесть и снимок экспорта GRE (2026-09-30)
|
||||
|
||||
- Успешный ответ каталога и опрос каждого включённого устройства имеют отдельные отметки получения браузером. При failures отметка устройства сохраняется, при глобальной ошибке не обновляется. Подтверждение API записи/восстановления показано отдельно от опроса. Эти отметки не объявляются временем измерения RouterOS.
|
||||
- Экспорт фиксирует туннель, имена устройств, время и актуальность при открытии. Фоновый опрос не меняет уже открытый код/контекст; устаревший статус — неизвестный, mock явно подписан. Source/URL смена закрывает прежний снимок.
|
||||
- 11 E2E прошли: часы/частичный и полный отказ/восстановление/экспорт плюс readiness и данные. TypeScript и lint экрана чистые.
|
||||
- Далее — корректность самого GRE .rsc и параметров формы, реальные возможности live-пулов и desktop/mobile. Локальный коммит без push.
|
||||
|
||||
## Блок 9.59 — готовность и изоляция операций GRE (2026-09-30)
|
||||
|
||||
- Запись разрешена только после успешного чтения inventory/GRE на включённом устройстве без его failure. Частичный отказ не блокирует исправное устройство; меню и выбор сервера показывают недоступность отказавшего/выключенного. Проверка повторяется непосредственно перед запросом.
|
||||
|
||||
@@ -337,3 +337,11 @@ Live-копирование читает актуальный целевой rul
|
||||
- Проверено 25 различных E2E: 11 истории, три формы, пять readiness/delete/toggle и шесть поздних ответов в отдельной source-конфигурации. TypeScript и lint чистые. Проверки обнаружили и устранили незавершённое освобождение lock в delete/toggle; повтор прошёл.
|
||||
- Далее — временные отметки и экспорт GRE, достоверность live-пулов, desktop/mobile приёмка. JWT/RouterOS остаются внешней приёмкой. Локальный коммит без push.
|
||||
|
||||
|
||||
## Блок 9.60 — свежесть и снимок экспорта GRE (2026-09-30)
|
||||
|
||||
- Успешный ответ каталога и опрос каждого включённого устройства имеют отдельные отметки получения браузером. При failures отметка устройства сохраняется, при глобальной ошибке не обновляется. Подтверждение API записи/восстановления показано отдельно от опроса. Эти отметки не объявляются временем измерения RouterOS.
|
||||
- Экспорт фиксирует туннель, имена устройств, время и актуальность при открытии. Фоновый опрос не меняет уже открытый код/контекст; устаревший статус — неизвестный, mock явно подписан. Source/URL смена закрывает прежний снимок.
|
||||
- 11 E2E прошли: часы/частичный и полный отказ/восстановление/экспорт плюс readiness и данные. TypeScript и lint экрана чистые.
|
||||
- Далее — корректность самого GRE .rsc и параметров формы, реальные возможности live-пулов и desktop/mobile. Локальный коммит без push.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
const devices = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU" }))
|
||||
const tunnel = (id: string, remoteAddress: string) => ({ id: "*" + id, serverId: id, name: "gre-" + id, localAddress: "192.0.2.1", remoteAddress, localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "live", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" })
|
||||
|
||||
test("GRE: отметка чтения и закреплённый экспорт при частичном отказе", async ({ page }) => {
|
||||
await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") })
|
||||
let phase = 0, writes = 0
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => {
|
||||
const path = new URL(r.request().url()).pathname
|
||||
if (r.request().method() !== "GET") writes++
|
||||
if (path === "/api/gre/tunnels" && phase === 2) return r.fulfill({ status: 503, json: { error: "Опрос GRE недоступен" } })
|
||||
return r.fulfill({ json: path === "/api/gre/tunnels" ? { tunnels: phase === 1 ? [tunnel("1", "198.51.100.1")] : [tunnel("1", "198.51.100.1"), tunnel("2", phase === 3 ? "198.51.100.33" : "198.51.100.22")], failures: phase === 1 ? [{ serverId: "2", error: "Таймаут тест" }] : [] } : path === "/api/servers" ? devices : path === "/api/auth/config" ? { required: false } : {} })
|
||||
})
|
||||
await page.goto("/gre?server=2")
|
||||
const rows = page.locator("tr[data-row-id]")
|
||||
await expect(rows).toHaveCount(1)
|
||||
const time = page.getByRole("group", { name: "Свежесть GRE" }).locator('[data-source-time="read"] time')
|
||||
const first = await time.getAttribute("datetime")
|
||||
await page.clock.fastForward(60_000)
|
||||
phase = 1
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Таймаут тест" })).toBeVisible()
|
||||
await expect(rows).toContainText("Неизвестно")
|
||||
await expect(time).toHaveAttribute("datetime", first!)
|
||||
await page.getByRole("button", { name: "Действия: gre-2" }).click()
|
||||
await page.getByRole("menuitem", { name: "Просмотр кода", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toContainText("актуальность не подтверждена")
|
||||
await expect(dialog).toContainText("198.51.100.22")
|
||||
phase = 3
|
||||
const refresh = page.getByRole("button", { name: "Обновить", exact: true, includeHidden: true })
|
||||
await refresh.evaluate(el => (el as HTMLButtonElement).click())
|
||||
await expect(rows).toContainText("198.51.100.33")
|
||||
await expect(dialog).toContainText("198.51.100.22")
|
||||
await expect(dialog).not.toContainText("198.51.100.33")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(time).not.toHaveAttribute("datetime", first!)
|
||||
const after = await time.getAttribute("datetime")
|
||||
phase = 2
|
||||
await page.clock.fastForward(60_000)
|
||||
await refresh.click()
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Опрос GRE недоступен" })).toBeVisible()
|
||||
await expect(time).toHaveAttribute("datetime", after!)
|
||||
expect(writes).toBe(0)
|
||||
})
|
||||
@@ -58,6 +58,7 @@ test("GRE: общий замок до опроса после подтвержд
|
||||
expect(reads).toBe(before)
|
||||
release!()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByRole("group", { name: "Свежесть GRE" }).locator('[data-source-time="write"] time')).toHaveCount(1)
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Не удалось загрузить GRE" })).toContainText("Опрос после удаления недоступен")
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
await page.getByRole("button", { name: "Действия: GRE 1" }).click()
|
||||
|
||||
Reference in new issue
Block a user