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

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 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}

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 {
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<number | null>(null)
const columns: ColumnsType<PolicySnapshotStatus> = [
{
title: 'Политика',
@ -383,7 +390,10 @@ function ObjectPoliciesTable({
type="text"
size="small"
icon={<PlayCircleOutlined />}
onClick={() => onCollect(row.policy_id)}
onClick={(e) => {
e.stopPropagation()
onCollect(row.policy_id)
}}
/>
</Tooltip>
),
@ -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) => (
<DevicesList
objId={obj.object_id}
objName={obj.object_name}
policyId={record.policy_id}
/>
),
}}
/>
</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}
/>
)
}