From 057a798f3c548fc521a2ef446ae2b20e8118e659 Mon Sep 17 00:00:00 2001 From: dv Date: Thu, 28 May 2026 15:40:02 +0300 Subject: [PATCH] =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=20=D1=84=D0=B8=D0=BA?= =?UTF-8?q?=D1=81=20=D1=84=D0=B8=D0=BA=D1=81=20=D1=84=D0=B8=D0=BA=D1=81=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/snapshot_policies.py | 104 ++++++++++++++++- frontend/src/api/snapshotPolicies.ts | 24 ++++ frontend/src/pages/SnapshotDashboard.tsx | 138 ++++++++++++++++++++++- 3 files changed, 264 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/snapshot_policies.py b/backend/app/routers/snapshot_policies.py index 01b55fd..e9c2f32 100644 --- a/backend/app/routers/snapshot_policies.py +++ b/backend/app/routers/snapshot_policies.py @@ -1,3 +1,5 @@ +from typing import Optional + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession @@ -5,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db from app.models.device import Device from app.models.object import Object -from app.models.snapshot import ConfigSnapshot +from app.models.snapshot import ConfigSnapshot, SnapshotContent from app.models.snapshot_policy import SnapshotPolicy from app.models.user import User from app.schemas.snapshot_policy import ( @@ -247,3 +249,103 @@ async def collect_for_object( user_id=current_user.id, ) return {"task_id": task.id} + + +# ─── Device details per object+policy ──────────────────────────────────────── + +@router.get("/snapshots/dashboard/{obj_id}/{policy_id}/devices") +async def dashboard_devices( + obj_id: int, + policy_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """Return per-device snapshot status for a given object and policy.""" + policy = await db.get(SnapshotPolicy, policy_id) + if policy is None: + raise HTTPException(status_code=404, detail="Policy not found") + + snap_label = _policy_snapshot_label(policy) + + # All eligible devices for this object+category + devices_q = ( + select(Device.id, Device.ip, Device.hostname) + .where( + Device.object_id == obj_id, + Device.category == policy.category, + Device.is_active == True, + ) + .order_by(Device.ip) + ) + devices_result = await db.execute(devices_q) + devices = devices_result.all() + + if not devices: + return [] + + device_ids = [d.id for d in devices] + + # Latest snapshot per device matching this label + latest_snap_q = ( + select( + ConfigSnapshot.device_id, + func.max(ConfigSnapshot.created_at).label("last_at"), + ) + .where( + ConfigSnapshot.device_id.in_(device_ids), + ConfigSnapshot.label == snap_label, + ) + .group_by(ConfigSnapshot.device_id) + ) + latest_result = await db.execute(latest_snap_q) + latest_map: dict[int, object] = {r.device_id: r.last_at for r in latest_result.all()} + + # Get snapshot IDs for content access (latest per device) + snap_ids_q = ( + select(ConfigSnapshot.id, ConfigSnapshot.device_id, ConfigSnapshot.sha256) + .where( + ConfigSnapshot.device_id.in_(device_ids), + ConfigSnapshot.label == snap_label, + ) + .order_by(ConfigSnapshot.created_at.desc()) + ) + snap_ids_result = await db.execute(snap_ids_q) + # Take first (latest) per device + device_snap_map: dict[int, tuple[int, str]] = {} + for r in snap_ids_result.all(): + if r.device_id not in device_snap_map: + device_snap_map[r.device_id] = (r.id, r.sha256) + + result = [] + for d in devices: + has_snapshot = d.id in latest_map + snap_info = device_snap_map.get(d.id) + result.append({ + "device_id": d.id, + "ip": d.ip, + "hostname": d.hostname, + "has_snapshot": has_snapshot, + "last_collected_at": latest_map.get(d.id), + "snapshot_id": snap_info[0] if snap_info else None, + }) + + return result + + +@router.get("/snapshots/dashboard/content/{snapshot_id}") +async def get_snapshot_content( + snapshot_id: int, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + """Return raw snapshot content for download/copy.""" + snap = await db.get(ConfigSnapshot, snapshot_id) + if snap is None: + raise HTTPException(status_code=404, detail="Snapshot not found") + + content_row = await db.get(SnapshotContent, snap.sha256) + if content_row is None: + raise HTTPException(status_code=404, detail="Content not found") + + return {"content": content_row.content} + diff --git a/frontend/src/api/snapshotPolicies.ts b/frontend/src/api/snapshotPolicies.ts index 9dc90e2..e85c053 100644 --- a/frontend/src/api/snapshotPolicies.ts +++ b/frontend/src/api/snapshotPolicies.ts @@ -130,3 +130,27 @@ export const useCollectPolicyForObject = () => { }, }) } + +export interface DeviceSnapshotStatus { + device_id: number + ip: string + hostname: string | null + has_snapshot: boolean + last_collected_at: string | null + snapshot_id: number | null +} + +export const useDashboardDevices = (objId: number, policyId: number, enabled: boolean) => + useQuery({ + queryKey: ['snapshot-dashboard-devices', objId, policyId], + queryFn: () => + api + .get(`/api/v1/snapshots/dashboard/${objId}/${policyId}/devices`) + .then((r) => r.data), + enabled, + }) + +export const fetchSnapshotContent = (snapshotId: number) => + api + .get<{ content: string }>(`/api/v1/snapshots/dashboard/content/${snapshotId}`) + .then((r) => r.data.content) diff --git a/frontend/src/pages/SnapshotDashboard.tsx b/frontend/src/pages/SnapshotDashboard.tsx index 703089e..79ddb16 100644 --- a/frontend/src/pages/SnapshotDashboard.tsx +++ b/frontend/src/pages/SnapshotDashboard.tsx @@ -1,5 +1,7 @@ import { + CopyOutlined, DeleteOutlined, + DownloadOutlined, EditOutlined, PlayCircleOutlined, PlusOutlined, @@ -34,9 +36,12 @@ import { useDeleteSnapshotPolicy, useCollectPolicy, useCollectPolicyForObject, + useDashboardDevices, + fetchSnapshotContent, type SnapshotPolicy, type ObjectSnapshotSummary, type PolicySnapshotStatus, + type DeviceSnapshotStatus, } from '../api/snapshotPolicies' dayjs.extend(relativeTime) @@ -333,6 +338,8 @@ function ObjectPoliciesTable({ obj: ObjectSnapshotSummary onCollect: (policyId: number) => void }) { + const [expandedKey, setExpandedKey] = useState(null) + const columns: ColumnsType = [ { title: 'Политика', @@ -383,7 +390,10 @@ function ObjectPoliciesTable({ type="text" size="small" icon={} - onClick={() => onCollect(row.policy_id)} + onClick={(e) => { + e.stopPropagation() + onCollect(row.policy_id) + }} /> ), @@ -403,7 +413,133 @@ function ObjectPoliciesTable({ size="small" pagination={false} style={{ marginTop: 4 }} + expandable={{ + expandedRowKeys: expandedKey !== null ? [expandedKey] : [], + onExpand: (expanded, record) => setExpandedKey(expanded ? record.policy_id : null), + expandedRowRender: (record) => ( + + ), + }} /> ) } + +function DevicesList({ + objId, + objName, + policyId, +}: { + objId: number + objName: string + policyId: number +}) { + const { data: devices, isLoading } = useDashboardDevices(objId, policyId, true) + + const handleCopy = async (snapshotId: number) => { + try { + const content = await fetchSnapshotContent(snapshotId) + await navigator.clipboard.writeText(content) + message.success('Скопировано в буфер обмена') + } catch { + message.error('Ошибка при копировании') + } + } + + const handleDownload = async (device: DeviceSnapshotStatus) => { + if (!device.snapshot_id) return + try { + const content = await fetchSnapshotContent(device.snapshot_id) + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `${objName}-${device.hostname || 'device'}-${device.ip}.txt` + a.click() + URL.revokeObjectURL(url) + } catch { + message.error('Ошибка при скачивании') + } + } + + if (isLoading) return Загрузка... + if (!devices || devices.length === 0) return Нет устройств + + const columns: ColumnsType = [ + { + title: 'IP', + dataIndex: 'ip', + key: 'ip', + render: (ip: string) => {ip}, + }, + { + title: 'Hostname', + dataIndex: 'hostname', + key: 'hostname', + render: (val: string | null) => val || , + }, + { + title: 'Статус', + key: 'status', + render: (_, row) => + row.has_snapshot ? ( + Есть + ) : ( + Нет + ), + }, + { + title: 'Собран', + dataIndex: 'last_collected_at', + key: 'last_collected_at', + render: (val: string | null) => + val ? ( + + {dayjs(val).fromNow()} + + ) : ( + + ), + }, + { + title: '', + key: 'actions', + width: 80, + render: (_, row) => + row.snapshot_id ? ( + + +