351 lines
12 KiB
Python
351 lines
12 KiB
Python
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy import select, func
|
|
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, SnapshotContent
|
|
from app.models.snapshot_policy import SnapshotPolicy
|
|
from app.models.user import User
|
|
from app.schemas.snapshot_policy import (
|
|
ObjectSnapshotSummary,
|
|
PolicySnapshotStatus,
|
|
SnapshotPolicyCreate,
|
|
SnapshotPolicyRead,
|
|
SnapshotPolicyUpdate,
|
|
)
|
|
from app.services import task as task_service
|
|
|
|
router = APIRouter(prefix="/api/v1", tags=["snapshot-policies"])
|
|
|
|
|
|
def _policy_snapshot_label(policy: SnapshotPolicy) -> str:
|
|
if policy.action_type == "mikrotik_get_config":
|
|
return "routeros-export"
|
|
return policy.params.get("path", "")
|
|
|
|
|
|
# ─── Dashboard ───────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/snapshots/dashboard", response_model=list[ObjectSnapshotSummary])
|
|
async def snapshot_dashboard(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
# 1. Load all active global policies
|
|
policies_result = await db.execute(
|
|
select(SnapshotPolicy).where(SnapshotPolicy.is_active == True).order_by(SnapshotPolicy.label)
|
|
)
|
|
policies = policies_result.scalars().all()
|
|
if not policies:
|
|
return []
|
|
|
|
# Collect categories we care about
|
|
categories = list({p.category for p in policies})
|
|
|
|
# 2. Find all active objects that have devices in relevant categories
|
|
obj_q = (
|
|
select(Object.id, Object.name, Object.city, Object.client)
|
|
.where(Object.is_active == True)
|
|
.order_by(Object.city, Object.name)
|
|
)
|
|
obj_result = await db.execute(obj_q)
|
|
all_objects = obj_result.all()
|
|
|
|
if not all_objects:
|
|
return []
|
|
|
|
# 3. Count eligible devices per (object_id, category)
|
|
eligible_q = (
|
|
select(
|
|
Device.object_id,
|
|
Device.category,
|
|
func.count(Device.id).label("total"),
|
|
)
|
|
.where(Device.is_active == True, Device.category.in_(categories))
|
|
.group_by(Device.object_id, Device.category)
|
|
)
|
|
eligible_result = await db.execute(eligible_q)
|
|
eligible_map: dict[tuple[int, str], int] = {
|
|
(r.object_id, r.category): r.total for r in eligible_result.all()
|
|
}
|
|
|
|
# 4. Snapshot stats per (object_id, category, label)
|
|
stats_q = (
|
|
select(
|
|
Device.object_id,
|
|
Device.category,
|
|
ConfigSnapshot.label,
|
|
func.count(func.distinct(ConfigSnapshot.device_id)).label("success_count"),
|
|
func.max(ConfigSnapshot.created_at).label("last_collected_at"),
|
|
)
|
|
.join(Device, ConfigSnapshot.device_id == Device.id)
|
|
.where(Device.is_active == True, Device.category.in_(categories))
|
|
.group_by(Device.object_id, Device.category, ConfigSnapshot.label)
|
|
)
|
|
stats_result = await db.execute(stats_q)
|
|
stats_map: dict[tuple[int, str, str], tuple[int, object]] = {
|
|
(r.object_id, r.category, r.label): (r.success_count, r.last_collected_at)
|
|
for r in stats_result.all()
|
|
}
|
|
|
|
# 5. Assemble: for each object, build policy statuses
|
|
result = []
|
|
for obj_id, obj_name, obj_city, obj_client in all_objects:
|
|
obj_policies = []
|
|
for policy in policies:
|
|
total = eligible_map.get((obj_id, policy.category), 0)
|
|
if total == 0:
|
|
continue
|
|
snap_label = _policy_snapshot_label(policy)
|
|
stats = stats_map.get((obj_id, policy.category, snap_label))
|
|
success_count = stats[0] if stats else 0
|
|
last_collected_at = stats[1] if stats else None
|
|
|
|
obj_policies.append(PolicySnapshotStatus(
|
|
policy_id=policy.id,
|
|
policy_label=policy.label,
|
|
category=policy.category,
|
|
action_type=policy.action_type,
|
|
params=policy.params,
|
|
last_collected_at=last_collected_at,
|
|
success_count=success_count,
|
|
total_devices=total,
|
|
))
|
|
|
|
if obj_policies:
|
|
result.append(ObjectSnapshotSummary(
|
|
object_id=obj_id,
|
|
object_name=obj_name,
|
|
city=obj_city,
|
|
client=obj_client,
|
|
policies=obj_policies,
|
|
))
|
|
|
|
return result
|
|
|
|
|
|
# ─── Policies CRUD (global) ──────────────────────────────────────────────────
|
|
|
|
@router.get("/snapshot-policies", response_model=list[SnapshotPolicyRead])
|
|
async def list_policies(
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
result = await db.execute(
|
|
select(SnapshotPolicy).order_by(SnapshotPolicy.label)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.post("/snapshot-policies", response_model=SnapshotPolicyRead, status_code=201)
|
|
async def create_policy(
|
|
body: SnapshotPolicyCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
policy = SnapshotPolicy(
|
|
category=body.category,
|
|
action_type=body.action_type,
|
|
params=body.params,
|
|
label=body.label,
|
|
is_active=body.is_active,
|
|
)
|
|
db.add(policy)
|
|
await db.flush()
|
|
await db.refresh(policy)
|
|
return policy
|
|
|
|
|
|
@router.put("/snapshot-policies/{policy_id}", response_model=SnapshotPolicyRead)
|
|
async def update_policy(
|
|
policy_id: int,
|
|
body: SnapshotPolicyUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
policy = await db.get(SnapshotPolicy, policy_id)
|
|
if policy is None:
|
|
raise HTTPException(status_code=404, detail="Policy not found")
|
|
|
|
for field, value in body.model_dump(exclude_unset=True).items():
|
|
setattr(policy, field, value)
|
|
|
|
await db.flush()
|
|
await db.refresh(policy)
|
|
return policy
|
|
|
|
|
|
@router.delete("/snapshot-policies/{policy_id}", status_code=204)
|
|
async def delete_policy(
|
|
policy_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_: User = Depends(get_current_user),
|
|
):
|
|
policy = await db.get(SnapshotPolicy, policy_id)
|
|
if policy is None:
|
|
raise HTTPException(status_code=404, detail="Policy not found")
|
|
|
|
await db.delete(policy)
|
|
await db.flush()
|
|
|
|
|
|
# ─── Collect ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/snapshot-policies/{policy_id}/collect", status_code=202)
|
|
async def collect_all(
|
|
policy_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Collect snapshots for ALL objects matching this policy's category."""
|
|
policy = await db.get(SnapshotPolicy, policy_id)
|
|
if policy is None:
|
|
raise HTTPException(status_code=404, detail="Policy not found")
|
|
if not policy.is_active:
|
|
raise HTTPException(status_code=400, detail="Policy is disabled")
|
|
|
|
task = await task_service.create_task(
|
|
action_type=policy.action_type,
|
|
target_scope={
|
|
"type": "category",
|
|
"category": policy.category,
|
|
},
|
|
params=policy.params,
|
|
user_id=current_user.id,
|
|
)
|
|
return {"task_id": task.id}
|
|
|
|
|
|
@router.post("/snapshot-policies/{policy_id}/collect/{obj_id}", status_code=202)
|
|
async def collect_for_object(
|
|
policy_id: int,
|
|
obj_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Collect snapshots for a specific object matching this policy's category."""
|
|
policy = await db.get(SnapshotPolicy, policy_id)
|
|
if policy is None:
|
|
raise HTTPException(status_code=404, detail="Policy not found")
|
|
if not policy.is_active:
|
|
raise HTTPException(status_code=400, detail="Policy is disabled")
|
|
|
|
obj = await db.get(Object, obj_id)
|
|
if obj is None:
|
|
raise HTTPException(status_code=404, detail="Object not found")
|
|
|
|
task = await task_service.create_task(
|
|
action_type=policy.action_type,
|
|
target_scope={
|
|
"type": "category",
|
|
"object_id": obj_id,
|
|
"category": policy.category,
|
|
},
|
|
params=policy.params,
|
|
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}
|
|
|