From d0f957a30fd89587e1dfd10edb553211815bb165 Mon Sep 17 00:00:00 2001 From: dv Date: Tue, 14 Apr 2026 13:11:13 +0300 Subject: [PATCH] =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../versions/0015_task_cancel_requested.py | 30 ++ backend/app/models/task.py | 4 +- backend/app/routers/tasks.py | 97 +++++- backend/app/schemas/task.py | 14 + backend/app/services/task.py | 31 +- frontend/src/api/tasks.ts | 50 ++- frontend/src/pages/TaskDetail.tsx | 289 ++++++++++++++---- frontend/src/pages/Tasks.tsx | 150 ++++++++- 8 files changed, 571 insertions(+), 94 deletions(-) create mode 100644 backend/alembic/versions/0015_task_cancel_requested.py diff --git a/backend/alembic/versions/0015_task_cancel_requested.py b/backend/alembic/versions/0015_task_cancel_requested.py new file mode 100644 index 0000000..c463fc3 --- /dev/null +++ b/backend/alembic/versions/0015_task_cancel_requested.py @@ -0,0 +1,30 @@ +"""0015 task cancel_requested + +Revision ID: 0015 +Revises: 0014 +Create Date: 2026-04-14 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0015" +down_revision = "0014" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "tasks", + sa.Column( + "cancel_requested", + sa.Boolean(), + nullable=False, + server_default="false", + ), + ) + + +def downgrade() -> None: + op.drop_column("tasks", "cancel_requested") diff --git a/backend/app/models/task.py b/backend/app/models/task.py index 81dde01..3f6eedf 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -2,7 +2,7 @@ from datetime import datetime from typing import Any, Optional from uuid import uuid4 -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.types import Uuid @@ -28,6 +28,8 @@ class Task(Base): created_by: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) + cancel_requested: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py index e6098a5..729f66c 100644 --- a/backend/app/routers/tasks.py +++ b/backend/app/routers/tasks.py @@ -13,7 +13,7 @@ from app.models.task import Task, TaskResult from app.models.zone import Zone from app.models.user import User from app.plugins.registry import action_registry -from app.schemas.task import ActionInfo, TaskCreate, TaskDetail, TaskRead +from app.schemas.task import ActionInfo, TaskCreate, TaskDetail, TaskPreviewResponse, TaskRead from app.services import task as task_service router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"]) @@ -110,6 +110,45 @@ async def list_actions(_: User = Depends(get_current_user)): ] +@router.post("/preview", response_model=TaskPreviewResponse) +async def preview_task( + body: TaskCreate, + _: User = Depends(get_current_user), +): + """Resolve scope and return device count breakdown before creating a task.""" + action = action_registry.get(body.type) + if action is None: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unknown action: {body.type}") + + targets = await task_service._resolve_scope(body.target_scope) + + by_category: dict[str, int] = {} + for d, _ in targets: + by_category[d.category] = by_category.get(d.category, 0) + 1 + + if action.compatible_categories: + compatible = [(d, o) for d, o in targets if action.is_compatible(d.category)] + skipped = [(d, o) for d, o in targets if not action.is_compatible(d.category)] + else: + compatible = targets + skipped = [] + + compatible_by_category: dict[str, int] = {} + for d, _ in compatible: + compatible_by_category[d.category] = compatible_by_category.get(d.category, 0) + 1 + + skipped_categories = sorted({d.category for d, _ in skipped}) + + return TaskPreviewResponse( + total=len(targets), + compatible=len(compatible), + skipped=len(skipped), + by_category=by_category, + compatible_by_category=compatible_by_category, + skipped_categories=skipped_categories, + ) + + @router.post("", response_model=TaskRead, status_code=status.HTTP_202_ACCEPTED) async def create_task( body: TaskCreate, @@ -176,16 +215,60 @@ async def get_task( result = TaskDetail.model_validate(task) result.target_label = labels.get(task.id) - # Enrich device_results with IP and hostname + # Enrich device_results with IP, hostname, object, rack if result.device_results: device_ids = [r.device_id for r in result.device_results] dev_rows = await db.execute( - select(Device.id, Device.ip, Device.hostname).where(Device.id.in_(device_ids)) + select(Device.id, Device.ip, Device.hostname, Device.object_id, Device.rack_id) + .where(Device.id.in_(device_ids)) ) - dev_map = {row[0]: (row[1], row[2]) for row in dev_rows.all()} + dev_map = { + row[0]: {"ip": row[1], "hostname": row[2], "object_id": row[3], "rack_id": row[4]} + for row in dev_rows.all() + } + + obj_ids = {v["object_id"] for v in dev_map.values() if v["object_id"]} + rack_ids = {v["rack_id"] for v in dev_map.values() if v["rack_id"]} + + obj_names: dict[int, str] = {} + rack_name_map: dict[int, str] = {} + + if obj_ids: + obj_rows = await db.execute( + select(Object.id, Object.name).where(Object.id.in_(obj_ids)) + ) + obj_names = {r[0]: r[1] for r in obj_rows.all()} + + if rack_ids: + rack_rows = await db.execute( + select(Rack.id, Rack.name).where(Rack.id.in_(rack_ids)) + ) + rack_name_map = {r[0]: r[1] for r in rack_rows.all()} + for r in result.device_results: - ip, hostname = dev_map.get(r.device_id, (None, None)) - r.device_ip = ip - r.device_hostname = hostname + dev_info = dev_map.get(r.device_id, {}) + r.device_ip = dev_info.get("ip") + r.device_hostname = dev_info.get("hostname") + r.object_id = dev_info.get("object_id") + r.rack_id = dev_info.get("rack_id") + r.object_name = obj_names.get(dev_info.get("object_id")) if dev_info.get("object_id") else None + r.rack_name = rack_name_map.get(dev_info.get("rack_id")) if dev_info.get("rack_id") else None return result + + +@router.post("/{task_id}/cancel", status_code=status.HTTP_204_NO_CONTENT) +async def cancel_task( + task_id: str, + db: AsyncSession = Depends(get_db), + _: User = Depends(get_current_user), +): + task = await db.get(Task, task_id) + if task is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") + if task.status not in ("pending", "running"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot cancel task in status '{task.status}'", + ) + task.cancel_requested = True diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 8462843..04103a8 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -24,6 +24,10 @@ class TaskResultRead(BaseModel): device_id: int device_ip: Optional[str] = None device_hostname: Optional[str] = None + object_id: Optional[int] = None + object_name: Optional[str] = None + rack_id: Optional[int] = None + rack_name: Optional[str] = None status: str stdout: Optional[str] = None stderr: Optional[str] = None @@ -39,6 +43,7 @@ class TaskRead(BaseModel): id: str type: str status: str + cancel_requested: bool = False target_scope: dict target_label: Optional[str] = None params: dict @@ -62,3 +67,12 @@ class ActionInfo(BaseModel): display_name: str compatible_categories: list[str] params_schema: list[dict] + + +class TaskPreviewResponse(BaseModel): + total: int + compatible: int + skipped: int + by_category: dict[str, int] + compatible_by_category: dict[str, int] + skipped_categories: list[str] diff --git a/backend/app/services/task.py b/backend/app/services/task.py index fe3cd3c..f005967 100644 --- a/backend/app/services/task.py +++ b/backend/app/services/task.py @@ -114,7 +114,13 @@ async def _run_on_device( action, params: dict, sem: asyncio.Semaphore, -) -> bool: +) -> bool | None: + # Bail out before acquiring the semaphore if task was cancelled + async with AsyncSessionLocal() as db: + task_row = await db.get(Task, task_id) + if task_row and task_row.cancel_requested: + return None # None = skipped due to cancellation + async with sem: emit = lambda msg, level="info": _emit(task_id, msg, level) @@ -275,22 +281,35 @@ async def _run_task(task_id: str) -> None: ) ok = sum(1 for r in results if r is True) - failed = len(results) - ok - final_status = "success" if failed == 0 else ("failed" if ok == 0 else "partial") + skipped = sum(1 for r in results if r is None) + failed = len(results) - ok - skipped + ran = len(results) - skipped + + # Check if cancellation was requested + was_cancelled = False + async with AsyncSessionLocal() as db: + task_row = await db.get(Task, task_id) + was_cancelled = bool(task_row and task_row.cancel_requested) + + if was_cancelled: + final_status = "cancelled" + else: + final_status = "success" if failed == 0 else ("failed" if ok == 0 else "partial") async with AsyncSessionLocal() as db: async with db.begin(): task = await db.get(Task, task_id) if task: task.status = final_status - task.result = {"total": len(results), "ok": ok, "failed": failed} + task.result = {"total": ran, "ok": ok, "failed": failed} task.finished_at = datetime.now(timezone.utc) - await _emit(task_id, f"Done: {ok}/{len(results)} succeeded", "info") + summary = f"Done: {ok}/{ran} succeeded" + (f" ({skipped} skipped)" if skipped else "") + await _emit(task_id, summary, "info") await sse_manager.publish(SSEEvent( type="task_status", task_id=task_id, - data={"status": final_status, "total": len(results), "ok": ok, "failed": failed}, + data={"status": final_status, "total": ran, "ok": ok, "failed": failed}, )) diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index ef8f6c7..539d7c7 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { api } from './client' export interface TaskEvent { @@ -13,6 +13,10 @@ export interface TaskResult { device_id: number device_ip: string | null device_hostname: string | null + object_id: number | null + object_name: string | null + rack_id: number | null + rack_name: string | null status: string stdout: string | null stderr: string | null @@ -26,6 +30,7 @@ export interface Task { id: string type: string status: string + cancel_requested: boolean target_scope: Record target_label: string | null params: Record @@ -49,6 +54,15 @@ export interface ActionInfo { params_schema: { name: string; type: string; label: string; required?: boolean }[] } +export interface TaskPreviewResponse { + total: number + compatible: number + skipped: number + by_category: Record + compatible_by_category: Record + skipped_categories: string[] +} + export const useTasks = (page = 1, pageSize = 50) => useQuery({ queryKey: ['tasks', page, pageSize], @@ -76,10 +90,40 @@ export const useActions = () => queryFn: () => api.get('/api/v1/tasks/actions').then((r) => r.data), }) -export const useCreateTask = () => - useMutation({ +export const useCreateTask = () => { + const qc = useQueryClient() + return useMutation({ mutationFn: (body: { type: string; target_scope: object; params: object }) => api.post('/api/v1/tasks', body).then((r) => r.data), + onSuccess: () => qc.invalidateQueries({ queryKey: ['tasks'] }), + }) +} + +export const useCancelTask = () => { + const qc = useQueryClient() + return useMutation({ + mutationFn: (taskId: string) => + api.post(`/api/v1/tasks/${taskId}/cancel`).then((r) => r.data), + onSuccess: (_data, taskId) => { + qc.invalidateQueries({ queryKey: ['tasks', taskId] }) + qc.invalidateQueries({ queryKey: ['tasks'] }) + }, + }) +} + +export const usePreviewTask = ( + type: string | undefined, + target_scope: object | undefined, + enabled: boolean, +) => + useQuery({ + queryKey: ['task-preview', type, target_scope], + queryFn: () => + api + .post('/api/v1/tasks/preview', { type, target_scope, params: {} }) + .then((r) => r.data), + enabled: enabled && !!type && !!target_scope, + staleTime: 10_000, }) export const useDeviceTasks = (deviceId: number) => diff --git a/frontend/src/pages/TaskDetail.tsx b/frontend/src/pages/TaskDetail.tsx index 59c3362..b13e540 100644 --- a/frontend/src/pages/TaskDetail.tsx +++ b/frontend/src/pages/TaskDetail.tsx @@ -1,9 +1,33 @@ -import { ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined, ExpandAltOutlined, LoadingOutlined } from '@ant-design/icons' -import { Breadcrumb, Card, Col, Descriptions, Modal, Row, Spin, Table, Tag, Typography } from 'antd' +import { + ArrowLeftOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + ExpandAltOutlined, + LoadingOutlined, + RedoOutlined, + StopOutlined, +} from '@ant-design/icons' +import { + Breadcrumb, + Button, + Card, + Col, + Collapse, + Descriptions, + Modal, + Popconfirm, + Row, + Space, + Spin, + Table, + Tag, + Typography, + message, +} from 'antd' import dayjs from 'dayjs' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { useTask, type TaskResult } from '../api/tasks' +import { useCancelTask, useCreateTask, useTask, type TaskResult } from '../api/tasks' import { TaskStatusBadge } from '../components/StatusBadge' import { useSSE } from '../hooks/useSSE' @@ -29,26 +53,39 @@ const LEVEL_COLOR: Record = { error: '#ff4d4f', } -function renderOutput(r: TaskResult & Partial) { - const text = r.stdout || r.stderr || (r.data as any)?.error || null - if (!text) return '—' - return ( - - {text.slice(0, 120)}{text.length > 120 ? '…' : ''} - - ) +const CATEGORY_COLOR: Record = { + main_server: 'blue', + vm: 'cyan', + router: 'purple', + embedded: 'orange', + camera: 'green', + io_board: 'gold', + bank_terminal: 'volcano', + cash_register: 'red', + other: 'default', +} + +// Group an array of results by a string key +function groupBy(items: T[], key: (item: T) => string): Record { + return items.reduce>((acc, item) => { + const k = key(item) + ;(acc[k] ??= []).push(item) + return acc + }, {}) } export function TaskDetailPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const { data: task, isLoading } = useTask(id) + const cancelTask = useCancelTask() + const createTask = useCreateTask() + const [log, setLog] = useState([]) - const [deviceResults, setDeviceResults] = useState>({}) + const [liveResults, setLiveResults] = useState>({}) const logEndRef = useRef(null) const [stdoutModal, setStdoutModal] = useState(null) - // Deduplication key set for log lines (ts + message) const seenLogKeys = useRef(new Set()) const addLogLines = useCallback((lines: LogLine[]) => { @@ -61,17 +98,15 @@ export function TaskDetailPage() { if (newLines.length > 0) setLog((prev) => [...prev, ...newLines]) }, []) - // Merge DB events on every task refetch (deduplication handles overlaps with SSE) useEffect(() => { if (!task?.events?.length) return addLogLines(task.events.map((e) => ({ ts: e.created_at, message: e.message, level: e.level }))) }, [task?.events, addLogLines]) - // Live SSE updates useSSE(id, { onTaskEvent: (data) => addLogLines([{ ts: data.ts, message: data.message, level: data.level }]), onTaskResult: (data) => - setDeviceResults((prev) => ({ + setLiveResults((prev) => ({ ...prev, [data.device_id]: { status: data.status, @@ -85,33 +120,84 @@ export function TaskDetailPage() { })), }) - // Auto-scroll log useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [log]) + // Merge DB results with live SSE updates + const mergedResults = useMemo<(TaskResult & Partial)[]>(() => { + if (!task?.device_results) return [] + return task.device_results.map((r) => { + const live = liveResults[r.device_id] + return live ? { ...r, ...live } : r + }) + }, [task?.device_results, liveResults]) + + // Determine if results span multiple objects + const multiObject = useMemo(() => { + const objIds = new Set(mergedResults.map((r) => r.object_id).filter(Boolean)) + return objIds.size > 1 + }, [mergedResults]) + + // Group results: by object_name (if multi-object), then by rack_name + const grouped = useMemo(() => { + const sorted = [...mergedResults].sort((a, b) => { + const oCmp = (a.object_name ?? '').localeCompare(b.object_name ?? '') + if (oCmp !== 0) return oCmp + return (a.rack_name ?? '').localeCompare(b.rack_name ?? '') + }) + + if (multiObject) { + const byObj = groupBy(sorted, (r) => r.object_name ?? `Объект #${r.object_id ?? '?'}`) + return Object.entries(byObj).map(([objName, objItems]) => ({ + groupLabel: objName, + byRack: groupBy(objItems, (r) => r.rack_name ?? 'Без стойки'), + })) + } else { + return [{ + groupLabel: null, + byRack: groupBy(sorted, (r) => r.rack_name ?? 'Без стойки'), + }] + } + }, [mergedResults, multiObject]) + + const handleCancel = async () => { + if (!id) return + try { + await cancelTask.mutateAsync(id) + message.success('Задача отменена') + } catch { + message.error('Не удалось отменить задачу') + } + } + + const handleRepeat = async () => { + if (!task) return + try { + const newTask = await createTask.mutateAsync({ + type: task.type, + target_scope: task.target_scope, + params: task.params, + }) + message.success('Задача создана') + navigate(`/tasks/${newTask.id}`) + } catch { + message.error('Не удалось создать задачу') + } + } + if (isLoading) return - const scopeLabel = (() => { - const s = task?.target_scope as any - if (!s) return '—' - if (s.type === 'device') return `Устройство #${s.id}` - if (s.type === 'object') return `Объект #${s.id}` - if (s.type === 'category') return `Категория: ${s.value ?? s.category}` - if (s.type === 'role') return `Роль: ${s.value ?? s.role}` - if (s.type === 'device_list') return `${s.ids?.length ?? 0} устройств` - if (s.type === 'filter') return 'Фильтр' - return JSON.stringify(s) - })() + const isActive = task?.status === 'pending' || task?.status === 'running' + const canCancel = isActive && !task?.cancel_requested const resultColumns = [ { title: 'Устройство', key: 'device', - render: (_: unknown, r: TaskResult) => { - const live = deviceResults[r.device_id] - const ip = live?.device_ip ?? r.device_ip ?? null - const hostname = live?.device_hostname ?? r.device_hostname ?? null + render: (_: unknown, r: TaskResult & Partial) => { + const ip = r.device_ip ?? null + const hostname = r.device_hostname ?? null if (ip) return hostname ? `${ip} (${hostname})` : ip return `#${r.device_id}` }, @@ -119,8 +205,9 @@ export function TaskDetailPage() { { title: 'Статус', key: 'status', - render: (_: unknown, r: TaskResult) => { - const s = deviceResults[r.device_id]?.status ?? r.status + width: 120, + render: (_: unknown, r: TaskResult & Partial) => { + const s = r.status if (s === 'success') return } color="success">Успешно if (s === 'failed') return } color="error">Ошибка return } color="processing">Ожидание @@ -129,23 +216,15 @@ export function TaskDetailPage() { { title: 'Время', key: 'duration_ms', - render: (_: unknown, r: TaskResult) => { - const ms = deviceResults[r.device_id]?.duration_ms ?? r.duration_ms - return ms != null ? `${ms}ms` : '—' - }, + width: 80, + render: (_: unknown, r: TaskResult & Partial) => + r.duration_ms != null ? `${r.duration_ms}ms` : '—', }, { title: 'Вывод', key: 'output', - render: (_: unknown, r: TaskResult) => { - const live = deviceResults[r.device_id] - const merged = live ? { ...r, ...live } : r - const text = - merged.stdout || - merged.stderr || - (merged as any).error || - (r.data as any)?.error || - null + render: (_: unknown, r: TaskResult & Partial) => { + const text = r.stdout || r.stderr || r.error || (r.data as any)?.error || null if (!text) return '—' return (
@@ -164,6 +243,45 @@ export function TaskDetailPage() { }, ] + // Build Collapse panels: one per rack within each object group + const collapseItems = grouped.flatMap(({ groupLabel, byRack }) => + Object.entries(byRack).map(([rackName, items]) => { + const ok = items.filter((r) => r.status === 'success').length + const failed = items.filter((r) => r.status === 'failed').length + const running = items.filter((r) => r.status === 'running').length + + const labelParts = [] + if (groupLabel) labelParts.push(groupLabel) + labelParts.push(rackName) + + return { + key: `${groupLabel ?? ''}::${rackName}`, + label: ( + + {labelParts.join(' / ')} + {ok > 0 && {ok} ок} + {failed > 0 && {failed} ошибок} + {running > 0 && }>{running}} + {items.length} всего + + ), + children: ( + 1} + /> + ), + } + }) + ) + + // Default open all panels + const defaultActiveKeys = collapseItems.map((p) => p.key) + return (
+
+ + {task && } + {task?.cancel_requested && isActive && ( + Отмена... + )} + + + {canCancel && ( + + + + )} + + +
{task?.type} - - {task && } - - - {task?.target_label ?? scopeLabel} - + {task?.target_label} {task && dayjs(task.created_at).format('DD.MM.YYYY HH:mm:ss')} @@ -203,7 +353,6 @@ export function TaskDetailPage() {
- {task?.device_results && task.device_results.length > 0 && ( + {mergedResults.length > 0 && (
- -
+ + {collapseItems.length > 1 ? ( + + ) : ( + // Single group — skip the collapse wrapper, render table directly +
+ )} )} diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx index f1899f2..9bcafd5 100644 --- a/frontend/src/pages/Tasks.tsx +++ b/frontend/src/pages/Tasks.tsx @@ -1,22 +1,39 @@ import { PlusOutlined } from '@ant-design/icons' -import { Button, Form, Input, Modal, Select, Space, Table, Typography, message } from 'antd' +import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Table, Tag, Typography, message } from 'antd' import dayjs from 'dayjs' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useObjects } from '../api/objects' -import { useActions, useCreateTask, useTasks, type Task } from '../api/tasks' +import { useActions, useCreateTask, usePreviewTask, useTasks, type Task } from '../api/tasks' import { TaskStatusBadge } from '../components/StatusBadge' const PAGE_SIZE = 50 -const CATEGORY_OPTIONS = [ - { value: 'server', label: 'Сервер' }, - { value: 'raspberry', label: 'Raspberry Pi' }, - { value: 'mikrotik', label: 'Mikrotik' }, - { value: 'camera', label: 'Камера' }, - { value: 'embedded', label: 'Embedded' }, - { value: 'other', label: 'Другое' }, -] +const CATEGORY_LABELS: Record = { + main_server: 'Сервер', + vm: 'ВМ', + router: 'Роутер', + embedded: 'Embedded', + camera: 'Камера', + io_board: 'IO Board', + bank_terminal: 'Терминал', + cash_register: 'Касса', + other: 'Другое', +} + +const CATEGORY_OPTIONS = Object.entries(CATEGORY_LABELS).map(([value, label]) => ({ value, label })) + +const CATEGORY_COLOR: Record = { + main_server: 'blue', + vm: 'cyan', + router: 'purple', + embedded: 'orange', + camera: 'green', + io_board: 'gold', + bank_terminal: 'volcano', + cash_register: 'red', + other: 'default', +} export function TasksPage() { const navigate = useNavigate() @@ -29,11 +46,38 @@ export function TasksPage() { const [modalOpen, setModalOpen] = useState(false) const [form] = Form.useForm() - // watched form values for conditional rendering + // watched form values for conditional rendering and preview const scopeType: string = Form.useWatch('scope_type', form) const selectedAction: string = Form.useWatch('action', form) + const scopeCategory: string = Form.useWatch('scope_category', form) + const scopeObjectIds: number[] = Form.useWatch('scope_object_ids', form) ?? [] + const scopeObjectId: number = Form.useWatch('scope_object_id', form) const currentAction = actions?.find((a) => a.name === selectedAction) + // Build preview scope from current form values + const previewScope = useMemo(() => { + if (!scopeType) return undefined + if (scopeType === 'category') { + if (!scopeCategory) return undefined + if (scopeObjectIds?.length > 0) { + return { type: 'category', value: scopeCategory, object_ids: scopeObjectIds } + } + return { type: 'category', value: scopeCategory } + } + if (scopeType === 'object') { + if (!scopeObjectId) return undefined + return { type: 'object', id: scopeObjectId } + } + return undefined + }, [scopeType, scopeCategory, scopeObjectIds, scopeObjectId]) + + const previewEnabled = !!selectedAction && !!previewScope + const { data: preview, isFetching: previewLoading } = usePreviewTask( + selectedAction, + previewScope, + previewEnabled, + ) + const openModal = () => { form.resetFields() setModalOpen(true) @@ -136,7 +180,7 @@ export function TasksPage() { onOk={handleSubmit} confirmLoading={createTask.isPending} okText="Запустить" - width={520} + width={540} destroyOnClose >
@@ -151,6 +195,20 @@ export function TasksPage() { /> + {/* Совместимые категории выбранного действия */} + {currentAction && currentAction.compatible_categories.length > 0 && ( +
+ + Совместимо:{' '} + + {currentAction.compatible_categories.map((cat) => ( + + {CATEGORY_LABELS[cat] ?? cat} + + ))} +
+ )} + {/* Тип цели */} ))} + + {/* Preview */} + {previewEnabled && ( +
+ {previewLoading ? ( +
+ + + Подсчёт устройств... + +
+ ) : preview ? ( +
+
+ + {preview.compatible} устройств + + {preview.skipped > 0 && ( + + ({preview.skipped} будет пропущено) + + )} +
+
+ {Object.entries(preview.compatible_by_category).map(([cat, count]) => ( + + {CATEGORY_LABELS[cat] ?? cat} ×{count} + + ))} +
+ {preview.skipped_categories.length > 0 && ( + + Несовместимые категории будут пропущены:{' '} + {preview.skipped_categories.map((cat) => ( + + {CATEGORY_LABELS[cat] ?? cat} + + ))} + + } + /> + )} + {preview.total === 0 && ( + + )} +
+ ) : null} +
+ )}