Compare commits

..

No commits in common. "3de961b901c621803c3b190e84593d7bad21cd9f" and "5f1bc35579aa6c0287ec99aa12c6e06a056be6df" have entirely different histories.

9 changed files with 54 additions and 222 deletions

View file

@ -15,14 +15,13 @@ 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", "1", "-W", "2", device.ip,
"ping", "-c", "4", "-W", "2", device.ip,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5)
duration = int((time.monotonic() - start) * 1000)
success = proc.returncode == 0
msg = f"{device.ip}: {'reachable' if success else 'unreachable'} ({duration}ms)"
@ -30,17 +29,10 @@ 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,9 +33,6 @@ 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":
@ -80,11 +77,6 @@ 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"):
@ -175,17 +167,4 @@ 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,8 +22,6 @@ 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

@ -32,7 +32,6 @@ from .ssh_client import (
fetch_slave_config,
get_ip_neighbors_multi,
ssh_run_multi,
verify_is_mikrotik,
)
from .types import DeviceAction, DiscoveredDevice, DiscoveryResult, ProgressCallback
@ -812,15 +811,7 @@ async def _discover_mikrotik(
logger.info("[discovery] MikroTik: no IP found via SSH_CLIENT")
return
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)
logger.info("[discovery] MikroTik found at %s", mikrotik_ip)
await _emit("disc_infra_found", {"type": "mikrotik", "ip": mikrotik_ip})
try:
existing = (await db.execute(

View file

@ -177,62 +177,6 @@ async def open_ssh_tunnel(
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 ──────────────────────────────────────────────────────────
def _parse_neigh_output(stdout: str) -> list[str]:

View file

@ -142,7 +142,7 @@ async def _run_on_device(
action_stdout = ""
action_stderr = ""
action_exit_code = -1
action_data = {"error": str(exc)}
action_data = {}
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 {}), "error": action_error} if action_error else (action_data or None)
tr.data = action_data or None
tr.duration_ms = duration_ms
# For ping action: update device status, last_seen, last_ping_ms
@ -199,11 +199,8 @@ 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,13 +11,10 @@ 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,16 +4,7 @@ import { useAuthStore } from '../store/auth'
interface SSEHandlers {
onTaskEvent?: (data: { message: string; level: string; ts: string }) => 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
onTaskResult?: (data: { device_id: number; device_ip: string; status: string; duration_ms: number }) => void
onTaskStatus?: (data: { status: string; total: number; ok: number; failed: number }) => void
}

View file

@ -1,7 +1,7 @@
import { ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined, ExpandAltOutlined, LoadingOutlined } from '@ant-design/icons'
import { ArrowLeftOutlined, CheckCircleOutlined, CloseCircleOutlined, ExpandAltOutlined } from '@ant-design/icons'
import { Breadcrumb, Card, Col, Descriptions, Modal, Row, Spin, Table, Tag, Typography } from 'antd'
import dayjs from 'dayjs'
import { useCallback, useEffect, useRef, useState } from 'react'
import { 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,76 +13,34 @@ 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, DeviceResultState>>({})
const [deviceResults, setDeviceResults] = useState<Record<number, { status: string; duration_ms: number }>>({})
const logEndRef = useRef<HTMLDivElement>(null)
const [stdoutModal, setStdoutModal] = useState<string | null>(null)
// 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)
// Populate log from persisted events on load
useEffect(() => {
if (!task?.events?.length) return
addLogLines(task.events.map((e) => ({ ts: e.created_at, message: e.message, level: e.level })))
}, [task?.events, addLogLines])
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
// Live SSE updates
useSSE(id, {
onTaskEvent: (data) => addLogLines([{ ts: data.ts, message: data.message, level: data.level }]),
onTaskEvent: (data) =>
setLog((prev) => [...prev, { 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,
stdout: data.stdout ?? null,
stderr: data.stderr ?? null,
error: data.error ?? null,
device_ip: data.device_ip ?? null,
device_hostname: data.device_hostname ?? null,
},
})),
setDeviceResults((prev) => ({ ...prev, [data.device_id]: { status: data.status, duration_ms: data.duration_ms } })),
})
// Auto-scroll log
@ -97,73 +55,60 @@ 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 ?? 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 'Фильтр'
if (s.type === 'category') return `Категория: ${s.value}`
if (s.type === 'role') return `Роль: ${s.value}`
return JSON.stringify(s)
})()
const resultColumns = [
{
title: 'Устройство',
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}`
},
dataIndex: 'device_id',
key: 'device_id',
render: (id: number) => `#${id}`,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
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>
},
render: (s: string) =>
s === 'success' ? (
<Tag icon={<CheckCircleOutlined />} color="success">Успешно</Tag>
) : (
<Tag icon={<CloseCircleOutlined />} color="error">Ошибка</Tag>
),
},
{
title: 'Время',
dataIndex: 'duration_ms',
key: 'duration_ms',
render: (_: unknown, r: TaskResult) => {
const ms = deviceResults[r.device_id]?.duration_ms ?? r.duration_ms
return ms != null ? `${ms}ms` : '—'
},
render: (ms: number | null) => (ms != null ? `${ms}ms` : '—'),
},
{
title: 'Вывод',
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 (
dataIndex: 'stdout',
key: 'stdout',
render: (s: string | null) => s ? (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4 }}>
<code style={{ fontSize: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-all', flex: 1 }}>
{text.slice(0, 120)}{text.length > 120 ? '…' : ''}
{s.slice(0, 120)}{s.length > 120 ? '…' : ''}
</code>
{text.length > 120 && (
{s.length > 120 && (
<ExpandAltOutlined
style={{ color: '#1677ff', cursor: 'pointer', flexShrink: 0, marginTop: 2 }}
onClick={() => setStdoutModal(text)}
onClick={() => setStdoutModal(s)}
/>
)}
</div>
)
},
) : '—',
},
]
const mergedResults = (task?.device_results ?? []).map((r: TaskResult) => ({
...r,
...(deviceResults[r.device_id] ?? {}),
}))
return (
<div>
<Breadcrumb
@ -181,9 +126,7 @@ export function TaskDetailPage() {
<Descriptions.Item label="Статус">
{task && <TaskStatusBadge status={task.status} />}
</Descriptions.Item>
<Descriptions.Item label="Цель">
{task?.target_label ?? scopeLabel}
</Descriptions.Item>
<Descriptions.Item label="Цель">{scopeLabel}</Descriptions.Item>
<Descriptions.Item label="Создана">
{task && dayjs(task.created_at).format('DD.MM.YYYY HH:mm:ss')}
</Descriptions.Item>
@ -234,11 +177,11 @@ export function TaskDetailPage() {
</Card>
</Col>
{task?.device_results && task.device_results.length > 0 && (
{mergedResults.length > 0 && (
<Col span={24}>
<Card title="Результаты по устройствам" size="small">
<Table
dataSource={task.device_results}
dataSource={mergedResults}
columns={resultColumns}
rowKey="id"
size="small"