fix(ui): показывать свежесть GRE и закреплять экспорт

This commit is contained in:
Denozordec committed 2026-09-30 16:00:48 +07:00
1 parent ceb2113209
commit a21252fb02
5 files changed
+94 -8

No files matched your search

+31 -7
View File
@@ -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}