фикс политик

This commit is contained in:
dv 2026-05-28 17:02:12 +03:00
parent 1ff64e4881
commit 735d8b2736
6 changed files with 52 additions and 44 deletions

View file

@ -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),

View file

@ -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)

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { api } from './client'
export interface SnapshotPolicy {
id: number
category: string
categories: string[]
action_type: string
params: Record<string, unknown>
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<string, unknown>
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<string, unknown>
label: string
@ -77,7 +77,7 @@ export const useUpdateSnapshotPolicy = () => {
...body
}: {
policyId: number
category?: string
categories?: string[]
action_type?: string
params?: Record<string, unknown>
label?: string

View file

@ -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<SnapshotPolicy> = [
{ title: 'Название', dataIndex: 'label', key: 'label' },
{
title: 'Категория',
dataIndex: 'category',
key: 'category',
render: (cat: string) => <Tag>{cat}</Tag>,
title: 'Категории',
dataIndex: 'categories',
key: 'categories',
render: (cats: string[]) => cats.map((c) => <Tag key={c}>{c}</Tag>),
},
{
title: 'Действие',
@ -329,8 +329,8 @@ export function SnapshotDashboardPage() {
<Form.Item name="label" label="Название" rules={[{ required: true, message: 'Введите название' }]}>
<Input placeholder="CAPS config" />
</Form.Item>
<Form.Item name="category" label="Категория устройств" rules={[{ required: true }]}>
<Select options={CATEGORY_OPTIONS} placeholder="Выберите категорию" />
<Form.Item name="categories" label="Категории устройств" rules={[{ required: true }]}>
<Select mode="multiple" options={CATEGORY_OPTIONS} placeholder="Выберите категории" />
</Form.Item>
<Form.Item name="action_type" label="Действие" rules={[{ required: true }]}>
<Select options={ACTION_OPTIONS} placeholder="Выберите действие" />
@ -480,7 +480,7 @@ function ObjectDevicesExpanded({
<div key={p.policy_id} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<Typography.Text strong>{p.policy_label}</Typography.Text>
<Tag>{p.category}</Tag>
{p.categories.map((c) => <Tag key={c}>{c}</Tag>)}
<Tooltip title="Собрать">
<Button
type="text"