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

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

View file

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

View file

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

View file

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

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' 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) =>

View file

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

View file

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