(`/api/v1/objects/${objId}/snapshots/diff`, {
+ params: { a, b },
+ })
+ .then((r) => r.data),
+ enabled: a !== null && b !== null,
+ })
diff --git a/frontend/src/pages/ObjectDetail.tsx b/frontend/src/pages/ObjectDetail.tsx
index 066ae08..5f00492 100644
--- a/frontend/src/pages/ObjectDetail.tsx
+++ b/frontend/src/pages/ObjectDetail.tsx
@@ -1,6 +1,7 @@
import {
DeleteOutlined,
EditOutlined,
+ FileTextOutlined,
PlayCircleOutlined,
PlusOutlined,
SyncOutlined,
@@ -473,6 +474,12 @@ export function ObjectDetailPage() {
} onClick={openAddDevice}>
Добавить устройство
+ }
+ onClick={() => navigate(`/objects/${objId}/snapshots`)}
+ >
+ Снапшоты
+
} onClick={() => setRackModal(true)}>
Стойки и зоны
diff --git a/frontend/src/pages/Snapshots.tsx b/frontend/src/pages/Snapshots.tsx
new file mode 100644
index 0000000..54690dd
--- /dev/null
+++ b/frontend/src/pages/Snapshots.tsx
@@ -0,0 +1,353 @@
+import {
+ DiffOutlined,
+ EyeOutlined,
+ FileTextOutlined,
+} from '@ant-design/icons'
+import {
+ Breadcrumb,
+ Button,
+ Col,
+ Descriptions,
+ Empty,
+ Modal,
+ Row,
+ Select,
+ Space,
+ Spin,
+ Table,
+ Tag,
+ Tooltip,
+ Typography,
+ message,
+} from 'antd'
+import { useMemo, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { useObject } from '../api/objects'
+import {
+ useSnapshotDiff,
+ useSnapshots,
+ type SnapshotBrief,
+ type SnapshotDetail,
+} from '../api/snapshots'
+import { api } from '../api/client'
+import dayjs from 'dayjs'
+
+// ─────────────────────────────────────────
+// Inline diff viewer (no extra dependency)
+// ─────────────────────────────────────────
+function DiffViewer({ diff }: { diff: string }) {
+ if (!diff) {
+ return (
+ Файлы идентичны — изменений нет.
+ )
+ }
+
+ const lines = diff.split('\n')
+ return (
+
+ {lines.map((line, i) => {
+ let color = '#ccc'
+ let bg = 'transparent'
+ if (line.startsWith('+++') || line.startsWith('---')) {
+ color = '#9cdcfe'
+ } else if (line.startsWith('+')) {
+ color = '#b5f0a0'
+ bg = '#1a3a1a'
+ } else if (line.startsWith('-')) {
+ color = '#f0a0a0'
+ bg = '#3a1a1a'
+ } else if (line.startsWith('@@')) {
+ color = '#ce9178'
+ }
+ return (
+
+ {line || ' '}
+
+ )
+ })}
+
+ )
+}
+
+// ─────────────────────────────────────────
+// Content viewer modal
+// ─────────────────────────────────────────
+function ContentModal({
+ objId,
+ snapshot,
+ onClose,
+}: {
+ objId: number
+ snapshot: SnapshotBrief | null
+ onClose: () => void
+}) {
+ const [content, setContent] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ const loadContent = async () => {
+ if (!snapshot) return
+ setLoading(true)
+ try {
+ const res = await api.get(
+ `/api/v1/objects/${objId}/snapshots/${snapshot.id}`
+ )
+ setContent(res.data.content)
+ } catch {
+ message.error('Не удалось загрузить содержимое')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+ { onClose(); setContent(null) }}
+ afterOpenChange={(open) => { if (open) loadContent() }}
+ footer={null}
+ width={900}
+ >
+ {loading ? (
+
+ ) : content !== null ? (
+
+ {content}
+
+ ) : null}
+
+ )
+}
+
+// ─────────────────────────────────────────
+// Main page
+// ─────────────────────────────────────────
+export function SnapshotsPage() {
+ const { id } = useParams<{ id: string }>()
+ const objId = Number(id)
+ const navigate = useNavigate()
+
+ const { data: obj } = useObject(objId)
+ const { data: snapshots, isLoading } = useSnapshots(objId)
+
+ const [viewSnap, setViewSnap] = useState(null)
+ const [diffModal, setDiffModal] = useState(false)
+ const [diffA, setDiffA] = useState(null)
+ const [diffB, setDiffB] = useState(null)
+
+ const { data: diffResult, isFetching: diffLoading } = useSnapshotDiff(
+ objId,
+ diffModal ? diffA : null,
+ diffModal ? diffB : null
+ )
+
+ const snapshotOptions = useMemo(
+ () =>
+ (snapshots ?? []).map((s) => ({
+ value: s.id,
+ label: `#${s.id} ${s.device_ip ?? '?'} — ${s.label ?? s.sha256.slice(0, 8)} (${dayjs(s.created_at).format('DD.MM HH:mm')})`,
+ })),
+ [snapshots]
+ )
+
+ const columns = [
+ {
+ title: 'Устройство',
+ key: 'device',
+ render: (_: unknown, row: SnapshotBrief) => (
+
+ {row.device_ip ?? '—'}
+ {row.device_hostname && (
+
+ {row.device_hostname}
+
+ )}
+
+ ),
+ },
+ {
+ title: 'Метка',
+ dataIndex: 'label',
+ key: 'label',
+ render: (v: string | null) =>
+ v ? }>{v} : —,
+ },
+ {
+ title: 'Размер',
+ dataIndex: 'size',
+ key: 'size',
+ render: (v: number) => `${(v / 1024).toFixed(1)} KB`,
+ },
+ {
+ title: 'SHA-256',
+ dataIndex: 'sha256',
+ key: 'sha256',
+ render: (v: string) => (
+
+
+ {v.slice(0, 12)}…
+
+
+ ),
+ },
+ {
+ title: 'Дата',
+ dataIndex: 'created_at',
+ key: 'created_at',
+ render: (v: string) => (
+
+ {dayjs(v).format('DD.MM HH:mm')}
+
+ ),
+ },
+ {
+ title: '',
+ key: 'actions',
+ width: 80,
+ render: (_: unknown, row: SnapshotBrief) => (
+ }
+ onClick={() => setViewSnap(row)}
+ >
+ Просмотр
+
+ ),
+ },
+ ]
+
+ return (
+
+
navigate('/objects')}>Объекты },
+ { title: navigate(`/objects/${objId}`)}>{obj?.name} },
+ { title: 'Снапшоты' },
+ ]}
+ />
+
+
+
+
+ Снапшоты конфигураций
+
+
+
+ }
+ onClick={() => setDiffModal(true)}
+ disabled={!snapshots?.length}
+ >
+ Сравнить снапшоты
+
+
+
+
+ }}
+ />
+
+ {/* ── Content Viewer Modal ── */}
+ setViewSnap(null)} />
+
+ {/* ── Diff Modal ── */}
+ { setDiffModal(false); setDiffA(null); setDiffB(null) }}
+ footer={null}
+ width={960}
+ >
+
+
+
+ Снапшот A (старый)
+
+
+
+ {diffLoading && }
+
+ {diffResult && !diffLoading && (
+ <>
+ {diffResult.is_identical ? (
+
+ ✓ Файлы идентичны — содержимое не изменилось.
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ {!diffResult && !diffLoading && diffA !== null && diffB !== null && (
+ Загрузка…
+ )}
+
+
+ )
+}