feat(config-revisions): implement configuration history for Firewall, GRE, and WireGuard
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m20s
Docker images / frontend-image (push) Successful in 2m51s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m20s
Docker images / frontend-image (push) Successful in 2m51s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
- Added configuration history management for Firewall, GRE, and WireGuard pages, enabling users to view and restore previous configurations. - Introduced new components for displaying configuration history and integrated them into the respective pages. - Enhanced API routes to support fetching and restoring configuration revisions, ensuring data consistency across the application. - Updated state management to handle loading and restoring states effectively, improving user experience during data operations. - Enhanced tests to cover new functionalities and ensure reliability. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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<Partial<AddressListEntry> | null>(null)
|
||||
const [addrSheetOpen, setAddrSheetOpen] = useState(false)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||
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() {
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setHistoryOpen(true)
|
||||
void loadRevisions()
|
||||
}}
|
||||
disabled={!isLive || !historyServerId || dataLoading}
|
||||
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
История
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setExportOpen(true)}>
|
||||
<CodeXmlIcon className="size-4" />Экспорт .rsc
|
||||
</Button>
|
||||
@@ -2586,6 +2641,17 @@ function FirewallPageInner() {
|
||||
onClose={() => setExportOpen(false)}
|
||||
rules={familyRules}
|
||||
/>
|
||||
|
||||
<ConfigHistorySheet
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
title="История Firewall"
|
||||
itemLabel="объектов"
|
||||
revisions={revisions}
|
||||
loading={historyLoading}
|
||||
restoring={historyRestoring}
|
||||
onRestore={restoreRevision}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+281
-12
@@ -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<GreTunnel | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<GreTunnel | null>(null)
|
||||
const [mutateBusy, setMutateBusy] = useState(false)
|
||||
const [liveStale, setLiveStale] = useState(false)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||
const [poolOpen, setPoolOpen] = useState(false)
|
||||
const [codePreviewTunnel, setCodePreviewTunnel] = useState<GreTunnel | null>(null)
|
||||
|
||||
@@ -260,14 +282,19 @@ export default function GrePage() {
|
||||
try {
|
||||
const [backendServers, greRes] = await Promise.all([
|
||||
apiFetch<BackendServer[]>("/api/servers"),
|
||||
apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels"),
|
||||
apiFetch<GreTunnelsApiResponse>("/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 ? (
|
||||
<div className="shrink-0 border-b border-amber-500/30 bg-amber-500/10 px-6 py-2.5 text-xs text-amber-700 dark:text-amber-400 flex items-center gap-2">
|
||||
<TriangleAlertIcon className="size-3.5 shrink-0" />
|
||||
Роутер недоступен — показан кэш. Изменения заблокированы, пока не удастся прочитать CHR.
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
header={
|
||||
<PageHeader
|
||||
crumbs={[{ label: "Управление" }, { label: "GRE-туннели" }]}
|
||||
@@ -384,7 +601,20 @@ export default function GrePage() {
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setHistoryOpen(true)
|
||||
void loadRevisions()
|
||||
}}
|
||||
disabled={!isLive || !historyServerId || dataLoading}
|
||||
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
История
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateTunnel} disabled={mutateBusy}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
</>
|
||||
@@ -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}
|
||||
/>
|
||||
</DataPageCard>
|
||||
)}
|
||||
@@ -593,18 +827,18 @@ export default function GrePage() {
|
||||
<Sheet open={tunnelOpen} onOpenChange={setTunnelOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>Новый GRE-туннель</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.20+ · /interface gre add</SheetDescription>
|
||||
<SheetTitle>{tunnelMode === "edit" ? "Редактировать GRE-туннель" : "Новый GRE-туннель"}</SheetTitle>
|
||||
<SheetDescription>RouterOS 7.20+ · /interface gre {tunnelMode === "edit" ? "set" : "add"}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Основные</SectionTitle>
|
||||
<FormField label="Имя интерфейса" required hint="Только латиница, цифры и дефис, например gre-msk-spb">
|
||||
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} onChange={(e) => setT("name", e.target.value)} />
|
||||
<Input className="font-mono" placeholder="gre-msk-spb" value={tForm.name} disabled={tunnelMode === "edit"} onChange={(e) => setT("name", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Сервер (MikroTik)" required>
|
||||
<select value={tForm.serverId} onChange={(e) => setT("serverId", e.target.value)}
|
||||
<select value={tForm.serverId} onChange={(e) => 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">
|
||||
<option value="" disabled>Выбрать сервер…</option>
|
||||
{displayServers.map((s) => <option key={s.id} value={s.id}>{s.name} ({s.site})</option>)}
|
||||
@@ -631,7 +865,7 @@ export default function GrePage() {
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Внутренний IP</SectionTitle>
|
||||
<FormField label="IP-пул" required hint="Из какого пула выделяется /30-блок">
|
||||
<FormField label="IP-пул" hint="Необязательно — внутренний IP можно указать вручную">
|
||||
<select value={tForm.poolId} onChange={(e) => setT("poolId", e.target.value)}
|
||||
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">
|
||||
<option value="" disabled>Выбрать пул…</option>
|
||||
@@ -753,7 +987,9 @@ export default function GrePage() {
|
||||
|
||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
||||
<SheetClose render={<Button variant="outline" className="flex-1" />}>Отмена</SheetClose>
|
||||
<Button className="flex-1" onClick={() => setTunnelOpen(false)}>Создать туннель</Button>
|
||||
<Button className="flex-1" onClick={() => void submitTunnel()} disabled={mutateBusy}>
|
||||
{tunnelMode === "edit" ? "Сохранить" : "Создать туннель"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
@@ -800,6 +1036,39 @@ export default function GrePage() {
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ConfigHistorySheet
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
title="История GRE"
|
||||
itemLabel="туннелей"
|
||||
revisions={revisions}
|
||||
loading={historyLoading}
|
||||
restoring={historyRestoring}
|
||||
onRestore={restoreRevision}
|
||||
/>
|
||||
|
||||
<AlertDialog open={!!pendingDelete} onOpenChange={(v) => { if (!v) setPendingDelete(null) }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<Trash2Icon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Удалить GRE-туннель?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete
|
||||
? `${pendingDelete.name} на сервере ${serverById[pendingDelete.serverId]?.name ?? pendingDelete.serverId}. Будут удалены интерфейс и связанный /ip/address.`
|
||||
: null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => setPendingDelete(null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => void confirmDeleteTunnel()}>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,10 +57,12 @@ import {
|
||||
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"
|
||||
import {
|
||||
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
|
||||
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon, InfoIcon,
|
||||
Trash2Icon, CodeXmlIcon, AlertCircleIcon,
|
||||
Trash2Icon, CodeXmlIcon, AlertCircleIcon, HistoryIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
type WgWorkspaceTab = "interfaces" | "peers" | "cli"
|
||||
@@ -188,6 +190,10 @@ export default function WireGuardPage() {
|
||||
const [exportPeerId, setExportPeerId] = useState<string | null>(null)
|
||||
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||
const [liveExport, setLiveExport] = useState<{
|
||||
rsc?: string
|
||||
conf?: string
|
||||
@@ -242,6 +248,44 @@ export default function WireGuardPage() {
|
||||
? selectedServerId
|
||||
: ALL_SERVERS_ID
|
||||
|
||||
const historyServerId = effectiveServerId === ALL_SERVERS_ID ? null : effectiveServerId
|
||||
|
||||
const loadRevisions = useCallback(async () => {
|
||||
if (!isLive || !historyServerId) return
|
||||
setHistoryLoading(true)
|
||||
try {
|
||||
const res = await requestJson<{ revisions: ConfigRevisionDto[] }>(
|
||||
backendUrl,
|
||||
`/api/wireguard/revisions?serverId=${encodeURIComponent(historyServerId)}`,
|
||||
)
|
||||
setRevisions(res.revisions)
|
||||
} catch (err) {
|
||||
toast.error("Не удалось загрузить историю", { description: String(err) })
|
||||
setRevisions([])
|
||||
} finally {
|
||||
setHistoryLoading(false)
|
||||
}
|
||||
}, [isLive, historyServerId, backendUrl])
|
||||
|
||||
const restoreRevision = useCallback(async (id: string) => {
|
||||
if (!isLive || !historyServerId) return
|
||||
setHistoryRestoring(true)
|
||||
try {
|
||||
await requestJson(
|
||||
backendUrl,
|
||||
`/api/wireguard/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, backendUrl, loadLive, loadRevisions])
|
||||
|
||||
const scopedIfaces = useMemo(() => {
|
||||
if (effectiveServerId === ALL_SERVERS_ID) return displayIfaces
|
||||
return displayIfaces.filter((i) => i.serverId === effectiveServerId)
|
||||
@@ -544,6 +588,7 @@ export default function WireGuardPage() {
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
{isLive && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -553,6 +598,20 @@ export default function WireGuardPage() {
|
||||
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={loading || !historyServerId}
|
||||
title={!historyServerId ? "Выберите сервер, чтобы смотреть историю" : "История версий и откат на CHR"}
|
||||
onClick={() => {
|
||||
setHistoryOpen(true)
|
||||
void loadRevisions()
|
||||
}}
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
История
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<UploadIcon className="size-4" />
|
||||
@@ -822,6 +881,17 @@ export default function WireGuardPage() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<ConfigHistorySheet
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
title="История WireGuard"
|
||||
itemLabel="интерфейсов"
|
||||
revisions={revisions}
|
||||
loading={historyLoading}
|
||||
restoring={historyRestoring}
|
||||
onRestore={restoreRevision}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user