Files
MikrotikManager/components/config-history-sheet.tsx
T

224 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useMemo, useRef, 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,
loadError,
onRetry,
restoring,
onRestore,
restoreDisabled = true,
}: {
open: boolean
onOpenChange: (open: boolean) => void
title: string
itemLabel: string
revisions: ConfigRevisionDto[]
loading: boolean
loadError?: string | null
onRetry?: () => void
restoring: boolean
restoreDisabled: boolean
onRestore: (id: string) => Promise<void> | void
}) {
const [pending, setPending] = useState<ConfigRevisionDto | null>(null)
const restoreLock = useRef(false)
const [submitting, setSubmitting] = useState(false)
const [restoreError, setRestoreError] = useState<string | null>(null)
const busy = restoring || submitting
async function confirmRestore() {
if (!pending || restoreLock.current || restoring || restoreDisabled) return
restoreLock.current = true
setSubmitting(true)
setRestoreError(null)
try {
await onRestore(pending.id)
setPending(null)
} catch (error) {
setRestoreError(error instanceof Error ? error.message : "Не удалось восстановить версию")
} finally {
restoreLock.current = false
setSubmitting(false)
}
}
const newestFirst = useMemo(() => revisions, [revisions])
return (
<>
<Sheet open={open} onOpenChange={v => {
if (restoreLock.current || restoring) return
if (!v) {
setPending(null)
setRestoreError(null)
}
onOpenChange(v)
}}>
<SheetContent showCloseButton={!busy} 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">
{restoreDisabled && !busy && <p role="status" className="px-4 text-sm text-muted-foreground">Откат недоступен. Проверьте права на изменение и состояние выбранного устройства.</p>}
{loading ? (
<div role="status" className="flex items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
<LoaderCircleIcon className="size-4 animate-spin" />
Загрузка истории…
</div>
) : loadError ? (
<div className="flex flex-col gap-3">
<p role="alert" className="text-sm text-destructive">Не удалось загрузить историю: {loadError}</p>
{onRetry && <Button type="button" variant="outline" disabled={busy} onClick={onRetry}>Повторить загрузку</Button>}
</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={busy || restoreDisabled}
onClick={() => { setRestoreError(null); setPending(rev) }}
>
<RotateCcwIcon className="size-3.5" />
Откатить
</Button>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
</FramePanel>
</Frame>
)}
</div>
</SheetContent>
</Sheet>
<AlertDialog open={Boolean(pending)} onOpenChange={(v) => { if (!v && !restoreLock.current && !restoring) { setPending(null); setRestoreError(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>
</AlertDialogDescription>
<Alert variant="warning">
<TriangleAlertIcon />
<AlertTitle>Изменятся только объекты MikrotikManager</AlertTitle>
<AlertDescription>
Чужие правила и маршруты на роутере не удаляются.
</AlertDescription>
</Alert>
</AlertDialogHeader>
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание результата восстановления…</p>}
{restoreError && <p role="alert" className="text-sm text-destructive">{restoreError}</p>}
<AlertDialogFooter>
<AlertDialogCancel disabled={busy} onClick={() => setPending(null)}>Отмена</AlertDialogCancel>
<AlertDialogAction
disabled={busy || restoreDisabled || !pending}
type="button"
onClick={() => void confirmRestore()}
>
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
Откатить
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}