feat(watch): Baota-style server detail drawer

Expose load/process counts, per-core CPU, memory MB breakdown, and
separate CPU/MEM top-5 processes from SSH probes in the detail drawer.
This commit is contained in:
Nexus Agent
2026-06-14 06:23:45 +08:00
parent 86a45612c8
commit 1a79c0fbfb
10 changed files with 387 additions and 30 deletions
@@ -0,0 +1,36 @@
# 审计 — 监测服务器详情(宝塔对齐)
**Changelog**: `docs/changelog/2026-06-14-watch-baota-server-detail.md`
## 审计范围
| 文件 | 变更 | 状态 |
|------|------|------|
| `remote_probe.py` | 负载进程数、分核 CPU、内存 MB、双 Top5 | ☑ |
| `watch_metrics.py` | `sanitize_process_bundle`、live 字段 | ☑ |
| `watch_service.py` | 进程 API 返回 bundle | ☑ |
| `WatchProcessDrawer.vue` | 宝塔式详情抽屉 | ☑ |
| `watchFormat.ts` / `useWatchPins.ts` | 类型与格式化 | ☑ |
| `WatchSlotCard.vue` | 「详情」入口 | ☑ |
| `tests/test_watch_metrics.py` | 新字段单测 | ☑ |
## Step 3
| H | 规则 | 结论 |
|---|------|------|
| H1 | SSH 脚本注入 | SAFE — 固定 psutil 脚本 |
| H2 | API 契约 | SAFE — `processes` 兼容 list→bundle |
| H3 | 性能 | NOTE — 分核 CPU 每 5s 一次 SSH,与现有探针同频 |
## Closure
| H | 判定 |
|---|------|
| H1H3 | SAFE / 接受 |
## DoD
- [x] changelog
- [x] pytest watch_metrics
- [x] vite build
- [ ] 生产终验详情抽屉
@@ -0,0 +1,25 @@
# 2026-06-14 — 监测服务器详情(对齐宝塔)
## 摘要
探针新增负载进程数、分核 CPU、内存 MB 明细;进程探针分 CPU/MEM Top5;槽位「详情」抽屉按宝塔布局展示。
## 动机
用户提供的宝塔面板含 1/5/15 负载、活动/总进程、分核占用、内存分项与 CPU/MEM Top5,Nexus 原先仅圆环 + 单一进程列表。
## 变更
- `remote_probe.py`metrics 增 `process_running/total``per_cpu_pct`、内存 MB;进程返回 `top_cpu` + `top_mem`
- `watch_metrics.py``sanitize_process_bundle`、live 推送新字段
- `WatchProcessDrawer.vue`:宝塔式详情抽屉
- `watchFormat.ts``formatLoadTriple``normalizeProcessBundle`
## 验证
```bash
pytest tests/test_watch_metrics.py -q -k "not ssh_watch_metrics_cmd_runs_locally"
cd frontend && npx vite build
```
开启监测 → 点「详情」应见与宝塔同结构的负载/进程/内存/Top5。
@@ -1,7 +1,14 @@
<script setup lang="ts">
import { computed, watch, ref } from 'vue'
import { http } from '@/api'
import type { WatchSlot, WatchProcess } from '@/composables/useWatchPins'
import type { WatchSlot, WatchProcess, WatchMetrics } from '@/composables/useWatchPins'
import {
formatLoadTriple,
formatMemMb,
formatProcessCounts,
normalizeProcessBundle,
formatProbePct,
} from '@/utils/watchFormat'
const props = defineProps<{
modelValue: boolean
@@ -10,7 +17,7 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:modelValue': [v: boolean] }>()
const processes = ref<WatchProcess[]>([])
const processBundle = ref<{ top_cpu: WatchProcess[]; top_mem: WatchProcess[] }>({ top_cpu: [], top_mem: [] })
const loading = ref(false)
const open = computed({
@@ -18,16 +25,52 @@ const open = computed({
set: (v) => emit('update:modelValue', v),
})
const metrics = computed(() => props.slotData?.metrics as WatchMetrics | null | undefined)
const loadLine = computed(() =>
formatLoadTriple(metrics.value?.load_1, metrics.value?.load_5, metrics.value?.load_15),
)
const processCountLine = computed(() =>
formatProcessCounts(metrics.value?.process_running, metrics.value?.process_total),
)
const perCpu = computed(() => metrics.value?.per_cpu_pct || [])
const memLines = computed(() => {
const m = metrics.value
if (!m) return []
const lines: Array<{ label: string; value: string }> = []
if (m.mem_free_mb != null) lines.push({ label: '空闲内存', value: formatMemMb(m.mem_free_mb) })
if (m.memory_used_gb != null) {
lines.push({ label: '已用', value: formatMemMb(Math.round(m.memory_used_gb * 1024)) })
}
if (m.memory_total_gb != null) {
lines.push({ label: '总内存', value: formatMemMb(Math.round(m.memory_total_gb * 1024)) })
}
if (m.mem_shared_mb != null) lines.push({ label: '共享', value: formatMemMb(m.mem_shared_mb) })
if (m.memory_available_gb != null) {
lines.push({ label: '可分配内存', value: formatMemMb(Math.round(m.memory_available_gb * 1024)) })
}
if (m.mem_buffers_mb != null || m.mem_cached_mb != null) {
lines.push({
label: 'buff/cache',
value: `${formatMemMb(m.mem_buffers_mb ?? 0)} / ${formatMemMb(m.mem_cached_mb ?? 0)}`,
})
}
return lines
})
async function loadProcesses() {
if (!props.slotData?.server_id) return
loading.value = true
try {
const data = await http.get<{ processes: WatchProcess[] }>(
const data = await http.get<{ processes: WatchProcess[] | { top_cpu?: WatchProcess[]; top_mem?: WatchProcess[] } }>(
`/watch/processes/${props.slotData.server_id}`,
)
processes.value = data.processes || props.slotData.processes || []
processBundle.value = normalizeProcessBundle(data.processes || props.slotData.processes)
} catch {
processes.value = props.slotData.processes || []
processBundle.value = normalizeProcessBundle(props.slotData.processes)
} finally {
loading.value = false
}
@@ -46,25 +89,100 @@ watch(
v-model="open"
location="right"
temporary
width="360"
width="400"
class="watch-detail-drawer"
>
<v-toolbar density="compact" flat>
<v-toolbar-title class="text-subtitle-1">
进程 Top5 · {{ slotData?.server_name }}
服务器详情 · {{ slotData?.server_name }}
</v-toolbar-title>
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="open = false" />
</v-toolbar>
<v-divider />
<v-progress-linear v-if="loading" indeterminate />
<v-list v-if="processes.length" density="compact">
<v-list-item v-for="(p, i) in processes" :key="`${p.pid}-${i}`">
<v-list-item-title class="text-body-2">{{ p.name || '—' }}</v-list-item-title>
<v-list-item-subtitle>PID {{ p.pid ?? '—' }} · CPU {{ p.cpu_pct ?? '—' }}% · MEM {{ p.mem_pct ?? '—' }}%</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="pa-4 text-medium-emphasis text-caption">
暂无进程数据 10 秒刷新一次
<div class="pa-3">
<section class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-1">最近 1 / 5 / 15 分钟平均负载</div>
<div class="text-body-2 font-weight-medium">{{ loadLine }}</div>
</section>
<section class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-1">活动进程数 / 总进程数</div>
<div class="text-body-2">{{ processCountLine }}</div>
</section>
<section v-if="perCpu.length" class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-2">各核心 CPU 占用</div>
<div class="d-flex flex-wrap ga-1">
<v-chip
v-for="(pct, idx) in perCpu"
:key="idx"
size="x-small"
variant="tonal"
:color="pct >= 80 ? 'error' : pct >= 50 ? 'warning' : 'primary'"
label
>
核心 {{ idx + 1 }} {{ formatProbePct(pct) }}
</v-chip>
</div>
</section>
<section v-if="memLines.length" class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-2">内存信息</div>
<div
v-for="row in memLines"
:key="row.label"
class="d-flex justify-space-between text-body-2 watch-detail-kv"
>
<span class="text-medium-emphasis">{{ row.label }}</span>
<span class="font-weight-medium">{{ row.value }}</span>
</div>
</section>
<section class="watch-detail-section mb-3">
<div class="text-caption text-medium-emphasis mb-2">CPU 占用率 Top5</div>
<v-list v-if="processBundle.top_cpu.length" density="compact" class="watch-detail-list py-0">
<v-list-item v-for="(p, i) in processBundle.top_cpu" :key="`cpu-${p.pid}-${i}`">
<v-list-item-title class="text-body-2 text-truncate">{{ p.name || '—' }}</v-list-item-title>
<v-list-item-subtitle>
PID {{ p.pid ?? '—' }} · CPU {{ formatProbePct(p.cpu_pct) }} · MEM {{ formatProbePct(p.mem_pct) }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-caption text-medium-emphasis">暂无数据 10 秒刷新</div>
</section>
<section class="watch-detail-section">
<div class="text-caption text-medium-emphasis mb-2">内存占用率 Top5</div>
<v-list v-if="processBundle.top_mem.length" density="compact" class="watch-detail-list py-0">
<v-list-item v-for="(p, i) in processBundle.top_mem" :key="`mem-${p.pid}-${i}`">
<v-list-item-title class="text-body-2 text-truncate">{{ p.name || '—' }}</v-list-item-title>
<v-list-item-subtitle>
PID {{ p.pid ?? '—' }} · MEM {{ formatProbePct(p.mem_pct) }} · CPU {{ formatProbePct(p.cpu_pct) }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="text-caption text-medium-emphasis">暂无数据 10 秒刷新</div>
</section>
</div>
</v-navigation-drawer>
</template>
<style scoped>
.watch-detail-section {
padding-bottom: 4px;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.watch-detail-section:last-child {
border-bottom: none;
}
.watch-detail-kv + .watch-detail-kv {
margin-top: 4px;
}
.watch-detail-list :deep(.v-list-item) {
min-height: 44px;
padding-inline: 0;
}
</style>
@@ -280,7 +280,7 @@ const showSwap = computed(() => (props.slot.metrics?.swap_total_gb ?? 0) > 0)
>
暂停
</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">进程</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('processes', slot)">详情</v-btn>
<v-btn size="x-small" variant="text" @click.stop="emit('history', slot)">历史</v-btn>
</div>
</div>
+15 -3
View File
@@ -35,11 +35,23 @@ export interface WatchMetrics {
load_1?: number | null
load_5?: number | null
load_15?: number | null
process_running?: number | null
process_total?: number | null
per_cpu_pct?: number[] | null
mem_free_mb?: number | null
mem_shared_mb?: number | null
mem_buffers_mb?: number | null
mem_cached_mb?: number | null
error?: string | null
processes?: WatchProcess[]
processes?: WatchProcessBundle | WatchProcess[]
ts?: string
}
export interface WatchProcessBundle {
top_cpu?: WatchProcess[]
top_mem?: WatchProcess[]
}
export interface WatchProcess {
pid?: number
name?: string
@@ -70,7 +82,7 @@ export interface WatchSlot {
ttl_hours?: number
metrics?: WatchMetrics | null
sparkline?: WatchSparkPoint[]
processes?: WatchProcess[]
processes?: WatchProcessBundle | WatchProcess[]
}
const slots = ref<WatchSlot[]>([
@@ -115,7 +127,7 @@ function mergeMetrics(serverId: number, metrics: WatchMetrics) {
for (const slot of slots.value) {
if (slot.empty || slot.server_id !== serverId || slot.monitoring_enabled === false) continue
slot.metrics = { ...(slot.metrics || {}), ...metrics }
if (metrics.processes?.length) {
if (metrics.processes) {
slot.processes = metrics.processes
}
const point: WatchSparkPoint = {
+34
View File
@@ -128,6 +128,40 @@ export function formatLoadAvg(load: number | null | undefined): string {
return load.toFixed(2)
}
export function formatLoadTriple(
load1: number | null | undefined,
load5: number | null | undefined,
load15: number | null | undefined,
): string {
return `${formatLoadAvg(load1)} / ${formatLoadAvg(load5)} / ${formatLoadAvg(load15)}`
}
export function formatMemMb(mb: number | null | undefined): string {
if (mb == null || mb < 0) return '—'
return `${mb} MB`
}
export function formatProcessCounts(
running: number | null | undefined,
total: number | null | undefined,
): string {
if (running == null && total == null) return '—'
return `${running ?? '—'} / ${total ?? '—'}`
}
export interface WatchProcessBundleLike {
top_cpu?: Array<{ pid?: number; name?: string; cpu_pct?: number; mem_pct?: number }>
top_mem?: Array<{ pid?: number; name?: string; cpu_pct?: number; mem_pct?: number }>
}
export function normalizeProcessBundle(
raw: WatchProcessBundleLike | Array<{ pid?: number; name?: string; cpu_pct?: number; mem_pct?: number }> | null | undefined,
): { top_cpu: NonNullable<WatchProcessBundleLike['top_cpu']>; top_mem: NonNullable<WatchProcessBundleLike['top_mem']> } {
if (!raw) return { top_cpu: [], top_mem: [] }
if (Array.isArray(raw)) return { top_cpu: raw, top_mem: [] }
return { top_cpu: raw.top_cpu || [], top_mem: raw.top_mem || [] }
}
export function loadAvgColor(load: number | null | undefined): string {
if (load == null || Number.isNaN(load)) return 'grey'
if (load >= 2) return 'error'
+15 -3
View File
@@ -63,16 +63,25 @@ async def _load_sparkline(server_id: int) -> list[dict[str, Any]]:
return out
async def _load_processes(server_id: int) -> list[dict[str, Any]] | None:
async def _load_processes(server_id: int) -> dict[str, Any] | list[dict[str, Any]] | None:
redis = get_redis()
raw = await redis.get(f"{REDIS_WATCH_PROC_PREFIX}{server_id}")
if not raw:
return None
try:
data = json.loads(raw)
return data if isinstance(data, list) else None
except json.JSONDecodeError:
return None
if isinstance(data, dict):
from server.utils.watch_metrics import sanitize_process_bundle
return sanitize_process_bundle(data)
if isinstance(data, list):
from server.utils.watch_metrics import sanitize_processes
legacy = sanitize_processes(data)
return {"top_cpu": legacy or [], "top_mem": []} if legacy else None
return None
async def _restore_idle_agent_watch(watch_repo: WatchRepositoryImpl, server_id: int) -> None:
@@ -439,7 +448,10 @@ class WatchService:
async def get_processes(self, server_id: int) -> dict[str, Any]:
processes = await _load_processes(server_id)
return {"server_id": server_id, "processes": processes or []}
return {
"server_id": server_id,
"processes": processes if processes is not None else {"top_cpu": [], "top_mem": []},
}
async def install_psutil_ssh(
self,
+40 -5
View File
@@ -60,8 +60,17 @@ except (AttributeError, OSError, NotImplementedError):
load = [0.0, 0.0, 0.0]
try:
import time, platform
cpu = psutil.cpu_percent(interval=0.3)
per_cpu = [round(float(x), 1) for x in psutil.cpu_percent(interval=0.3, percpu=True)]
cpu = round(sum(per_cpu) / len(per_cpu), 1) if per_cpu else 0.0
vm = psutil.virtual_memory()
load_line = open('/proc/loadavg').read().split()
_rt = load_line[3].split('/') if len(load_line) > 3 else ['0', '0']
proc_running = int(_rt[0])
proc_total = int(_rt[1])
mem_free_mb = int(vm.free / (1024 * 1024))
mem_shared_mb = int(getattr(vm, 'shared', 0) / (1024 * 1024))
mem_buffers_mb = int(vm.buffers / (1024 * 1024))
mem_cached_mb = int(vm.cached / (1024 * 1024))
du = psutil.disk_usage('/')
swap = psutil.swap_memory()
mem = vm.percent
@@ -119,6 +128,13 @@ out = {
'uptime_seconds': uptime_seconds,
'disk_mount': '/',
'load_avg': load,
'process_running': proc_running,
'process_total': proc_total,
'per_cpu_pct': per_cpu,
'mem_free_mb': mem_free_mb,
'mem_shared_mb': mem_shared_mb,
'mem_buffers_mb': mem_buffers_mb,
'mem_cached_mb': mem_cached_mb,
}
print(json.dumps(out))
" 2>/dev/null || python3.12 -c "
@@ -134,8 +150,17 @@ except (AttributeError, OSError, NotImplementedError):
load = [0.0, 0.0, 0.0]
try:
import time, platform
cpu = psutil.cpu_percent(interval=0.3)
per_cpu = [round(float(x), 1) for x in psutil.cpu_percent(interval=0.3, percpu=True)]
cpu = round(sum(per_cpu) / len(per_cpu), 1) if per_cpu else 0.0
vm = psutil.virtual_memory()
load_line = open('/proc/loadavg').read().split()
_rt = load_line[3].split('/') if len(load_line) > 3 else ['0', '0']
proc_running = int(_rt[0])
proc_total = int(_rt[1])
mem_free_mb = int(vm.free / (1024 * 1024))
mem_shared_mb = int(getattr(vm, 'shared', 0) / (1024 * 1024))
mem_buffers_mb = int(vm.buffers / (1024 * 1024))
mem_cached_mb = int(vm.cached / (1024 * 1024))
du = psutil.disk_usage('/')
swap = psutil.swap_memory()
mem = vm.percent
@@ -193,6 +218,13 @@ out = {
'uptime_seconds': uptime_seconds,
'disk_mount': '/',
'load_avg': load,
'process_running': proc_running,
'process_total': proc_total,
'per_cpu_pct': per_cpu,
'mem_free_mb': mem_free_mb,
'mem_shared_mb': mem_shared_mb,
'mem_buffers_mb': mem_buffers_mb,
'mem_cached_mb': mem_cached_mb,
}
print(json.dumps(out))
" 2>/dev/null || echo '{"status":"error","error":"ssh_probe_failed"}'
@@ -238,7 +270,10 @@ for p in psutil.process_iter(['pid','name','cpu_percent','memory_percent']):
except Exception:
pass
procs.sort(key=lambda x: x.get('cpu_pct') or 0, reverse=True)
print(json.dumps({'status':'ok','top_processes': procs[:5]}))
top_cpu = procs[:5]
procs.sort(key=lambda x: x.get('mem_pct') or 0, reverse=True)
top_mem = procs[:5]
print(json.dumps({'status':'ok','top_cpu': top_cpu, 'top_mem': top_mem}))
" 2>/dev/null || echo '{"status":"error","error":"ssh_probe_failed"}'
"""
@@ -364,9 +399,9 @@ async def ssh_watch_processes(server: Server, timeout: int = 8) -> list[dict[str
parsed = _parse_json_stdout(result.get("stdout", ""))
if not parsed or parsed.get("status") != "ok":
return None
from server.utils.watch_metrics import sanitize_processes
from server.utils.watch_metrics import sanitize_process_bundle
return sanitize_processes(parsed.get("top_processes"))
return sanitize_process_bundle(parsed)
def _parse_health_stdout(stdout: str) -> dict[str, Any] | None:
+60 -2
View File
@@ -79,6 +79,13 @@ class WatchProbeSample:
load_1: float | None = None
load_5: float | None = None
load_15: float | None = None
process_running: int | None = None
process_total: int | None = None
per_cpu_pct: list[float] | None = None
mem_free_mb: int | None = None
mem_shared_mb: int | None = None
mem_buffers_mb: int | None = None
mem_cached_mb: int | None = None
net_bytes_sent: int | None = None
net_bytes_recv: int | None = None
disk_read_bytes: int | None = None
@@ -87,7 +94,7 @@ class WatchProbeSample:
net_down_bps: int | None = None
disk_read_bps: int | None = None
disk_write_bps: int | None = None
processes_json: list[dict[str, Any]] | None = None
processes_json: list[dict[str, Any]] | dict[str, Any] | None = None
_LIVE_HARDWARE_KEYS = (
"cpu_cores",
@@ -105,6 +112,13 @@ class WatchProbeSample:
"swap_pct",
"os_release",
"uptime_seconds",
"process_running",
"process_total",
"per_cpu_pct",
"mem_free_mb",
"mem_shared_mb",
"mem_buffers_mb",
"mem_cached_mb",
)
def to_live_dict(self, *, server_id: int) -> dict[str, Any]:
@@ -156,6 +170,19 @@ def sanitize_processes(raw: Any) -> list[dict[str, Any]] | None:
return out or None
def sanitize_process_bundle(raw: dict[str, Any] | None) -> dict[str, Any] | None:
if not isinstance(raw, dict):
return None
top_cpu = sanitize_processes(raw.get("top_cpu")) or []
top_mem = sanitize_processes(raw.get("top_mem")) or []
legacy = sanitize_processes(raw.get("top_processes"))
if legacy and not top_cpu:
top_cpu = legacy
if not top_cpu and not top_mem:
return None
return {"top_cpu": top_cpu, "top_mem": top_mem}
@dataclass
class _RateState:
net_bytes_sent: int | None = None
@@ -235,9 +262,28 @@ def _parse_hardware_fields(data: dict[str, Any]) -> dict[str, Any]:
"swap_pct": _float_gb(data.get("swap_pct")),
"os_release": _short_text(data.get("os_release")),
"uptime_seconds": _positive_int(data.get("uptime_seconds")),
"process_running": _positive_int(data.get("process_running")),
"process_total": _positive_int(data.get("process_total")),
"per_cpu_pct": _parse_per_cpu_pct(data.get("per_cpu_pct")),
"mem_free_mb": _positive_int(data.get("mem_free_mb")),
"mem_shared_mb": _positive_int(data.get("mem_shared_mb")),
"mem_buffers_mb": _positive_int(data.get("mem_buffers_mb")),
"mem_cached_mb": _positive_int(data.get("mem_cached_mb")),
}
def _parse_per_cpu_pct(raw: Any) -> list[float] | None:
if not isinstance(raw, (list, tuple)):
return None
out: list[float] = []
for item in raw[:128]:
try:
out.append(round(float(item), 1))
except (TypeError, ValueError):
continue
return out or None
def fill_hardware_gaps(sample: WatchProbeSample) -> None:
"""Derive used/free GB from totals + % when probe omitted absolute values."""
if sample.memory_used_gb is None and sample.memory_total_gb is not None and sample.mem_pct is not None:
@@ -318,7 +364,11 @@ def parse_ssh_watch_payload(data: dict[str, Any]) -> WatchProbeSample:
load_5 = float(data["load_5"]) if data.get("load_5") is not None else None
load_15 = float(data["load_15"]) if data.get("load_15") is not None else None
processes = sanitize_processes(data.get("top_processes"))
processes = sanitize_process_bundle(data) if "top_cpu" in data or "top_mem" in data else None
if processes is None:
legacy = sanitize_processes(data.get("top_processes"))
if legacy:
processes = {"top_cpu": legacy, "top_mem": []}
sample = WatchProbeSample(
probe_status=PROBE_OK,
@@ -433,6 +483,14 @@ def merge_redis_and_ssh(redis_sample: WatchProbeSample | None, ssh_sample: Watch
swap_pct=hw.get("swap_pct"),
os_release=hw.get("os_release"),
uptime_seconds=hw.get("uptime_seconds"),
process_running=hw.get("process_running"),
process_total=hw.get("process_total"),
per_cpu_pct=hw.get("per_cpu_pct"),
mem_free_mb=hw.get("mem_free_mb"),
mem_shared_mb=hw.get("mem_shared_mb"),
mem_buffers_mb=hw.get("mem_buffers_mb"),
mem_cached_mb=hw.get("mem_cached_mb"),
processes_json=ssh_sample.processes_json or redis_sample.processes_json,
)
fill_hardware_gaps(out)
return out
+28 -1
View File
@@ -78,7 +78,34 @@ def test_parse_ssh_watch_payload():
sample = parse_ssh_watch_payload(data)
assert sample.cpu_pct == 12
assert sample.load_1 == 0.8
assert sample.processes_json and sample.processes_json[0]["name"] == "nginx"
assert isinstance(sample.processes_json, dict)
assert sample.processes_json["top_cpu"][0]["name"] == "nginx"
def test_parse_ssh_watch_payload_baota_detail_fields():
sample = parse_ssh_watch_payload(
{
"cpu_usage": 30,
"mem_usage": 13,
"disk_usage": 50,
"load_avg": [0.31, 0.38, 0.42],
"process_running": 1,
"process_total": 146,
"per_cpu_pct": [332.4, 29.5, 30.0, 27.2],
"mem_free_mb": 329,
"mem_shared_mb": 2,
"mem_buffers_mb": 3046,
"mem_cached_mb": 77,
"top_cpu": [{"pid": 1, "name": "a", "cpu_pct": 10, "mem_pct": 1}],
"top_mem": [{"pid": 2, "name": "b", "cpu_pct": 1, "mem_pct": 20}],
},
)
assert sample.load_1 == 0.31
assert sample.process_total == 146
assert sample.per_cpu_pct == [332.4, 29.5, 30.0, 27.2]
assert sample.mem_free_mb == 329
bundle = sample.processes_json
assert bundle["top_mem"][0]["name"] == "b"
def test_sanitize_processes_truncates_name():