Compare commits
No commits in common. "3de961b901c621803c3b190e84593d7bad21cd9f" and "5f1bc35579aa6c0287ec99aa12c6e06a056be6df" have entirely different histories.
3de961b901
...
5f1bc35579
9 changed files with 54 additions and 222 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue