feat(filters, recursive-routes): enhance configuration history and live data handling
- Introduced configuration history management in Filters and Recursive Routes pages, allowing users to view and restore previous configurations. - Updated state management to handle live data loading and error states more effectively, improving user experience during data fetching. - Added new components for displaying configuration history and integrated them into existing pages. - Enhanced API interactions to support fetching and applying configuration revisions, ensuring data consistency across the application. - Updated tests to cover new functionalities and ensure reliability. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+172
-138
@@ -27,7 +27,7 @@ import {
|
||||
StarIcon, ArrowUpDownIcon, ArrowUpIcon, ArrowDownIcon,
|
||||
FileCodeIcon, CopyIcon, NetworkIcon, TagIcon,
|
||||
ArrowRightIcon, AlertTriangleIcon, LoaderCircleIcon, RouteIcon,
|
||||
CheckCircle2Icon, XCircleIcon, CircleDashedIcon, RefreshCwIcon,
|
||||
RefreshCwIcon, HistoryIcon,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter,
|
||||
@@ -36,13 +36,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { toast } from "sonner"
|
||||
import { CodeExportSheet } from "@/components/reui-kit/code-export-sheet"
|
||||
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
|
||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type FilterRouterSyncStatus = "synced" | "drift" | "missing"
|
||||
|
||||
function newId() { return `r${Date.now()}-${Math.random().toString(36).slice(2, 6)}` }
|
||||
function innerIpToGateway(ip: string) { return ip.split("/")[0] }
|
||||
|
||||
@@ -1239,6 +1239,9 @@ interface BackendServer {
|
||||
interface LiveFiltersResponse {
|
||||
rulesets: ServerFilterRuleset[]
|
||||
greTunnels: GreTunnel[]
|
||||
live?: boolean
|
||||
stale?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
function buildRulesets(serverList: Server[], sourceRulesets: ServerFilterRuleset[]): ServerFilterRuleset[] {
|
||||
@@ -1325,18 +1328,14 @@ export default function FiltersPage() {
|
||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||
const [sheetInitial, setSheetInitial]= useState<RuleForm>(emptyForm())
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [copyOpen, setCopyOpen] = useState(false)
|
||||
const [syncBusy, setSyncBusy] = useState<"from" | "to" | null>(null)
|
||||
const [routerCompare, setRouterCompare] = useState<{
|
||||
serverId: string
|
||||
byCommunity: Record<string, FilterRouterSyncStatus>
|
||||
} | null>(null)
|
||||
const [routerCompareLoading, setRouterCompareLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setRouterCompare(null)
|
||||
}, [selectedServerId])
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
const [copyOpen, setCopyOpen] = useState(false)
|
||||
const [applyBusy, setApplyBusy] = useState(false)
|
||||
const [liveStale, setLiveStale] = useState(false)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive) {
|
||||
@@ -1347,6 +1346,7 @@ export default function FiltersPage() {
|
||||
setRulesets(buildRulesets(servers, serverFilterRulesets))
|
||||
setSelectedServerId(servers[0]?.id ?? "")
|
||||
setLiveLoadState("idle")
|
||||
setLiveStale(false)
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1390,18 +1390,43 @@ export default function FiltersPage() {
|
||||
})
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
const loadLiveRules = useCallback(async (serverId: string) => {
|
||||
if (!isLive || !serverId) return
|
||||
try {
|
||||
const fresh = await apiFetch<LiveFiltersResponse>(
|
||||
`/api/filters/rules?serverId=${encodeURIComponent(serverId)}`,
|
||||
)
|
||||
const liveRules = fresh.rulesets.find((r) => r.serverId === serverId)?.rules ?? fresh.rulesets[0]?.rules ?? []
|
||||
setRulesets((prev) => {
|
||||
const has = prev.some((rs) => rs.serverId === serverId)
|
||||
if (!has) return [...prev, { serverId, rules: liveRules }]
|
||||
return prev.map((rs) => rs.serverId === serverId ? { ...rs, rules: liveRules } : rs)
|
||||
})
|
||||
setLiveStale(Boolean(fresh.stale))
|
||||
if (fresh.greTunnels?.length) {
|
||||
setGreByServer((prev) => ({ ...prev, [serverId]: fresh.greTunnels }))
|
||||
}
|
||||
} catch (err) {
|
||||
setLiveStale(true)
|
||||
toast.error("Не удалось прочитать правила с роутера", { description: String(err) })
|
||||
}
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
if (!isLive || !selectedServerId || liveLoadState !== "idle") return
|
||||
if (!liveServers.some((s) => s.id === selectedServerId)) return
|
||||
void Promise.all([
|
||||
loadLiveRules(selectedServerId),
|
||||
ensureGreTunnels(selectedServerId),
|
||||
ensureRecursiveRoutes(selectedServerId),
|
||||
])
|
||||
}, [isLive, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes])
|
||||
}, [isLive, selectedServerId, liveLoadState, liveServers, ensureGreTunnels, ensureRecursiveRoutes, loadLiveRules])
|
||||
|
||||
const allServers = isLive ? liveServers : servers
|
||||
const allTunnels = isLive ? (greByServer[selectedServerId] ?? []) : greTunnels
|
||||
const allServers = !isLive || liveLoadState === "error" ? servers : liveServers
|
||||
const allTunnels = !isLive || liveLoadState === "error" ? greTunnels : (greByServer[selectedServerId] ?? [])
|
||||
const selectedServer = allServers.find(s => s.id === selectedServerId) ?? allServers[0]
|
||||
const totalRules = rulesets.reduce((s, r) => s + r.rules.length, 0)
|
||||
const mutationsLocked = isLive && (applyBusy || liveStale || liveLoadState === "error")
|
||||
|
||||
const filterRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
allServers.map((s) => ({
|
||||
@@ -1427,21 +1452,6 @@ export default function FiltersPage() {
|
||||
[rulesets, selectedServerId],
|
||||
)
|
||||
|
||||
const fetchRouterCompare = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setRouterCompareLoading(true)
|
||||
try {
|
||||
const d = await apiFetch<{ byCommunity: Record<string, FilterRouterSyncStatus> }>(
|
||||
`/api/filters/router-compare?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||
)
|
||||
setRouterCompare({ serverId: selectedServerId, byCommunity: d.byCommunity })
|
||||
} catch {
|
||||
setRouterCompare(null)
|
||||
} finally {
|
||||
setRouterCompareLoading(false)
|
||||
}
|
||||
}, [isLive, selectedServerId, apiFetch])
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
const q = search.toLowerCase()
|
||||
if (!q) return currentRules
|
||||
@@ -1453,68 +1463,94 @@ export default function FiltersPage() {
|
||||
)
|
||||
}, [currentRules, search, communityNameMap])
|
||||
|
||||
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
|
||||
setRouterCompare(rc => (rc && rc.serverId === serverId ? null : rc))
|
||||
setRulesets(prev => {
|
||||
const next = prev.map(rs =>
|
||||
rs.serverId === serverId ? { ...rs, rules: updater(rs.rules) } : rs
|
||||
)
|
||||
if (isLive) {
|
||||
void apiFetch<{ ok: boolean }>("/api/filters/rules", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ rulesets: next }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [isLive, apiFetch])
|
||||
|
||||
const syncFromRouter = useCallback(async () => {
|
||||
if (!isLive || syncBusy) return
|
||||
setSyncBusy("from")
|
||||
const applyRules = useCallback(async (
|
||||
serverId: string,
|
||||
nextRules: FilterRule[],
|
||||
source: "apply" | "copy" = "apply",
|
||||
) => {
|
||||
const prev = rulesets
|
||||
setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: nextRules } : rs))
|
||||
if (!isLive) return
|
||||
if (liveStale) {
|
||||
setRulesets(prev)
|
||||
toast.error("Роутер недоступен — изменения заблокированы")
|
||||
return
|
||||
}
|
||||
setApplyBusy(true)
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: selectedServerId }),
|
||||
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>("/api/filters/rules", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ serverId, rules: nextRules, source }),
|
||||
})
|
||||
const fresh = await apiFetch<LiveFiltersResponse>("/api/filters/rules")
|
||||
setRulesets(buildRulesets(allServers, fresh.rulesets))
|
||||
if (res.rules) {
|
||||
setRulesets((p) => p.map((rs) => rs.serverId === serverId ? { ...rs, rules: res.rules ?? nextRules } : rs))
|
||||
}
|
||||
toast.success("Правила применены на роутер")
|
||||
} catch (err) {
|
||||
setRulesets(prev)
|
||||
toast.error("Не удалось применить правила на роутер", { description: String(err) })
|
||||
} finally {
|
||||
setApplyBusy(false)
|
||||
}
|
||||
}, [isLive, apiFetch, rulesets, liveStale])
|
||||
|
||||
const refreshFromRouter = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId || applyBusy) return
|
||||
setApplyBusy(true)
|
||||
try {
|
||||
await loadLiveRules(selectedServerId)
|
||||
await Promise.all([
|
||||
ensureGreTunnels(selectedServerId),
|
||||
ensureRecursiveRoutes(selectedServerId),
|
||||
])
|
||||
await fetchRouterCompare()
|
||||
} finally {
|
||||
setSyncBusy(null)
|
||||
setApplyBusy(false)
|
||||
}
|
||||
}, [isLive, syncBusy, apiFetch, allServers, selectedServerId, ensureGreTunnels, ensureRecursiveRoutes, fetchRouterCompare])
|
||||
}, [isLive, selectedServerId, applyBusy, loadLiveRules, ensureGreTunnels, ensureRecursiveRoutes])
|
||||
|
||||
const syncToRouter = useCallback(async () => {
|
||||
if (!isLive || syncBusy || !selectedServerId) return
|
||||
setSyncBusy("to")
|
||||
const loadRevisions = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setHistoryLoading(true)
|
||||
try {
|
||||
const res = await apiFetch<{
|
||||
ok: boolean
|
||||
updatedServers: number
|
||||
pushedRules: number
|
||||
errors?: Array<{ serverId: number; error: string }>
|
||||
}>("/api/filters/sync/to-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: selectedServerId }),
|
||||
})
|
||||
if (res.ok) {
|
||||
toast.success(`Загружено правил на роутер: ${res.pushedRules}`)
|
||||
} else {
|
||||
const detail = res.errors?.[0]?.error ?? "неизвестная ошибка"
|
||||
toast.error("Не удалось загрузить правила на роутер", { description: detail })
|
||||
}
|
||||
await fetchRouterCompare()
|
||||
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
|
||||
`/api/filters/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||
)
|
||||
setRevisions(res.revisions)
|
||||
} catch (err) {
|
||||
toast.error("Не удалось загрузить правила на роутер", { description: String(err) })
|
||||
toast.error("Не удалось загрузить историю", { description: String(err) })
|
||||
setRevisions([])
|
||||
} finally {
|
||||
setSyncBusy(null)
|
||||
setHistoryLoading(false)
|
||||
}
|
||||
}, [isLive, syncBusy, selectedServerId, apiFetch, fetchRouterCompare])
|
||||
}, [isLive, selectedServerId, apiFetch])
|
||||
|
||||
const restoreRevision = useCallback(async (id: string) => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setHistoryRestoring(true)
|
||||
try {
|
||||
const res = await apiFetch<{ ok: boolean; rules?: FilterRule[] }>(
|
||||
`/api/filters/revisions/${encodeURIComponent(id)}/restore`,
|
||||
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
|
||||
)
|
||||
if (res.rules) {
|
||||
setRulesets((p) => p.map((rs) => rs.serverId === selectedServerId ? { ...rs, rules: res.rules ?? [] } : rs))
|
||||
} else {
|
||||
await loadLiveRules(selectedServerId)
|
||||
}
|
||||
setLiveStale(false)
|
||||
toast.success("Версия применена на роутер")
|
||||
await loadRevisions()
|
||||
} catch (err) {
|
||||
toast.error("Не удалось откатить", { description: String(err) })
|
||||
} finally {
|
||||
setHistoryRestoring(false)
|
||||
}
|
||||
}, [isLive, selectedServerId, apiFetch, loadLiveRules, loadRevisions])
|
||||
|
||||
const updateRules = useCallback((serverId: string, updater: (rules: FilterRule[]) => FilterRule[]) => {
|
||||
const current = rulesets.find((rs) => rs.serverId === serverId)?.rules ?? []
|
||||
void applyRules(serverId, updater(current))
|
||||
}, [rulesets, applyRules])
|
||||
|
||||
const openCreate = () => {
|
||||
setSheetInitial(emptyForm()); setSheetMode("create"); setEditingId(null); setSheetOpen(true)
|
||||
@@ -1532,6 +1568,7 @@ export default function FiltersPage() {
|
||||
}
|
||||
|
||||
const handleSave = (form: RuleForm) => {
|
||||
if (mutationsLocked) return
|
||||
const { gatewayKind: _gk, ...payload } = form
|
||||
if (sheetMode === "create") {
|
||||
updateRules(selectedServerId, rules => [
|
||||
@@ -1549,34 +1586,30 @@ export default function FiltersPage() {
|
||||
setSheetOpen(false)
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => updateRules(selectedServerId, rules => rules.filter(r => r.id !== id))
|
||||
const handleDelete = (id: string) => {
|
||||
if (mutationsLocked) return
|
||||
updateRules(selectedServerId, rules => rules.filter(r => r.id !== id))
|
||||
}
|
||||
|
||||
const handleCopyRules = useCallback((targetServerId: string, rules: FilterRule[], mode: CopyMode) => {
|
||||
updateRules(targetServerId, existing =>
|
||||
mode === "replace" ? rules : [...existing, ...rules]
|
||||
)
|
||||
}, [updateRules])
|
||||
const existing = rulesets.find((rs) => rs.serverId === targetServerId)?.rules ?? []
|
||||
const next = mode === "replace" ? rules : [...existing, ...rules]
|
||||
void applyRules(targetServerId, next, "copy")
|
||||
}, [rulesets, applyRules])
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index === 0) return
|
||||
if (mutationsLocked || index === 0) return
|
||||
updateRules(selectedServerId, rules => {
|
||||
const n = [...rules]; [n[index - 1], n[index]] = [n[index], n[index - 1]]; return n
|
||||
})
|
||||
}
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (mutationsLocked) return
|
||||
updateRules(selectedServerId, rules => {
|
||||
if (index >= rules.length - 1) return rules
|
||||
const n = [...rules]; [n[index], n[index + 1]] = [n[index + 1], n[index]]; return n
|
||||
})
|
||||
}
|
||||
|
||||
if (!selectedServer) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Нет доступных серверов
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLive && liveLoadState === "loading") {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
@@ -1586,6 +1619,14 @@ export default function FiltersPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!selectedServer) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
Нет доступных серверов
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ServerRailLayout
|
||||
@@ -1604,35 +1645,25 @@ export default function FiltersPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncFromRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация Router → БД"
|
||||
onClick={() => void refreshFromRouter()}
|
||||
disabled={applyBusy}
|
||||
title="Прочитать актуальные правила с роутера"
|
||||
>
|
||||
{syncBusy === "from" ? "Синк Router → DB…" : "Router → DB"}
|
||||
<RefreshCwIcon className={cn("size-4", applyBusy && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={syncToRouter}
|
||||
disabled={syncBusy !== null}
|
||||
title="Синхронизация БД → Router"
|
||||
onClick={() => {
|
||||
setHistoryOpen(true)
|
||||
void loadRevisions()
|
||||
}}
|
||||
disabled={applyBusy}
|
||||
title="История версий и откат на CHR"
|
||||
>
|
||||
{syncBusy === "to" ? "Синк DB → Router…" : "DB → Router"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void fetchRouterCompare()}
|
||||
disabled={syncBusy !== null || routerCompareLoading}
|
||||
title="Сравнить правила в БД с цепочкой bgp-in на MikroTik"
|
||||
className="gap-1.5"
|
||||
>
|
||||
{routerCompareLoading ? (
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCwIcon className="size-4" />
|
||||
)}
|
||||
Сверить
|
||||
<HistoryIcon className="size-4" />
|
||||
История
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -1642,12 +1673,12 @@ export default function FiltersPage() {
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={() => setCopyOpen(true)}
|
||||
disabled={currentRules.length === 0}
|
||||
disabled={currentRules.length === 0 || mutationsLocked}
|
||||
title="Копировать правила на другой сервер"
|
||||
>
|
||||
<CopyIcon className="size-4" />Копировать
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
|
||||
<PlusIcon className="size-4" />Новое правило
|
||||
</Button>
|
||||
</>
|
||||
@@ -1662,6 +1693,12 @@ export default function FiltersPage() {
|
||||
Бекенд недоступен — показаны демо-данные из lib/data. Проверьте URL бекенда в настройках.
|
||||
</div>
|
||||
)}
|
||||
{isLive && liveStale && liveLoadState !== "error" && (
|
||||
<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">
|
||||
<AlertTriangleIcon className="size-3.5 shrink-0" />
|
||||
Роутер недоступен — показан кэш. Изменения заблокированы, пока не удастся прочитать CHR.
|
||||
</div>
|
||||
)}
|
||||
<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" />
|
||||
@@ -1745,15 +1782,7 @@ export default function FiltersPage() {
|
||||
)}>{selectedServer.latency}мс</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground flex-wrap justify-end">
|
||||
{isLive && routerCompare?.serverId === selectedServerId && currentRules.length > 0 && (
|
||||
<span className="font-mono tabular-nums">
|
||||
роутер:{" "}
|
||||
<span className="text-emerald-600 dark:text-emerald-500">
|
||||
{Object.values(routerCompare.byCommunity).filter(s => s === "synced").length}
|
||||
</span>
|
||||
/{currentRules.length} совпало
|
||||
</span>
|
||||
)}
|
||||
{applyBusy && <span>Применение на роутер…</span>}
|
||||
<span>{currentRules.length} правил</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1790,12 +1819,6 @@ export default function FiltersPage() {
|
||||
serversList={allServers}
|
||||
communityNameMap={communityNameMap}
|
||||
recursiveRoutes={recRoutesByServer[selectedServerId] ?? []}
|
||||
routerSyncByCommunity={
|
||||
!isLive || !routerCompare || routerCompare.serverId !== selectedServerId
|
||||
? null
|
||||
: routerCompare.byCommunity
|
||||
}
|
||||
isLive={isLive}
|
||||
enableSorting={!!search}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
@@ -1805,7 +1828,7 @@ export default function FiltersPage() {
|
||||
)}
|
||||
|
||||
{/* add rule shortcut */}
|
||||
<button onClick={openCreate}
|
||||
<button onClick={openCreate} disabled={mutationsLocked}
|
||||
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||
<PlusIcon className="size-3.5" />
|
||||
Добавить правило для {selectedServer.name}
|
||||
@@ -1854,6 +1877,17 @@ export default function FiltersPage() {
|
||||
recRoutesByServer={recRoutesByServer}
|
||||
ensureRecursiveFor={ensureRecursiveRoutes}
|
||||
/>
|
||||
|
||||
<ConfigHistorySheet
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
title="История фильтров"
|
||||
itemLabel="правил"
|
||||
revisions={revisions}
|
||||
loading={historyLoading}
|
||||
restoring={historyRestoring}
|
||||
onRestore={restoreRevision}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -237,7 +237,6 @@ export default function GrePage() {
|
||||
const [liveTunnels, setLiveTunnels] = useState<GreTunnel[]>([])
|
||||
const [dataLoading, setDataLoading] = useState(false)
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
const [syncJhBusy, setSyncJhBusy] = useState(false)
|
||||
|
||||
const [pageTab, setPageTab] = useState<PageTab>("tunnels")
|
||||
const [tabFilter, setTabFilter] = useState<TabFilter>("all")
|
||||
@@ -323,40 +322,6 @@ export default function GrePage() {
|
||||
[displayPools],
|
||||
)
|
||||
|
||||
const syncJhToDb = useCallback(async () => {
|
||||
if (!isLive || syncJhBusy) return
|
||||
const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled)
|
||||
if (jh.length === 0) {
|
||||
toast.info("Нет включённых Jump Host в списке серверов")
|
||||
return
|
||||
}
|
||||
setSyncJhBusy(true)
|
||||
const errors: string[] = []
|
||||
try {
|
||||
for (const s of jh) {
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: s.id }),
|
||||
})
|
||||
} catch (e) {
|
||||
errors.push(`${s.name}: ${e instanceof Error ? e.message : "ошибка"}`)
|
||||
}
|
||||
}
|
||||
const fresh = await apiFetch<GreTunnelsApiResponse>("/api/filters/gre-tunnels")
|
||||
setLiveTunnels(fresh.tunnels)
|
||||
if (errors.length) {
|
||||
toast.warning(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`)
|
||||
} else {
|
||||
toast.success(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`)
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка после синхронизации")
|
||||
} finally {
|
||||
setSyncJhBusy(false)
|
||||
}
|
||||
}, [isLive, syncJhBusy, apiFetch, displayServers])
|
||||
|
||||
useEffect(() => {
|
||||
if (dataError) toast.error(dataError)
|
||||
}, [dataError])
|
||||
@@ -419,16 +384,6 @@ export default function GrePage() {
|
||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void syncJhToDb() }}
|
||||
disabled={!isLive || syncJhBusy || dataLoading}
|
||||
title="Загрузить правила фильтрации с каждого Jump Host в БД и обновить опрос GRE"
|
||||
>
|
||||
<DatabaseIcon className={cn("size-4", syncJhBusy && "animate-pulse")} />
|
||||
JH → БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setTForm(defaultTunnelForm); setTunnelOpen(true) }}>
|
||||
<PlusIcon className="size-4" />Добавить туннель
|
||||
</Button>
|
||||
|
||||
@@ -18,9 +18,12 @@ import { Flag } from "@/components/flag"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { servers as mockServers, type Server } from "@/lib/data"
|
||||
import { PlusIcon, SaveIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, CheckIcon, AlertCircleIcon } from "lucide-react"
|
||||
import { PlusIcon, TrashIcon, SearchIcon, XIcon, PencilIcon, 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"
|
||||
import { ConfigHistorySheet } from "@/components/config-history-sheet"
|
||||
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||
import { type ServerTileItem } from "@/components/server-tile-rail"
|
||||
|
||||
interface BackendServer {
|
||||
@@ -364,7 +367,7 @@ export default function RecursiveRoutesPage() {
|
||||
const [servers, setServers] = useState<Server[]>([])
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("")
|
||||
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
|
||||
const [busy, setBusy] = useState<"load" | "save" | "from" | "to" | null>(null)
|
||||
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [sheetMode, setSheetMode] = useState<"create" | "edit">("create")
|
||||
@@ -373,6 +376,11 @@ 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 [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
const [historyRestoring, setHistoryRestoring] = useState(false)
|
||||
const [revisions, setRevisions] = useState<ConfigRevisionDto[]>([])
|
||||
/** В live не дергаем API с id мока (srv1…) пока не подтянули /api/servers */
|
||||
const [liveServerListReady, setLiveServerListReady] = useState(false)
|
||||
|
||||
@@ -407,10 +415,13 @@ export default function RecursiveRoutesPage() {
|
||||
setOpError(null)
|
||||
setBusy("load")
|
||||
try {
|
||||
const res = await apiFetch<{ routes: RecursiveRouteRow[] }>(`/api/recursive-routes?serverId=${selectedServerId}`)
|
||||
const res = await apiFetch<{ routes: RecursiveRouteRow[]; stale?: boolean }>(
|
||||
`/api/recursive-routes?serverId=${selectedServerId}`,
|
||||
)
|
||||
setRows(res.routes)
|
||||
setLiveStale(Boolean(res.stale))
|
||||
} catch (e) {
|
||||
setRows([])
|
||||
setLiveStale(true)
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось загрузить маршруты")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
@@ -437,56 +448,65 @@ export default function RecursiveRoutesPage() {
|
||||
void loadGateways()
|
||||
}, [loadGateways])
|
||||
|
||||
const saveToDb = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
const applyRoutes = useCallback(async (next: RecursiveRouteRow[]) => {
|
||||
if (!isLive || !selectedServerId || liveStale) return
|
||||
const prev = rows
|
||||
setRows(next)
|
||||
setOpError(null)
|
||||
setBusy("save")
|
||||
setBusy("apply")
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes", {
|
||||
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>("/api/recursive-routes", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ serverId: selectedServerId, routes: rows }),
|
||||
body: JSON.stringify({ serverId: selectedServerId, routes: next }),
|
||||
})
|
||||
await loadRoutes()
|
||||
setRows(res.routes ?? next)
|
||||
setLiveStale(false)
|
||||
toast.success("Маршруты применены на роутер")
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось сохранить маршруты в БД")
|
||||
setRows(prev)
|
||||
const msg = e instanceof Error ? e.message : "Не удалось применить маршруты на роутер"
|
||||
setOpError(msg)
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}, [isLive, selectedServerId, rows, apiFetch, loadRoutes])
|
||||
}, [isLive, selectedServerId, liveStale, rows, apiFetch])
|
||||
|
||||
const syncFromRouter = useCallback(async () => {
|
||||
const loadRevisions = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setOpError(null)
|
||||
setBusy("from")
|
||||
setHistoryLoading(true)
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/from-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: selectedServerId }),
|
||||
})
|
||||
await loadRoutes()
|
||||
const res = await apiFetch<{ revisions: ConfigRevisionDto[] }>(
|
||||
`/api/recursive-routes/revisions?serverId=${encodeURIComponent(selectedServerId)}`,
|
||||
)
|
||||
setRevisions(res.revisions)
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось синхронизировать маршруты с роутера")
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось загрузить историю")
|
||||
setRevisions([])
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}, [isLive, selectedServerId, apiFetch, loadRoutes])
|
||||
|
||||
const syncToRouter = useCallback(async () => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setOpError(null)
|
||||
setBusy("to")
|
||||
try {
|
||||
await apiFetch<{ ok: boolean }>("/api/recursive-routes/sync/to-router", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ serverId: selectedServerId }),
|
||||
})
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось применить маршруты на роутер")
|
||||
} finally {
|
||||
setBusy(null)
|
||||
setHistoryLoading(false)
|
||||
}
|
||||
}, [isLive, selectedServerId, apiFetch])
|
||||
|
||||
const restoreRevision = useCallback(async (id: string) => {
|
||||
if (!isLive || !selectedServerId) return
|
||||
setHistoryRestoring(true)
|
||||
try {
|
||||
const res = await apiFetch<{ ok: boolean; routes?: RecursiveRouteRow[] }>(
|
||||
`/api/recursive-routes/revisions/${encodeURIComponent(id)}/restore`,
|
||||
{ method: "POST", body: JSON.stringify({ serverId: selectedServerId }) },
|
||||
)
|
||||
setRows(res.routes ?? [])
|
||||
setLiveStale(false)
|
||||
toast.success("Версия применена на роутер")
|
||||
await loadRevisions()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось откатить")
|
||||
} finally {
|
||||
setHistoryRestoring(false)
|
||||
}
|
||||
}, [isLive, selectedServerId, apiFetch, loadRevisions])
|
||||
|
||||
function groupKeyOf(row: RecursiveRouteRow): string {
|
||||
return row.dstAddress.trim().toLowerCase()
|
||||
}
|
||||
@@ -529,22 +549,26 @@ export default function RecursiveRoutesPage() {
|
||||
disabled: false,
|
||||
country: ep.country || inferCountry(ep.gateway) || "",
|
||||
})
|
||||
let next: RecursiveRouteRow[]
|
||||
if (sheetMode === "create") {
|
||||
const base = `new-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||
setRows(prev => [...prev, ...expanded])
|
||||
next = [...rows, ...expanded]
|
||||
} else if (editingGroupKey) {
|
||||
setRows(prev => {
|
||||
const kept = prev.filter(r => groupKeyOf(r) !== editingGroupKey)
|
||||
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||
return [...kept, ...expanded]
|
||||
})
|
||||
const kept = rows.filter(r => groupKeyOf(r) !== editingGroupKey)
|
||||
const base = `edit-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`
|
||||
const expanded = v.endpoints.map((ep, i) => toRow(ep, `${base}-${i}`))
|
||||
next = [...kept, ...expanded]
|
||||
} else {
|
||||
setSheetOpen(false)
|
||||
return
|
||||
}
|
||||
setSheetOpen(false)
|
||||
void applyRoutes(next)
|
||||
}
|
||||
|
||||
const currentServer = servers.find(s => s.id === selectedServerId)
|
||||
const mutationsLocked = !isLive || busy !== null || liveStale
|
||||
const rrRailItems = useMemo<ServerTileItem[]>(() => (
|
||||
servers.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -602,16 +626,33 @@ export default function RecursiveRoutesPage() {
|
||||
actions={
|
||||
<>
|
||||
<ServerRailMobileButton />
|
||||
<Button variant="outline" size="sm" onClick={syncFromRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "from" ? "Синхронизация..." : "Router => DB"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={syncToRouter} disabled={!isLive || busy !== null}>
|
||||
{busy === "to" ? "Применение..." : "DB => Router"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={saveToDb} disabled={!isLive || busy !== null}>
|
||||
<SaveIcon className="size-4" />Сохранить в БД
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate} disabled={!isLive || busy !== null}>
|
||||
{isLive && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void loadRoutes()}
|
||||
disabled={busy !== null}
|
||||
title="Прочитать маршруты с роутера"
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", busy === "load" && "animate-spin")} />
|
||||
Обновить
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setHistoryOpen(true)
|
||||
void loadRevisions()
|
||||
}}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
История
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
|
||||
<PlusIcon className="size-4" />Добавить
|
||||
</Button>
|
||||
</>
|
||||
@@ -643,6 +684,12 @@ export default function RecursiveRoutesPage() {
|
||||
{opError}
|
||||
</div>
|
||||
)}
|
||||
{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" />
|
||||
Роутер недоступен — показан кэш. Изменения заблокированы.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -679,10 +726,13 @@ export default function RecursiveRoutesPage() {
|
||||
expandedKey={expandedGroupKey}
|
||||
onExpandedChange={setExpandedGroupKey}
|
||||
onEdit={openEdit}
|
||||
onDelete={(g) => setRows((prev) => prev.filter((r) => groupKeyOf(r) !== g.key))}
|
||||
onDelete={(g) => {
|
||||
if (mutationsLocked) return
|
||||
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
|
||||
}}
|
||||
/>
|
||||
<button onClick={openCreate}
|
||||
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
|
||||
<button onClick={openCreate} disabled={mutationsLocked}
|
||||
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
|
||||
<PlusIcon className="size-3.5" />
|
||||
Добавить маршрут
|
||||
</button>
|
||||
@@ -698,6 +748,17 @@ export default function RecursiveRoutesPage() {
|
||||
onClose={() => setSheetOpen(false)}
|
||||
gateways={gatewayOptions}
|
||||
/>
|
||||
|
||||
<ConfigHistorySheet
|
||||
open={historyOpen}
|
||||
onOpenChange={setHistoryOpen}
|
||||
title="История маршрутов"
|
||||
itemLabel="маршрутов"
|
||||
revisions={revisions}
|
||||
loading={historyLoading}
|
||||
restoring={historyRestoring}
|
||||
onRestore={restoreRevision}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user