diff --git a/backend/alembic/versions/0020_snapshot_policies.py b/backend/alembic/versions/0020_snapshot_policies.py index 7c79d74..57cee10 100644 --- a/backend/alembic/versions/0020_snapshot_policies.py +++ b/backend/alembic/versions/0020_snapshot_policies.py @@ -2,7 +2,7 @@ Revision ID: 0020 Revises: 0019 -Create Date: 2026-05-27 +Create Date: 2026-05-28 """ import sqlalchemy as sa @@ -17,10 +17,11 @@ depends_on = None def upgrade() -> None: + op.execute("DROP TABLE IF EXISTS snapshot_policies") op.create_table( "snapshot_policies", sa.Column("id", sa.Integer(), autoincrement=True, primary_key=True), - sa.Column("category", sa.String(50), nullable=False), + sa.Column("categories", postgresql.ARRAY(sa.String(50)), nullable=False), sa.Column("action_type", sa.String(100), nullable=False), sa.Column("params", postgresql.JSONB(), nullable=False, server_default="{}"), sa.Column("label", sa.String(255), nullable=False), diff --git a/backend/app/models/snapshot_policy.py b/backend/app/models/snapshot_policy.py index 9870c28..09210f4 100644 --- a/backend/app/models/snapshot_policy.py +++ b/backend/app/models/snapshot_policy.py @@ -1,5 +1,5 @@ from sqlalchemy import Boolean, Integer, String -from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import Mapped, mapped_column from .base import Base, TimestampMixin @@ -9,7 +9,7 @@ class SnapshotPolicy(Base, TimestampMixin): __tablename__ = "snapshot_policies" id: Mapped[int] = mapped_column(Integer(), autoincrement=True, primary_key=True) - category: Mapped[str] = mapped_column(String(50), nullable=False) + categories: Mapped[list[str]] = mapped_column(ARRAY(String(50)), nullable=False) action_type: Mapped[str] = mapped_column(String(100), nullable=False) params: Mapped[dict] = mapped_column(JSONB(), nullable=False, server_default="{}") label: Mapped[str] = mapped_column(String(255), nullable=False) diff --git a/backend/app/routers/snapshot_policies.py b/backend/app/routers/snapshot_policies.py index ee3f234..0d1a407 100644 --- a/backend/app/routers/snapshot_policies.py +++ b/backend/app/routers/snapshot_policies.py @@ -43,10 +43,10 @@ async def snapshot_dashboard( if not policies: return [] - # Collect categories we care about - categories = list({p.category for p in policies}) + # Collect all categories across all policies + all_categories = list({cat for p in policies for cat in p.categories}) - # 2. Find all active objects that have devices in relevant categories + # 2. Find all active objects obj_q = ( select(Object.id, Object.name, Object.city, Object.client) .where(Object.is_active == True) @@ -65,7 +65,7 @@ async def snapshot_dashboard( Device.category, func.count(Device.id).label("total"), ) - .where(Device.is_active == True, Device.category.in_(categories)) + .where(Device.is_active == True, Device.category.in_(all_categories)) .group_by(Device.object_id, Device.category) ) eligible_result = await db.execute(eligible_q) @@ -83,7 +83,7 @@ async def snapshot_dashboard( 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)) + .where(Device.is_active == True, Device.category.in_(all_categories)) .group_by(Device.object_id, Device.category, ConfigSnapshot.label) ) stats_result = await db.execute(stats_q) @@ -97,18 +97,27 @@ async def snapshot_dashboard( 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) + # Sum across all categories in this policy + total = sum(eligible_map.get((obj_id, cat), 0) for cat in policy.categories) 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 + success_count = sum( + (stats_map.get((obj_id, cat, snap_label)) or (0, None))[0] + for cat in policy.categories + ) + # Latest collected_at across categories + last_collected_at = None + for cat in policy.categories: + stats = stats_map.get((obj_id, cat, snap_label)) + if stats and stats[1]: + if last_collected_at is None or stats[1] > last_collected_at: + last_collected_at = stats[1] obj_policies.append(PolicySnapshotStatus( policy_id=policy.id, policy_label=policy.label, - category=policy.category, + categories=policy.categories, action_type=policy.action_type, params=policy.params, last_collected_at=last_collected_at, @@ -148,7 +157,7 @@ async def create_policy( _: User = Depends(get_current_user), ): policy = SnapshotPolicy( - category=body.category, + categories=body.categories, action_type=body.action_type, params=body.params, label=body.label, @@ -201,7 +210,7 @@ async def collect_all( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Collect snapshots for ALL objects matching this policy's category.""" + """Collect snapshots for ALL objects matching this policy's categories.""" policy = await db.get(SnapshotPolicy, policy_id) if policy is None: raise HTTPException(status_code=404, detail="Policy not found") @@ -211,8 +220,8 @@ async def collect_all( task = await task_service.create_task( action_type=policy.action_type, target_scope={ - "type": "category", - "category": policy.category, + "type": "filter", + "category": policy.categories, }, params=policy.params, user_id=current_user.id, @@ -227,7 +236,7 @@ async def collect_for_object( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Collect snapshots for a specific object matching this policy's category.""" + """Collect snapshots for a specific object matching this policy's categories.""" policy = await db.get(SnapshotPolicy, policy_id) if policy is None: raise HTTPException(status_code=404, detail="Policy not found") @@ -241,9 +250,9 @@ async def collect_for_object( task = await task_service.create_task( action_type=policy.action_type, target_scope={ - "type": "category", + "type": "filter", "object_id": obj_id, - "category": policy.category, + "category": policy.categories, }, params=policy.params, user_id=current_user.id, @@ -267,12 +276,12 @@ async def dashboard_devices( snap_label = _policy_snapshot_label(policy) - # All eligible devices for this object+category + # All eligible devices for this object matching policy categories devices_q = ( select(Device.id, Device.ip, Device.hostname, Device.status) .where( Device.object_id == obj_id, - Device.category == policy.category, + Device.category.in_(policy.categories), Device.is_active == True, ) .order_by(Device.ip) @@ -310,7 +319,6 @@ async def dashboard_devices( .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: @@ -349,4 +357,3 @@ async def get_snapshot_content( raise HTTPException(status_code=404, detail="Content not found") return {"content": content_row.content} - diff --git a/backend/app/schemas/snapshot_policy.py b/backend/app/schemas/snapshot_policy.py index 2aba645..99f4899 100644 --- a/backend/app/schemas/snapshot_policy.py +++ b/backend/app/schemas/snapshot_policy.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, ConfigDict class SnapshotPolicyCreate(BaseModel): - category: str + categories: list[str] action_type: str params: dict = {} label: str @@ -13,7 +13,7 @@ class SnapshotPolicyCreate(BaseModel): class SnapshotPolicyUpdate(BaseModel): - category: Optional[str] = None + categories: Optional[list[str]] = None action_type: Optional[str] = None params: Optional[dict] = None label: Optional[str] = None @@ -24,7 +24,7 @@ class SnapshotPolicyRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: int - category: str + categories: list[str] action_type: str params: dict label: str @@ -36,7 +36,7 @@ class SnapshotPolicyRead(BaseModel): class PolicySnapshotStatus(BaseModel): policy_id: int policy_label: str - category: str + categories: list[str] action_type: str params: dict last_collected_at: Optional[datetime] = None diff --git a/frontend/src/api/snapshotPolicies.ts b/frontend/src/api/snapshotPolicies.ts index b3a219d..2dd4604 100644 --- a/frontend/src/api/snapshotPolicies.ts +++ b/frontend/src/api/snapshotPolicies.ts @@ -3,7 +3,7 @@ import { api } from './client' export interface SnapshotPolicy { id: number - category: string + categories: string[] action_type: string params: Record label: string @@ -15,7 +15,7 @@ export interface SnapshotPolicy { export interface PolicySnapshotStatus { policy_id: number policy_label: string - category: string + categories: string[] action_type: string params: Record last_collected_at: string | null @@ -53,7 +53,7 @@ export const useCreateSnapshotPolicy = () => { const qc = useQueryClient() return useMutation({ mutationFn: (body: { - category: string + categories: string[] action_type: string params?: Record label: string @@ -77,7 +77,7 @@ export const useUpdateSnapshotPolicy = () => { ...body }: { policyId: number - category?: string + categories?: string[] action_type?: string params?: Record label?: string diff --git a/frontend/src/pages/SnapshotDashboard.tsx b/frontend/src/pages/SnapshotDashboard.tsx index b98e1f7..f9882eb 100644 --- a/frontend/src/pages/SnapshotDashboard.tsx +++ b/frontend/src/pages/SnapshotDashboard.tsx @@ -84,7 +84,7 @@ const ACTION_OPTIONS = [ interface PolicyFormValues { label: string - category: string + categories: string[] action_type: string path?: string } @@ -141,7 +141,7 @@ export function SnapshotDashboardPage() { form.resetFields() form.setFieldsValue({ label: policy.label, - category: policy.category, + categories: policy.categories, action_type: policy.action_type, path: policy.action_type === 'get_file' ? (policy.params as any)?.path ?? '' : undefined, }) @@ -161,7 +161,7 @@ export function SnapshotDashboardPage() { await updateMutation.mutateAsync({ policyId: editPolicy.id, label: values.label, - category: values.category, + categories: values.categories, action_type: values.action_type, params, }) @@ -169,7 +169,7 @@ export function SnapshotDashboardPage() { } else { await createMutation.mutateAsync({ label: values.label, - category: values.category, + categories: values.categories, action_type: values.action_type, params, }) @@ -212,10 +212,10 @@ export function SnapshotDashboardPage() { const policyColumns: ColumnsType = [ { title: 'Название', dataIndex: 'label', key: 'label' }, { - title: 'Категория', - dataIndex: 'category', - key: 'category', - render: (cat: string) => {cat}, + title: 'Категории', + dataIndex: 'categories', + key: 'categories', + render: (cats: string[]) => cats.map((c) => {c}), }, { title: 'Действие', @@ -329,8 +329,8 @@ export function SnapshotDashboardPage() { - -