дешборд снепшотов

This commit is contained in:
dv 2026-05-27 17:37:09 +03:00
parent 50dc75b35f
commit 5d1c409739
10 changed files with 823 additions and 2 deletions

View file

@ -0,0 +1,37 @@
"""0020 snapshot policies
Revision ID: 0020
Revises: 0019
Create Date: 2026-05-27
"""
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision = "0020"
down_revision = "0019"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"snapshot_policies",
sa.Column("id", sa.Integer(), autoincrement=True, primary_key=True),
sa.Column("object_id", sa.Integer(), sa.ForeignKey("objects.id", ondelete="CASCADE"), nullable=False),
sa.Column("category", 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),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_snapshot_policies_object_id", "snapshot_policies", ["object_id"])
def downgrade() -> None:
op.drop_index("ix_snapshot_policies_object_id", table_name="snapshot_policies")
op.drop_table("snapshot_policies")

View file

@ -7,7 +7,7 @@ from fastapi.openapi.utils import get_openapi
from app.middleware.auth import AuthMiddleware from app.middleware.auth import AuthMiddleware
from app.plugins.loader import load_plugins from app.plugins.loader import load_plugins
from app.routers import admin, alerts, auth, dashboard, devices, events, global_devices, objects, racks, snapshots, ssh_keys, tags, tasks, zones from app.routers import admin, alerts, auth, dashboard, devices, events, global_devices, objects, racks, snapshot_policies, snapshots, ssh_keys, tags, tasks, zones
from app.services.dashboard_monitor import ensure_dashboard_bootstrap from app.services.dashboard_monitor import ensure_dashboard_bootstrap
from app.workers.dashboard_monitor import start_dashboard_monitor, stop_dashboard_monitor from app.workers.dashboard_monitor import start_dashboard_monitor, stop_dashboard_monitor
from app.workers.monitor import start_monitor, stop_monitor from app.workers.monitor import start_monitor, stop_monitor
@ -63,6 +63,7 @@ def create_app() -> FastAPI:
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(alerts.router) app.include_router(alerts.router)
app.include_router(snapshots.router) app.include_router(snapshots.router)
app.include_router(snapshot_policies.router)
app.include_router(tasks.router) app.include_router(tasks.router)
app.include_router(events.router) app.include_router(events.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)

View file

@ -7,6 +7,7 @@ from .user import User, UserSession
from .rack import Rack from .rack import Rack
from .zone import Zone from .zone import Zone
from .snapshot import ConfigSnapshot, SnapshotContent from .snapshot import ConfigSnapshot, SnapshotContent
from .snapshot_policy import SnapshotPolicy
from .alert import Alert from .alert import Alert
from .plugin import Plugin from .plugin import Plugin
from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential, GlobalSSHCredential from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential, GlobalSSHCredential
@ -35,7 +36,8 @@ __all__ = [
"Rack", "Rack",
"Zone", "Zone",
"ConfigSnapshot", "ConfigSnapshot",
"SnapShotContent", "SnapshotContent",
"SnapshotPolicy",
"Alert", "Alert",
"Plugin", "Plugin",
"SSHKey", "SSHKey",

View file

@ -0,0 +1,21 @@
from sqlalchemy import Boolean, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin
class SnapshotPolicy(Base, TimestampMixin):
__tablename__ = "snapshot_policies"
id: Mapped[int] = mapped_column(Integer(), autoincrement=True, primary_key=True)
object_id: Mapped[int] = mapped_column(
Integer(), ForeignKey("objects.id", ondelete="CASCADE"), nullable=False, index=True
)
category: Mapped[str] = mapped_column(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)
is_active: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default="true")
object: Mapped["Object"] = relationship("Object", backref="snapshot_policies")

View file

