feat(watch): 监测槽 SSH 一键安装 psutil
探针报 psutil missing 时显示安装按钮;POST install-psutil 经 SSH pip/apt 安装并审计。
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# 审计 — 监测槽 SSH 安装 psutil
|
||||
|
||||
**日期**:2026-06-12
|
||||
**Changelog**: `docs/changelog/2026-06-12-watch-install-psutil-ssh.md`
|
||||
|
||||
## 范围
|
||||
|
||||
| 文件 |
|
||||
|------|
|
||||
| `server/utils/psutil_install.py` |
|
||||
| `server/application/services/watch_service.py` |
|
||||
| `server/api/watch.py` |
|
||||
| `frontend/src/components/watch/WatchSlotCard.vue` |
|
||||
| `frontend/src/components/watch/WatchSlotRow.vue` |
|
||||
| `frontend/src/utils/watchFormat.ts` |
|
||||
| `server/utils/watch_probe_errors.py` |
|
||||
| `tests/test_psutil_install.py` |
|
||||
| `tests/test_watch_pins.py` |
|
||||
|
||||
## Step 3
|
||||
|
||||
| H | 结论 |
|
||||
|---|------|
|
||||
| H1 鉴权 | PASS — 须 JWT;仅已 Pin 的服务器可安装 |
|
||||
| H2 吞错 | PASS — SSH/安装失败 422 + snackbar |
|
||||
| H4 审计 | PASS — `watch_install_psutil` |
|
||||
|
||||
## Closure
|
||||
|
||||
H1–H4 PASS
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] pytest test_psutil_install + test_watch_pins
|
||||
- [x] npm run type-check
|
||||
- [ ] 生产浏览器:psutil 错误时出现按钮且安装后探针恢复
|
||||
@@ -0,0 +1,40 @@
|
||||
# Changelog — 监测槽一键 SSH 安装 psutil
|
||||
|
||||
**日期**:2026-06-12
|
||||
|
||||
## 摘要
|
||||
|
||||
监测槽探针报「子机未安装 psutil」时,卡片内显示 **「安装 psutil(SSH)」** 按钮,通过 SSH 在子机系统 Python 安装 psutil(pip --user → apt/yum → pip)。
|
||||
|
||||
## 动机
|
||||
|
||||
SSH 监测探针依赖系统 `python3` 的 psutil;Agent venv 内的 psutil 无法被探针脚本使用。手动 SSH 安装成本高。
|
||||
|
||||
## 变更
|
||||
|
||||
- 后端:`POST /api/watch/servers/{server_id}/install-psutil`(须已 Pin 该服务器)
|
||||
- `server/utils/psutil_install.py`:远程安装脚本 + 验证
|
||||
- 前端:`WatchSlotCard` 在 psutil 错误时显示按钮;`WatchSlotRow` 调用 API 并 refresh
|
||||
- 审计:`watch_install_psutil`
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `server/utils/psutil_install.py`(新)
|
||||
- `server/application/services/watch_service.py`
|
||||
- `server/api/watch.py`
|
||||
- `frontend/src/components/watch/WatchSlotCard.vue`
|
||||
- `frontend/src/components/watch/WatchSlotRow.vue`
|
||||
- `frontend/src/utils/watchFormat.ts`
|
||||
- `tests/test_psutil_install.py`(新)
|
||||
- `tests/test_watch_pins.py`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无需迁移;需重启 API + 前端构建
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_psutil_install.py tests/test_watch_pins.py -q
|
||||
cd frontend && npm run type-check
|
||||
```
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { WatchSlot } from '@/composables/useWatchPins'
|
||||
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError } from '@/utils/watchFormat'
|
||||
import { formatBytesPerSec, formatRemaining, probeStatusColor, probeStatusLabel, formatProbeError, isPsutilMissingError } from '@/utils/watchFormat'
|
||||
import WatchSparkline from '@/components/watch/WatchSparkline.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
slot: WatchSlot
|
||||
psutilInstallLoading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -15,10 +16,14 @@ const emit = defineEmits<{
|
||||
pick: [slotIndex: number]
|
||||
monitoring: [slotIndex: number, enabled: boolean]
|
||||
ttl: [slotIndex: number, hours: 8 | 24]
|
||||
installPsutil: [slotIndex: number]
|
||||
}>()
|
||||
|
||||
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
|
||||
const slotTtl = computed(() => (props.slot.ttl_hours === 24 ? 24 : 8) as 8 | 24)
|
||||
const showPsutilInstall = computed(() =>
|
||||
monitoringOn.value && isPsutilMissingError(props.slot.metrics?.error, props.slot.metrics?.probe_status),
|
||||
)
|
||||
|
||||
const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
|
||||
|
||||
@@ -119,10 +124,23 @@ function pct(v: number | null | undefined) {
|
||||
|
||||
<div
|
||||
v-if="slot.metrics?.error"
|
||||
class="text-caption text-error mt-1 text-truncate"
|
||||
class="text-caption text-error mt-1"
|
||||
:title="formatProbeError(slot.metrics.error, slot.metrics.probe_status)"
|
||||
>
|
||||
{{ formatProbeError(slot.metrics.error, slot.metrics.probe_status) }}
|
||||
<div class="text-truncate">
|
||||
{{ formatProbeError(slot.metrics.error, slot.metrics.probe_status) }}
|
||||
</div>
|
||||
<v-btn
|
||||
v-if="showPsutilInstall"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
class="mt-1"
|
||||
:loading="props.psutilInstallLoading"
|
||||
@click.stop="emit('installPsutil', slot.slot_index)"
|
||||
>
|
||||
安装 psutil(SSH)
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mt-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import WatchSlotCard from '@/components/watch/WatchSlotCard.vue'
|
||||
import WatchProcessDrawer from '@/components/watch/WatchProcessDrawer.vue'
|
||||
@@ -7,6 +7,7 @@ import WatchSlotPickDialog from '@/components/watch/WatchSlotPickDialog.vue'
|
||||
import { useWatchPins, type WatchSlot } from '@/composables/useWatchPins'
|
||||
import { useSnackbar } from '@/composables/useSnackbar'
|
||||
import { formatApiError } from '@/utils/apiError'
|
||||
import { http } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const snackbar = useSnackbar()
|
||||
@@ -17,6 +18,7 @@ const processSlot = ref<WatchSlot | null>(null)
|
||||
const showPickDialog = ref(false)
|
||||
const pickSlotIndex = ref<number | null>(null)
|
||||
const pickLoading = ref(false)
|
||||
const psutilLoadingSlot = ref<number | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
refreshPins()
|
||||
@@ -71,6 +73,24 @@ function onPickSlot(slotIndex: number) {
|
||||
showPickDialog.value = true
|
||||
}
|
||||
|
||||
async function onInstallPsutil(slotIndex: number) {
|
||||
const slot = slots.value.find((s) => s.slot_index === slotIndex)
|
||||
if (!slot?.server_id) return
|
||||
psutilLoadingSlot.value = slotIndex
|
||||
try {
|
||||
const data = await http.post<{ message?: string }>(
|
||||
`/watch/servers/${slot.server_id}/install-psutil`,
|
||||
{},
|
||||
)
|
||||
snackbar(data.message || 'psutil 安装成功', 'success')
|
||||
await refreshPins()
|
||||
} catch (e: unknown) {
|
||||
snackbar(formatApiError(e, '安装 psutil 失败'), 'error')
|
||||
} finally {
|
||||
psutilLoadingSlot.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function onPickServer(serverId: number) {
|
||||
if (pickSlotIndex.value == null) return
|
||||
pickLoading.value = true
|
||||
@@ -105,12 +125,14 @@ async function onPickServer(serverId: number) {
|
||||
<v-col v-for="slot in slots" :key="slot.slot_index" cols="12" sm="6" lg="3">
|
||||
<WatchSlotCard
|
||||
:slot="slot"
|
||||
:psutil-install-loading="psutilLoadingSlot === slot.slot_index"
|
||||
@remove="onRemove"
|
||||
@processes="openProcesses"
|
||||
@history="openHistory"
|
||||
@pick="onPickSlot"
|
||||
@monitoring="onMonitoring"
|
||||
@ttl="onTtl"
|
||||
@install-psutil="onInstallPsutil"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -27,7 +27,7 @@ const PROBE_STATUS_LABELS: Record<string, string> = {
|
||||
|
||||
const PROBE_ERROR_EXACT: Record<string, string> = {
|
||||
parse_error: '探针返回数据无法解析',
|
||||
'psutil missing': '子机未安装 psutil(请执行 pip install psutil)',
|
||||
'psutil missing': '子机未安装 psutil',
|
||||
ssh_probe_failed: 'SSH 探针脚本执行失败',
|
||||
'SSH watch probe failed': 'SSH 监测探针失败',
|
||||
'SSH probe failed': 'SSH 探针失败',
|
||||
@@ -94,6 +94,17 @@ export function formatProbeError(
|
||||
return `探针异常:${text}`
|
||||
}
|
||||
|
||||
/** True when probe error indicates missing psutil on the remote host. */
|
||||
export function isPsutilMissingError(
|
||||
error: string | null | undefined,
|
||||
status?: string | null,
|
||||
): boolean {
|
||||
const text = (error || '').toLowerCase()
|
||||
if (text.includes('psutil missing')) return true
|
||||
if (status?.toLowerCase().includes('parse_error') && text.includes('psutil')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function probeStatusColor(status: string | null | undefined): string {
|
||||
if (!status || status === 'ok') return 'success'
|
||||
if (status.includes('timeout') || status.includes('auth')) return 'warning'
|
||||
|
||||
@@ -211,6 +211,25 @@ async def get_watch_processes(
|
||||
return await svc.get_processes(server_id)
|
||||
|
||||
|
||||
@router.post("/servers/{server_id}/install-psutil")
|
||||
async def install_watch_psutil(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Install psutil on the pinned server via SSH (for watch probe metrics)."""
|
||||
svc = WatchService(db)
|
||||
try:
|
||||
return await svc.install_psutil_ssh(
|
||||
admin=admin,
|
||||
server_id=server_id,
|
||||
ip_address=_client_ip(request),
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
|
||||
|
||||
def _parse_server_ids(server_ids: str) -> list[int]:
|
||||
ids: list[int] = []
|
||||
for part in server_ids.split(","):
|
||||
|
||||
@@ -420,6 +420,45 @@ class WatchService:
|
||||
processes = await _load_processes(server_id)
|
||||
return {"server_id": server_id, "processes": processes or []}
|
||||
|
||||
async def install_psutil_ssh(
|
||||
self,
|
||||
*,
|
||||
admin: Admin,
|
||||
server_id: int,
|
||||
ip_address: str = "",
|
||||
) -> dict[str, Any]:
|
||||
pinned = await self.pinned_server_map(admin.id)
|
||||
if server_id not in pinned:
|
||||
raise ValueError("该服务器不在当前监测槽中")
|
||||
server = await self.server_repo.get_by_id(server_id)
|
||||
if not server:
|
||||
raise ValueError("服务器不存在")
|
||||
|
||||
from server.utils.psutil_install import install_psutil_via_ssh
|
||||
|
||||
try:
|
||||
result = await install_psutil_via_ssh(server)
|
||||
except Exception as e:
|
||||
raise ValueError(f"SSH 连接失败: {e}") from e
|
||||
|
||||
if not result.get("success"):
|
||||
msg = str(result.get("message") or "psutil 安装失败")
|
||||
raise ValueError(msg)
|
||||
|
||||
await self._audit(
|
||||
admin=admin,
|
||||
action="watch_install_psutil",
|
||||
server=server,
|
||||
detail=f"SSH 安装 psutil · {server.name or server_id}",
|
||||
ip_address=ip_address,
|
||||
)
|
||||
await self.db.commit()
|
||||
return {
|
||||
"success": True,
|
||||
"server_id": server_id,
|
||||
"message": result.get("message") or "psutil 已就绪",
|
||||
}
|
||||
|
||||
async def _audit(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Install psutil on managed servers via SSH (system Python for watch probe)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from server.application.server_batch_common import sudo_wrap
|
||||
from server.domain.models import Server
|
||||
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
|
||||
|
||||
# Try --user pip, then distro packages, then system pip. Verify with same python3 as watch probe.
|
||||
PSUTIL_INSTALL_SHELL = r"""
|
||||
set -e
|
||||
verify_psutil() {
|
||||
for py in python3 python3.12 python3.11 python3.10; do
|
||||
if command -v "$py" >/dev/null 2>&1 && "$py" -c "import psutil" 2>/dev/null; then
|
||||
echo psutil_ok
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
verify_psutil || true
|
||||
|
||||
for py in python3 python3.12 python3.11 python3.10; do
|
||||
command -v "$py" >/dev/null 2>&1 || continue
|
||||
if "$py" -m pip install --user -q psutil 2>/dev/null; then
|
||||
verify_psutil && exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq python3-psutil \
|
||||
&& verify_psutil && exit 0
|
||||
fi
|
||||
if command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y -q python3-psutil && verify_psutil && exit 0
|
||||
fi
|
||||
if command -v yum >/dev/null 2>&1; then
|
||||
yum install -y -q python3-psutil && verify_psutil && exit 0
|
||||
fi
|
||||
|
||||
for py in python3 python3.12 python3.11 python3.10; do
|
||||
command -v "$py" >/dev/null 2>&1 || continue
|
||||
if "$py" -m pip install -q psutil 2>/dev/null; then
|
||||
verify_psutil && exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "ERROR: psutil install failed — pip/apt unavailable or permission denied" >&2
|
||||
exit 1
|
||||
"""
|
||||
|
||||
|
||||
def build_psutil_install_cmd(*, ssh_user: str) -> str:
|
||||
return sudo_wrap(PSUTIL_INSTALL_SHELL.strip(), ssh_user)
|
||||
|
||||
|
||||
async def install_psutil_via_ssh(server: Server, *, timeout: int = 90) -> dict[str, str | int]:
|
||||
"""Run remote install; returns {exit_code, stdout, stderr, already_installed?}."""
|
||||
ssh_user = (server.username or "root").strip()
|
||||
cmd = build_psutil_install_cmd(ssh_user=ssh_user)
|
||||
result = await exec_ssh_command(server, cmd, timeout=timeout)
|
||||
stdout = (result.get("stdout") or "").strip()
|
||||
stderr = (result.get("stderr") or "").strip()
|
||||
raw_exit = result.get("exit_code")
|
||||
exit_code = int(raw_exit) if raw_exit is not None else 1
|
||||
if exit_code == 0 and "psutil_ok" in stdout:
|
||||
return {
|
||||
"success": True,
|
||||
"exit_code": 0,
|
||||
"stdout": stdout[:2000],
|
||||
"stderr": stderr[:500],
|
||||
"message": "psutil 已就绪",
|
||||
}
|
||||
detail = stderr or stdout or f"exit {exit_code}"
|
||||
return {
|
||||
"success": False,
|
||||
"exit_code": exit_code,
|
||||
"stdout": stdout[:2000],
|
||||
"stderr": stderr[:500],
|
||||
"message": detail[:300],
|
||||
}
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
_EXACT: dict[str, str] = {
|
||||
"parse_error": "探针返回数据无法解析",
|
||||
"psutil missing": "子机未安装 psutil(请执行 pip install psutil)",
|
||||
"psutil missing": "子机未安装 psutil",
|
||||
"ssh_probe_failed": "SSH 探针脚本执行失败",
|
||||
"SSH watch probe failed": "SSH 监测探针失败",
|
||||
"SSH probe failed": "SSH 探针失败",
|
||||
@@ -50,7 +50,7 @@ def watch_probe_error_zh(error: str | None, *, status: str | None = None) -> str
|
||||
if "permission denied" in lower or "authentication failed" in lower:
|
||||
return f"SSH 认证失败:{text}"
|
||||
if "psutil missing" in lower:
|
||||
return "子机未安装 psutil(请执行 pip install psutil)"
|
||||
return "子机未安装 psutil"
|
||||
if "ssh_probe_failed" in lower or "ssh watch probe failed" in lower:
|
||||
return "SSH 探针脚本执行失败"
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for SSH psutil install helper."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from server.utils.psutil_install import build_psutil_install_cmd, install_psutil_via_ssh
|
||||
|
||||
|
||||
def test_build_psutil_install_cmd_root():
|
||||
cmd = build_psutil_install_cmd(ssh_user="root")
|
||||
assert "pip install --user -q psutil" in cmd
|
||||
assert "python3-psutil" in cmd
|
||||
assert "verify_psutil" in cmd
|
||||
|
||||
|
||||
def test_build_psutil_install_cmd_non_root_wraps_sudo():
|
||||
cmd = build_psutil_install_cmd(ssh_user="deploy")
|
||||
assert "sudoers.d/nexus-agent" in cmd
|
||||
assert "pip install --user -q psutil" in cmd
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_psutil_via_ssh_success():
|
||||
server = type("S", (), {"username": "root", "domain": "h.example.com", "port": 22})()
|
||||
with patch(
|
||||
"server.utils.psutil_install.exec_ssh_command",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"exit_code": 0, "stdout": "psutil_ok\n", "stderr": ""},
|
||||
):
|
||||
result = await install_psutil_via_ssh(server)
|
||||
assert result["success"] is True
|
||||
assert result["message"] == "psutil 已就绪"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_psutil_via_ssh_failure():
|
||||
server = type("S", (), {"username": "root", "domain": "h.example.com", "port": 22})()
|
||||
with patch(
|
||||
"server.utils.psutil_install.exec_ssh_command",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"exit_code": 1, "stdout": "", "stderr": "permission denied"},
|
||||
):
|
||||
result = await install_psutil_via_ssh(server)
|
||||
assert result["success"] is False
|
||||
assert "permission denied" in result["message"]
|
||||
@@ -206,6 +206,46 @@ async def test_watch_pin_ttl_expiry_pin_server_no_duplicate(db_session, test_adm
|
||||
assert result["slots"][0]["monitoring_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watch_install_psutil_requires_pin(db_session, test_admin, mock_watch_redis):
|
||||
from server.application.services.watch_service import WatchService
|
||||
|
||||
server = Server(name="watch-psutil", domain="wp.example.com", port=22, agent_version="1.0")
|
||||
db_session.add(server)
|
||||
await db_session.commit()
|
||||
|
||||
svc = WatchService(db_session)
|
||||
admin = test_admin["admin"]
|
||||
|
||||
with pytest.raises(ValueError, match="不在当前监测槽"):
|
||||
await svc.install_psutil_ssh(admin=admin, server_id=server.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watch_install_psutil_success(db_session, test_admin, mock_watch_redis, monkeypatch):
|
||||
from server.application.services.watch_service import WatchService
|
||||
|
||||
server = Server(name="watch-psutil2", domain="wp2.example.com", port=22, agent_version="1.0")
|
||||
db_session.add(server)
|
||||
await db_session.commit()
|
||||
|
||||
svc = WatchService(db_session)
|
||||
admin = test_admin["admin"]
|
||||
await svc.pin_server(admin=admin, server_id=server.id)
|
||||
|
||||
async def _fake_install(_server):
|
||||
return {"success": True, "message": "psutil 已就绪"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"server.utils.psutil_install.install_psutil_via_ssh",
|
||||
_fake_install,
|
||||
)
|
||||
|
||||
result = await svc.install_psutil_ssh(admin=admin, server_id=server.id)
|
||||
assert result["success"] is True
|
||||
assert result["message"] == "psutil 已就绪"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_watch_pin_ttl_expiry_pauses_not_deletes(db_session, test_admin, mock_watch_redis):
|
||||
from datetime import timedelta
|
||||
|
||||
@@ -5,7 +5,7 @@ from server.utils.watch_probe_errors import watch_probe_error_zh
|
||||
|
||||
def test_parse_error_zh():
|
||||
assert watch_probe_error_zh("parse_error") == "探针返回数据无法解析"
|
||||
assert watch_probe_error_zh("psutil missing") == "子机未安装 psutil(请执行 pip install psutil)"
|
||||
assert watch_probe_error_zh("psutil missing") == "子机未安装 psutil"
|
||||
|
||||
|
||||
def test_parse_error_with_detail():
|
||||
|
||||
Reference in New Issue
Block a user