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:
@@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState, type ComponentProps } from "react"
|
||||
import { HistoryIcon, LoaderCircleIcon, RotateCcwIcon } from "lucide-react"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
} from "@/components/reui/timeline"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { TriangleAlertIcon } from "lucide-react"
|
||||
import type { ConfigRevisionDto } from "@/lib/config-revisions"
|
||||
|
||||
const SOURCE_LABEL: Record<ConfigRevisionDto["source"], string> = {
|
||||
apply: "Изменение",
|
||||
rollback: "Откат",
|
||||
observed: "С роутера",
|
||||
copy: "Копирование",
|
||||
}
|
||||
|
||||
const SOURCE_BADGE: Record<ConfigRevisionDto["source"], ComponentProps<typeof Badge>["variant"]> = {
|
||||
apply: "primary-light",
|
||||
rollback: "warning-light",
|
||||
observed: "secondary",
|
||||
copy: "success-light",
|
||||
}
|
||||
|
||||
function formatWhen(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" })
|
||||
}
|
||||
|
||||
export function ConfigHistorySheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
itemLabel,
|
||||
revisions,
|
||||
loading,
|
||||
restoring,
|
||||
onRestore,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
itemLabel: string
|
||||
revisions: ConfigRevisionDto[]
|
||||
loading: boolean
|
||||
restoring: boolean
|
||||
onRestore: (id: string) => Promise<void> | void
|
||||
}) {
|
||||
const [pending, setPending] = useState<ConfigRevisionDto | null>(null)
|
||||
const newestFirst = useMemo(() => revisions, [revisions])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
|
||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>
|
||||
Снапшоты managed-объектов. Откат применяет выбранную версию на CHR.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<LoaderCircleIcon className="size-4 animate-spin" />
|
||||
Загрузка истории…
|
||||
</div>
|
||||
) : newestFirst.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<HistoryIcon className="size-4" />}
|
||||
title="Истории пока нет"
|
||||
description="Снапшот появится после первого чтения или изменения на роутере"
|
||||
className="py-12"
|
||||
/>
|
||||
) : (
|
||||
<Frame>
|
||||
<FramePanel>
|
||||
<Timeline value={newestFirst.length} className="px-1">
|
||||
{newestFirst.map((rev, idx) => (
|
||||
<TimelineItem key={rev.id} step={newestFirst.length - idx}>
|
||||
<TimelineHeader>
|
||||
<TimelineDate dateTime={rev.createdAt}>{formatWhen(rev.createdAt)}</TimelineDate>
|
||||
<TimelineTitle className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={SOURCE_BADGE[rev.source]} size="sm">
|
||||
{SOURCE_LABEL[rev.source]}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground font-normal tabular-nums">
|
||||
{rev.itemCount} {itemLabel}
|
||||
</span>
|
||||
</TimelineTitle>
|
||||
</TimelineHeader>
|
||||
<TimelineIndicator />
|
||||
<TimelineSeparator />
|
||||
<TimelineContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restoring}
|
||||
onClick={() => setPending(rev)}
|
||||
>
|
||||
<RotateCcwIcon className="size-3.5" />
|
||||
Откатить
|
||||
</Button>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
))}
|
||||
</Timeline>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog open={Boolean(pending)} onOpenChange={(v) => { if (!v && !restoring) setPending(null) }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-warning/10 text-warning">
|
||||
<TriangleAlertIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Откатить на эту версию?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="flex flex-col gap-3">
|
||||
<span>
|
||||
На CHR будут применены {pending?.itemCount ?? 0} {itemLabel} от{" "}
|
||||
{pending ? formatWhen(pending.createdAt) : ""}.
|
||||
</span>
|
||||
<Alert variant="warning">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>Изменятся только объекты MikrotikManager</AlertTitle>
|
||||
<AlertDescription>
|
||||
Чужие правила и маршруты на роутере не удаляются.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={restoring} onClick={() => setPending(null)}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={restoring || !pending}
|
||||
onClick={() => {
|
||||
if (!pending) return
|
||||
void Promise.resolve(onRestore(pending.id)).finally(() => setPending(null))
|
||||
}}
|
||||
>
|
||||
{restoring ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
Откатить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import type { FilterRule, GreTunnel, Server } from "@/lib/data"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
@@ -22,21 +21,15 @@ import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sor
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import {
|
||||
AlertCircleIcon,
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CircleDashedIcon,
|
||||
PencilIcon,
|
||||
RouteIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
XCircleIcon,
|
||||
FilterIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type FilterRouterSyncStatus = "synced" | "drift" | "missing"
|
||||
|
||||
export interface RecursiveRouteLite {
|
||||
id: string
|
||||
dstAddress: string
|
||||
@@ -47,42 +40,6 @@ export interface RecursiveRouteLite {
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
function RouterSyncMarker({
|
||||
status,
|
||||
}: {
|
||||
status: FilterRouterSyncStatus | null | "skip"
|
||||
}) {
|
||||
if (status === "skip") {
|
||||
return <span className="size-3.5 shrink-0 block" aria-hidden />
|
||||
}
|
||||
const icon =
|
||||
status === "synced"
|
||||
? <CheckCircle2Icon className="size-3.5 text-emerald-600 dark:text-emerald-500 shrink-0" />
|
||||
: status === "drift"
|
||||
? <AlertTriangleIcon className="size-3.5 text-amber-500 shrink-0" />
|
||||
: status === "missing"
|
||||
? <XCircleIcon className="size-3.5 text-destructive shrink-0" />
|
||||
: <CircleDashedIcon className="size-3.5 text-muted-foreground/35 shrink-0" />
|
||||
const title =
|
||||
status === "synced"
|
||||
? "Совпадает с цепочкой bgp-in на MikroTik"
|
||||
: status === "drift"
|
||||
? "В БД и на роутере разное действие (gateway, blackhole или out-interface)"
|
||||
: status === "missing"
|
||||
? "Эта community не найдена в правиле bgp-in на роутере"
|
||||
: "Не проверено — нажмите «Сверить с роутером»"
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="inline-flex cursor-default border-0 bg-transparent p-0">
|
||||
{icon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function innerIpToGateway(ip: string) {
|
||||
return ip.split("/")[0]
|
||||
}
|
||||
@@ -234,8 +191,6 @@ interface FiltersDataGridProps {
|
||||
serversList: Server[]
|
||||
communityNameMap: Record<string, string>
|
||||
recursiveRoutes: RecursiveRouteLite[]
|
||||
routerSyncByCommunity?: Record<string, FilterRouterSyncStatus> | null
|
||||
isLive?: boolean
|
||||
enableSorting?: boolean
|
||||
onEdit: (rule: FilterRule) => void
|
||||
onDelete: (id: string) => void
|
||||
@@ -249,8 +204,6 @@ function FiltersDataGrid({
|
||||
serversList,
|
||||
communityNameMap,
|
||||
recursiveRoutes,
|
||||
routerSyncByCommunity,
|
||||
isLive,
|
||||
enableSorting = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -308,33 +261,6 @@ function FiltersDataGrid({
|
||||
size: 28,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "routerSync",
|
||||
header: () => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="cursor-help font-mono text-xs text-muted-foreground border-0 bg-transparent p-0">
|
||||
MT
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
Совпадение с MikroTik (bgp-in)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<RouterSyncMarker
|
||||
status={
|
||||
!isLive
|
||||
? "skip"
|
||||
: !routerSyncByCommunity
|
||||
? null
|
||||
: routerSyncByCommunity[row.original.community.trim()] ?? null
|
||||
}
|
||||
/>
|
||||
),
|
||||
size: 32,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "community",
|
||||
accessorKey: "community",
|
||||
@@ -413,13 +339,11 @@ function FiltersDataGrid({
|
||||
[
|
||||
communityNameMap,
|
||||
enableSorting,
|
||||
isLive,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onMoveDown,
|
||||
onMoveUp,
|
||||
recursiveRoutes,
|
||||
routerSyncByCommunity,
|
||||
serversList,
|
||||
tunnelsList,
|
||||
],
|
||||
@@ -454,4 +378,4 @@ function FiltersDataGrid({
|
||||
)
|
||||
}
|
||||
|
||||
export { FiltersDataGrid, RouterSyncMarker, type FiltersDataGridProps }
|
||||
export { FiltersDataGrid, type FiltersDataGridProps }
|
||||
|
||||
Reference in New Issue
Block a user