Compare commits

...

2 commits

Author SHA1 Message Date
dv
3de961b901 фикс задач 2026-04-14 10:20:39 +03:00
dv
a6f8722ad3 +проверка микротика 2026-04-13 16:38:19 +03:00
9 changed files with 222 additions and 54 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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 { 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"