работа с задачами
This commit is contained in:
parent
00101ff6b3
commit
d0f957a30f
8 changed files with 571 additions and 94 deletions
30
backend/alembic/versions/0015_task_cancel_requested.py
Normal file
30
backend/alembic/versions/0015_task_cancel_requested.py
Normal 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")
|
||||||
|
|
@ -2,7 +2,7 @@ from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from uuid import uuid4
|
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.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.types import Uuid
|
from sqlalchemy.types import Uuid
|
||||||
|
|
@ -28,6 +28,8 @@ class Task(Base):
|
||||||
created_by: Mapped[Optional[int]] = mapped_column(
|
created_by: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
finished_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ from app.models.task import Task, TaskResult
|
||||||
from app.models.zone import Zone
|
from app.models.zone import Zone
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.plugins.registry import action_registry
|
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
|
from app.services import task as task_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/tasks", tags=["tasks"])
|
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)
|
@router.post("", response_model=TaskRead, status_code=status.HTTP_202_ACCEPTED)
|
||||||
async def create_task(
|
async def create_task(
|
||||||
body: TaskCreate,
|
body: TaskCreate,
|
||||||
|
|
@ -176,16 +215,60 @@ async def get_task(
|
||||||
result = TaskDetail.model_validate(task)
|
result = TaskDetail.model_validate(task)
|
||||||
result.target_label = labels.get(task.id)
|
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:
|
if result.device_results:
|
||||||
device_ids = [r.device_id for r in result.device_results]
|
device_ids = [r.device_id for r in result.device_results]
|
||||||
dev_rows = await db.execute(
|
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:
|
for r in result.device_results:
|
||||||
ip, hostname = dev_map.get(r.device_id, (None, None))
|
dev_info = dev_map.get(r.device_id, {})
|
||||||
r.device_ip = ip
|
r.device_ip = dev_info.get("ip")
|
||||||
r.device_hostname = hostname
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ class TaskResultRead(BaseModel):
|
||||||
device_id: int
|
device_id: int
|
||||||
device_ip: Optional[str] = None
|
device_ip: Optional[str] = None
|
||||||
device_hostname: 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
|
status: str
|
||||||
stdout: Optional[str] = None
|
stdout: Optional[str] = None
|
||||||
stderr: Optional[str] = None
|
stderr: Optional[str] = None
|
||||||
|
|
@ -39,6 +43,7 @@ class TaskRead(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: str
|
type: str
|
||||||
status: str
|
status: str
|
||||||
|
cancel_requested: bool = False
|
||||||
target_scope: dict
|
target_scope: dict
|
||||||
target_label: Optional[str] = None
|
target_label: Optional[str] = None
|
||||||
params: dict
|
params: dict
|
||||||
|
|
@ -62,3 +67,12 @@ class ActionInfo(BaseModel):
|
||||||
display_name: str
|
display_name: str
|
||||||
compatible_categories: list[str]
|
compatible_categories: list[str]
|
||||||
params_schema: list[dict]
|
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]
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,13 @@ async def _run_on_device(
|
||||||
action,
|
action,
|
||||||
params: dict,
|
params: dict,
|
||||||
sem: asyncio.Semaphore,
|
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:
|
async with sem:
|
||||||
emit = lambda msg, level="info": _emit(task_id, msg, level)
|
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)
|
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)
|
||||||
final_status = "success" if failed == 0 else ("failed" if ok == 0 else "partial")
|
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 AsyncSessionLocal() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
task = await db.get(Task, task_id)
|
task = await db.get(Task, task_id)
|
||||||
if task:
|
if task:
|
||||||
task.status = final_status
|
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)
|
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(
|
await sse_manager.publish(SSEEvent(
|
||||||
type="task_status",
|
type="task_status",
|
||||||
task_id=task_id,
|
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},
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
|
|
||||||
export interface TaskEvent {
|
export interface TaskEvent {
|
||||||
|
|
@ -13,6 +13,10 @@ export interface TaskResult {
|
||||||
device_id: number
|
device_id: number
|
||||||
device_ip: string | null
|
device_ip: string | null
|
||||||
device_hostname: string | null
|
device_hostname: string | null
|
||||||
|
object_id: number | null
|
||||||
|
object_name: string | null
|
||||||
|
rack_id: number | null
|
||||||
|
rack_name: string | null
|
||||||
status: string
|
status: string
|
||||||
stdout: string | null
|
stdout: string | null
|
||||||
stderr: string | null
|
stderr: string | null
|
||||||
|
|
@ -26,6 +30,7 @@ export interface Task {
|
||||||
id: string
|
id: string
|
||||||
type: string
|
type: string
|
||||||
status: string
|
status: string
|
||||||
|
cancel_requested: boolean
|
||||||
target_scope: Record<string, unknown>
|
target_scope: Record<string, unknown>
|
||||||
target_label: string | null
|
target_label: string | null
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
|
|
@ -49,6 +54,15 @@ export interface ActionInfo {
|
||||||
params_schema: { name: string; type: string; label: string; required?: boolean }[]
|
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) =>
|
export const useTasks = (page = 1, pageSize = 50) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ['tasks', page, pageSize],
|
queryKey: ['tasks', page, pageSize],
|
||||||
|
|
@ -76,10 +90,40 @@ export const useActions = () =>
|
||||||
queryFn: () => api.get<ActionInfo[]>('/api/v1/tasks/actions').then((r) => r.data),
|
queryFn: () => api.get<ActionInfo[]>('/api/v1/tasks/actions').then((r) => r.data),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const useCreateTask = () =>
|
export const useCreateTask = () => {
|
||||||
useMutation({
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
mutationFn: (body: { type: string; target_scope: object; params: object }) =>
|
mutationFn: (body: { type: string; target_scope: object; params: object }) =>
|
||||||
api.post<Task>('/api/v1/tasks', body).then((r) => r.data),
|
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) =>
|
export const useDeviceTasks = (deviceId: number) =>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,33 @@
|
||||||
import { ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined, ExpandAltOutlined, LoadingOutlined } from '@ant-design/icons'
|
import {
|
||||||
import { Breadcrumb, Card, Col, Descriptions, Modal, Row, Spin, Table, Tag, Typography } from 'antd'
|
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 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 { 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 { TaskStatusBadge } from '../components/StatusBadge'
|
||||||
import { useSSE } from '../hooks/useSSE'
|
import { useSSE } from '../hooks/useSSE'
|
||||||
|
|
||||||
|
|
@ -29,26 +53,39 @@ const LEVEL_COLOR: Record<string, string> = {
|
||||||
error: '#ff4d4f',
|
error: '#ff4d4f',
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderOutput(r: TaskResult & Partial<DeviceResultState>) {
|
const CATEGORY_COLOR: Record<string, string> = {
|
||||||
const text = r.stdout || r.stderr || (r.data as any)?.error || null
|
main_server: 'blue',
|
||||||
if (!text) return '—'
|
vm: 'cyan',
|
||||||
return (
|
router: 'purple',
|
||||||
<code style={{ fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
embedded: 'orange',
|
||||||
{text.slice(0, 120)}{text.length > 120 ? '…' : ''}
|
camera: 'green',
|
||||||
</code>
|
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() {
|
export function TaskDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { data: task, isLoading } = useTask(id)
|
const { data: task, isLoading } = useTask(id)
|
||||||
|
const cancelTask = useCancelTask()
|
||||||
|
const createTask = useCreateTask()
|
||||||
|
|
||||||
const [log, setLog] = useState<LogLine[]>([])
|
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 logEndRef = useRef<HTMLDivElement>(null)
|
||||||
const [stdoutModal, setStdoutModal] = useState<string | null>(null)
|
const [stdoutModal, setStdoutModal] = useState<string | null>(null)
|
||||||
|
|
||||||
// Deduplication key set for log lines (ts + message)
|
|
||||||
const seenLogKeys = useRef(new Set<string>())
|
const seenLogKeys = useRef(new Set<string>())
|
||||||
|
|
||||||
const addLogLines = useCallback((lines: LogLine[]) => {
|
const addLogLines = useCallback((lines: LogLine[]) => {
|
||||||
|
|
@ -61,17 +98,15 @@ export function TaskDetailPage() {
|
||||||
if (newLines.length > 0) setLog((prev) => [...prev, ...newLines])
|
if (newLines.length > 0) setLog((prev) => [...prev, ...newLines])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Merge DB events on every task refetch (deduplication handles overlaps with SSE)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!task?.events?.length) return
|
if (!task?.events?.length) return
|
||||||
addLogLines(task.events.map((e) => ({ ts: e.created_at, message: e.message, level: e.level })))
|
addLogLines(task.events.map((e) => ({ ts: e.created_at, message: e.message, level: e.level })))
|
||||||
}, [task?.events, addLogLines])
|
}, [task?.events, addLogLines])
|
||||||
|
|
||||||
// Live SSE updates
|
|
||||||
useSSE(id, {
|
useSSE(id, {
|
||||||
onTaskEvent: (data) => addLogLines([{ ts: data.ts, message: data.message, level: data.level }]),
|
onTaskEvent: (data) => addLogLines([{ ts: data.ts, message: data.message, level: data.level }]),
|
||||||
onTaskResult: (data) =>
|
onTaskResult: (data) =>
|
||||||
setDeviceResults((prev) => ({
|
setLiveResults((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[data.device_id]: {
|
[data.device_id]: {
|
||||||
status: data.status,
|
status: data.status,
|
||||||
|
|
@ -85,33 +120,84 @@ export function TaskDetailPage() {
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-scroll log
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [log])
|
}, [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 />
|
if (isLoading) return <Spin />
|
||||||
|
|
||||||
const scopeLabel = (() => {
|
const isActive = task?.status === 'pending' || task?.status === 'running'
|
||||||
const s = task?.target_scope as any
|
const canCancel = isActive && !task?.cancel_requested
|
||||||
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 resultColumns = [
|
const resultColumns = [
|
||||||
{
|
{
|
||||||
title: 'Устройство',
|
title: 'Устройство',
|
||||||
key: 'device',
|
key: 'device',
|
||||||
render: (_: unknown, r: TaskResult) => {
|
render: (_: unknown, r: TaskResult & Partial<DeviceResultState>) => {
|
||||||
const live = deviceResults[r.device_id]
|
const ip = r.device_ip ?? null
|
||||||
const ip = live?.device_ip ?? r.device_ip ?? null
|
const hostname = r.device_hostname ?? null
|
||||||
const hostname = live?.device_hostname ?? r.device_hostname ?? null
|
|
||||||
if (ip) return hostname ? `${ip} (${hostname})` : ip
|
if (ip) return hostname ? `${ip} (${hostname})` : ip
|
||||||
return `#${r.device_id}`
|
return `#${r.device_id}`
|
||||||
},
|
},
|
||||||
|
|
@ -119,8 +205,9 @@ export function TaskDetailPage() {
|
||||||
{
|
{
|
||||||
title: 'Статус',
|
title: 'Статус',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
render: (_: unknown, r: TaskResult) => {
|
width: 120,
|
||||||
const s = deviceResults[r.device_id]?.status ?? r.status
|
render: (_: unknown, r: TaskResult & Partial<DeviceResultState>) => {
|
||||||
|
const s = r.status
|
||||||
if (s === 'success') return <Tag icon={<CheckCircleOutlined />} color="success">Успешно</Tag>
|
if (s === 'success') return <Tag icon={<CheckCircleOutlined />} color="success">Успешно</Tag>
|
||||||
if (s === 'failed') return <Tag icon={<CloseCircleOutlined />} color="error">Ошибка</Tag>
|
if (s === 'failed') return <Tag icon={<CloseCircleOutlined />} color="error">Ошибка</Tag>
|
||||||
return <Tag icon={<LoadingOutlined />} color="processing">Ожидание</Tag>
|
return <Tag icon={<LoadingOutlined />} color="processing">Ожидание</Tag>
|
||||||
|
|
@ -129,23 +216,15 @@ export function TaskDetailPage() {
|
||||||
{
|
{
|
||||||
title: 'Время',
|
title: 'Время',
|
||||||
key: 'duration_ms',
|
key: 'duration_ms',
|
||||||
render: (_: unknown, r: TaskResult) => {
|
width: 80,
|
||||||
const ms = deviceResults[r.device_id]?.duration_ms ?? r.duration_ms
|
render: (_: unknown, r: TaskResult & Partial<DeviceResultState>) =>
|
||||||
return ms != null ? `${ms}ms` : '—'
|
r.duration_ms != null ? `${r.duration_ms}ms` : '—',
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Вывод',
|
title: 'Вывод',
|
||||||
key: 'output',
|
key: 'output',
|
||||||
render: (_: unknown, r: TaskResult) => {
|
render: (_: unknown, r: TaskResult & Partial<DeviceResultState>) => {
|
||||||
const live = deviceResults[r.device_id]
|
const text = r.stdout || r.stderr || r.error || (r.data as any)?.error || null
|
||||||
const merged = live ? { ...r, ...live } : r
|
|
||||||
const text =
|
|
||||||
merged.stdout ||
|
|
||||||
merged.stderr ||
|
|
||||||
(merged as any).error ||
|
|
||||||
(r.data as any)?.error ||
|
|
||||||
null
|
|
||||||
if (!text) return '—'
|
if (!text) return '—'
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
|
<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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
|
|
@ -176,14 +294,46 @@ export function TaskDetailPage() {
|
||||||
|
|
||||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||||
<Col span={24}>
|
<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 size="small" bordered column={4}>
|
||||||
<Descriptions.Item label="Тип">{task?.type}</Descriptions.Item>
|
<Descriptions.Item label="Тип">{task?.type}</Descriptions.Item>
|
||||||
<Descriptions.Item label="Статус">
|
<Descriptions.Item label="Цель">{task?.target_label}</Descriptions.Item>
|
||||||
{task && <TaskStatusBadge status={task.status} />}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Цель">
|
|
||||||
{task?.target_label ?? scopeLabel}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="Создана">
|
<Descriptions.Item label="Создана">
|
||||||
{task && dayjs(task.created_at).format('DD.MM.YYYY HH:mm:ss')}
|
{task && dayjs(task.created_at).format('DD.MM.YYYY HH:mm:ss')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|
@ -203,7 +353,6 @@ export function TaskDetailPage() {
|
||||||
<Card
|
<Card
|
||||||
title="Лог выполнения"
|
title="Лог выполнения"
|
||||||
size="small"
|
size="small"
|
||||||
bodyStyle={{ padding: 0 }}
|
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|
@ -234,16 +383,28 @@ export function TaskDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{task?.device_results && task.device_results.length > 0 && (
|
{mergedResults.length > 0 && (
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Card title="Результаты по устройствам" size="small">
|
<Card
|
||||||
<Table
|
title={`Результаты по устройствам (${mergedResults.length})`}
|
||||||
dataSource={task.device_results}
|
size="small"
|
||||||
columns={resultColumns}
|
>
|
||||||
rowKey="id"
|
{collapseItems.length > 1 ? (
|
||||||
size="small"
|
<Collapse
|
||||||
pagination={false}
|
size="small"
|
||||||
/>
|
defaultActiveKey={defaultActiveKeys}
|
||||||
|
items={collapseItems}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Single group — skip the collapse wrapper, render table directly
|
||||||
|
<Table
|
||||||
|
dataSource={mergedResults}
|
||||||
|
columns={resultColumns}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,39 @@
|
||||||
import { PlusOutlined } from '@ant-design/icons'
|
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 dayjs from 'dayjs'
|
||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useObjects } from '../api/objects'
|
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'
|
import { TaskStatusBadge } from '../components/StatusBadge'
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
const CATEGORY_OPTIONS = [
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
{ value: 'server', label: 'Сервер' },
|
main_server: 'Сервер',
|
||||||
{ value: 'raspberry', label: 'Raspberry Pi' },
|
vm: 'ВМ',
|
||||||
{ value: 'mikrotik', label: 'Mikrotik' },
|
router: 'Роутер',
|
||||||
{ value: 'camera', label: 'Камера' },
|
embedded: 'Embedded',
|
||||||
{ value: 'embedded', label: 'Embedded' },
|
camera: 'Камера',
|
||||||
{ value: 'other', label: 'Другое' },
|
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() {
|
export function TasksPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -29,11 +46,38 @@ export function TasksPage() {
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [form] = Form.useForm()
|
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 scopeType: string = Form.useWatch('scope_type', form)
|
||||||
const selectedAction: string = Form.useWatch('action', 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)
|
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 = () => {
|
const openModal = () => {
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
setModalOpen(true)
|
setModalOpen(true)
|
||||||
|
|
@ -136,7 +180,7 @@ export function TasksPage() {
|
||||||
onOk={handleSubmit}
|
onOk={handleSubmit}
|
||||||
confirmLoading={createTask.isPending}
|
confirmLoading={createTask.isPending}
|
||||||
okText="Запустить"
|
okText="Запустить"
|
||||||
width={520}
|
width={540}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
|
@ -151,6 +195,20 @@ export function TasksPage() {
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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: 'Выберите тип цели' }]}>
|
<Form.Item name="scope_type" label="Тип цели" rules={[{ required: true, message: 'Выберите тип цели' }]}>
|
||||||
<Select placeholder="Выберите тип цели">
|
<Select placeholder="Выберите тип цели">
|
||||||
|
|
@ -215,6 +273,72 @@ export function TasksPage() {
|
||||||
<Input placeholder={p.label} />
|
<Input placeholder={p.label} />
|
||||||
</Form.Item>
|
</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>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue