фикс фикс фикс фикс фикса

This commit is contained in:
dv 2026-05-28 15:40:02 +03:00
parent c03e542f81
commit 057a798f3c
3 changed files with 264 additions and 2 deletions

View file

@ -1,3 +1,5 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession 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.dependencies import get_current_user, get_db
from app.models.device import Device from app.models.device import Device
from app.models.object import Object 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.snapshot_policy import SnapshotPolicy
from app.models.user import User from app.models.user import User
from app.schemas.snapshot_policy import ( from app.schemas.snapshot_policy import (
@ -247,3 +249,103 @@ async def collect_for_object(
user_id=current_user.id, user_id=current_user.id,
) )
return {"task_id": task.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}

View file

@ -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<DeviceSnapshotStatus[]>(`/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)

View file

@ -1,5 +1,7 @@
import { import {
CopyOutlined,
DeleteOutlined, DeleteOutlined,
DownloadOutlined,
EditOutlined, EditOutlined,
PlayCircleOutlined, PlayCircleOutlined,
PlusOutlined, PlusOutlined,
@ -34,9 +36,12 @@ import {
useDeleteSnapshotPolicy, useDeleteSnapshotPolicy,
useCollectPolicy, useCollectPolicy,
useCollectPolicyForObject, useCollectPolicyForObject,
useDashboardDevices,
fetchSnapshotContent,
type SnapshotPolicy, type SnapshotPolicy,
type ObjectSnapshotSummary, type ObjectSnapshotSummary,
type PolicySnapshotStatus, type PolicySnapshotStatus,
type DeviceSnapshotStatus,
} from '../api/snapshotPolicies' } from '../api/snapshotPolicies'
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
@ -333,6 +338,8 @@ function ObjectPoliciesTable({
obj: ObjectSnapshotSummary obj: ObjectSnapshotSummary
onCollect: (policyId: number) => void onCollect: (policyId: number) => void
}) { }) {
const [expandedKey, setExpandedKey] = useState<number | null>(null)
const columns: ColumnsType<PolicySnapshotStatus> = [ const columns: ColumnsType<PolicySnapshotStatus> = [
{ {
title: 'Политика', title: 'Политика',
@ -383,7 +390,10 @@ function ObjectPoliciesTable({
type="text" type="text"
size="small" size="small"
icon={<PlayCircleOutlined />} icon={<PlayCircleOutlined />}
onClick={() => onCollect(row.policy_id)} onClick={(e) => {
e.stopPropagation()
onCollect(row.policy_id)
}}
/> />
</Tooltip> </Tooltip>
), ),
@ -403,7 +413,133 @@ function ObjectPoliciesTable({
size="small" size="small"
pagination={false} pagination={false}
style={{ marginTop: 4 }} style={{ marginTop: 4 }}
expandable={{
expandedRowKeys: expandedKey !== null ? [expandedKey] : [],
onExpand: (expanded, record) => setExpandedKey(expanded ? record.policy_id : null),
expandedRowRender: (record) => (
<DevicesList
objId={obj.object_id}
objName={obj.object_name}
policyId={record.policy_id}
/>
),
}}
/> />
</div> </div>
) )
} }
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 <Typography.Text type="secondary">Загрузка...</Typography.Text>
if (!devices || devices.length === 0) return <Typography.Text type="secondary">Нет устройств</Typography.Text>
const columns: ColumnsType<DeviceSnapshotStatus> = [
{
title: 'IP',
dataIndex: 'ip',
key: 'ip',
render: (ip: string) => <Typography.Text code>{ip}</Typography.Text>,
},
{
title: 'Hostname',
dataIndex: 'hostname',
key: 'hostname',
render: (val: string | null) => val || <Typography.Text type="secondary"></Typography.Text>,
},
{
title: 'Статус',
key: 'status',
render: (_, row) =>
row.has_snapshot ? (
<Tag color="green">Есть</Tag>
) : (
<Tag color="red">Нет</Tag>
),
},
{
title: 'Собран',
dataIndex: 'last_collected_at',
key: 'last_collected_at',
render: (val: string | null) =>
val ? (
<Tooltip title={dayjs(val).format('DD.MM.YYYY HH:mm:ss')}>
{dayjs(val).fromNow()}
</Tooltip>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
{
title: '',
key: 'actions',
width: 80,
render: (_, row) =>
row.snapshot_id ? (
<Space size="small">
<Tooltip title="Скопировать">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => handleCopy(row.snapshot_id!)}
/>
</Tooltip>
<Tooltip title="Скачать">
<Button
type="text"
size="small"
icon={<DownloadOutlined />}
onClick={() => handleDownload(row)}
/>
</Tooltip>
</Space>
) : null,
},
]
return (
<Table
dataSource={devices}
columns={columns}
rowKey="device_id"
size="small"
pagination={false}
/>
)
}