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

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.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.workers.dashboard_monitor import start_dashboard_monitor, stop_dashboard_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(alerts.router)
app.include_router(snapshots.router)
app.include_router(snapshot_policies.router)
app.include_router(tasks.router)
app.include_router(events.router)
app.include_router(dashboard.router)

View file

@ -7,6 +7,7 @@ from .user import User, UserSession
from .rack import Rack
from .zone import Zone
from .snapshot import ConfigSnapshot, SnapshotContent
from .snapshot_policy import SnapshotPolicy
from .alert import Alert
from .plugin import Plugin
from .ssh_key import SSHKey, ObjectSSHCredential, ObjectCategoryCredential, GlobalSSHCredential
@ -35,7 +36,8 @@ __all__ = [
"Rack",
"Zone",
"ConfigSnapshot",
"SnapShotContent",
"SnapshotContent",
"SnapshotPolicy",
"Alert",
"Plugin",
"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 { LoginPage } from './pages/Login'
import { ObjectsPage } from './pages/Objects'
import { SnapshotDashboardPage } from './pages/SnapshotDashboard'
import { SnapshotsPage } from './pages/Snapshots'
import { TaskDetailPage } from './pages/TaskDetail'
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/:id" element={<ObjectDetailPage defaultMode="work" />} />
<Route path="work/devices" element={<WorkDevicesPage />} />
<Route path="snapshots" element={<SnapshotDashboardPage />} />
{/* Common */}
<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,
ControlOutlined,
DatabaseOutlined,
FileTextOutlined,
LogoutOutlined,
SettingOutlined,
ThunderboltOutlined,
@ -43,6 +44,8 @@ export function AppLayout() {
? 'home'
: location.pathname.startsWith('/inventory/objects')
? 'inventory-objects'
: location.pathname.startsWith('/snapshots')
? 'snapshots'
: location.pathname.startsWith('/work/devices')
? 'work-devices'
: location.pathname.startsWith('/work/objects')
@ -114,6 +117,12 @@ export function AppLayout() {
label: 'Все устройства',
onClick: () => navigate('/work/devices'),
},
{
key: 'snapshots',
icon: <FileTextOutlined />,
label: 'Снапшоты',
onClick: () => navigate('/snapshots'),
},
],
},
{ 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>
)
}