diff --git a/backend/alembic/versions/0020_snapshot_policies.py b/backend/alembic/versions/0020_snapshot_policies.py new file mode 100644 index 0000000..c9abcba --- /dev/null +++ b/backend/alembic/versions/0020_snapshot_policies.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index b324c96..fae5fc0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8991fdf..2110002 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/snapshot_policy.py b/backend/app/models/snapshot_policy.py new file mode 100644 index 0000000..8b1ff89 --- /dev/null +++ b/backend/app/models/snapshot_policy.py @@ -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") diff --git a/backend/app/routers/snapshot_policies.py b/backend/app/routers/snapshot_policies.py new file mode 100644 index 0000000..d4461ea --- /dev/null +++ b/backend/app/routers/snapshot_policies.py @@ -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} diff --git a/backend/app/schemas/snapshot_policy.py b/backend/app/schemas/snapshot_policy.py new file mode 100644 index 0000000..6cb61dc --- /dev/null +++ b/backend/app/schemas/snapshot_policy.py @@ -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] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ab410c..5997db1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* Common */} } /> diff --git a/frontend/src/api/snapshotPolicies.ts b/frontend/src/api/snapshotPolicies.ts new file mode 100644 index 0000000..842f28e --- /dev/null +++ b/frontend/src/api/snapshotPolicies.ts @@ -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 + 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 + 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('/api/v1/snapshots/dashboard') + .then((r) => r.data), + }) + +export const useSnapshotPolicies = (objId: number) => + useQuery({ + queryKey: ['snapshot-policies', objId], + queryFn: () => + api + .get(`/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 + label: string + is_active?: boolean + }) => + api + .post(`/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 + label?: string + is_active?: boolean + }) => + api + .put( + `/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'] }) + }, + }) +} diff --git a/frontend/src/components/AppLayout.tsx b/frontend/src/components/AppLayout.tsx index 02791b7..ef9f88a 100644 --- a/frontend/src/components/AppLayout.tsx +++ b/frontend/src/components/AppLayout.tsx @@ -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: , + label: 'Снапшоты', + onClick: () => navigate('/snapshots'), + }, ], }, { type: 'divider' }, diff --git a/frontend/src/pages/SnapshotDashboard.tsx b/frontend/src/pages/SnapshotDashboard.tsx new file mode 100644 index 0000000..23059fd --- /dev/null +++ b/frontend/src/pages/SnapshotDashboard.tsx @@ -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(null) + const [form] = Form.useForm() + 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() + 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 = {} + 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 ( +
+
+ + Снапшоты + + setSearchText(e.target.value)} + /> +
+ + {isLoading && Загрузка...} + + {!isLoading && filtered.length === 0 && ( + + Нет объектов с политиками снапшотов. Добавьте политику через страницу объекта. + + )} + + city)} + items={cityGroups.map(([city, objects]) => ({ + key: city, + label: ( + + {city === UNGROUPED ? 'Без города' : city} + {objects.length} + + ), + children: objects.map((obj) => ( + openCreate(obj.object_id)} + onEditPolicy={(p) => openEdit(obj.object_id, p)} + /> + )), + }))} + /> + + setModalOpen(false)} + onOk={handleSubmit} + confirmLoading={createPolicyMutation.isPending || updatePolicyMutation.isPending} + okText="Сохранить" + cancelText="Отмена" + > +
+ + + + + + + {actionType === 'get_file' && ( + + + + )} +
+
+
+ ) +} + +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 = [ + { + title: 'Политика', + dataIndex: 'policy_label', + key: 'label', + }, + { + title: 'Категория', + dataIndex: 'category', + key: 'category', + render: (cat: string) => {cat}, + }, + { + title: 'Последний сбор', + dataIndex: 'last_collected_at', + key: 'last_collected', + render: (val: string | null) => + val ? ( + + {dayjs(val).fromNow()} + + ) : ( + + ), + }, + { + title: 'Покрытие', + key: 'coverage', + render: (_, row) => { + const { success_count, total_devices } = row + if (total_devices === 0) return нет устройств + const ratio = success_count / total_devices + const color = ratio >= 1 ? 'green' : ratio > 0 ? 'orange' : 'red' + return ( + + {success_count} / {total_devices} + + ) + }, + }, + { + title: '', + key: 'actions', + width: 140, + render: (_, row) => ( + + + + + + + ) +}