Compare commits
2 commits
5f1bc35579
...
3de961b901
| Author | SHA1 | Date | |
|---|---|---|---|
| 3de961b901 | |||
| a6f8722ad3 |
9 changed files with 222 additions and 54 deletions
|
|
@ -15,13 +15,14 @@ class PingAction(BaseAction):
|
||||||
async def execute(self, device: Device, obj: Object, params: dict, emit: Emitter) -> ActionResult:
|
async def execute(self, device: Device, obj: Object, params: dict, emit: Emitter) -> ActionResult:
|
||||||
await emit(f"Pinging {device.ip}...", "info")
|
await emit(f"Pinging {device.ip}...", "info")
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
|
proc = None
|
||||||
try:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
"ping", "-c", "4", "-W", "2", device.ip,
|
"ping", "-c", "1", "-W", "2", device.ip,
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=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)
|
duration = int((time.monotonic() - start) * 1000)
|
||||||
success = proc.returncode == 0
|
success = proc.returncode == 0
|
||||||
msg = f"{device.ip}: {'reachable' if success else 'unreachable'} ({duration}ms)"
|
msg = f"{device.ip}: {'reachable' if success else 'unreachable'} ({duration}ms)"
|
||||||
|
|
@ -29,10 +30,17 @@ class PingAction(BaseAction):
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
success=success,
|
success=success,
|
||||||
stdout=stdout.decode(errors="replace").strip(),
|
stdout=stdout.decode(errors="replace").strip(),
|
||||||
|
stderr=stderr.decode(errors="replace").strip(),
|
||||||
exit_code=proc.returncode or 0,
|
exit_code=proc.returncode or 0,
|
||||||
data={"ping_ms": duration if success else None},
|
data={"ping_ms": duration if success else None},
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
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")
|
await emit(f"{device.ip}: ping timed out", "error")
|
||||||
return ActionResult(success=False, error="Timeout")
|
return ActionResult(success=False, error="Timeout")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ async def _resolve_labels(db: AsyncSession, tasks: list[Task]) -> dict[str, str]
|
||||||
obj_ids.add(scope["id"])
|
obj_ids.add(scope["id"])
|
||||||
elif kind == "device":
|
elif kind == "device":
|
||||||
dev_ids.add(scope["id"])
|
dev_ids.add(scope["id"])
|
||||||
|
elif kind == "device_list":
|
||||||
|
for did in scope.get("ids", []):
|
||||||
|
dev_ids.add(did)
|
||||||
elif kind == "rack":
|
elif kind == "rack":
|
||||||
rack_ids.add(scope["id"])
|
rack_ids.add(scope["id"])
|
||||||
elif kind == "zone":
|
elif kind == "zone":
|
||||||
|
|
@ -77,6 +80,11 @@ async def _resolve_labels(db: AsyncSession, tasks: list[Task]) -> dict[str, str]
|
||||||
elif kind == "zone":
|
elif kind == "zone":
|
||||||
_zid = scope.get('id')
|
_zid = scope.get('id')
|
||||||
labels[t.id] = f"Зона: {zone_names.get(_zid, f'#{_zid}')}"
|
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"):
|
elif kind in ("category", "role"):
|
||||||
val = scope.get("value", "?")
|
val = scope.get("value", "?")
|
||||||
if oid := scope.get("object_id"):
|
if oid := scope.get("object_id"):
|
||||||
|
|
@ -167,4 +175,17 @@ async def get_task(
|
||||||
labels = await _resolve_labels(db, [task])
|
labels = await _resolve_labels(db, [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
|
||||||
|
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
|
return result
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ class TaskEventRead(BaseModel):
|
||||||
class TaskResultRead(BaseModel):
|
class TaskResultRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
device_id: int
|
device_id: int
|
||||||
|
device_ip: Optional[str] = None
|
||||||
|
device_hostname: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
stdout: Optional[str] = None
|
stdout: Optional[str] = None
|
||||||
stderr: Optional[str] = None
|
stderr: Optional[str] = None
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ from .ssh_client import (
|
||||||
fetch_slave_config,
|
fetch_slave_config,
|
||||||
get_ip_neighbors_multi,
|
get_ip_neighbors_multi,
|
||||||
ssh_run_multi,
|
ssh_run_multi,
|
||||||
|
verify_is_mikrotik,
|
||||||
)
|
)
|
||||||
from .types import DeviceAction, DiscoveredDevice, DiscoveryResult, ProgressCallback
|
from .types import DeviceAction, DiscoveredDevice, DiscoveryResult, ProgressCallback
|
||||||
|
|
||||||
|
|
@ -811,7 +812,15 @@ async def _discover_mikrotik(
|
||||||
logger.info("[discovery] MikroTik: no IP found via SSH_CLIENT")
|
logger.info("[discovery] MikroTik: no IP found via SSH_CLIENT")
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info("[discovery] MikroTik found at %s", mikrotik_ip)
|
logger.info("[discovery] MikroTik candidate: %s — verifying...", mikrotik_ip)
|
||||||
|
await _emit("disc_action", {"message": f"Проверка MikroTik {mikrotik_ip} (WinBox/SSH)..."})
|
||||||
|
is_mikrotik = await verify_is_mikrotik(mikrotik_ip, ssh_port, ssh_options)
|
||||||
|
if not is_mikrotik:
|
||||||
|
logger.info("[discovery] %s — NOT a MikroTik (WinBox and SSH banner check failed), skipping", mikrotik_ip)
|
||||||
|
await _emit("disc_action", {"message": f"{mikrotik_ip} — не MikroTik, пропускаем"})
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("[discovery] MikroTik confirmed at %s", mikrotik_ip)
|
||||||
await _emit("disc_infra_found", {"type": "mikrotik", "ip": mikrotik_ip})
|
await _emit("disc_infra_found", {"type": "mikrotik", "ip": mikrotik_ip})
|
||||||
try:
|
try:
|
||||||
existing = (await db.execute(
|
existing = (await db.execute(
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,62 @@ async def open_ssh_tunnel(
|
||||||
raise last_exc
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
|
# ── MikroTik verification ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def verify_is_mikrotik(
|
||||||
|
host: str,
|
||||||
|
ssh_port: int = 22,
|
||||||
|
ssh_options: list | None = None,
|
||||||
|
timeout: float = 3.0,
|
||||||
|
) -> bool:
|
||||||
|
"""Check whether host is actually a MikroTik RouterOS device.
|
||||||
|
|
||||||
|
Two independent probes are tried (first positive result wins):
|
||||||
|
1. TCP connect to port 8291 (WinBox) — exclusively used by MikroTik.
|
||||||
|
2. SSH banner check — RouterOS advertises "ROSSSH" as its SSH version.
|
||||||
|
|
||||||
|
Returns True if either probe confirms MikroTik, False otherwise.
|
||||||
|
"""
|
||||||
|
# Probe 1: WinBox port 8291 (MikroTik-exclusive)
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(host, 8291), timeout=timeout
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
try:
|
||||||
|
await writer.wait_closed()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
logger.info("[discovery] %s — MikroTik confirmed via WinBox port 8291", host)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Probe 2: SSH banner
|
||||||
|
if ssh_options:
|
||||||
|
for opt in ssh_options:
|
||||||
|
try:
|
||||||
|
kwargs = _build_connect_kwargs(host, ssh_port, opt)
|
||||||
|
kwargs["connect_timeout"] = int(timeout)
|
||||||
|
async with asyncssh.connect(**kwargs) as conn:
|
||||||
|
banner = (conn.get_extra_info("server_version") or "").lower()
|
||||||
|
if "rosssh" in banner or "mikrotik" in banner:
|
||||||
|
logger.info(
|
||||||
|
"[discovery] %s — MikroTik confirmed via SSH banner: %s", host, banner
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
logger.info(
|
||||||
|
"[discovery] %s — SSH connected but banner does not match MikroTik: %s",
|
||||||
|
host, banner,
|
||||||
|
)
|
||||||
|
return False # Connected successfully — definitely not MikroTik
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info("[discovery] %s — could not verify MikroTik (no probe succeeded)", host)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ── Internal helpers ──────────────────────────────────────────────────────────
|
# ── Internal helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _parse_neigh_output(stdout: str) -> list[str]:
|
def _parse_neigh_output(stdout: str) -> list[str]:
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,7 @@ async def _run_on_device(
|
||||||
action_stdout = ""
|
action_stdout = ""
|
||||||
action_stderr = ""
|
action_stderr = ""
|
||||||
action_exit_code = -1
|
action_exit_code = -1
|
||||||
action_data = {}
|
action_data = {"error": str(exc)}
|
||||||
action_error = str(exc)
|
action_error = str(exc)
|
||||||
else:
|
else:
|
||||||
action_result_success = action_result.success
|
action_result_success = action_result.success
|
||||||
|
|
@ -163,7 +163,7 @@ async def _run_on_device(
|
||||||
tr.stdout = action_stdout
|
tr.stdout = action_stdout
|
||||||
tr.stderr = action_stderr
|
tr.stderr = action_stderr
|
||||||
tr.exit_code = action_exit_code
|
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
|
tr.duration_ms = duration_ms
|
||||||
|
|
||||||
# For ping action: update device status, last_seen, last_ping_ms
|
# For ping action: update device status, last_seen, last_ping_ms
|
||||||
|
|
@ -199,8 +199,11 @@ async def _run_on_device(
|
||||||
data={
|
data={
|
||||||
"device_id": device.id,
|
"device_id": device.id,
|
||||||
"device_ip": device.ip,
|
"device_ip": device.ip,
|
||||||
|
"device_hostname": device.hostname,
|
||||||
"status": "success" if action_result_success else "failed",
|
"status": "success" if action_result_success else "failed",
|
||||||
"duration_ms": duration_ms,
|
"duration_ms": duration_ms,
|
||||||
|
"stdout": action_stdout or None,
|
||||||
|
"stderr": action_stderr or None,
|
||||||
"error": action_error or None,
|
"error": action_error or None,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,13 @@ export interface TaskEvent {
|
||||||
export interface TaskResult {
|
export interface TaskResult {
|
||||||
id: number
|
id: number
|
||||||
device_id: number
|
device_id: number
|
||||||
|
device_ip: string | null
|
||||||
|
device_hostname: string | null
|
||||||
status: string
|
status: string
|
||||||
stdout: string | null
|
stdout: string | null
|
||||||
stderr: string | null
|
stderr: string | null
|
||||||
exit_code: number | null
|
exit_code: number | null
|
||||||
|
data: Record<string, unknown> | null
|
||||||
duration_ms: number | null
|
duration_ms: number | null
|
||||||
executed_at: string | null
|
executed_at: string | null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,16 @@ import { useAuthStore } from '../store/auth'
|
||||||
|
|
||||||
interface SSEHandlers {
|
interface SSEHandlers {
|
||||||
onTaskEvent?: (data: { message: string; level: string; ts: string }) => void
|
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
|
onTaskStatus?: (data: { status: string; total: number; ok: number; failed: number }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { Breadcrumb, Card, Col, Descriptions, Modal, Row, Spin, Table, Tag, Typography } from 'antd'
|
||||||
import dayjs from 'dayjs'
|
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 { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useTask, type TaskResult } from '../api/tasks'
|
import { useTask, type TaskResult } from '../api/tasks'
|
||||||
import { TaskStatusBadge } from '../components/StatusBadge'
|
import { TaskStatusBadge } from '../components/StatusBadge'
|
||||||
|
|
@ -13,34 +13,76 @@ interface LogLine {
|
||||||
level: string
|
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<string, string> = {
|
const LEVEL_COLOR: Record<string, string> = {
|
||||||
info: '#52c41a',
|
info: '#52c41a',
|
||||||
warn: '#faad14',
|
warn: '#faad14',
|
||||||
error: '#ff4d4f',
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
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 [log, setLog] = useState<LogLine[]>([])
|
const [log, setLog] = useState<LogLine[]>([])
|
||||||
const [deviceResults, setDeviceResults] = useState<Record<number, { status: string; duration_ms: number }>>({})
|
const [deviceResults, setDeviceResults] = 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)
|
||||||
|
|
||||||
// Populate log from persisted events on load
|
// Deduplication key set for log lines (ts + message)
|
||||||
|
const seenLogKeys = useRef(new Set<string>())
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (task?.events?.length) {
|
if (!task?.events?.length) return
|
||||||
setLog(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?.id]) // only on initial load
|
|
||||||
|
|
||||||
// Live SSE updates
|
// Live SSE updates
|
||||||
useSSE(id, {
|
useSSE(id, {
|
||||||
onTaskEvent: (data) =>
|
onTaskEvent: (data) => addLogLines([{ ts: data.ts, message: data.message, level: data.level }]),
|
||||||
setLog((prev) => [...prev, { ts: data.ts, message: data.message, level: data.level }]),
|
|
||||||
onTaskResult: (data) =>
|
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
|
// Auto-scroll log
|
||||||
|
|
@ -55,60 +97,73 @@ export function TaskDetailPage() {
|
||||||
if (!s) return '—'
|
if (!s) return '—'
|
||||||
if (s.type === 'device') return `Устройство #${s.id}`
|
if (s.type === 'device') return `Устройство #${s.id}`
|
||||||
if (s.type === 'object') return `Объект #${s.id}`
|
if (s.type === 'object') return `Объект #${s.id}`
|
||||||
if (s.type === 'category') return `Категория: ${s.value}`
|
if (s.type === 'category') return `Категория: ${s.value ?? s.category}`
|
||||||
if (s.type === 'role') return `Роль: ${s.value}`
|
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)
|
return JSON.stringify(s)
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const resultColumns = [
|
const resultColumns = [
|
||||||
{
|
{
|
||||||
title: 'Устройство',
|
title: 'Устройство',
|
||||||
dataIndex: 'device_id',
|
key: 'device',
|
||||||
key: 'device_id',
|
render: (_: unknown, r: TaskResult) => {
|
||||||
render: (id: number) => `#${id}`,
|
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: 'Статус',
|
title: 'Статус',
|
||||||
dataIndex: 'status',
|
|
||||||
key: 'status',
|
key: 'status',
|
||||||
render: (s: string) =>
|
render: (_: unknown, r: TaskResult) => {
|
||||||
s === 'success' ? (
|
const s = deviceResults[r.device_id]?.status ?? r.status
|
||||||
<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>
|
||||||
<Tag icon={<CloseCircleOutlined />} color="error">Ошибка</Tag>
|
return <Tag icon={<LoadingOutlined />} color="processing">Ожидание</Tag>
|
||||||
),
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Время',
|
title: 'Время',
|
||||||
dataIndex: 'duration_ms',
|
|
||||||
key: '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: 'Вывод',
|
title: 'Вывод',
|
||||||
dataIndex: 'stdout',
|
key: 'output',
|
||||||
key: 'stdout',
|
render: (_: unknown, r: TaskResult) => {
|
||||||
render: (s: string | null) => s ? (
|
const live = deviceResults[r.device_id]
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
|
const merged = live ? { ...r, ...live } : r
|
||||||
<code style={{ fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all', flex: 1 }}>
|
const text =
|
||||||
{s.slice(0, 120)}{s.length > 120 ? '…' : ''}
|
merged.stdout ||
|
||||||
</code>
|
merged.stderr ||
|
||||||
{s.length > 120 && (
|
(merged as any).error ||
|
||||||
<ExpandAltOutlined
|
(r.data as any)?.error ||
|
||||||
style={{ color: '#1677ff', cursor: 'pointer', flexShrink: 0, marginTop: 2 }}
|
null
|
||||||
onClick={() => setStdoutModal(s)}
|
if (!text) return '—'
|
||||||
/>
|
return (
|
||||||
)}
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
|
||||||
</div>
|
<code style={{ fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all', flex: 1 }}>
|
||||||
) : '—',
|
{text.slice(0, 120)}{text.length > 120 ? '…' : ''}
|
||||||
|
</code>
|
||||||
|
{text.length > 120 && (
|
||||||
|
<ExpandAltOutlined
|
||||||
|
style={{ color: '#1677ff', cursor: 'pointer', flexShrink: 0, marginTop: 2 }}
|
||||||
|
onClick={() => setStdoutModal(text)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const mergedResults = (task?.device_results ?? []).map((r: TaskResult) => ({
|
|
||||||
...r,
|
|
||||||
...(deviceResults[r.device_id] ?? {}),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Breadcrumb
|
<Breadcrumb
|
||||||
|
|
@ -126,7 +181,9 @@ export function TaskDetailPage() {
|
||||||
<Descriptions.Item label="Статус">
|
<Descriptions.Item label="Статус">
|
||||||
{task && <TaskStatusBadge status={task.status} />}
|
{task && <TaskStatusBadge status={task.status} />}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="Цель">{scopeLabel}</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>
|
||||||
|
|
@ -177,11 +234,11 @@ export function TaskDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{mergedResults.length > 0 && (
|
{task?.device_results && task.device_results.length > 0 && (
|
||||||
<Col span={24}>
|
<Col span={24}>
|
||||||
<Card title="Результаты по устройствам" size="small">
|
<Card title="Результаты по устройствам" size="small">
|
||||||
<Table
|
<Table
|
||||||
dataSource={mergedResults}
|
dataSource={task.device_results}
|
||||||
columns={resultColumns}
|
columns={resultColumns}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue