224 lines
8.7 KiB
TypeScript
224 lines
8.7 KiB
TypeScript
"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>
|
||
</>
|
||
)
|
||
}
|