фикс задач

This commit is contained in:
dv 2026-04-14 10:20:39 +03:00
parent a6f8722ad3
commit 3de961b901
7 changed files with 156 additions and 53 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, unknown> | null
duration_ms: number | null
executed_at: string | null
}

View file

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

View file

@ -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<string, string> = {
info: '#52c41a',
warn: '#faad14',
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() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const { data: task, isLoading } = useTask(id)
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 [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(() => {
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' ? (
<Tag icon={<CheckCircleOutlined />} color="success">Успешно</Tag>
) : (
<Tag icon={<CloseCircleOutlined />} color="error">Ошибка</Tag>
),
render: (_: unknown, r: TaskResult) => {
const s = deviceResults[r.device_id]?.status ?? r.status
if (s === 'success') return <Tag icon={<CheckCircleOutlined />} color="success">Успешно</Tag>
if (s === 'failed') return <Tag icon={<CloseCircleOutlined />} color="error">Ошибка</Tag>
return <Tag icon={<LoadingOutlined />} color="processing">Ожидание</Tag>
},
},
{
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 ? (
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 (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
<code style={{ fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all', flex: 1 }}>
{s.slice(0, 120)}{s.length > 120 ? '…' : ''}
{text.slice(0, 120)}{text.length > 120 ? '…' : ''}
</code>
{s.length > 120 && (
{text.length > 120 && (
<ExpandAltOutlined
style={{ color: '#1677ff', cursor: 'pointer', flexShrink: 0, marginTop: 2 }}
onClick={() => setStdoutModal(s)}
onClick={() => setStdoutModal(text)}
/>
)}
</div>
) : '—',
)
},
},
]
const mergedResults = (task?.device_results ?? []).map((r: TaskResult) => ({
...r,
...(deviceResults[r.device_id] ?? {}),
}))
return (
<div>
<Breadcrumb
@ -126,7 +181,9 @@ export function TaskDetailPage() {
<Descriptions.Item label="Статус">
{task && <TaskStatusBadge status={task.status} />}
</Descriptions.Item>
<Descriptions.Item label="Цель">{scopeLabel}</Descriptions.Item>
<Descriptions.Item label="Цель">
{task?.target_label ?? scopeLabel}
</Descriptions.Item>
<Descriptions.Item label="Создана">
{task && dayjs(task.created_at).format('DD.MM.YYYY HH:mm:ss')}
</Descriptions.Item>
@ -177,11 +234,11 @@ export function TaskDetailPage() {
</Card>
</Col>
{mergedResults.length > 0 && (
{task?.device_results && task.device_results.length > 0 && (
<Col span={24}>
<Card title="Результаты по устройствам" size="small">
<Table
dataSource={mergedResults}
dataSource={task.device_results}
columns={resultColumns}
rowKey="id"
size="small"