@ -0,0 +1,219 @@
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
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
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 policies with their objects
policies_q = (
select(SnapshotPolicy, Object.name, Object.city, Object.client)
.join(Object, SnapshotPolicy.object_id == Object.id)
.where(SnapshotPolicy.is_active == True, Object.is_active == True)
.order_by(Object.city, Object.name, SnapshotPolicy.label)
)
rows = await db.execute(policies_q)
policy_rows = rows.all()
if not policy_rows:
return []
# 2. 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)
.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()
}
# 3. Snapshot stats: distinct devices with snapshots + last collected time
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)
.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()
}
# 4. Assemble response grouped by object
objects_map: dict[int, ObjectSnapshotSummary] = {}
for policy, obj_name, obj_city, obj_client in policy_rows:
snap_label = _policy_snapshot_label(policy)
stats = stats_map.get((policy.object_id, policy.category, snap_label))
success_count = stats[0] if stats else 0
last_collected_at = stats[1] if stats else None
total_devices = eligible_map.get((policy.object_id, policy.category), 0)
policy_status = 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_devices,
)
if policy.object_id not in objects_map:
objects_map[policy.object_id] = ObjectSnapshotSummary(
object_id=policy.object_id,
object_name=obj_name,
city=obj_city,
client=obj_client,
policies=[],
)
objects_map[policy.object_id].policies.append(policy_status)
return list(objects_map.values())
# ─── CRUD ────────────────────────────────────────────────────────────────────
@router.get("/objects/{obj_id}/snapshot-policies", response_model=list[SnapshotPolicyRead])
async def list_policies(
obj_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
result = await db.execute(
select(SnapshotPolicy)
.where(SnapshotPolicy.object_id == obj_id)
.order_by(SnapshotPolicy.label)
)
return result.scalars().all()
@router.post("/objects/{obj_id}/snapshot-policies", response_model=SnapshotPolicyRead, status_code=201)
async def create_policy(
obj_id: int,
body: SnapshotPolicyCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
obj = await db.get(Object, obj_id)
if obj is None:
raise HTTPException(status_code=404, detail="Object not found")
policy = SnapshotPolicy(
object_id=obj_id,
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("/objects/{obj_id}/snapshot-policies/{policy_id}", response_model=SnapshotPolicyRead)
async def update_policy(
obj_id: int,
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 or policy.object_id != obj_id:
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("/objects/{obj_id}/snapshot-policies/{policy_id}", status_code=204)
async def delete_policy(
obj_id: int,
policy_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
policy = await db.get(SnapshotPolicy, policy_id)
if policy is None or policy.object_id != obj_id:
raise HTTPException(status_code=404, detail="Policy not found")
await db.delete(policy)
await db.flush()
# ─── Collect ─────────────────────────────────────────────────────────────────
@router.post("/objects/{obj_id}/snapshot-policies/{policy_id}/collect", status_code=202)
async def collect_policy(
obj_id: int,
policy_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
policy = await db.get(SnapshotPolicy, policy_id)
if policy is None or policy.object_id != obj_id:
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",
"object_id": obj_id,
"category": policy.category,
},
params=policy.params,
user_id=current_user.id,
)
return {"task_id": task.id}

View file

@ -0,0 +1,53 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
class SnapshotPolicyCreate(BaseModel):
category: str
action_type: str
params: dict = {}
label: str
is_active: bool = True
class SnapshotPolicyUpdate(BaseModel):
category: Optional[str] = None
action_type: Optional[str] = None
params: Optional[dict] = None
label: Optional[str] = None
is_active: Optional[bool] = None
class SnapshotPolicyRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
object_id: int
category: str
action_type: str
params: dict
label: str
is_active: bool
created_at: datetime
updated_at: datetime
class PolicySnapshotStatus(BaseModel):
policy_id: int
policy_label: str
category: str
action_type: str
params: dict
last_collected_at: Optional[datetime] = None
success_count: int
total_devices: int
class ObjectSnapshotSummary(BaseModel):
object_id: int
object_name: str
city: Optional[str] = None
client: Optional[str] = None
policies: list[PolicySnapshotStatus]

View file

@ -8,6 +8,7 @@ import { HomeObjectDetailPage } from './pages/HomeObjectDetail'
import { HomeSettingsPage } from './pages/HomeSettings' import { HomeSettingsPage } from './pages/HomeSettings'
import { LoginPage } from './pages/Login' import { LoginPage } from './pages/Login'
import { ObjectsPage } from './pages/Objects' import { ObjectsPage } from './pages/Objects'
import { SnapshotDashboardPage } from './pages/SnapshotDashboard'
import { SnapshotsPage } from './pages/Snapshots' import { SnapshotsPage } from './pages/Snapshots'
import { TaskDetailPage } from './pages/TaskDetail' import { TaskDetailPage } from './pages/TaskDetail'
import { TasksPage } from './pages/Tasks' import { TasksPage } from './pages/Tasks'
@ -47,6 +48,7 @@ export default function App() {
<Route path="work/objects" element={<ObjectsPage mode="work" />} /> <Route path="work/objects" element={<ObjectsPage mode="work" />} />
<Route path="work/objects/:id" element={<ObjectDetailPage defaultMode="work" />} /> <Route path="work/objects/:id" element={<ObjectDetailPage defaultMode="work" />} />
<Route path="work/devices" element={<WorkDevicesPage />} /> <Route path="work/devices" element={<WorkDevicesPage />} />
<Route path="snapshots" element={<SnapshotDashboardPage />} />
{/* Common */} {/* Common */}
<Route path="alerts" element={<AlertsPage />} /> <Route path="alerts" element={<AlertsPage />} />

View file

@ -0,0 +1,125 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from './client'
export interface SnapshotPolicy {
id: number
object_id: number
category: string
action_type: string
params: Record<string, unknown>
label: string
is_active: boolean
created_at: string
updated_at: string
}
export interface PolicySnapshotStatus {
policy_id: number
policy_label: string
category: string
action_type: string
params: Record<string, unknown>
last_collected_at: string | null
success_count: number
total_devices: number
}
export interface ObjectSnapshotSummary {
object_id: number
object_name: string
city: string | null
client: string | null
policies: PolicySnapshotStatus[]
}
export const useSnapshotDashboard = () =>
useQuery({
queryKey: ['snapshot-dashboard'],
queryFn: () =>
api
.get<ObjectSnapshotSummary[]>('/api/v1/snapshots/dashboard')
.then((r) => r.data),
})
export const useSnapshotPolicies = (objId: number) =>
useQuery({
queryKey: ['snapshot-policies', objId],
queryFn: () =>
api
.get<SnapshotPolicy[]>(`/api/v1/objects/${objId}/snapshot-policies`)
.then((r) => r.data),
})
export const useCreateSnapshotPolicy = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: {
category: string
action_type: string
params?: Record<string, unknown>
label: string
is_active?: boolean
}) =>
api
.post<SnapshotPolicy>(`/api/v1/objects/${objId}/snapshot-policies`, body)
.then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['snapshot-policies', objId] })
qc.invalidateQueries({ queryKey: ['snapshot-dashboard'] })
},
})
}
export const useUpdateSnapshotPolicy = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
policyId,
...body
}: {
policyId: number
category?: string
action_type?: string
params?: Record<string, unknown>
label?: string
is_active?: boolean
}) =>
api
.put<SnapshotPolicy>(
`/api/v1/objects/${objId}/snapshot-policies/${policyId}`,
body
)
.then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['snapshot-policies', objId] })
qc.invalidateQueries({ queryKey: ['snapshot-dashboard'] })
},
})
}
export const useDeleteSnapshotPolicy = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: (policyId: number) =>
api.delete(`/api/v1/objects/${objId}/snapshot-policies/${policyId}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['snapshot-policies', objId] })
qc.invalidateQueries({ queryKey: ['snapshot-dashboard'] })
},
})
}
export const useCollectPolicy = (objId: number) => {
const qc = useQueryClient()
return useMutation({
mutationFn: (policyId: number) =>
api
.post<{ task_id: string }>(
`/api/v1/objects/${objId}/snapshot-policies/${policyId}/collect`
)
.then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tasks'] })
},
})
}

View file

@ -4,6 +4,7 @@ import {
BellOutlined, BellOutlined,
ControlOutlined, ControlOutlined,
DatabaseOutlined, DatabaseOutlined,
FileTextOutlined,
LogoutOutlined, LogoutOutlined,
SettingOutlined, SettingOutlined,
ThunderboltOutlined, ThunderboltOutlined,
@ -43,6 +44,8 @@ export function AppLayout() {
? 'home' ? 'home'
: location.pathname.startsWith('/inventory/objects') : location.pathname.startsWith('/inventory/objects')
? 'inventory-objects' ? 'inventory-objects'
: location.pathname.startsWith('/snapshots')
? 'snapshots'
: location.pathname.startsWith('/work/devices') : location.pathname.startsWith('/work/devices')
? 'work-devices' ? 'work-devices'
: location.pathname.startsWith('/work/objects') : location.pathname.startsWith('/work/objects')
@ -114,6 +117,12 @@ export function AppLayout() {
label: 'Все устройства', label: 'Все устройства',
onClick: () => navigate('/work/devices'), onClick: () => navigate('/work/devices'),
}, },
{
key: 'snapshots',
icon: <FileTextOutlined />,
label: 'Снапшоты',
onClick: () => navigate('/snapshots'),
},
], ],
}, },
{ type: 'divider' }, { type: 'divider' },

View file

@ -0,0 +1,352 @@
import {
DeleteOutlined,
EditOutlined,
PlayCircleOutlined,
PlusOutlined,
} from '@ant-design/icons'
import {
Button,
Collapse,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/ru'
import { useMemo, useState } from 'react'
import {
useSnapshotDashboard,
useCreateSnapshotPolicy,
useUpdateSnapshotPolicy,
useDeleteSnapshotPolicy,
useCollectPolicy,
type ObjectSnapshotSummary,
type PolicySnapshotStatus,
} from '../api/snapshotPolicies'
dayjs.extend(relativeTime)
dayjs.locale('ru')
const UNGROUPED = '__none__'
const CATEGORY_OPTIONS = [
{ value: 'main_server', label: 'Main Server' },
{ value: 'vm', label: 'VM' },
{ value: 'router', label: 'Router' },
{ value: 'embedded', label: 'Embedded' },
{ value: 'camera', label: 'Camera' },
{ value: 'io_board', label: 'IO Board' },
{ value: 'bank_terminal', label: 'Bank Terminal' },
{ value: 'cash_register', label: 'Cash Register' },
{ value: 'other', label: 'Other' },
]
const ACTION_OPTIONS = [
{ value: 'get_file', label: 'Get File (SSH)' },
{ value: 'mikrotik_get_config', label: 'MikroTik Get Config' },
]
interface PolicyFormValues {
label: string
category: string
action_type: string
path?: string
}
export function SnapshotDashboardPage() {
const { data: dashboard, isLoading } = useSnapshotDashboard()
const [searchText, setSearchText] = useState('')
const [modalOpen, setModalOpen] = useState(false)
const [editTarget, setEditTarget] = useState<{ objId: number; policy: PolicySnapshotStatus } | null>(null)
const [targetObjId, setTargetObjId] = useState<number | null>(null)
const [form] = Form.useForm<PolicyFormValues>()
const actionType = Form.useWatch('action_type', form)
const filtered = useMemo(() => {
if (!searchText || !dashboard) return dashboard ?? []
const q = searchText.toLowerCase()
return dashboard.filter(
(o) =>
o.object_name.toLowerCase().includes(q) ||
(o.city ?? '').toLowerCase().includes(q) ||
(o.client ?? '').toLowerCase().includes(q)
)
}, [dashboard, searchText])
const cityGroups = useMemo(() => {
const map = new Map<string, ObjectSnapshotSummary[]>()
for (const obj of filtered) {
const key = obj.city ?? UNGROUPED
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(obj)
}
const sorted = [...map.entries()].sort(([a], [b]) => {
if (a === UNGROUPED) return 1
if (b === UNGROUPED) return -1
return a.localeCompare(b, 'ru')
})
return sorted
}, [filtered])
const openCreate = (objId: number) => {
form.resetFields()
setEditTarget(null)
setTargetObjId(objId)
setModalOpen(true)
}
const openEdit = (objId: number, policy: PolicySnapshotStatus) => {
form.resetFields()
form.setFieldsValue({
label: policy.policy_label,
category: policy.category,
action_type: policy.action_type,
path: policy.action_type === 'get_file' ? (policy.params as any)?.path ?? '' : undefined,
})
setEditTarget({ objId, policy })
setTargetObjId(objId)
setModalOpen(true)
}
const handleSubmit = async () => {
const values = await form.validateFields()
const objId = targetObjId!
const params: Record<string, unknown> = {}
if (values.action_type === 'get_file' && values.path) {
params.path = values.path
}
try {
if (editTarget) {
await updatePolicyMutation.mutateAsync({
policyId: editTarget.policy.policy_id,
label: values.label,
category: values.category,
action_type: values.action_type,
params,
})
message.success('Политика обновлена')
} else {
await createPolicyMutation.mutateAsync({
label: values.label,
category: values.category,
action_type: values.action_type,
params,
})
message.success('Политика создана')
}
setModalOpen(false)
} catch {
message.error('Ошибка при сохранении')
}
}
const createPolicyMutation = useCreateSnapshotPolicy(targetObjId ?? 0)
const updatePolicyMutation = useUpdateSnapshotPolicy(targetObjId ?? 0)
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
Снапшоты
</Typography.Title>
<Input.Search
placeholder="Поиск по объекту, городу..."
allowClear
style={{ width: 300 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>
</div>
{isLoading && <Typography.Text type="secondary">Загрузка...</Typography.Text>}
{!isLoading && filtered.length === 0 && (
<Typography.Text type="secondary">
Нет объектов с политиками снапшотов. Добавьте политику через страницу объекта.
</Typography.Text>
)}
<Collapse
defaultActiveKey={cityGroups.map(([city]) => city)}
items={cityGroups.map(([city, objects]) => ({
key: city,
label: (
<Space>
<span>{city === UNGROUPED ? 'Без города' : city}</span>
<Tag>{objects.length}</Tag>
</Space>
),
children: objects.map((obj) => (
<ObjectPoliciesTable
key={obj.object_id}
obj={obj}
onAddPolicy={() => openCreate(obj.object_id)}
onEditPolicy={(p) => openEdit(obj.object_id, p)}
/>
)),
}))}
/>
<Modal
title={editTarget ? 'Редактировать политику' : 'Новая политика'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSubmit}
confirmLoading={createPolicyMutation.isPending || updatePolicyMutation.isPending}
okText="Сохранить"
cancelText="Отмена"
>
<Form form={form} layout="vertical">
<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>
<Form.Item name="action_type" label="Действие" rules={[{ required: true }]}>
<Select options={ACTION_OPTIONS} placeholder="Выберите действие" />
</Form.Item>
{actionType === 'get_file' && (
<Form.Item name="path" label="Путь к файлу" rules={[{ required: true, message: 'Укажите путь' }]}>
<Input placeholder="/etc/caps/config.ini" />
</Form.Item>
)}
</Form>
</Modal>
</div>
)
}
function ObjectPoliciesTable({
obj,
onAddPolicy,
onEditPolicy,
}: {
obj: ObjectSnapshotSummary
onAddPolicy: () => void
onEditPolicy: (p: PolicySnapshotStatus) => void
}) {
const deleteMutation = useDeleteSnapshotPolicy(obj.object_id)
const collectMutation = useCollectPolicy(obj.object_id)
const handleCollect = async (policyId: number) => {
try {
const result = await collectMutation.mutateAsync(policyId)
message.success(`Задача создана: ${result.task_id.slice(0, 8)}...`)
} catch {
message.error('Ошибка при запуске сбора')
}
}
const handleDelete = async (policyId: number) => {
try {
await deleteMutation.mutateAsync(policyId)
message.success('Политика удалена')
} catch {
message.error('Ошибка при удалении')
}
}
const columns: ColumnsType<PolicySnapshotStatus> = [
{
title: 'Политика',
dataIndex: 'policy_label',
key: 'label',
},
{
title: 'Категория',
dataIndex: 'category',
key: 'category',
render: (cat: string) => <Tag>{cat}</Tag>,
},
{
title: 'Последний сбор',
dataIndex: 'last_collected_at',
key: 'last_collected',
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: 'coverage',
render: (_, row) => {
const { success_count, total_devices } = row
if (total_devices === 0) return <Tag>нет устройств</Tag>
const ratio = success_count / total_devices
const color = ratio >= 1 ? 'green' : ratio > 0 ? 'orange' : 'red'
return (
<Tag color={color}>
{success_count} / {total_devices}
</Tag>
)
},
},
{
title: '',
key: 'actions',
width: 140,
render: (_, row) => (
<Space size="small">
<Tooltip title="Собрать">
<Button
type="text"
size="small"
icon={<PlayCircleOutlined />}
loading={collectMutation.isPending}
onClick={() => handleCollect(row.policy_id)}
/>
</Tooltip>
<Tooltip title="Редактировать">
<Button
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => onEditPolicy(row)}
/>
</Tooltip>
<Popconfirm title="Удалить политику?" onConfirm={() => handleDelete(row.policy_id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
]
return (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text strong>
{obj.object_name}
{obj.client && <Typography.Text type="secondary"> {obj.client}</Typography.Text>}
</Typography.Text>
<Button size="small" icon={<PlusOutlined />} onClick={onAddPolicy}>
Добавить
</Button>
</div>
<Table
dataSource={obj.policies}
columns={columns}
rowKey="policy_id"
size="small"
pagination={false}
/>
</div>
)
}