diff --git a/backend/app/plugins/builtin/common/ping.py b/backend/app/plugins/builtin/common/ping.py index e21203e..077fab4 100644 --- a/backend/app/plugins/builtin/common/ping.py +++ b/backend/app/plugins/builtin/common/ping.py @@ -15,13 +15,14 @@ class PingAction(BaseAction): async def execute(self, device: Device, obj: Object, params: dict, emit: Emitter) -> ActionResult: await emit(f"Pinging {device.ip}...", "info") start = time.monotonic() + proc = None try: proc = await asyncio.create_subprocess_exec( - "ping", "-c", "4", "-W", "2", device.ip, + "ping", "-c", "1", "-W", "2", device.ip, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) duration = int((time.monotonic() - start) * 1000) success = proc.returncode == 0 msg = f"{device.ip}: {'reachable' if success else 'unreachable'} ({duration}ms)" @@ -29,10 +30,17 @@ class PingAction(BaseAction): return ActionResult( success=success, stdout=stdout.decode(errors="replace").strip(), + stderr=stderr.decode(errors="replace").strip(), exit_code=proc.returncode or 0, data={"ping_ms": duration if success else None}, ) except asyncio.TimeoutError: + if proc is not None: + try: + proc.kill() + await proc.wait() + except Exception: + pass await emit(f"{device.ip}: ping timed out", "error") return ActionResult(success=False, error="Timeout") except Exception as e: diff --git a/backend/app/routers/tasks.py b/backend/app/routers/tasks.py index 8fee559..e6098a5 100644 --- a/backend/app/routers/tasks.py +++ b/backend/app/routers/tasks.py @@ -33,6 +33,9 @@ async def _resolve_labels(db: AsyncSession, tasks: list[Task]) -> dict[str, str] obj_ids.add(scope["id"]) elif kind == "device": dev_ids.add(scope["id"]) + elif kind == "device_list": + for did in scope.get("ids", []): + dev_ids.add(did) elif kind == "rack": rack_ids.add(scope["id"]) elif kind == "zone": @@ -77,6 +80,11 @@ async def _resolve_labels(db: AsyncSession, tasks: list[Task]) -> dict[str, str] elif kind == "zone": _zid = scope.get('id') labels[t.id] = f"Зона: {zone_names.get(_zid, f'#{_zid}')}" + elif kind == "device_list": + ids = scope.get("ids", []) + names = [devs.get(did, f"#{did}") for did in ids[:3]] + suffix = f" и ещё {len(ids) - 3}" if len(ids) > 3 else "" + labels[t.id] = ", ".join(names) + suffix elif kind in ("category", "role"): val = scope.get("value", "?") if oid := scope.get("object_id"): @@ -167,4 +175,17 @@ async def get_task( labels = await _resolve_labels(db, [task]) result = TaskDetail.model_validate(task) result.target_label = labels.get(task.id) + + # Enrich device_results with IP and hostname + 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)) + ) + dev_map = {row[0]: (row[1], row[2]) for row in dev_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 + return result diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index 5d95870..8462843 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -22,6 +22,8 @@ class TaskEventRead(BaseModel): class TaskResultRead(BaseModel): id: int device_id: int + device_ip: Optional[str] = None + device_hostname: Optional[str] = None status: str stdout: Optional[str] = None stderr: Optional[str] = None diff --git a/backend/app/services/task.py b/backend/app/services/task.py index a30a108..fe3cd3c 100644 --- a/backend/app/services/task.py +++ b/backend/app/services/task.py @@ -142,7 +142,7 @@ async def _run_on_device( action_stdout = "" action_stderr = "" action_exit_code = -1 - action_data = {} + action_data = {"error": str(exc)} action_error = str(exc) else: action_result_success = action_result.success @@ -163,7 +163,7 @@ async def _run_on_device( tr.stdout = action_stdout tr.stderr = action_stderr tr.exit_code = action_exit_code - tr.data = action_data or None + tr.data = {**(action_data or {}), "error": action_error} if action_error else (action_data or None) tr.duration_ms = duration_ms # For ping action: update device status, last_seen, last_ping_ms @@ -199,8 +199,11 @@ async def _run_on_device( data={ "device_id": device.id, "device_ip": device.ip, + "device_hostname": device.hostname, "status": "success" if action_result_success else "failed", "duration_ms": duration_ms, + "stdout": action_stdout or None, + "stderr": action_stderr or None, "error": action_error or None, }, )) diff --git a/frontend/src/api/tasks.ts b/frontend/src/api/tasks.ts index 59513b0..ef8f6c7 100644 --- a/frontend/src/api/tasks.ts +++ b/frontend/src/api/tasks.ts @@ -11,10 +11,13 @@ export interface TaskEvent { export interface TaskResult { id: number device_id: number + device_ip: string | null + device_hostname: string | null status: string stdout: string | null stderr: string | null exit_code: number | null + data: Record | null duration_ms: number | null executed_at: string | null } diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 8e7ee1e..756bdf8 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -4,7 +4,16 @@ import { useAuthStore } from '../store/auth' interface SSEHandlers { onTaskEvent?: (data: { message: string; level: string; ts: string }) => void - onTaskResult?: (data: { device_id: number; device_ip: string; status: string; duration_ms: number }) => void + onTaskResult?: (data: { + device_id: number + device_ip: string + device_hostname: string | null + status: string + duration_ms: number + stdout: string | null + stderr: string | null + error: string | null + }) => void onTaskStatus?: (data: { status: string; total: number; ok: number; failed: number }) => void } diff --git a/frontend/src/pages/TaskDetail.tsx b/frontend/src/pages/TaskDetail.tsx index 13d7c9c..59c3362 100644 --- a/frontend/src/pages/TaskDetail.tsx +++ b/frontend/src/pages/TaskDetail.tsx @@ -1,7 +1,7 @@ -import { ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined, ExpandAltOutlined } from '@ant-design/icons' +import { ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined, ExpandAltOutlined, LoadingOutlined } from '@ant-design/icons' import { Breadcrumb, Card, Col, Descriptions, Modal, Row, Spin, Table, Tag, Typography } from 'antd' import dayjs from 'dayjs' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { useTask, type TaskResult } from '../api/tasks' import { TaskStatusBadge } from '../components/StatusBadge' @@ -13,34 +13,76 @@ interface LogLine { level: string } +interface DeviceResultState { + status: string + duration_ms: number | null + stdout: string | null + stderr: string | null + error: string | null + device_ip: string | null + device_hostname: string | null +} + const LEVEL_COLOR: Record = { info: '#52c41a', warn: '#faad14', error: '#ff4d4f', } +function renderOutput(r: TaskResult & Partial) { + const text = r.stdout || r.stderr || (r.data as any)?.error || null + if (!text) return '—' + return ( + + {text.slice(0, 120)}{text.length > 120 ? '…' : ''} + + ) +} + export function TaskDetailPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const { data: task, isLoading } = useTask(id) const [log, setLog] = useState([]) - const [deviceResults, setDeviceResults] = useState>({}) + const [deviceResults, setDeviceResults] = useState>({}) const logEndRef = useRef(null) const [stdoutModal, setStdoutModal] = useState(null) - // Populate log from persisted events on load + // Deduplication key set for log lines (ts + message) + const seenLogKeys = useRef(new Set()) + + const addLogLines = useCallback((lines: LogLine[]) => { + const newLines = lines.filter((l) => { + const key = `${l.ts}:${l.level}:${l.message}` + if (seenLogKeys.current.has(key)) return false + seenLogKeys.current.add(key) + return true + }) + 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) { - setLog(task.events.map((e) => ({ ts: e.created_at, message: e.message, level: e.level }))) - } - }, [task?.id]) // only on initial load + 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) => - setLog((prev) => [...prev, { ts: data.ts, message: data.message, level: data.level }]), + onTaskEvent: (data) => addLogLines([{ ts: data.ts, message: data.message, level: data.level }]), onTaskResult: (data) => - setDeviceResults((prev) => ({ ...prev, [data.device_id]: { status: data.status, duration_ms: data.duration_ms } })), + setDeviceResults((prev) => ({ + ...prev, + [data.device_id]: { + status: data.status, + duration_ms: data.duration_ms, + stdout: data.stdout ?? null, + stderr: data.stderr ?? null, + error: data.error ?? null, + device_ip: data.device_ip ?? null, + device_hostname: data.device_hostname ?? null, + }, + })), }) // Auto-scroll log @@ -55,60 +97,73 @@ export function TaskDetailPage() { if (!s) return '—' if (s.type === 'device') return `Устройство #${s.id}` if (s.type === 'object') return `Объект #${s.id}` - if (s.type === 'category') return `Категория: ${s.value}` - if (s.type === 'role') return `Роль: ${s.value}` + 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 = [ { title: 'Устройство', - dataIndex: 'device_id', - key: 'device_id', - render: (id: number) => `#${id}`, + 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 + if (ip) return hostname ? `${ip} (${hostname})` : ip + return `#${r.device_id}` + }, }, { title: 'Статус', - dataIndex: 'status', key: 'status', - render: (s: string) => - s === 'success' ? ( - } color="success">Успешно - ) : ( - } color="error">Ошибка - ), + render: (_: unknown, r: TaskResult) => { + const s = deviceResults[r.device_id]?.status ?? r.status + if (s === 'success') return } color="success">Успешно + if (s === 'failed') return } color="error">Ошибка + return } color="processing">Ожидание + }, }, { title: 'Время', - dataIndex: 'duration_ms', key: 'duration_ms', - render: (ms: number | null) => (ms != null ? `${ms}ms` : '—'), + render: (_: unknown, r: TaskResult) => { + const ms = deviceResults[r.device_id]?.duration_ms ?? r.duration_ms + return ms != null ? `${ms}ms` : '—' + }, }, { title: 'Вывод', - dataIndex: 'stdout', - key: 'stdout', - render: (s: string | null) => s ? ( -
- - {s.slice(0, 120)}{s.length > 120 ? '…' : ''} - - {s.length > 120 && ( - setStdoutModal(s)} - /> - )} -
- ) : '—', + 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 + if (!text) return '—' + return ( +
+ + {text.slice(0, 120)}{text.length > 120 ? '…' : ''} + + {text.length > 120 && ( + setStdoutModal(text)} + /> + )} +
+ ) + }, }, ] - const mergedResults = (task?.device_results ?? []).map((r: TaskResult) => ({ - ...r, - ...(deviceResults[r.device_id] ?? {}), - })) - return (
{task && } - {scopeLabel} + + {task?.target_label ?? scopeLabel} + {task && dayjs(task.created_at).format('DD.MM.YYYY HH:mm:ss')} @@ -177,11 +234,11 @@ export function TaskDetailPage() { - {mergedResults.length > 0 && ( + {task?.device_results && task.device_results.length > 0 && (