дешборд снепшотов
This commit is contained in:
parent
50dc75b35f
commit
5d1c409739
10 changed files with 823 additions and 2 deletions
37
backend/alembic/versions/0020_snapshot_policies.py
Normal file
37
backend/alembic/versions/0020_snapshot_policies.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
21
backend/app/models/snapshot_policy.py
Normal file
21
backend/app/models/snapshot_policy.py
Normal 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")
|
||||
219
backend/app/routers/snapshot_policies.py
Normal file
219
backend/app/routers/snapshot_policies.py
Normal 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}
|
||||
53
backend/app/schemas/snapshot_policy.py
Normal file
53
backend/app/schemas/snapshot_policy.py
Normal 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]
|
||||
|
|
@ -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 />} />
|
||||
|
|
|
|||
125
frontend/src/api/snapshotPolicies.ts
Normal file
125
frontend/src/api/snapshotPolicies.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
352
frontend/src/pages/SnapshotDashboard.tsx
Normal file
352
frontend/src/pages/SnapshotDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue