feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
"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>
</>
)
}