fix(ui): подтверждать операции рекурсивных маршрутов
This commit is contained in:
1 parent
79a8d95e7d
commit
f5fc9282e2
11 files changed
+364
-138
No files matched your search
@@ -16,9 +16,11 @@ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetT
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { can } from "@/lib/auth"
|
||||
import { recursiveReadSchema, recursiveReceiptSchema, recursiveGatewaysSchema, recursiveServersSchema, type RecursiveRouteRow } from "@/lib/recursive-route-response"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { servers as mockServers, type Server } from "@/lib/data"
|
||||
import { PlusIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
|
||||
import { PlusIcon, TrashIcon, SearchIcon, XIcon, CheckIcon, AlertCircleIcon, RefreshCwIcon, HistoryIcon, AlertTriangleIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { requestJson } from "@/shared/api/http-client"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
@@ -35,7 +37,7 @@ interface BackendServer {
|
||||
country: string
|
||||
asn: string
|
||||
enabled: boolean
|
||||
status: "online" | "offline" | null
|
||||
status: "online" | "offline" | "degraded" | null
|
||||
latency: number | null
|
||||
}
|
||||
|
||||
@@ -61,21 +63,7 @@ const COUNTRY_OPTIONS = [
|
||||
{ code: "NO", label: "Норвегия" },
|
||||
]
|
||||
|
||||
interface RecursiveRouteRow {
|
||||
id: string
|
||||
dstAddress: string
|
||||
gateway: string
|
||||
distance: number
|
||||
scope: number | null
|
||||
targetScope: number | null
|
||||
routingTable: string
|
||||
checkGateway: string
|
||||
comment: string
|
||||
disabled: boolean
|
||||
country: string
|
||||
}
|
||||
|
||||
interface RouteGroup extends RecursiveRouteGroup {}
|
||||
type RouteGroup = RecursiveRouteGroup
|
||||
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -83,7 +71,7 @@ function makeApiFetch(backendUrl: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function mapBackendServer(s: BackendServer): Server {
|
||||
function mapBackendServer(s: BackendServer): Omit<Server, "status"> & { status: Server["status"] | null } {
|
||||
return {
|
||||
id: String(s.id),
|
||||
name: s.name || s.host,
|
||||
@@ -95,7 +83,7 @@ function mapBackendServer(s: BackendServer): Server {
|
||||
asn: s.asn,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
status: (s.status ?? "offline") as Server["status"],
|
||||
status: s.status,
|
||||
latency: s.latency != null ? Math.round(s.latency) : null,
|
||||
sessions: 0,
|
||||
}
|
||||
@@ -191,14 +179,16 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
|
||||
}
|
||||
|
||||
function RouteSheet({
|
||||
open, mode, initial, onSave, onClose, gateways,
|
||||
open, mode, initial, onSave, onClose, gateways, busy, saveError,
|
||||
}: {
|
||||
open: boolean
|
||||
mode: "create" | "edit"
|
||||
initial: RouteForm
|
||||
onSave: (v: RouteForm) => void
|
||||
onSave: (v: RouteForm) => Promise<boolean>
|
||||
onClose: () => void
|
||||
gateways: GatewayOption[]
|
||||
busy: boolean
|
||||
saveError: string | null
|
||||
}) {
|
||||
const [form, setForm] = useState<RouteForm>(initial)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -209,16 +199,21 @@ function RouteSheet({
|
||||
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
|
||||
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
|
||||
|
||||
function handleSave() {
|
||||
const saveLock = useRef(false)
|
||||
async function handleSave() {
|
||||
if (busy || saveLock.current) return
|
||||
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
|
||||
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
|
||||
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return }
|
||||
if (!form.routingTable.trim()) { setError("Укажите таблицу маршрутизации"); return }
|
||||
if (form.endpoints.some(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))) { setError("Distance, Scope и T.Scope должны быть целыми числами от 0 до 255"); return }
|
||||
setError(null)
|
||||
onSave(form)
|
||||
saveLock.current = true
|
||||
try { await onSave(form) } finally { saveLock.current = false }
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||
<Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
|
||||
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -226,10 +221,10 @@ function RouteSheet({
|
||||
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
|
||||
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" disabled={busy} aria-label="Закрыть форму маршрута" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||
<fieldset disabled={busy} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
|
||||
@@ -250,6 +245,7 @@ function RouteSheet({
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label={`Удалить конечную точку ${idx + 1}`}
|
||||
disabled={form.endpoints.length <= 1}
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
@@ -264,7 +260,7 @@ function RouteSheet({
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<FormField label="Distance (приоритет)">
|
||||
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value) || 1)} />
|
||||
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value))} />
|
||||
</FormField>
|
||||
<FormField label="Check Gateway">
|
||||
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
|
||||
@@ -334,11 +330,13 @@ function RouteSheet({
|
||||
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
|
||||
</FormField>
|
||||
</div>
|
||||
{saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
|
||||
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения роутера…</p>}
|
||||
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
|
||||
</div>
|
||||
</fieldset>
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
|
||||
<Button variant="outline" onClick={onClose} className="flex-1">Отмена</Button>
|
||||
<Button onClick={handleSave} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={onClose} className="flex-1">Отмена</Button>
|
||||
<Button type="button" disabled={busy} onClick={() => void handleSave()} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -361,10 +359,14 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
|
||||
|
||||
export default function RecursiveRoutesPage() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
return <RecursiveRoutesContent key={JSON.stringify([mode, backendUrl])} mode={mode} backendUrl={backendUrl} />
|
||||
}
|
||||
|
||||
function RecursiveRoutesContent({ mode, backendUrl }: { mode: string; backendUrl: string }) {
|
||||
const isLive = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [servers, setServers] = useState<Server[]>([])
|
||||
const [servers, setServers] = useState<Array<Omit<Server, "status"> & { status: Server["status"] | null }>>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
|
||||
@@ -376,15 +378,27 @@ export default function RecursiveRoutesPage() {
|
||||
const [gatewayOptions, setGatewayOptions] = useState<GatewayOption[]>([])
|
||||
const [expandedGroupKey, setExpandedGroupKey] = useState<string | null>(null)
|
||||
const [opError, setOpError] = useState<string | null>(null)
|
||||
const [liveStale, setLiveStale] = useState(false)
|
||||
const [liveStale, setLiveStale] = useState(true)
|
||||
const [routeOwner, setRouteOwner] = useState("")
|
||||
const [readAt, setReadAt] = useState<string | null>(null)
|
||||
const [writeAt, setWriteAt] = useState<string | null>(null)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [gatewayError, setGatewayError] = useState<string | null>(null)
|
||||
const historyEpoch = useRef(0)
|
||||
const alive = useRef(false)
|
||||
const readEpoch = useRef(0), gatewayEpoch = useRef(0), writeLock = useRef(false)
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
return () => { alive.current = false }
|
||||
}, [])
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
const historyEpoch = useRef(0)
|
||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
||||
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
||||
const [inventoryTick, setInventoryTick] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
@@ -395,84 +409,97 @@ export default function RecursiveRoutesPage() {
|
||||
setRows([])
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLiveServerListReady(false)
|
||||
apiFetch<BackendServer[]>("/api/servers")
|
||||
let active = true
|
||||
apiFetch<unknown>("/api/servers")
|
||||
.then((data) => {
|
||||
const mapped = data.map(mapBackendServer)
|
||||
if (!active) return
|
||||
const parsed = recursiveServersSchema.safeParse(data)
|
||||
if (!parsed.success) throw new Error("Некорректный список устройств в ответе API")
|
||||
const mapped = parsed.data.map(s => mapBackendServer({ ...s, type: s.type ?? "exit-node", site: s.site ?? "", asn: s.asn ?? "", status: s.status ?? null, latency: s.latency ?? null }))
|
||||
setServers(mapped)
|
||||
setSelectedServerId(prev => (mapped.some(s => s.id === prev) ? prev : (mapped[0]?.id ?? "")))
|
||||
})
|
||||
.catch(() => {
|
||||
.catch(error => {
|
||||
if (!active) return
|
||||
setOpError(error instanceof Error ? error.message : "Не удалось загрузить устройства")
|
||||
setServers([])
|
||||
setSelectedServerId("")
|
||||
})
|
||||
.finally(() => {
|
||||
setLiveServerListReady(true)
|
||||
if (active) setLiveServerListReady(true)
|
||||
})
|
||||
}, [isLive, apiFetch])
|
||||
return () => { active = false }
|
||||
}, [isLive, apiFetch, inventoryTick])
|
||||
|
||||
const loadRoutes = useCallback(async () => {
|
||||
if (!isLive || !liveServerListReady || !selectedServerId) return
|
||||
setOpError(null)
|
||||
setBusy("load")
|
||||
if (!isLive || !liveServerListReady || !selectedServerId || writeLock.current) return
|
||||
const epoch = ++readEpoch.current
|
||||
setOpError(null); setBusy("load"); setLiveStale(true)
|
||||
try {
|
||||
const res = await apiFetch<{ routes: RecursiveRouteRow[]; stale?: boolean }>(
|
||||
`/api/recursive-routes?serverId=${selectedServerId}`,
|
||||
)
|
||||
setRows(res.routes)
|
||||
setLiveStale(Boolean(res.stale))
|
||||
} catch (e) {
|
||||
setLiveStale(true)
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
|
||||
const parsed = recursiveReadSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes?serverId=${encodeURIComponent(selectedServerId)}`))
|
||||
if (!parsed.success) throw new Error("Некорректный снимок маршрутов в ответе API")
|
||||
const res = parsed.data
|
||||
if (String(res.serverId) !== selectedServerId) throw new Error("Маршруты принадлежат другому устройству")
|
||||
if (!alive.current || epoch !== readEpoch.current) return
|
||||
setRows(res.routes); setRouteOwner(selectedServerId); setLiveStale(res.stale)
|
||||
if (res.live) setReadAt(new Date().toISOString())
|
||||
else setOpError(res.error ?? "Роутер недоступен")
|
||||
} catch (error) {
|
||||
if (!alive.current || epoch !== readEpoch.current) return
|
||||
setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось загрузить маршруты")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
if (alive.current && epoch === readEpoch.current) setBusy(null)
|
||||
}
|
||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
||||
|
||||
const loadGateways = useCallback(async () => {
|
||||
if (!isLive || !liveServerListReady || !selectedServerId) return
|
||||
const epoch = ++gatewayEpoch.current
|
||||
try {
|
||||
const res = await apiFetch<{ gateways: GatewayOption[] }>(`/api/recursive-routes/gateways?serverId=${selectedServerId}`)
|
||||
setGatewayOptions(res.gateways)
|
||||
} catch {
|
||||
setGatewayOptions([])
|
||||
const parsed = recursiveGatewaysSchema.safeParse(await apiFetch<unknown>(`/api/recursive-routes/gateways?serverId=${encodeURIComponent(selectedServerId)}`))
|
||||
if (!parsed.success) throw new Error("Некорректный список шлюзов в ответе API")
|
||||
const res = parsed.data
|
||||
if (String(res.serverId) !== selectedServerId) throw new Error("Шлюзы принадлежат другому устройству")
|
||||
if (!alive.current || epoch !== gatewayEpoch.current) return
|
||||
setGatewayOptions(res.gateways); setGatewayError(null)
|
||||
} catch (error) {
|
||||
if (!alive.current || epoch !== gatewayEpoch.current) return
|
||||
setGatewayOptions([]); setGatewayError(error instanceof Error ? error.message : "Не удалось загрузить шлюзы")
|
||||
}
|
||||
}, [isLive, liveServerListReady, selectedServerId, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadRoutes()
|
||||
}, [loadRoutes])
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadGateways()
|
||||
}, [loadGateways])
|
||||
void loadRoutes(); void loadGateways()
|
||||
const reads = readEpoch, gateways = gatewayEpoch
|
||||
return () => { reads.current++; gateways.current++ }
|
||||
}, [loadRoutes, loadGateways])
|
||||
|
||||
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
|
||||
if (!isLive || !selectedServerId || liveStale) return
|
||||
const prev = rows
|
||||
setRows(next)
|
||||
setOpError(null)
|
||||
setBusy("apply")
|
||||
if (!isLive || !selectedServerId || liveStale || routeOwner !== selectedServerId || busy !== null || writeLock.current || !can("mm:network:write")) return false
|
||||
writeLock.current = true; readEpoch.current++
|
||||
let failed = false
|
||||
setOpError(null); setSaveError(null); setBusy("apply")
|
||||
try {
|
||||
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>("/api/recursive-routes", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ serverId: selectedServerId, routes: next }),
|
||||
})
|
||||
setRows(res.routes ?? next)
|
||||
setLiveStale(false)
|
||||
const raw = await apiFetch<unknown>("/api/recursive-routes", { method: "PUT", body: JSON.stringify({ serverId: selectedServerId, routes: next }) })
|
||||
const parsed = recursiveReceiptSchema.safeParse(raw)
|
||||
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId || parsed.data.pushedRoutes !== next.length) throw new Error("Сервер не подтвердил применение маршрутов")
|
||||
if (!alive.current) return false
|
||||
setRows(parsed.data.routes); setLiveStale(false); setWriteAt(new Date().toISOString())
|
||||
toast.success("Маршруты применены на роутер")
|
||||
} catch (e) {
|
||||
setRows(prev)
|
||||
const msg = e instanceof Error ? e.message : "Не удалось применить маршруты на роутер"
|
||||
setOpError(msg)
|
||||
toast.error(msg)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!alive.current) return false
|
||||
const message = error instanceof Error ? error.message : "Не удалось применить маршруты на роутер"
|
||||
failed = true
|
||||
setOpError(message); setSaveError(message); setLiveStale(true); toast.error(message)
|
||||
return false
|
||||
} finally {
|
||||
setBusy(null)
|
||||
writeLock.current = false
|
||||
if (alive.current) { setBusy(null); if (failed) void loadRoutes() }
|
||||
}
|
||||
}, [isLive, selectedServerId, liveStale, rows, apiFetch])
|
||||
}, [isLive, selectedServerId, routeOwner, liveStale, busy, apiFetch, loadRoutes])
|
||||
|
||||
useEffect(() => () => { historyEpoch.current += 1 }, [selectedServerId, backendUrl, isLive])
|
||||
|
||||
@@ -496,42 +523,47 @@ export default function RecursiveRoutesPage() {
|
||||
}, [isLive, selectedServerId, apiFetch])
|
||||
|
||||
const restoreRevision = useCallback(async (id: string) => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setHistoryRestoring(true)
|
||||
if (!isLive || !selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || writeLock.current || busy !== null || !can("mm:network:write")) throw new Error("Восстановление сейчас недоступно")
|
||||
writeLock.current = true; readEpoch.current++
|
||||
setHistoryRestoring(true); setBusy("apply")
|
||||
try {
|
||||
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>(
|
||||
`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`,
|
||||
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
|
||||
)
|
||||
if (res?.ok !== true) throw new Error("Сервер не подтвердил восстановление")
|
||||
setRows(res.routes ?? [])
|
||||
setLiveStale(false)
|
||||
toast.success("Версия применена на роутер")
|
||||
await loadRevisions()
|
||||
} catch (e) {
|
||||
throw e
|
||||
const raw = await apiFetch<unknown>(`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`, { method: "POST", body: JSON.stringify({ serverId: selectedServerId }) })
|
||||
const parsed = recursiveReceiptSchema.safeParse(raw)
|
||||
if (!parsed.success || String(parsed.data.serverId) !== selectedServerId) throw new Error("Сервер не подтвердил восстановление")
|
||||
if (!alive.current) return
|
||||
setRows(parsed.data.routes); setRouteOwner(selectedServerId); setLiveStale(false); setWriteAt(new Date().toISOString())
|
||||
toast.success("Версия применена на роутер"); await loadRevisions()
|
||||
} catch (error) {
|
||||
if (alive.current) { setLiveStale(true); setOpError(error instanceof Error ? error.message : "Не удалось восстановить маршруты") }
|
||||
throw error
|
||||
} finally {
|
||||
setHistoryRestoring(false)
|
||||
writeLock.current = false
|
||||
if (alive.current) { setHistoryRestoring(false); setBusy(null) }
|
||||
}
|
||||
}, [isLive, selectedServerId, apiFetch, loadRevisions])
|
||||
}, [isLive, selectedServerId, servers, busy, apiFetch, loadRevisions])
|
||||
|
||||
function groupKeyOf(row: RecursiveRouteRow): string {
|
||||
return row.dstAddress.trim().toLowerCase()
|
||||
return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
|
||||
}
|
||||
function openCreate() {
|
||||
if (mutationsLocked) return
|
||||
setSaveError(null)
|
||||
setSheetMode("create")
|
||||
setEditingGroupKey(null)
|
||||
setSheetInitial(emptyForm())
|
||||
setSheetOpen(true)
|
||||
}
|
||||
function openEdit(group: RouteGroup) {
|
||||
if (mutationsLocked) return
|
||||
setSaveError(null)
|
||||
const endpoints = rows.filter(row => groupKeyOf(row) === group.key)
|
||||
setSheetMode("edit")
|
||||
setEditingGroupKey(group.key)
|
||||
setSheetInitial({
|
||||
dstAddress: group.dstAddress,
|
||||
routingTable: group.routingTable,
|
||||
comment: group.comment,
|
||||
endpoints: group.endpoints.map(ep => ({
|
||||
endpoints: endpoints.map(ep => ({
|
||||
id: ep.id,
|
||||
gateway: ep.gateway,
|
||||
distance: ep.distance,
|
||||
@@ -543,7 +575,7 @@ export default function RecursiveRoutesPage() {
|
||||
})
|
||||
setSheetOpen(true)
|
||||
}
|
||||
function handleSaveSheet(v: RouteForm) {
|
||||
async function handleSaveSheet(v: RouteForm) {
|
||||
const toRow = (ep: RouteEndpointForm, id: string): RecursiveRouteRow => ({
|
||||
id,
|
||||
dstAddress: v.dstAddress,
|
||||
@@ -568,15 +600,16 @@ export default function RecursiveRoutesPage() {
|
||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||
next = [...kept, ...expanded]
|
||||
} else {
|
||||
setSheetOpen(false)
|
||||
return
|
||||
return false
|
||||
}
|
||||
setSheetOpen(false)
|
||||
void applyRoutes(next)
|
||||
const saved = await applyRoutes(next)
|
||||
if (saved && alive.current) setSheetOpen(false)
|
||||
return saved
|
||||
}
|
||||
|
||||
const hasRouteSnapshot = routeOwner === selectedServerId && (!liveStale || rows.length > 0)
|
||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
||||
const mutationsLocked = !isLive || busy !== null || liveStale
|
||||
const mutationsLocked = !isLive || busy !== null || liveStale || routeOwner !== selectedServerId || !servers.some(s => s.id === selectedServerId && s.enabled) || !can("mm:network:write")
|
||||
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
servers.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -584,7 +617,7 @@ export default function RecursiveRoutesPage() {
|
||||
host: s.host,
|
||||
site: s.site,
|
||||
country: s.country,
|
||||
status: s.status,
|
||||
status: s.status ?? undefined,
|
||||
type: s.type,
|
||||
enabled: s.enabled,
|
||||
}))
|
||||
@@ -624,7 +657,14 @@ export default function RecursiveRoutesPage() {
|
||||
<ServerRailLayout
|
||||
items={rrRailItems}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
onSelect={id => {
|
||||
if (writeLock.current || id === selectedServerId) return
|
||||
readEpoch.current++; gatewayEpoch.current++; historyEpoch.current++
|
||||
setSelectedServerId(id); setRows([]); setRouteOwner(""); setLiveStale(true); setBusy(null)
|
||||
setGatewayOptions([]); setGatewayError(null); setOpError(null); setSaveError(null)
|
||||
setReadAt(null); setWriteAt(null); setSheetOpen(false); setHistoryOpen(false); setRevisions([]); setHistoryError(null)
|
||||
setExpandedGroupKey(null)
|
||||
}}
|
||||
showAll={false}
|
||||
showCount={false}
|
||||
loading={isLive && !liveServerListReady}
|
||||
@@ -639,7 +679,7 @@ export default function RecursiveRoutesPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadRoutes()}
|
||||
onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
|
||||
disabled={busy !== null}
|
||||
title="Прочитать маршруты с роутера"
|
||||
>
|
||||
@@ -671,10 +711,10 @@ export default function RecursiveRoutesPage() {
|
||||
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
|
||||
<div className="relative min-w-[200px] max-w-xs flex-1">
|
||||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||
<Input aria-label="Поиск маршрутов" className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
|
||||
value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
{search && (
|
||||
<button onClick={() => setSearch("")}
|
||||
<button type="button" aria-label="Сбросить поиск маршрутов" onClick={() => setSearch("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
@@ -682,20 +722,23 @@ export default function RecursiveRoutesPage() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground">Всего маршрутов</span>
|
||||
<span className="font-semibold tabular-nums">{rows.length}</span>
|
||||
<span className="font-semibold tabular-nums">{hasRouteSnapshot ? rows.length : "—"}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground ml-auto">
|
||||
{filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
|
||||
</p>
|
||||
{opError && (
|
||||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||||
{opError}
|
||||
{(opError || saveError) && (
|
||||
<div className="w-full text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2" role="alert">
|
||||
{opError || saveError}
|
||||
</div>
|
||||
)}
|
||||
{liveStale && (
|
||||
{isLive && readAt && <p role="status" className="w-full text-xs text-muted-foreground">Успешный ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время получения браузером, не измерения RouterOS.</p>}
|
||||
{isLive && writeAt && <p role="status" className="w-full text-xs text-muted-foreground">Запись подтверждена {new Date(writeAt).toLocaleTimeString("ru-RU")}.</p>}
|
||||
{isLive && gatewayError && <p role="status" className="w-full text-xs text-warning">Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.</p>}
|
||||
{isLive && liveStale && (
|
||||
<div className="w-full text-xs text-amber-700 dark:text-amber-400 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2 flex items-center gap-2">
|
||||
<AlertTriangleIcon className="size-3.5 shrink-0" />
|
||||
Роутер недоступен — показан кэш. Изменения заблокированы.
|
||||
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -711,7 +754,7 @@ export default function RecursiveRoutesPage() {
|
||||
<DataPageCard>
|
||||
{currentServer && (
|
||||
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
|
||||
<StatusDot status={currentServer.status} pulse={currentServer.status === "online"} />
|
||||
{currentServer.status ? <StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> : <span className="text-xs text-muted-foreground">Состояние неизвестно</span>}
|
||||
<Flag code={currentServer.country} size={16} />
|
||||
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
|
||||
<TypeChip type={currentServer.type} />
|
||||
@@ -724,15 +767,17 @@ export default function RecursiveRoutesPage() {
|
||||
)}>{currentServer.latency}мс</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>{rows.length} маршрутов</span>
|
||||
<span>{hasRouteSnapshot ? rows.length + " маршрутов" : "Количество неизвестно"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecursiveRoutesDataGrid
|
||||
emptyTitle={liveStale ? "Маршруты пока неизвестны" : search ? "Нет маршрутов по текущему поиску" : undefined}
|
||||
groups={groupedRoutes.map((g) => ({ ...g, id: g.key }))}
|
||||
expandedKey={expandedGroupKey}
|
||||
onExpandedChange={setExpandedGroupKey}
|
||||
mutationsDisabled={mutationsLocked}
|
||||
onEdit={openEdit}
|
||||
onDelete={(g) => {
|
||||
if (mutationsLocked) return
|
||||
@@ -749,11 +794,13 @@ export default function RecursiveRoutesPage() {
|
||||
</ServerRailLayout>
|
||||
|
||||
<RouteSheet
|
||||
busy={busy !== null}
|
||||
saveError={saveError}
|
||||
open={sheetOpen}
|
||||
mode={sheetMode}
|
||||
initial={sheetInitial}
|
||||
onSave={handleSaveSheet}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onClose={() => { if (!writeLock.current) setSheetOpen(false) }}
|
||||
gateways={gatewayOptions}
|
||||
/>
|
||||
|
||||
@@ -766,6 +813,7 @@ export default function RecursiveRoutesPage() {
|
||||
itemLabel="маршрутов"
|
||||
revisions={revisions}
|
||||
loading={historyLoading}
|
||||
restoreDisabled={!can("mm:network:write") || !currentServer?.enabled || busy !== null && !historyRestoring}
|
||||
restoring={historyRestoring}
|
||||
onRestore={restoreRevision}
|
||||
/>
|
||||
|
||||
@@ -197,7 +197,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
})
|
||||
}
|
||||
}
|
||||
return reply.send({ gateways: [...map.values()] })
|
||||
return reply.send({ serverId, gateways: [...map.values()] })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
@@ -226,10 +226,11 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
payload: canonicalRecursiveRoutes(live),
|
||||
})
|
||||
const stored = await mapDbRoutes(serverId)
|
||||
return reply.send({ routes: stored, live: true, stale: false })
|
||||
return reply.send({ serverId, routes: stored, live: true, stale: false })
|
||||
} catch (err) {
|
||||
app.log.warn({ serverId, err: String(err) }, "recursive live GET failed, serving cache")
|
||||
return reply.send({
|
||||
serverId,
|
||||
routes: cached,
|
||||
live: false,
|
||||
stale: true,
|
||||
@@ -256,7 +257,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
try {
|
||||
const result = await applyRecursiveToServer(server, routes, source)
|
||||
const stored = await mapDbRoutes(serverId)
|
||||
return reply.send({ ok: true, routes: stored, pushedRoutes: result.pushed })
|
||||
return reply.send({ ok: true, serverId, routes: stored, pushedRoutes: result.pushed })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
@@ -308,7 +309,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => {
|
||||
try {
|
||||
const result = await applyRecursiveToServer(server, routes, "rollback")
|
||||
const stored = await mapDbRoutes(server.id)
|
||||
return reply.send({ ok: true, routes: stored, pushedRoutes: result.pushed })
|
||||
return reply.send({ ok: true, serverId: server.id, routes: stored, pushedRoutes: result.pushed })
|
||||
} catch (err) {
|
||||
return reply.status(500).send({ error: String(err) })
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function ConfigHistorySheet({
|
||||
onRetry,
|
||||
restoring,
|
||||
onRestore,
|
||||
restoreDisabled = false,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -79,6 +80,7 @@ export function ConfigHistorySheet({
|
||||
loadError?: string | null
|
||||
onRetry?: () => void
|
||||
restoring: boolean
|
||||
restoreDisabled?: boolean
|
||||
onRestore: (id: string) => Promise<void> | void
|
||||
}) {
|
||||
const [pending, setPending] = useState<ConfigRevisionDto | null>(null)
|
||||
@@ -87,7 +89,7 @@ export function ConfigHistorySheet({
|
||||
const [restoreError, setRestoreError] = useState<string | null>(null)
|
||||
const busy = restoring || submitting
|
||||
async function confirmRestore() {
|
||||
if (!pending || restoreLock.current || restoring) return
|
||||
if (!pending || restoreLock.current || restoring || restoreDisabled) return
|
||||
restoreLock.current = true
|
||||
setSubmitting(true)
|
||||
setRestoreError(null)
|
||||
@@ -162,7 +164,7 @@ export function ConfigHistorySheet({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
disabled={busy || restoreDisabled}
|
||||
onClick={() => { setRestoreError(null); setPending(rev) }}
|
||||
>
|
||||
<RotateCcwIcon className="size-3.5" />
|
||||
@@ -205,7 +207,7 @@ export function ConfigHistorySheet({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={() => setPending(null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={busy || !pending}
|
||||
disabled={busy || restoreDisabled || !pending}
|
||||
type="button"
|
||||
onClick={() => void confirmRestore()}
|
||||
>
|
||||
|
||||
@@ -113,6 +113,8 @@ interface RecursiveRoutesDataGridProps {
|
||||
onExpandedChange?: (key: string | null) => void
|
||||
onEdit: (group: RecursiveRouteGroup) => void
|
||||
onDelete: (group: RecursiveRouteGroup) => void
|
||||
mutationsDisabled?: boolean
|
||||
emptyTitle?: string
|
||||
}
|
||||
|
||||
function RecursiveRoutesDataGrid({
|
||||
@@ -121,6 +123,8 @@ function RecursiveRoutesDataGrid({
|
||||
onExpandedChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
mutationsDisabled = false,
|
||||
emptyTitle = "Нет маршрутов",
|
||||
}: RecursiveRoutesDataGridProps) {
|
||||
const [confirmDeleteKey, setConfirmDeleteKey] = useState<string | null>(null)
|
||||
|
||||
@@ -243,13 +247,16 @@ function RecursiveRoutesDataGrid({
|
||||
const confirmDel = confirmDeleteKey === g.key
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5 justify-end opacity-0 group-hover/row:opacity-100 transition-opacity"
|
||||
className="flex items-center gap-0.5 justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
aria-label={`Редактировать маршрут ${g.dstAddress} · ${g.routingTable}`}
|
||||
disabled={mutationsDisabled}
|
||||
onClick={() => onEdit(g)}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
@@ -263,7 +270,11 @@ function RecursiveRoutesDataGrid({
|
||||
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||
: "text-muted-foreground hover:text-destructive",
|
||||
)}
|
||||
type="button"
|
||||
aria-label={`${confirmDel ? "Подтвердить удаление" : "Удалить маршрут"} ${g.dstAddress} · ${g.routingTable}`}
|
||||
disabled={mutationsDisabled}
|
||||
onClick={() => {
|
||||
if (mutationsDisabled) return
|
||||
if (!confirmDel) setConfirmDeleteKey(g.key)
|
||||
else {
|
||||
onDelete(g)
|
||||
@@ -285,7 +296,7 @@ function RecursiveRoutesDataGrid({
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[confirmDeleteKey, expandedKey, onDelete, onEdit],
|
||||
[confirmDeleteKey, expandedKey, onDelete, onEdit, mutationsDisabled],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
@@ -302,7 +313,7 @@ function RecursiveRoutesDataGrid({
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<RouteIcon className="size-4" />}
|
||||
title="Нет маршрутов"
|
||||
title={emptyTitle}
|
||||
className="border-0 py-10"
|
||||
/>
|
||||
)
|
||||
|
||||
+10
-1
@@ -26,11 +26,20 @@
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.23; OSPF принят локально, далее рекурсивные маршруты |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.24; операции рекурсивных маршрутов, далее backend validation |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 10.24 — подтверждённые операции рекурсивных маршрутов (2026-09-30)
|
||||
|
||||
- PUT и restore проверяют ok, serverId, полный список и pushedRoutes; backend добавляет serverId к чтению, шлюзам и receipts. Старые поля сохранены. Новый frontend требует совместимый обновлённый backend; старый ответ безопасно блокирует запись.
|
||||
- Форма сохраняется при HTTP/неподтверждённом ответе; данные таблицы не заменяются черновиком. Синхронный lock объединяет save/delete/restore, блокирует повтор и обычную смену устройства; поля/закрытие недоступны при ожидании. Ошибка применения запускает повторное чтение, поскольку RouterOS мог применить часть запроса.
|
||||
- Чтение валидирует snapshot и принадлежность устройству, сохраняет последний снимок при отказе; начальная неизвестность и stale empty не выдаются за «Нет маршрутов». Late read не заменяет другой срез. Mode/backend размонтируют прежний контекст; freshness чтения/записи раздельна. Gateway error показан явно.
|
||||
- Группа определяется адресом и таблицей. Редактирование поиска берёт все endpoints группы из полного снимка, сохраняя скрытые поиском endpoints и другие таблицы. Действия подписаны/видимы с клавиатуры, write-права и enabled устройства проверяются; readonly история доступна.
|
||||
- Проверки: 35 E2E (данные/операции/права/история/навигация/шапка), TS/lint, обе production-сборки и backend config-sync прошли. После разделения запрета restore и доступности чтения/отмены повторно прошли 11 общих history сценариев.
|
||||
- Далее 10.25: валидация backend PUT/restore до сетевых действий; затем URL/source E2E, nullable наблюдаемые поля и визуальная приёмка. Legacy mapRosManagedRoutes ещё подставляет значения при недостоверных полях — раздел целиком не принят. Реальные RouterOS/DB/JWT не проверены. Последняя квота: 34% окна, 36% недели использовано.
|
||||
|
||||
## Блок 10.23 — адаптивность и локальная приёмка OSPF (2026-09-30)
|
||||
|
||||
- Одна профильная сводка на вкладку вместо дублирования. Мобильные вкладки/таблицы прокручиваются внутри контейнеров; детали находятся под графом. Выбранный узел центрируется, легенда читабельна и включает согласование/неизвестность; семантические цвета текущей темы.
|
||||
|
||||
@@ -144,3 +144,7 @@ Route subtype и BFD multihop неизвестны без наблюдения.
|
||||
## 10.23. Приёмка OSPF на ПК/телефоне
|
||||
|
||||
Срез устройства → свежесть → профильная сводка → граф/таблица → детали → подтверждение API. Детали на телефоне под графом; Escape возвращает фокус; reduced motion соблюдается. 43 основных + 8 source E2E, 9 unit и production frontend прошли; screenshot/keyboard/motion проверены. Прежние BGP/OSPF backend fixtures/build прошли в 10.22. OSPF принят локально, следующий раздел — рекурсивные маршруты. Реальный RouterOS/JWT/DB не проверен.
|
||||
|
||||
## 10.24. Операции рекурсивных маршрутов
|
||||
|
||||
Strict receipts/serverId, общая блокировка save/delete/restore, форма и последний снимок при ошибке; разные routing tables и скрытые поиском endpoints сохранены. 35 E2E, TS/lint, обе сборки и config-sync прошли; 11 общих history повторно прошли после разделения доступности read/write. Далее backend validation, URL/source и observed metadata/layout. Legacy parser пока не мигрирован; сетевые правила применения прежние.
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod"
|
||||
import { vxlanServersSchema } from "@/lib/vxlan-response"
|
||||
export const recursiveServersSchema = vxlanServersSchema
|
||||
export const recursiveRowSchema = z.object({
|
||||
id: z.string().trim().min(1), dstAddress: z.string().trim().min(1), gateway: z.string().trim().min(1),
|
||||
distance: z.number().int().min(0).max(255), scope: z.number().int().min(0).max(255).nullable(),
|
||||
targetScope: z.number().int().min(0).max(255).nullable(), routingTable: z.string().trim().min(1),
|
||||
checkGateway: z.string(), comment: z.string(), disabled: z.boolean(), country: z.string(),
|
||||
})
|
||||
const rows = z.array(recursiveRowSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length)
|
||||
export const recursiveReadSchema = z.object({
|
||||
serverId: z.number().int().positive(), routes: rows, live: z.boolean(), stale: z.boolean(), error: z.string().optional(),
|
||||
}).refine(data => data.live !== data.stale)
|
||||
export const recursiveReceiptSchema = z.object({
|
||||
ok: z.literal(true), serverId: z.number().int().positive(), routes: rows, pushedRoutes: z.number().int().nonnegative(),
|
||||
}).refine(data => data.pushedRoutes === data.routes.length)
|
||||
export const recursiveGatewaysSchema = z.object({
|
||||
serverId: z.number().int().positive(), gateways: z.array(z.object({
|
||||
id: z.string().min(1), name: z.string(), ip: z.string().min(1), status: z.enum(["up", "down"]),
|
||||
})).refine(rows => new Set(rows.map(row => row.id)).size === rows.length),
|
||||
})
|
||||
export type RecursiveRouteRow = z.infer<typeof recursiveRowSchema>
|
||||
@@ -21,7 +21,7 @@ for (const section of ["wireguard", "ipsec", "gre", "firewall", "filters", "recu
|
||||
}
|
||||
if (attempts === 2) return route.fulfill({ json: { ok: false, routes: [], rules: [] } })
|
||||
if (attempts === 3) return route.fulfill({ json: {} })
|
||||
return route.fulfill({ json: { ok: true, routes: [], rules: [] } })
|
||||
return route.fulfill({ json: { ok: true, serverId: 1, pushedRoutes: 0, routes: [], rules: [] } })
|
||||
}
|
||||
const json = path.endsWith("/revisions") ? { revisions: [{ id: "rev/1", serverId: "1", section, source: "apply", fingerprint: "fixture", createdAt: "2026-09-29T00:00:00Z", note: null, itemCount: 1 }] }
|
||||
: path === "/api/auth/config" ? { required: false }
|
||||
@@ -31,8 +31,8 @@ for (const section of ["wireguard", "ipsec", "gre", "firewall", "filters", "recu
|
||||
: path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] }
|
||||
: path === "/api/firewall/all" ? { rules: [], addressLists: [] }
|
||||
: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true }
|
||||
: path === "/api/recursive-routes" ? { routes: [] }
|
||||
: path === "/api/recursive-routes/gateways" ? { gateways: [] }
|
||||
: path === "/api/recursive-routes" ? { serverId: 1, routes: [], live: true, stale: false }
|
||||
: path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] }
|
||||
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {}
|
||||
return route.fulfill({ json })
|
||||
})
|
||||
@@ -95,8 +95,8 @@ for (const section of ["ipsec", "gre", "firewall", "filters", "recursive-routes"
|
||||
: path === "/api/gre/tunnels" || path === "/api/filters/gre-tunnels" ? { tunnels: [] }
|
||||
: path === "/api/firewall/all" ? { rules: [], addressLists: [] }
|
||||
: path === "/api/filters/rules" ? { rulesets: [{ serverId: "1", rules: [] }], greTunnels: [], live: true }
|
||||
: path === "/api/recursive-routes" ? { routes: [] }
|
||||
: path === "/api/recursive-routes/gateways" ? { gateways: [] }
|
||||
: path === "/api/recursive-routes" ? { serverId: 1, routes: [], live: true, stale: false }
|
||||
: path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] }
|
||||
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false }
|
||||
: path === "/api/servers" ? [{ id: 1, name: "История роутер", host: "192.0.2.1", enabled: true, type: "exit-node", country: "RU", status: "online" }] : {} })
|
||||
})
|
||||
|
||||
@@ -8,8 +8,8 @@ for (const width of [320, 390, 1024, 1440]) {
|
||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
return route.fulfill({ json: path === "/api/recursive-routes" ? { routes: [] }
|
||||
: path === "/api/recursive-routes/gateways" ? { gateways: [] }
|
||||
return route.fulfill({ json: path === "/api/recursive-routes" ? { serverId: 1, routes: [], live: true, stale: false }
|
||||
: path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] }
|
||||
: path === "/api/auth/config" ? { required: false }
|
||||
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.100", type: "exit-node", site: "TEST", country: "RU", asn: "64512", enabled: true, status: "online", latency: 1 }]
|
||||
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} })
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
const routeRow = (id = "a", destination = "10.0.1.0/24", table = "main") => ({
|
||||
id, dstAddress: destination, gateway: "192.0.2.1", distance: 1, scope: 30, targetScope: 10,
|
||||
routingTable: table, checkGateway: "ping", comment: "Сохранённый маршрут", disabled: false, country: "RU",
|
||||
})
|
||||
const servers = [1,2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, enabled: true, country: "RU", status: null }))
|
||||
for (const result of ["success", "http", "false", "missing", "foreign", "count"] as const) {
|
||||
test("Рекурсивные маршруты: подтверждение записи и форма " + result, async ({ page }) => {
|
||||
let attempts = 0, saved = [routeRow()]
|
||||
let release!: () => void; const gate = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", async r => {
|
||||
const u = new URL(r.request().url()), p = u.pathname
|
||||
if (p === "/api/recursive-routes" && r.request().method() === "PUT") {
|
||||
attempts++; const body = r.request().postDataJSON()
|
||||
expect(body.serverId).toBe("1")
|
||||
await gate
|
||||
if (attempts === 1 && result !== "success") return r.fulfill(result === "http" ? { status: 403, json: { error: "Запись запрещена тест" } }
|
||||
: { json: result === "missing" ? {} : { ok: result !== "false", serverId: result === "foreign" ? 2 : 1, routes: body.routes, pushedRoutes: result === "count" ? 0 : body.routes.length } })
|
||||
saved = body.routes; return r.fulfill({ json: { ok: true, serverId: 1, routes: saved, pushedRoutes: saved.length } })
|
||||
}
|
||||
return r.fulfill({ json: p === "/api/servers" ? servers : p === "/api/auth/config" ? { required: false }
|
||||
: p === "/api/recursive-routes" ? { serverId: Number(u.searchParams.get("serverId")), routes: saved, live: true, stale: false }
|
||||
: p === "/api/recursive-routes/gateways" ? { serverId: Number(u.searchParams.get("serverId")), gateways: [] } : {} })
|
||||
})
|
||||
await page.goto("/recursive-routes")
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
await page.getByRole("button", { name: "Добавить", exact: true }).click()
|
||||
const sheet = page.getByRole("dialog")
|
||||
await sheet.getByPlaceholder("8.8.8.8/32").fill("203.0.113.0/24")
|
||||
await sheet.getByPlaceholder("1.2.3.4%GW-NAME").fill("192.0.2.9")
|
||||
await sheet.getByRole("button", { name: "Добавить", exact: true }).evaluate(el => { (el as HTMLButtonElement).click(); (el as HTMLButtonElement).click() })
|
||||
await expect.poll(() => attempts).toBe(1)
|
||||
await expect(sheet.getByRole("status")).toContainText("Ожидание подтверждения")
|
||||
await expect(sheet.getByRole("button", { name: "Отмена", exact: true })).toBeDisabled()
|
||||
await expect(sheet.getByPlaceholder("8.8.8.8/32")).toBeDisabled()
|
||||
await page.keyboard.press("Escape"); await expect(sheet).toBeVisible()
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
release()
|
||||
if (result !== "success") {
|
||||
await expect(sheet.getByRole("alert")).toContainText(result === "http" ? "Запись запрещена тест" : "Сервер не подтвердил")
|
||||
await expect(sheet.getByPlaceholder("8.8.8.8/32")).toHaveValue("203.0.113.0/24")
|
||||
await expect(page.getByText("Маршруты применены на роутер", { exact: true })).toHaveCount(0)
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
await sheet.getByRole("button", { name: "Добавить", exact: true }).click()
|
||||
}
|
||||
await expect(sheet).toHaveCount(0); await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
|
||||
await expect(page.getByRole("status").filter({ hasText: "Запись подтверждена" })).toBeVisible()
|
||||
})
|
||||
}
|
||||
for (const bad of ["http", "foreign", "payload", "duplicate", "stale"] as const) test("Рекурсивные маршруты: неизвестность, кэш и повтор " + bad, async ({ page }) => {
|
||||
let phase = 0
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => {
|
||||
const p = new URL(r.request().url()).pathname
|
||||
if (p === "/api/recursive-routes") {
|
||||
if (phase !== 1 && bad === "http") return r.fulfill({ status: 503, json: { error: "Чтение недоступно тест" } })
|
||||
const valid = { serverId: 1, routes: phase === 0 ? [] : [routeRow()], live: true, stale: false }
|
||||
return r.fulfill({ json: phase === 1 ? valid : bad === "foreign" ? { ...valid, serverId: 2 } : bad === "payload" ? { ...valid, routes: [{}] }
|
||||
: bad === "duplicate" ? { ...valid, routes: [routeRow(), routeRow()] } : { ...valid, live: false, stale: true, error: "Кэш тест" } })
|
||||
}
|
||||
return r.fulfill({ json: p === "/api/servers" ? [servers[0]] : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : {} })
|
||||
})
|
||||
await page.goto("/recursive-routes")
|
||||
await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeDisabled()
|
||||
await expect(page.getByText("Нет маршрутов", { exact: true })).toHaveCount(0)
|
||||
phase = 1; await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeEnabled()
|
||||
phase = 2; await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeDisabled()
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
await expect(page.getByRole("status").filter({ hasText: "Успешный ответ получен" })).toBeVisible()
|
||||
})
|
||||
test("Рекурсивные маршруты: чтение прежнего устройства не заменяет новый срез", async ({ page }) => {
|
||||
let release!: () => void; const gate = new Promise<void>(resolve => { release = resolve }); let requested = false
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", async r => {
|
||||
const u = new URL(r.request().url()), p = u.pathname, id = Number(u.searchParams.get("serverId"))
|
||||
if (p === "/api/recursive-routes") { if (id === 1) { requested = true; await gate }
|
||||
return r.fulfill({ json: { serverId: id, routes: [routeRow("r" + id, "10.0." + id + ".0/24")], live: true, stale: false } }) }
|
||||
return r.fulfill({ json: p === "/api/servers" ? servers : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes/gateways" ? { serverId: id, gateways: [] } : {} })
|
||||
})
|
||||
await page.goto("/recursive-routes"); await expect.poll(() => requested).toBe(true)
|
||||
await page.getByRole("option").filter({ hasText: "Роутер 2" }).click()
|
||||
await expect(page.locator("tr[data-row-id]")).toContainText("10.0.2.0/24")
|
||||
release(); await expect(page.getByRole("button", { name: "Обновить", exact: true })).toBeEnabled()
|
||||
await expect(page.locator("tr[data-row-id]")).not.toContainText("10.0.1.0/24")
|
||||
})
|
||||
test("Рекурсивные маршруты: редактирование поиска сохраняет скрытые endpoints и таблицы", async ({ page }) => {
|
||||
let saved: unknown; const rows = [{ ...routeRow(), gateway: "192.0.2.11" }, { ...routeRow("b"), gateway: "192.0.2.22" }, { ...routeRow("c", "10.0.1.0/24", "vpn"), gateway: "192.0.2.33" }]
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => {
|
||||
const p = new URL(r.request().url()).pathname
|
||||
if (p === "/api/recursive-routes" && r.request().method() === "PUT") { const next = r.request().postDataJSON().routes; saved = next; return r.fulfill({ json: { ok: true, serverId: 1, routes: next, pushedRoutes: next.length } }) }
|
||||
return r.fulfill({ json: p === "/api/servers" ? [servers[0]] : p === "/api/auth/config" ? { required: false } : p === "/api/recursive-routes" ? { serverId: 1, routes: rows, live: true, stale: false } : p === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] } : {} })
|
||||
})
|
||||
await page.goto("/recursive-routes"); await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
|
||||
await page.getByRole("textbox", { name: "Поиск маршрутов" }).fill("192.0.2.11")
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
await page.getByRole("button", { name: "Редактировать маршрут 10.0.1.0/24 · main", exact: true }).click()
|
||||
const sheet = page.getByRole("dialog")
|
||||
await expect(sheet.getByPlaceholder("1.2.3.4%GW-NAME")).toHaveCount(2)
|
||||
await sheet.getByRole("button", { name: "Сохранить", exact: true }).click(); await expect(sheet).toHaveCount(0)
|
||||
expect(saved).toEqual(expect.arrayContaining([expect.objectContaining({ routingTable: "main", gateway: "192.0.2.11" }), expect.objectContaining({ routingTable: "main", gateway: "192.0.2.22" }), expect.objectContaining({ routingTable: "vpn", gateway: "192.0.2.33" })]))
|
||||
expect(saved).toHaveLength(3)
|
||||
})
|
||||
|
||||
|
||||
for (const allowed of [false, true]) test("Рекурсивные маршруты: права и disabled устройство " + allowed, async ({ page }) => {
|
||||
const claims = { sub: "test", email: "[email protected]", name: "Reader", apps: ["mm"], permissions: ["mm:network:read", ...(allowed ? ["mm:network:write"] : [])], exp: 4102444800 }
|
||||
await page.addInitScript(value => localStorage.setItem("mmapp_token", "test." + btoa(JSON.stringify(value)) + ".test"), claims)
|
||||
let writes = 0
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => {
|
||||
const p = new URL(r.request().url()).pathname
|
||||
if (r.request().method() !== "GET") writes++
|
||||
return r.fulfill({ json: p === "/api/auth/config" ? { required: true } : p === "/api/servers" ? [{ ...servers[0], enabled: !allowed }]
|
||||
: p === "/api/recursive-routes" ? { serverId: 1, routes: [routeRow()], live: true, stale: false }
|
||||
: p === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] }
|
||||
: p.endsWith("/revisions") ? { revisions: [{ id: "rev", serverId: "1", section: "recursive-routes", source: "apply", fingerprint: "fixture", createdAt: "2026-09-30T00:00:00Z", note: null, itemCount: 1 }] } : {} })
|
||||
})
|
||||
await page.goto("/recursive-routes"); await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
await expect(page.getByRole("button", { name: "Добавить", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Редактировать маршрут 10.0.1.0/24 · main", exact: true })).toBeDisabled()
|
||||
await page.getByRole("button", { name: "История", exact: true }).click()
|
||||
await expect(page.getByRole("dialog").getByRole("button", { name: "Откатить", exact: true })).toBeDisabled()
|
||||
expect(writes).toBe(0)
|
||||
})
|
||||
@@ -11,8 +11,8 @@ test("Рекурсивные маршруты: все группы и раскр
|
||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
return route.fulfill({ json: path === "/api/recursive-routes" ? { routes }
|
||||
: path === "/api/recursive-routes/gateways" ? { gateways: [] }
|
||||
return route.fulfill({ json: path === "/api/recursive-routes" ? { serverId: 1, routes, live: true, stale: false }
|
||||
: path === "/api/recursive-routes/gateways" ? { serverId: 1, gateways: [] }
|
||||
: path === "/api/auth/config" ? { required: false }
|
||||
: path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.100", type: "exit-node", site: "TEST", country: "RU", asn: "64512", enabled: true, status: "online", latency: 1 }]
|
||||
: path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } : {} })
|
||||
|
||||
Reference in new issue
Block a user