diff --git a/app/(main)/firewall/page.tsx b/app/(main)/firewall/page.tsx index d738e90..955ce28 100644 --- a/app/(main)/firewall/page.tsx +++ b/app/(main)/firewall/page.tsx @@ -49,12 +49,14 @@ import { PowerIcon, CheckCircleIcon, PlayIcon, SquareIcon, RotateCcwIcon, ZapIcon, CheckCircle2Icon, XCircleIcon, MinusCircleIcon, SkipForwardIcon, - SlidersHorizontalIcon, RefreshCwIcon, + SlidersHorizontalIcon, RefreshCwIcon, HistoryIcon, } from "lucide-react" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail" import { toast } from "sonner" +import { ConfigHistorySheet } from "@/components/config-history-sheet" +import type { ConfigRevisionDto } from "@/lib/config-revisions" // ─── Types ──────────────────────────────────────────────────────────────────── @@ -1947,6 +1949,10 @@ function FirewallPageInner() { const [exportOpen, setExportOpen] = useState(false) const [editingAddr, setEditingAddr] = useState | null>(null) const [addrSheetOpen, setAddrSheetOpen] = useState(false) + const [historyOpen, setHistoryOpen] = useState(false) + const [revisions, setRevisions] = useState([]) + const [historyLoading, setHistoryLoading] = useState(false) + const [historyRestoring, setHistoryRestoring] = useState(false) const loadLive = useCallback(async () => { if (!isLive) return @@ -1970,6 +1976,42 @@ function FirewallPageInner() { } }, [isLive, apiFetch]) + const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId + + const loadRevisions = useCallback(async () => { + if (!isLive || !historyServerId) return + setHistoryLoading(true) + try { + const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>( + `/api/firewall/revisions?serverId=${encodeURIComponent(historyServerId)}`, + ) + setRevisions(res.revisions) + } catch (err) { + toast.error("Не удалось загрузить историю", { description: String(err) }) + setRevisions([]) + } finally { + setHistoryLoading(false) + } + }, [isLive, historyServerId, apiFetch]) + + const restoreRevision = useCallback(async (id: string) => { + if (!isLive || !historyServerId) return + setHistoryRestoring(true) + try { + await apiFetch( + `/api/firewall/revisions/${encodeURIComponent(id)}/restore`, + { method: "POST", body: JSON.stringify({ serverId: historyServerId }) }, + ) + toast.success("Версия применена на роутер") + await loadLive() + await loadRevisions() + } catch (err) { + toast.error("Не удалось откатить", { description: String(err) }) + } finally { + setHistoryRestoring(false) + } + }, [isLive, historyServerId, apiFetch, loadLive, loadRevisions]) + useEffect(() => { if (!isLive) { queueMicrotask(() => { @@ -2373,6 +2415,19 @@ function FirewallPageInner() { Обновить + @@ -2586,6 +2641,17 @@ function FirewallPageInner() { onClose={() => setExportOpen(false)} rules={familyRules} /> + + ) } diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx index f78b492..85765a0 100644 --- a/app/(main)/gre/page.tsx +++ b/app/(main)/gre/page.tsx @@ -13,11 +13,23 @@ import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" import { cn } from "@/lib/utils" import { toast } from "sonner" -import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { OpsPanel } from "@/components/ops-panel" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { ConfigHistorySheet } from "@/components/config-history-sheet" +import type { ConfigRevisionDto } from "@/lib/config-revisions" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, SheetClose, @@ -31,7 +43,7 @@ import { LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon, EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon, CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, - DatabaseIcon, + DatabaseIcon, HistoryIcon, TriangleAlertIcon, } from "lucide-react" import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet" import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout" @@ -164,6 +176,7 @@ interface BackendServer { interface GreTunnelsApiResponse { tunnels: GreTunnel[] + failures?: Array<{ serverId: string; serverName?: string; error: string }> } function makeApiFetch(backendUrl: string) { @@ -244,6 +257,15 @@ export default function GrePage() { const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID) const [tunnelOpen, setTunnelOpen] = useState(false) + const [tunnelMode, setTunnelMode] = useState<"create" | "edit">("create") + const [editingTunnel, setEditingTunnel] = useState(null) + const [pendingDelete, setPendingDelete] = useState(null) + const [mutateBusy, setMutateBusy] = useState(false) + const [liveStale, setLiveStale] = useState(false) + const [historyOpen, setHistoryOpen] = useState(false) + const [revisions, setRevisions] = useState([]) + const [historyLoading, setHistoryLoading] = useState(false) + const [historyRestoring, setHistoryRestoring] = useState(false) const [poolOpen, setPoolOpen] = useState(false) const [codePreviewTunnel, setCodePreviewTunnel] = useState(null) @@ -260,14 +282,19 @@ export default function GrePage() { try { const [backendServers, greRes] = await Promise.all([ apiFetch("/api/servers"), - apiFetch("/api/filters/gre-tunnels"), + apiFetch("/api/gre/tunnels"), ]) setLiveServers(backendServers.map(mapBackendToServer)) setLiveTunnels(greRes.tunnels) + setLiveStale(false) + if (greRes.failures?.length) { + toast.warning( + `Не удалось опросить: ${greRes.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`, + ) + } } catch (e) { setDataError(e instanceof Error ? e.message : "Ошибка загрузки") - setLiveServers([]) - setLiveTunnels([]) + setLiveStale(true) } finally { setDataLoading(false) } @@ -279,6 +306,7 @@ export default function GrePage() { setLiveServers([]) setLiveTunnels([]) setDataError(null) + setLiveStale(false) }) return } @@ -322,6 +350,187 @@ export default function GrePage() { [displayPools], ) + const historyServerId = selectedServerId === ALL_SERVERS_ID ? null : selectedServerId + const mutationsLocked = isLive && (mutateBusy || liveStale || historyRestoring) + + const loadRevisions = useCallback(async () => { + if (!isLive || !historyServerId) return + setHistoryLoading(true) + try { + const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>( + `/api/gre/revisions?serverId=${encodeURIComponent(historyServerId)}`, + ) + setRevisions(res.revisions) + } catch (err) { + toast.error("Не удалось загрузить историю", { description: String(err) }) + setRevisions([]) + } finally { + setHistoryLoading(false) + } + }, [isLive, historyServerId, apiFetch]) + + const restoreRevision = useCallback(async (id: string) => { + if (!isLive || !historyServerId) return + setHistoryRestoring(true) + try { + await apiFetch( + `/api/gre/revisions/${encodeURIComponent(id)}/restore`, + { method: "POST", body: JSON.stringify({ serverId: historyServerId }) }, + ) + toast.success("Версия применена на роутер") + await loadLive() + await loadRevisions() + } catch (err) { + toast.error("Не удалось откатить", { description: String(err) }) + } finally { + setHistoryRestoring(false) + } + }, [isLive, historyServerId, apiFetch, loadLive, loadRevisions]) + + function tunnelWriteBody(form: typeof defaultTunnelForm) { + return { + serverId: form.serverId, + name: form.name.trim(), + localAddress: form.localAddress.trim() || undefined, + remoteAddress: form.remoteAddress.trim(), + localInnerIp: form.localInnerIp.trim() || undefined, + remoteInnerIp: form.remoteInnerIp.trim() || undefined, + comment: form.comment || undefined, + enabled: form.enabled, + mtu: form.mtu, + keepaliveInterval: form.keepaliveInterval, + keepaliveRetries: form.keepaliveRetries, + dscp: form.dscp, + clampTcpMss: form.clampTcpMss, + allowFastPath: form.allowFastPath, + ipsecSecret: form.ipsecEnabled ? form.ipsecSecret : undefined, + } + } + + async function submitTunnel() { + if (!isLive) { + toast.info("Создание на роутер доступно только в live-режиме") + return + } + if (liveStale) { + toast.error("Роутер недоступен — изменения заблокированы") + return + } + if (!tForm.name.trim() || !tForm.serverId || !tForm.remoteAddress.trim()) { + toast.error("Заполните имя, сервер и удалённый адрес") + return + } + if (tForm.ipsecEnabled && tForm.ipsecSecret.trim().length < 8) { + toast.error("Для IPsec нужен PSK не короче 8 символов") + return + } + setMutateBusy(true) + try { + if (tunnelMode === "edit" && editingTunnel) { + await apiFetch("/api/gre/tunnels", { + method: "PATCH", + body: JSON.stringify({ + ...tunnelWriteBody(tForm), + rosId: editingTunnel.id, + name: editingTunnel.name, + }), + }) + toast.success(`Туннель ${tForm.name} обновлён`) + } else { + await apiFetch("/api/gre/tunnels", { + method: "POST", + body: JSON.stringify(tunnelWriteBody(tForm)), + }) + toast.success(`Туннель ${tForm.name} создан`) + } + setTunnelOpen(false) + setEditingTunnel(null) + await loadLive() + } catch (err) { + toast.error("Не удалось сохранить туннель", { description: String(err) }) + } finally { + setMutateBusy(false) + } + } + + async function toggleTunnel(t: GreTunnel) { + if (!isLive || mutationsLocked) return + setMutateBusy(true) + try { + await apiFetch("/api/gre/tunnels", { + method: "PATCH", + body: JSON.stringify({ + serverId: t.serverId, + rosId: t.id, + name: t.name, + enabled: !t.enabled, + remoteAddress: t.remoteAddress, + }), + }) + toast.success(t.enabled ? `Выключен ${t.name}` : `Включён ${t.name}`) + await loadLive() + } catch (err) { + toast.error("Не удалось изменить туннель", { description: String(err) }) + } finally { + setMutateBusy(false) + } + } + + async function confirmDeleteTunnel() { + const t = pendingDelete + if (!t || !isLive) return + setMutateBusy(true) + try { + await apiFetch("/api/gre/tunnels", { + method: "DELETE", + body: JSON.stringify({ serverId: t.serverId, rosId: t.id, name: t.name }), + }) + toast.success(`Удалён ${t.name}`) + setPendingDelete(null) + await loadLive() + } catch (err) { + toast.error("Не удалось удалить туннель", { description: String(err) }) + } finally { + setMutateBusy(false) + } + } + + function openCreateTunnel() { + setTunnelMode("create") + setEditingTunnel(null) + setTForm({ + ...defaultTunnelForm, + serverId: selectedServerId === ALL_SERVERS_ID ? "" : selectedServerId, + }) + setTunnelOpen(true) + } + + function openEditTunnel(t: GreTunnel) { + setTunnelMode("edit") + setEditingTunnel(t) + setTForm({ + ...defaultTunnelForm, + name: t.name, + serverId: t.serverId, + localAddress: t.localAddress === "0.0.0.0" ? "" : t.localAddress, + remoteAddress: t.remoteAddress, + poolId: t.poolId === "live" ? "" : t.poolId, + localInnerIp: t.localInnerIp, + remoteInnerIp: t.remoteInnerIp, + comment: t.comment, + enabled: t.enabled, + ipsecEnabled: !!t.ipsec, + ipsecSecret: t.ipsec?.secret ?? "", + mtu: t.mtu, + keepaliveInterval: t.keepaliveInterval, + keepaliveRetries: t.keepaliveRetries, + dscp: String(t.dscp), + clampTcpMss: t.clampTcpMss, + allowFastPath: t.allowFastPath, + }) + setTunnelOpen(true) + } + useEffect(() => { if (dataError) toast.error(dataError) }, [dataError]) @@ -368,6 +577,14 @@ export default function GrePage() { showAll allCount={displayServers.length} loading={isLive && dataLoading && displayServers.length === 0} + banner={ + isLive && liveStale ? ( +
+ + Роутер недоступен — показан кэш. Изменения заблокированы, пока не удастся прочитать CHR. +
+ ) : null + } header={ Обновить - + @@ -476,6 +706,10 @@ export default function GrePage() { servers={displayServers} pools={displayPools} onCodePreview={setCodePreviewTunnel} + onEdit={openEditTunnel} + onToggle={(t) => { void toggleTunnel(t) }} + onDelete={setPendingDelete} + mutationsLocked={mutationsLocked} /> )} @@ -593,18 +827,18 @@ export default function GrePage() { - Новый GRE-туннель - RouterOS 7.20+ · /interface gre add + {tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"} + RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"}
Основные - setT("name", e.target.value)} /> + setT("name", e.target.value)} /> - setT("serverId", e.target.value)} disabled={tunnelMode === "edit"} className="h-8 w-full rounded-lg border border-input bg-background px-2.5 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"> {displayServers.map((s) => )} @@ -631,7 +865,7 @@ export default function GrePage() {
Внутренний IP - +