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 <cursoragent@cursor.com>
This commit is contained in:
1 parent
9c0ee7940e
commit
b4a3c3a925
18 files changed
+1394
-661
No files matched your search
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user