работа с задачами

This commit is contained in:
dv 2026-04-14 13:11:13 +03:00
parent 00101ff6b3
commit d0f957a30f
8 changed files with 571 additions and 94 deletions

View file

@ -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")

View file

@ -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)

View file

@ -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

View file

@ -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]

View file

@ -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,7 +281,19 @@ async def _run_task(task_id: str) -> None:
)
ok = sum(1 for r in results if r is True)
failed = len(results) - ok
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:
@ -283,14 +301,15 @@ async def _run_task(task_id: str) -> None:
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},
))

View file

@ -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<string, unknown>
target_label: string | null
params: Record<string, unknown>
@ -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<string, number>
compatible_by_category: Record<string, number>
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<ActionInfo[]>('/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<Task>('/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<TaskPreviewResponse>('/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) =>

View file

@ -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<string, string> = {
error: '#ff4d4f',
}
function renderOutput(r: TaskResult & Partial<DeviceResultState>) {
const text = r.stdout || r.stderr || (r.data as any)?.error || null
if (!text) return '—'
return (
<code style={{ fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{text.slice(0, 120)}{text.length > 120 ? '…' : ''}
</code>
)
const CATEGORY_COLOR: Record<string, string> = {
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<T>(items: T[], key: (item: T) => string): Record<string, T[]> {
return items.reduce<Record<string, T[]>>((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<LogLine[]>([])
const [deviceResults, setDeviceResults] = useState<Record<number, DeviceResultState>>({})
const [liveResults, setLiveResults] = useState<Record<number, DeviceResultState>>({})
const logEndRef = useRef<HTMLDivElement>(null)
const [stdoutModal, setStdoutModal] = useState<string | null>(null)
// Deduplication key set for log lines (ts + message)
const seenLogKeys = useRef(new Set<string>())
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<DeviceResultState>)[]>(() => {
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 <Spin />
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<DeviceResultState>) => {
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<DeviceResultState>) => {
const s = r.status
if (s === 'success') return <Tag icon={<CheckCircleOutlined />} color="success">Успешно</Tag>
if (s === 'failed') return <Tag icon={<CloseCircleOutlined />} color="error">Ошибка</Tag>
return <Tag icon={<LoadingOutlined />} color="processing">Ожидание</Tag>
@ -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<DeviceResultState>) =>
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<DeviceResultState>) => {
const text = r.stdout || r.stderr || r.error || (r.data as any)?.error || null
if (!text) return '—'
return (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
@ -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: (
<Space>
<Typography.Text strong>{labelParts.join(' / ')}</Typography.Text>
{ok > 0 && <Tag color="success">{ok} ок</Tag>}
{failed > 0 && <Tag color="error">{failed} ошибок</Tag>}
{running > 0 && <Tag color="processing" icon={<LoadingOutlined />}>{running}</Tag>}
<Tag>{items.length} всего</Tag>
</Space>
),
children: (
<Table
dataSource={items}
columns={resultColumns}
rowKey="id"
size="small"
pagination={false}
showHeader={items.length > 1}
/>
),
}
})
)
// Default open all panels
const defaultActiveKeys = collapseItems.map((p) => p.key)
return (
<div>
<Breadcrumb
@ -176,14 +294,46 @@ export function TaskDetailPage() {
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={24}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8 }}>
<Space>
{task && <TaskStatusBadge status={task.status} />}
{task?.cancel_requested && isActive && (
<Tag color="orange">Отмена...</Tag>
)}
</Space>
<Space>
{canCancel && (
<Popconfirm
title="Отменить задачу?"
description="Устройства, уже выполняющиеся, завершат работу. Новые не запустятся."
onConfirm={handleCancel}
okText="Отменить"
cancelText="Нет"
okButtonProps={{ danger: true }}
>
<Button
danger
icon={<StopOutlined />}
loading={cancelTask.isPending}
size="small"
>
Отменить
</Button>
</Popconfirm>
)}
<Button
icon={<RedoOutlined />}
loading={createTask.isPending}
onClick={handleRepeat}
size="small"
>
Повторить
</Button>
</Space>
</div>
<Descriptions size="small" bordered column={4}>
<Descriptions.Item label="Тип">{task?.type}</Descriptions.Item>
<Descriptions.Item label="Статус">
{task && <TaskStatusBadge status={task.status} />}
</Descriptions.Item>
<Descriptions.Item label="Цель">
{task?.target_label ?? scopeLabel}
</Descriptions.Item>
<Descriptions.Item label="Цель">{task?.target_label}</Descriptions.Item>
<Descriptions.Item label="Создана">
{task && dayjs(task.created_at).format('DD.MM.YYYY HH:mm:ss')}
</Descriptions.Item>
@ -203,7 +353,6 @@ export function TaskDetailPage() {
<Card
title="Лог выполнения"
size="small"
bodyStyle={{ padding: 0 }}
style={{ marginBottom: 16 }}
>
<div
@ -234,16 +383,28 @@ export function TaskDetailPage() {
</Card>
</Col>
{task?.device_results && task.device_results.length > 0 && (
{mergedResults.length > 0 && (
<Col span={24}>
<Card title="Результаты по устройствам" size="small">
<Card
title={`Результаты по устройствам (${mergedResults.length})`}
size="small"
>
{collapseItems.length > 1 ? (
<Collapse
size="small"
defaultActiveKey={defaultActiveKeys}
items={collapseItems}
/>
) : (
// Single group — skip the collapse wrapper, render table directly
<Table
dataSource={task.device_results}
dataSource={mergedResults}
columns={resultColumns}
rowKey="id"
size="small"
pagination={false}
/>
)}
</Card>
</Col>
)}

View file

@ -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<string, string> = {
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<string, string> = {
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
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
@ -151,6 +195,20 @@ export function TasksPage() {
/>
</Form.Item>
{/* Совместимые категории выбранного действия */}
{currentAction && currentAction.compatible_categories.length > 0 && (
<div style={{ marginBottom: 12, marginTop: -8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Совместимо:{' '}
</Typography.Text>
{currentAction.compatible_categories.map((cat) => (
<Tag key={cat} color={CATEGORY_COLOR[cat] ?? 'default'} style={{ fontSize: 11 }}>
{CATEGORY_LABELS[cat] ?? cat}
</Tag>
))}
</div>
)}
{/* Тип цели */}
<Form.Item name="scope_type" label="Тип цели" rules={[{ required: true, message: 'Выберите тип цели' }]}>
<Select placeholder="Выберите тип цели">
@ -215,6 +273,72 @@ export function TasksPage() {
<Input placeholder={p.label} />
</Form.Item>
))}
{/* Preview */}
{previewEnabled && (
<div style={{ marginTop: 8 }}>
{previewLoading ? (
<div style={{ textAlign: 'center', padding: '8px 0' }}>
<Spin size="small" />
<Typography.Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
Подсчёт устройств...
</Typography.Text>
</div>
) : preview ? (
<div
style={{
background: '#fafafa',
border: '1px solid #f0f0f0',
borderRadius: 6,
padding: '10px 14px',
}}
>
<div style={{ marginBottom: 6 }}>
<Typography.Text strong style={{ fontSize: 13 }}>
{preview.compatible} устройств
</Typography.Text>
{preview.skipped > 0 && (
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 6 }}>
({preview.skipped} будет пропущено)
</Typography.Text>
)}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{Object.entries(preview.compatible_by_category).map(([cat, count]) => (
<Tag key={cat} color={CATEGORY_COLOR[cat] ?? 'default'} style={{ margin: 0 }}>
{CATEGORY_LABELS[cat] ?? cat} ×{count}
</Tag>
))}
</div>
{preview.skipped_categories.length > 0 && (
<Alert
type="warning"
showIcon
style={{ marginTop: 8, padding: '4px 8px', fontSize: 12 }}
message={
<>
Несовместимые категории будут пропущены:{' '}
{preview.skipped_categories.map((cat) => (
<Tag key={cat} style={{ margin: '0 2px', fontSize: 11 }}>
{CATEGORY_LABELS[cat] ?? cat}
</Tag>
))}
</>
}
/>
)}
{preview.total === 0 && (
<Alert
type="warning"
showIcon
message="Нет устройств, подходящих под заданные условия"
style={{ marginTop: 8, padding: '4px 8px', fontSize: 12 }}
/>
)}
</div>
) : null}
</div>
)}
</Form>
</Modal>
</div>