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:
@@ -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 | 判定 |
|
||||
|---|------|
|
||||
| H1–H3 | 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>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user