feat(watch): 监测槽 SSH 一键安装 psutil

探针报 psutil missing 时显示安装按钮;POST install-psutil 经 SSH pip/apt 安装并审计。
This commit is contained in:
Nexus Agent
2026-06-12 04:04:48 +08:00
parent 32a1885f0d
commit 75efd506f5
12 changed files with 360 additions and 8 deletions
@@ -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
H1H4 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」时,卡片内显示 **「安装 psutilSSH)」** 按钮,通过 SSH 在子机系统 Python 安装 psutilpip --user → apt/yum → pip)。
## 动机
SSH 监测探针依赖系统 `python3` 的 psutilAgent 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"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { WatchSlot } from '@/composables/useWatchPins' 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' import WatchSparkline from '@/components/watch/WatchSparkline.vue'
const props = defineProps<{ const props = defineProps<{
slot: WatchSlot slot: WatchSlot
psutilInstallLoading?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -15,10 +16,14 @@ const emit = defineEmits<{
pick: [slotIndex: number] pick: [slotIndex: number]
monitoring: [slotIndex: number, enabled: boolean] monitoring: [slotIndex: number, enabled: boolean]
ttl: [slotIndex: number, hours: 8 | 24] ttl: [slotIndex: number, hours: 8 | 24]
installPsutil: [slotIndex: number]
}>() }>()
const monitoringOn = computed(() => props.slot.monitoring_enabled !== false) const monitoringOn = computed(() => props.slot.monitoring_enabled !== false)
const slotTtl = computed(() => (props.slot.ttl_hours === 24 ? 24 : 8) as 8 | 24) 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') const chartMetric = ref<'cpu_pct' | 'mem_pct' | 'disk_pct'>('cpu_pct')
@@ -119,10 +124,23 @@ function pct(v: number | null | undefined) {
<div <div
v-if="slot.metrics?.error" 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)" :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)"
>
安装 psutilSSH
</v-btn>
</div> </div>
<div class="d-flex align-center justify-space-between mt-2"> <div class="d-flex align-center justify-space-between mt-2">
+23 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import WatchSlotCard from '@/components/watch/WatchSlotCard.vue' import WatchSlotCard from '@/components/watch/WatchSlotCard.vue'
import WatchProcessDrawer from '@/components/watch/WatchProcessDrawer.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 { useWatchPins, type WatchSlot } from '@/composables/useWatchPins'
import { useSnackbar } from '@/composables/useSnackbar' import { useSnackbar } from '@/composables/useSnackbar'
import { formatApiError } from '@/utils/apiError' import { formatApiError } from '@/utils/apiError'
import { http } from '@/api'
const router = useRouter() const router = useRouter()
const snackbar = useSnackbar() const snackbar = useSnackbar()
@@ -17,6 +18,7 @@ const processSlot = ref<WatchSlot | null>(null)
const showPickDialog = ref(false) const showPickDialog = ref(false)
const pickSlotIndex = ref<number | null>(null) const pickSlotIndex = ref<number | null>(null)
const pickLoading = ref(false) const pickLoading = ref(false)
const psutilLoadingSlot = ref<number | null>(null)
onMounted(() => { onMounted(() => {
refreshPins() refreshPins()
@@ -71,6 +73,24 @@ function onPickSlot(slotIndex: number) {
showPickDialog.value = true 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) { async function onPickServer(serverId: number) {
if (pickSlotIndex.value == null) return if (pickSlotIndex.value == null) return
pickLoading.value = true 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"> <v-col v-for="slot in slots" :key="slot.slot_index" cols="12" sm="6" lg="3">
<WatchSlotCard <WatchSlotCard
:slot="slot" :slot="slot"
:psutil-install-loading="psutilLoadingSlot === slot.slot_index"
@remove="onRemove" @remove="onRemove"
@processes="openProcesses" @processes="openProcesses"
@history="openHistory" @history="openHistory"
@pick="onPickSlot" @pick="onPickSlot"
@monitoring="onMonitoring" @monitoring="onMonitoring"
@ttl="onTtl" @ttl="onTtl"
@install-psutil="onInstallPsutil"
/> />
</v-col> </v-col>
</v-row> </v-row>
+12 -1
View File
@@ -27,7 +27,7 @@ const PROBE_STATUS_LABELS: Record<string, string> = {
const PROBE_ERROR_EXACT: Record<string, string> = { const PROBE_ERROR_EXACT: Record<string, string> = {
parse_error: '探针返回数据无法解析', parse_error: '探针返回数据无法解析',
'psutil missing': '子机未安装 psutil(请执行 pip install psutil', 'psutil missing': '子机未安装 psutil',
ssh_probe_failed: 'SSH 探针脚本执行失败', ssh_probe_failed: 'SSH 探针脚本执行失败',
'SSH watch probe failed': 'SSH 监测探针失败', 'SSH watch probe failed': 'SSH 监测探针失败',
'SSH probe failed': 'SSH 探针失败', 'SSH probe failed': 'SSH 探针失败',
@@ -94,6 +94,17 @@ export function formatProbeError(
return `探针异常:${text}` 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 { export function probeStatusColor(status: string | null | undefined): string {
if (!status || status === 'ok') return 'success' if (!status || status === 'ok') return 'success'
if (status.includes('timeout') || status.includes('auth')) return 'warning' if (status.includes('timeout') || status.includes('auth')) return 'warning'
+19
View File
@@ -211,6 +211,25 @@ async def get_watch_processes(
return await svc.get_processes(server_id) 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]: def _parse_server_ids(server_ids: str) -> list[int]:
ids: list[int] = [] ids: list[int] = []
for part in server_ids.split(","): for part in server_ids.split(","):
@@ -420,6 +420,45 @@ class WatchService:
processes = await _load_processes(server_id) processes = await _load_processes(server_id)
return {"server_id": server_id, "processes": processes or []} 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( async def _audit(
self, self,
*, *,
+81
View File
@@ -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],
}
+2 -2
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
_EXACT: dict[str, str] = { _EXACT: dict[str, str] = {
"parse_error": "探针返回数据无法解析", "parse_error": "探针返回数据无法解析",
"psutil missing": "子机未安装 psutil(请执行 pip install psutil", "psutil missing": "子机未安装 psutil",
"ssh_probe_failed": "SSH 探针脚本执行失败", "ssh_probe_failed": "SSH 探针脚本执行失败",
"SSH watch probe failed": "SSH 监测探针失败", "SSH watch probe failed": "SSH 监测探针失败",
"SSH 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: if "permission denied" in lower or "authentication failed" in lower:
return f"SSH 认证失败:{text}" return f"SSH 认证失败:{text}"
if "psutil missing" in lower: 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: if "ssh_probe_failed" in lower or "ssh watch probe failed" in lower:
return "SSH 探针脚本执行失败" return "SSH 探针脚本执行失败"
+46
View File
@@ -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"]
+40
View File
@@ -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 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 @pytest.mark.asyncio
async def test_watch_pin_ttl_expiry_pauses_not_deletes(db_session, test_admin, mock_watch_redis): async def test_watch_pin_ttl_expiry_pauses_not_deletes(db_session, test_admin, mock_watch_redis):
from datetime import timedelta from datetime import timedelta
+1 -1
View File
@@ -5,7 +5,7 @@ from server.utils.watch_probe_errors import watch_probe_error_zh
def test_parse_error_zh(): def test_parse_error_zh():
assert watch_probe_error_zh("parse_error") == "探针返回数据无法解析" 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(): def test_parse_error_with_detail():