5e69b1c3e2
User feedback: '进程比较关键,以下显示太少了,需要继续优化下监控UI'
Process monitor panel was rebuilt:
1. core/monitor.py: 200 -> 500 processes, added PPID / PRI / NICE
New ps fields: pid ppid user pcpu pmem vsz rss stat pri nice etimes times args
Python parser: new fields ppid, pri, nice
2. ui/widgets.py MonitorPanel:
a) Toolbar: search box + sort dropdown (CPU/MEM/PID/start/user/comm)
+ view toggle (Flat / Tree) + CPU% min filter (SpinBox 0-100%)
+ batch kill button
b) Table: 12 columns (Flat) / 13 cols (Tree + indent col)
PID PPID USER CPU% MEM% RSS STAT PRI NI '启动' 'CPU时间' 命令
- Compact row height 20px (was default 30)
- Extended selection (Ctrl/Shift multi)
- Color highlights:
CPU% >=80 red bg+fg bold, >=50 red fg bold, >=20 orange
MEM% >=15 orange bg+fg bold, >=5 orange
STAT: Z orange bg + orange-red fg bold, R green, D purple
NI: negative green, positive orange
- Red bg on EXTREME rows for instant visibility
c) Tree view: builds PPID tree with ├─/└─/│ connectors;
root processes (PID 1) at depth 0, sorted by CPU within siblings.
Useful for 'which process is spawning these things?'
d) Right side panel:
- 进程详情 (QTextEdit dark theme): full PID/PPID/USER/STAT/PRI/NI/
CPU/MEM/RSS/VSZ/etime/cputime, command, parent process name,
child processes list (top 10)
- 🔥 CPU TOP 5 (QListWidget dark): click to jump+select in main table
- 💾 内存 TOP 5 (QListWidget dark): same
e) Right-click menu enhanced:
kill (SIGTERM) | kill -9 (SIGKILL) | batch kill (if multi-select)
| copy PID | copy command | filter by command | view details
f) Batch kill: collects all selected PIDs, runs 'kill p1 p2 p3 ...'
in a single SSH call, then loops 'kill -0' to report survivors.
One confirmation dialog lists top 10 + '还有 N 个'.
g) Status row at bottom: 'Ctrl+A 全选, Shift+点击多选, 右键批量操作'
3. test_process_monitor.py: 5/5 tests pass with 500 rows, tree view,
TOP 5, CPU min filter, etc.
All tests pass: core 6/6 + UI 3/3 + E2E 6/6 + terminal 5/5 + proc 5/5
Build: 57 MB single-file exe.
1392 lines
57 KiB
Python
1392 lines
57 KiB
Python
"""
|
||
PyQt5 自定义控件
|
||
- FileBrowser: 远程文件浏览 + 上传/下载/删除
|
||
- MonitorPanel: CPU/内存/磁盘/网络实时监控面板
|
||
- AIChatPanel: AI Agent 对话面板
|
||
"""
|
||
import os
|
||
import time
|
||
from pathlib import Path
|
||
from datetime import datetime
|
||
from typing import Optional, List, Dict
|
||
|
||
from PyQt5.QtCore import Qt, pyqtSignal, QSize, QTimer
|
||
from PyQt5.QtGui import QFont, QColor, QIcon, QPixmap, QPainter, QBrush, QKeySequence
|
||
from PyQt5.QtWidgets import (
|
||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit,
|
||
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
|
||
QFileDialog, QMessageBox, QProgressBar, QTreeWidget, QTreeWidgetItem,
|
||
QTextEdit, QSplitter, QFrame, QSizePolicy, QGroupBox, QFormLayout,
|
||
QComboBox, QToolButton, QStyle, QApplication, QInputDialog,
|
||
QListWidget, QListWidgetItem, QTabWidget, QMenu, QShortcut, QSpinBox,
|
||
)
|
||
|
||
from core.ssh_client import SSHConnection
|
||
from core.monitor import SystemMonitor
|
||
from core.manager import ConnectionManager
|
||
from .workers import (
|
||
ListDirWorker, UploadWorker, DownloadWorker, MonitorWorker, AIWorker,
|
||
)
|
||
|
||
|
||
# ============================================================
|
||
# 文件浏览面板
|
||
# ============================================================
|
||
class FileBrowser(QWidget):
|
||
"""远程 SFTP 文件浏览:路径栏 + 工具栏 + 表格 + 状态栏"""
|
||
|
||
def __init__(self, manager: ConnectionManager, parent=None):
|
||
super().__init__(parent)
|
||
self.manager = manager
|
||
self.current_host_id: Optional[str] = None
|
||
self.current_path: str = "/"
|
||
self.cwd_history: List[str] = [] # 简单的前进/后退栈
|
||
self.list_worker: Optional[ListDirWorker] = None
|
||
|
||
self._build_ui()
|
||
|
||
def _build_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(8, 8, 8, 8)
|
||
layout.setSpacing(6)
|
||
|
||
# 路径栏
|
||
nav = QHBoxLayout()
|
||
self.btn_back = QPushButton("◀")
|
||
self.btn_back.setFixedWidth(32)
|
||
self.btn_back.clicked.connect(self._go_back)
|
||
self.btn_up = QPushButton("▲")
|
||
self.btn_up.setFixedWidth(32)
|
||
self.btn_up.clicked.connect(self._go_up)
|
||
self.path_edit = QLineEdit("/")
|
||
self.path_edit.returnPressed.connect(self._go_to_path)
|
||
self.btn_refresh = QPushButton("刷新")
|
||
self.btn_refresh.clicked.connect(lambda: self._refresh())
|
||
nav.addWidget(self.btn_back)
|
||
nav.addWidget(self.btn_up)
|
||
nav.addWidget(self.path_edit, 1)
|
||
nav.addWidget(self.btn_refresh)
|
||
layout.addLayout(nav)
|
||
|
||
# 工具栏
|
||
toolbar = QHBoxLayout()
|
||
self.btn_upload = QPushButton("⬆ 上传")
|
||
self.btn_upload.clicked.connect(self._upload)
|
||
self.btn_download = QPushButton("⬇ 下载")
|
||
self.btn_download.clicked.connect(self._download)
|
||
self.btn_mkdir = QPushButton("新建目录")
|
||
self.btn_mkdir.clicked.connect(self._mkdir)
|
||
self.btn_delete = QPushButton("删除")
|
||
self.btn_delete.clicked.connect(self._delete)
|
||
self.btn_rename = QPushButton("重命名")
|
||
self.btn_rename.clicked.connect(self._rename)
|
||
for w in (self.btn_upload, self.btn_download, self.btn_mkdir,
|
||
self.btn_delete, self.btn_rename):
|
||
toolbar.addWidget(w)
|
||
toolbar.addStretch(1)
|
||
self.progress = QProgressBar()
|
||
self.progress.setFixedWidth(180)
|
||
self.progress.setVisible(False)
|
||
toolbar.addWidget(self.progress)
|
||
layout.addLayout(toolbar)
|
||
|
||
# 文件表格
|
||
self.table = QTableWidget(0, 4)
|
||
self.table.setHorizontalHeaderLabels(["名称", "大小", "修改时间", "类型"])
|
||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||
self.table.setAlternatingRowColors(True)
|
||
self.table.doubleClicked.connect(self._on_double_clicked)
|
||
layout.addWidget(self.table, 1)
|
||
|
||
# 状态栏
|
||
self.status_label = QLabel("未连接")
|
||
self.status_label.setStyleSheet("color: #888;")
|
||
layout.addWidget(self.status_label)
|
||
|
||
# ------- 连接管理 -------
|
||
def set_host(self, host_id: str):
|
||
self.current_host_id = host_id
|
||
self.current_path = "/"
|
||
self.path_edit.setText("/")
|
||
self.cwd_history.clear()
|
||
conn = self.manager.get_connection(host_id)
|
||
if conn and conn.connected:
|
||
self.status_label.setText(f"已连接: {conn.username}@{conn.host}")
|
||
self._refresh()
|
||
else:
|
||
self.status_label.setText("主机未连接,无法浏览")
|
||
self.table.setRowCount(0)
|
||
|
||
def refresh(self):
|
||
self._refresh()
|
||
|
||
# ------- 内部操作 -------
|
||
def _conn(self) -> Optional[SSHConnection]:
|
||
if not self.current_host_id:
|
||
return None
|
||
c = self.manager.get_connection(self.current_host_id)
|
||
if not c or not c.connected:
|
||
QMessageBox.warning(self, "未连接", "请先在「主机」面板连接当前主机")
|
||
return None
|
||
return c
|
||
|
||
def _go_back(self):
|
||
if len(self.cwd_history) > 1:
|
||
self.cwd_history.pop()
|
||
self.current_path = self.cwd_history[-1]
|
||
self.path_edit.setText(self.current_path)
|
||
self._refresh()
|
||
|
||
def _go_up(self):
|
||
p = self.current_path.rstrip("/")
|
||
if not p:
|
||
return
|
||
parent = os.path.dirname(p) or "/"
|
||
self.current_path = parent
|
||
self.path_edit.setText(parent)
|
||
self._refresh()
|
||
|
||
def _go_to_path(self):
|
||
path = self.path_edit.text().strip() or "/"
|
||
self.current_path = path
|
||
self._refresh()
|
||
|
||
def _refresh(self):
|
||
conn = self._conn()
|
||
if not conn:
|
||
return
|
||
if self.list_worker and self.list_worker.isRunning():
|
||
return
|
||
self.status_label.setText(f"加载中: {self.current_path}")
|
||
self.list_worker = ListDirWorker(conn, self.current_path)
|
||
self.list_worker.finished_with.connect(self._on_list_done)
|
||
self.list_worker.start()
|
||
|
||
def _on_list_done(self, path: str, entries: list):
|
||
if path != self.current_path:
|
||
return # 用户已跳转
|
||
self.table.setRowCount(0)
|
||
# 当前目录放第一行
|
||
cur = QTableWidgetItem(f"📁 .")
|
||
self.table.insertRow(0)
|
||
self.table.setItem(0, 0, cur)
|
||
self.table.setItem(0, 1, QTableWidgetItem("-"))
|
||
self.table.setItem(0, 2, QTableWidgetItem("-"))
|
||
self.table.setItem(0, 3, QTableWidgetItem("dir"))
|
||
for e in entries:
|
||
row = self.table.rowCount()
|
||
self.table.insertRow(row)
|
||
icon = "📁" if e["is_dir"] else "📄"
|
||
self.table.setItem(row, 0, QTableWidgetItem(f"{icon} {e['name']}"))
|
||
self.table.setItem(row, 1, QTableWidgetItem(
|
||
"-" if e["is_dir"] else SystemMonitor.format_bytes(e["size"])
|
||
))
|
||
try:
|
||
ts = time.strftime("%Y-%m-%d %H:%M", time.localtime(e["mtime"]))
|
||
except Exception:
|
||
ts = "-"
|
||
self.table.setItem(row, 2, QTableWidgetItem(ts))
|
||
self.table.setItem(row, 3, QTableWidgetItem("dir" if e["is_dir"] else "file"))
|
||
if self.current_path not in self.cwd_history or self.cwd_history[-1] != self.current_path:
|
||
self.cwd_history.append(self.current_path)
|
||
self.status_label.setText(f"路径: {self.current_path} · 共 {len(entries)} 项")
|
||
|
||
def _on_double_clicked(self, idx):
|
||
row = idx.row()
|
||
if row == 0:
|
||
self._go_up()
|
||
return
|
||
name_item = self.table.item(row, 0)
|
||
if not name_item:
|
||
return
|
||
# 去掉 emoji 前缀
|
||
name = name_item.text().lstrip("📁📄 ").strip()
|
||
type_item = self.table.item(row, 3)
|
||
is_dir = type_item and type_item.text() == "dir"
|
||
if is_dir:
|
||
new_path = self.current_path.rstrip("/") + "/" + name
|
||
if not new_path.startswith("/"):
|
||
new_path = "/" + new_path
|
||
self.current_path = new_path
|
||
self.path_edit.setText(new_path)
|
||
self._refresh()
|
||
else:
|
||
self._download_for_item(name)
|
||
|
||
def _selected_remote_path(self) -> Optional[str]:
|
||
rows = self.table.selectionModel().selectedRows()
|
||
if not rows:
|
||
QMessageBox.information(self, "提示", "请先选中一个文件或目录")
|
||
return None
|
||
row = rows[0].row()
|
||
if row == 0:
|
||
return None
|
||
name_item = self.table.item(row, 0)
|
||
name = name_item.text().lstrip("📁📄 ").strip()
|
||
return self.current_path.rstrip("/") + "/" + name
|
||
|
||
def _upload(self):
|
||
conn = self._conn()
|
||
if not conn:
|
||
return
|
||
files, _ = QFileDialog.getOpenFileNames(self, "选择要上传的文件")
|
||
if not files:
|
||
return
|
||
for local in files:
|
||
base = os.path.basename(local)
|
||
remote = self.current_path.rstrip("/") + "/" + base
|
||
self._start_upload(conn, local, remote)
|
||
|
||
def _start_upload(self, conn, local, remote):
|
||
self.progress.setVisible(True)
|
||
self.progress.setValue(0)
|
||
worker = UploadWorker(conn, local, remote)
|
||
worker.progress.connect(lambda d, t: self.progress.setValue(
|
||
int(d / t * 100) if t else 0))
|
||
worker.finished_with.connect(
|
||
lambda ok, msg, w=worker: self._on_upload_done(ok, msg, w))
|
||
worker.start()
|
||
self._active_workers = getattr(self, "_active_workers", [])
|
||
self._active_workers.append(worker)
|
||
|
||
def _on_upload_done(self, ok, msg, worker):
|
||
self.progress.setVisible(False)
|
||
if ok:
|
||
self.status_label.setText(msg)
|
||
self._refresh()
|
||
else:
|
||
QMessageBox.critical(self, "上传失败", msg)
|
||
if worker in getattr(self, "_active_workers", []):
|
||
self._active_workers.remove(worker)
|
||
|
||
def _download(self):
|
||
rp = self._selected_remote_path()
|
||
if rp:
|
||
self._download_for_path(rp)
|
||
|
||
def _download_for_item(self, name: str):
|
||
rp = self.current_path.rstrip("/") + "/" + name
|
||
self._download_for_path(rp)
|
||
|
||
def _download_for_path(self, remote: str):
|
||
conn = self._conn()
|
||
if not conn:
|
||
return
|
||
default_name = os.path.basename(remote) or "download"
|
||
local, _ = QFileDialog.getSaveFileName(self, "保存到", default_name)
|
||
if not local:
|
||
return
|
||
self.progress.setVisible(True)
|
||
self.progress.setValue(0)
|
||
worker = DownloadWorker(conn, remote, local)
|
||
worker.progress.connect(lambda d, t: self.progress.setValue(
|
||
int(d / t * 100) if t else 0))
|
||
worker.finished_with.connect(
|
||
lambda ok, msg, w=worker: self._on_download_done(ok, msg, w))
|
||
worker.start()
|
||
self._active_workers = getattr(self, "_active_workers", [])
|
||
self._active_workers.append(worker)
|
||
|
||
def _on_download_done(self, ok, msg, worker):
|
||
self.progress.setVisible(False)
|
||
if ok:
|
||
self.status_label.setText(msg)
|
||
else:
|
||
QMessageBox.critical(self, "下载失败", msg)
|
||
if worker in getattr(self, "_active_workers", []):
|
||
self._active_workers.remove(worker)
|
||
|
||
def _mkdir(self):
|
||
conn = self._conn()
|
||
if not conn:
|
||
return
|
||
name, ok = QInputDialog.getText(self, "新建目录", "目录名:")
|
||
if not ok or not name.strip():
|
||
return
|
||
remote = self.current_path.rstrip("/") + "/" + name.strip()
|
||
ok2, msg = conn.mkdir(remote)
|
||
if ok2:
|
||
self._refresh()
|
||
else:
|
||
QMessageBox.critical(self, "创建失败", msg)
|
||
|
||
def _delete(self):
|
||
conn = self._conn()
|
||
if not conn:
|
||
return
|
||
rp = self._selected_remote_path()
|
||
if not rp:
|
||
return
|
||
if QMessageBox.question(
|
||
self, "确认删除",
|
||
f"确定要删除 {rp} 吗?\n目录将递归删除需用命令执行。",
|
||
QMessageBox.Yes | QMessageBox.No
|
||
) != QMessageBox.Yes:
|
||
return
|
||
ok, msg = conn.remove(rp)
|
||
if ok:
|
||
self._refresh()
|
||
else:
|
||
QMessageBox.critical(self, "删除失败", msg)
|
||
|
||
def _rename(self):
|
||
conn = self._conn()
|
||
if not conn:
|
||
return
|
||
rp = self._selected_remote_path()
|
||
if not rp:
|
||
return
|
||
new_name, ok = QInputDialog.getText(
|
||
self, "重命名", "新名称:", text=os.path.basename(rp))
|
||
if not ok or not new_name.strip():
|
||
return
|
||
new_path = self.current_path.rstrip("/") + "/" + new_name.strip()
|
||
ok2, msg = conn.rename(rp, new_path)
|
||
if ok2:
|
||
self._refresh()
|
||
else:
|
||
QMessageBox.critical(self, "重命名失败", msg)
|
||
|
||
|
||
# ============================================================
|
||
# 监控面板
|
||
# ============================================================
|
||
class MonitorPanel(QWidget):
|
||
"""实时监控:CPU、内存、磁盘、网络"""
|
||
|
||
def __init__(self, manager: ConnectionManager, parent=None):
|
||
super().__init__(parent)
|
||
self.manager = manager
|
||
self.current_host_id: Optional[str] = None
|
||
self.worker: Optional[MonitorWorker] = None
|
||
self._last_net: Dict[str, tuple] = {} # iface -> (rx, ts)
|
||
self._build_ui()
|
||
|
||
def _build_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(10, 10, 10, 10)
|
||
layout.setSpacing(10)
|
||
|
||
# 顶部:主机信息 + 控制
|
||
head = QHBoxLayout()
|
||
self.host_label = QLabel("未连接")
|
||
self.host_label.setStyleSheet("font-size: 14pt; font-weight: bold;")
|
||
head.addWidget(self.host_label)
|
||
head.addStretch(1)
|
||
self.interval_combo = QComboBox()
|
||
self.interval_combo.addItems(["1 秒", "2 秒", "3 秒", "5 秒", "10 秒"])
|
||
self.interval_combo.setCurrentIndex(2)
|
||
head.addWidget(QLabel("刷新:"))
|
||
head.addWidget(self.interval_combo)
|
||
self.btn_toggle = QPushButton("开始监控")
|
||
self.btn_toggle.setCheckable(True)
|
||
self.btn_toggle.toggled.connect(self._on_toggle)
|
||
head.addWidget(self.btn_toggle)
|
||
layout.addLayout(head)
|
||
|
||
# 主机信息
|
||
self.info_label = QLabel("-")
|
||
self.info_label.setStyleSheet("color: #666;")
|
||
layout.addWidget(self.info_label)
|
||
|
||
# 用 Splitter 把 4 行指标卡 + 磁盘网络 放上半,进程表放下半
|
||
splitter = QSplitter(Qt.Vertical)
|
||
layout.addWidget(splitter, 1)
|
||
|
||
# ---- 上半:指标卡 + 磁盘 + 网络 ----
|
||
upper = QWidget()
|
||
uv = QVBoxLayout(upper)
|
||
uv.setContentsMargins(0, 0, 0, 0)
|
||
uv.setSpacing(10)
|
||
|
||
# CPU + 内存行
|
||
grid = QHBoxLayout()
|
||
grid.addWidget(self._build_card("CPU 使用率", "cpu_card"))
|
||
grid.addWidget(self._build_card("内存", "mem_card"))
|
||
uv.addLayout(grid)
|
||
|
||
# 负载 + 启动时间
|
||
grid2 = QHBoxLayout()
|
||
grid2.addWidget(self._build_card("系统负载", "load_card"))
|
||
grid2.addWidget(self._build_card("启动时间", "uptime_card"))
|
||
uv.addLayout(grid2)
|
||
|
||
# 进程统计 + 磁盘
|
||
grid_proc_disk = QHBoxLayout()
|
||
grid_proc_disk.addWidget(self._build_proc_summary())
|
||
self.disk_group = self._build_table_card("磁盘", ["挂载点", "已用/总大小", "使用率", "进度"])
|
||
grid_proc_disk.addWidget(self.disk_group, 2)
|
||
uv.addLayout(grid_proc_disk)
|
||
|
||
# 网络
|
||
self.net_group = self._build_table_card("网络", ["网卡", "↓ 接收", "↑ 发送", "速率"])
|
||
uv.addWidget(self.net_group)
|
||
|
||
splitter.addWidget(upper)
|
||
|
||
# ---- 下半:进程区(左侧表格 + 右侧详情/TOP) ----
|
||
self.proc_group = self._build_proc_table()
|
||
self.proc_side = self._build_proc_side()
|
||
proc_splitter = QSplitter(Qt.Horizontal)
|
||
proc_splitter.addWidget(self.proc_group)
|
||
proc_splitter.addWidget(self.proc_side)
|
||
proc_splitter.setSizes([800, 380])
|
||
splitter.addWidget(proc_splitter)
|
||
splitter.setSizes([400, 350])
|
||
|
||
def _build_card(self, title: str, name: str) -> QGroupBox:
|
||
box = QGroupBox(title)
|
||
v = QVBoxLayout(box)
|
||
big = QLabel("0%")
|
||
big.setObjectName(f"{name}_value")
|
||
big.setStyleSheet("font-size: 28pt; font-weight: bold;")
|
||
big.setAlignment(Qt.AlignCenter)
|
||
v.addWidget(big)
|
||
sub = QLabel("-")
|
||
sub.setObjectName(f"{name}_sub")
|
||
sub.setAlignment(Qt.AlignCenter)
|
||
sub.setStyleSheet("color: #888;")
|
||
v.addWidget(sub)
|
||
return box
|
||
|
||
def _build_table_card(self, title: str, headers: list) -> QGroupBox:
|
||
box = QGroupBox(title)
|
||
v = QVBoxLayout(box)
|
||
table = QTableWidget(0, len(headers))
|
||
table.setHorizontalHeaderLabels(headers)
|
||
table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||
table.verticalHeader().setVisible(False)
|
||
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||
v.addWidget(table)
|
||
return box
|
||
|
||
def _build_proc_summary(self) -> QGroupBox:
|
||
"""进程总数 / 运行中 / 睡眠 / 僵尸 概览卡"""
|
||
box = QGroupBox("进程概览")
|
||
v = QVBoxLayout(box)
|
||
# 4 个数字一行
|
||
row1 = QHBoxLayout()
|
||
self.proc_total_value = QLabel("0")
|
||
self.proc_total_value.setStyleSheet("font-size: 18pt; font-weight: bold; color: #42a5f5;")
|
||
self.proc_total_value.setAlignment(Qt.AlignCenter)
|
||
row1.addWidget(self._wrap_with_caption(self.proc_total_value, "总数"))
|
||
self.proc_running_value = QLabel("0")
|
||
self.proc_running_value.setStyleSheet("font-size: 18pt; font-weight: bold; color: #66bb6a;")
|
||
self.proc_running_value.setAlignment(Qt.AlignCenter)
|
||
row1.addWidget(self._wrap_with_caption(self.proc_running_value, "运行"))
|
||
self.proc_sleep_value = QLabel("0")
|
||
self.proc_sleep_value.setStyleSheet("font-size: 18pt; font-weight: bold; color: #b0bec5;")
|
||
self.proc_sleep_value.setAlignment(Qt.AlignCenter)
|
||
row1.addWidget(self._wrap_with_caption(self.proc_sleep_value, "睡眠"))
|
||
self.proc_zombie_value = QLabel("0")
|
||
self.proc_zombie_value.setStyleSheet("font-size: 18pt; font-weight: bold; color: #ef5350;")
|
||
self.proc_zombie_value.setAlignment(Qt.AlignCenter)
|
||
row1.addWidget(self._wrap_with_caption(self.proc_zombie_value, "僵尸"))
|
||
v.addLayout(row1)
|
||
box.setMaximumWidth(380)
|
||
return box
|
||
|
||
def _wrap_with_caption(self, value_label: QLabel, caption: str) -> QWidget:
|
||
w = QWidget()
|
||
v = QVBoxLayout(w)
|
||
v.setContentsMargins(0, 0, 0, 0)
|
||
v.setSpacing(2)
|
||
v.addWidget(value_label)
|
||
cap = QLabel(caption)
|
||
cap.setAlignment(Qt.AlignCenter)
|
||
cap.setStyleSheet("color: #888; font-size: 9pt;")
|
||
v.addWidget(cap)
|
||
return w
|
||
|
||
def _build_proc_table(self) -> QGroupBox:
|
||
"""进程列表:搜索 + 排序 + 树形切换 + 紧凑行 + 高亮 + 多选"""
|
||
box = QGroupBox("进程列表(500 条,多选用 Ctrl/Shift;右键批量杀;双击详情)")
|
||
v = QVBoxLayout(box)
|
||
v.setContentsMargins(6, 6, 6, 6)
|
||
v.setSpacing(4)
|
||
|
||
# 工具栏
|
||
toolbar = QHBoxLayout()
|
||
toolbar.setSpacing(4)
|
||
self.proc_search = QLineEdit()
|
||
self.proc_search.setPlaceholderText("🔍 过滤 (PID/USER/命令) 空格分隔多个关键字 AND 匹配")
|
||
self.proc_search.textChanged.connect(self._apply_proc_filter)
|
||
self.proc_search.setClearButtonEnabled(True)
|
||
toolbar.addWidget(self.proc_search, 2)
|
||
self.proc_sort_combo = QComboBox()
|
||
self.proc_sort_combo.addItems([
|
||
"按 CPU 降序", "按内存降序", "按 PID 升序",
|
||
"按启动时间(新→旧)", "按用户", "按命令名",
|
||
])
|
||
self.proc_sort_combo.currentIndexChanged.connect(self._apply_proc_filter)
|
||
toolbar.addWidget(self.proc_sort_combo)
|
||
self.proc_view_combo = QComboBox()
|
||
self.proc_view_combo.addItems(["📋 扁平", "🌳 树形"])
|
||
self.proc_view_combo.currentIndexChanged.connect(self._apply_proc_filter)
|
||
toolbar.addWidget(self.proc_view_combo)
|
||
self.proc_min_cpu = QSpinBox()
|
||
self.proc_min_cpu.setRange(0, 100)
|
||
self.proc_min_cpu.setValue(0)
|
||
self.proc_min_cpu.setSuffix("%+")
|
||
self.proc_min_cpu.setToolTip("只显示 CPU% ≥ 此值")
|
||
self.proc_min_cpu.setFixedWidth(70)
|
||
self.proc_min_cpu.valueChanged.connect(self._apply_proc_filter)
|
||
toolbar.addWidget(self.proc_min_cpu)
|
||
self.btn_batch_kill = QPushButton("☠ 批量杀")
|
||
self.btn_batch_kill.setToolTip("杀掉所有选中行(先 Ctrl/Shift 多选)")
|
||
self.btn_batch_kill.clicked.connect(self._proc_batch_kill)
|
||
toolbar.addWidget(self.btn_batch_kill)
|
||
v.addLayout(toolbar)
|
||
|
||
# 表格
|
||
self.proc_table = QTableWidget(0, 0) # 列数动态
|
||
self.proc_table.verticalHeader().setVisible(False)
|
||
self.proc_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||
self.proc_table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||
self.proc_table.setSelectionMode(QAbstractItemView.ExtendedSelection) # 多选
|
||
self.proc_table.setAlternatingRowColors(True)
|
||
self.proc_table.verticalHeader().setDefaultSectionSize(20) # 紧凑行高
|
||
# 右键菜单
|
||
self.proc_table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||
self.proc_table.customContextMenuRequested.connect(self._proc_context_menu)
|
||
# 单击行 -> 详情面板
|
||
self.proc_table.itemSelectionChanged.connect(self._on_proc_row_selected)
|
||
# 双击行 -> 杀进程
|
||
self.proc_table.doubleClicked.connect(self._proc_kill_selected)
|
||
v.addWidget(self.proc_table, 1)
|
||
|
||
# 底部状态行
|
||
self.proc_status_label = QLabel("提示: Ctrl+A 全选, Shift+点击多选, 右键批量操作")
|
||
self.proc_status_label.setStyleSheet("color: #888; font-size: 9pt;")
|
||
v.addWidget(self.proc_status_label)
|
||
|
||
# 当前缓存的进程数据
|
||
self._proc_data: List[dict] = []
|
||
# 树形视图用的 PPID 索引
|
||
self._proc_tree_cache: Dict[int, dict] = {}
|
||
# 选中的 PID(多选)
|
||
self._selected_pids: List[int] = []
|
||
return box
|
||
|
||
def _build_proc_side(self) -> QWidget:
|
||
"""右侧栏:详情 + TOP 5 概览"""
|
||
w = QWidget()
|
||
v = QVBoxLayout(w)
|
||
v.setContentsMargins(0, 0, 0, 0)
|
||
v.setSpacing(6)
|
||
|
||
# ---- 详情面板 ----
|
||
detail_box = QGroupBox("进程详情(点左侧行查看)")
|
||
dv = QVBoxLayout(detail_box)
|
||
self.proc_detail = QTextEdit()
|
||
self.proc_detail.setReadOnly(True)
|
||
self.proc_detail.setMaximumHeight(220)
|
||
self.proc_detail.setStyleSheet(
|
||
"QTextEdit { background: #1e1e1e; color: #e0e0e0;"
|
||
" font-family: Consolas, monospace; font-size: 9pt; }"
|
||
)
|
||
self.proc_detail.setPlainText("(点左侧任意进程行查看完整信息)")
|
||
dv.addWidget(self.proc_detail)
|
||
v.addWidget(detail_box)
|
||
|
||
# ---- TOP 5 CPU ----
|
||
cpu_box = QGroupBox("🔥 CPU TOP 5")
|
||
cv = QVBoxLayout(cpu_box)
|
||
self.proc_top_cpu = QListWidget()
|
||
self.proc_top_cpu.setStyleSheet(
|
||
"QListWidget { background: #1e1e1e; color: #e0e0e0; }"
|
||
)
|
||
self.proc_top_cpu.itemClicked.connect(self._on_top_item_clicked)
|
||
cv.addWidget(self.proc_top_cpu)
|
||
v.addWidget(cpu_box)
|
||
|
||
# ---- TOP 5 MEM ----
|
||
mem_box = QGroupBox("💾 内存 TOP 5")
|
||
mv = QVBoxLayout(mem_box)
|
||
self.proc_top_mem = QListWidget()
|
||
self.proc_top_mem.setStyleSheet(
|
||
"QListWidget { background: #1e1e1e; color: #e0e0e0; }"
|
||
)
|
||
self.proc_top_mem.itemClicked.connect(self._on_top_item_clicked)
|
||
mv.addWidget(self.proc_top_mem)
|
||
v.addWidget(mem_box)
|
||
|
||
v.addStretch(1)
|
||
return w
|
||
|
||
def _value_label(self, name: str) -> QLabel:
|
||
return self.findChild(QLabel, f"{name}_value")
|
||
|
||
def _sub_label(self, name: str) -> QLabel:
|
||
return self.findChild(QLabel, f"{name}_sub")
|
||
|
||
def set_host(self, host_id: str):
|
||
"""切换主机时停止旧监控并刷新 UI"""
|
||
self._stop_worker()
|
||
self.current_host_id = host_id
|
||
if not host_id:
|
||
self.host_label.setText("未连接")
|
||
return
|
||
h = self.manager.get_host(host_id)
|
||
if h:
|
||
self.host_label.setText(f"{h.get('name', h.get('host'))} ({h.get('host')})")
|
||
if self.btn_toggle.isChecked():
|
||
self._start_worker()
|
||
|
||
def _on_toggle(self, checked: bool):
|
||
if checked:
|
||
self.btn_toggle.setText("停止监控")
|
||
self._start_worker()
|
||
else:
|
||
self.btn_toggle.setText("开始监控")
|
||
self._stop_worker()
|
||
|
||
def _start_worker(self):
|
||
if not self.current_host_id:
|
||
return
|
||
conn = self.manager.get_connection(self.current_host_id)
|
||
if not conn or not conn.connected:
|
||
self.info_label.setText("⚠ 当前主机未连接")
|
||
return
|
||
idx = self.interval_combo.currentIndex()
|
||
interval = [1, 2, 3, 5, 10][idx]
|
||
self._stop_worker()
|
||
self.worker = MonitorWorker(conn, interval=interval)
|
||
self.worker.sample_ready.connect(self._on_sample)
|
||
self.worker.error.connect(lambda m: self.info_label.setText(f"⚠ {m}"))
|
||
self.worker.start()
|
||
self.info_label.setText(f"已启动监控,每 {interval} 秒刷新")
|
||
|
||
def _stop_worker(self):
|
||
if self.worker:
|
||
self.worker.stop()
|
||
self.worker.wait(2000)
|
||
self.worker = None
|
||
self._last_net.clear()
|
||
|
||
def _on_sample(self, m: dict):
|
||
if m.get("error"):
|
||
self.info_label.setText(f"⚠ {m['error']}")
|
||
return
|
||
# 主机
|
||
os_info = m.get("os", "")
|
||
krn = m.get("kernel", "")
|
||
host = m.get("hostname", "")
|
||
self.info_label.setText(f"主机: {host} · 系统: {os_info} · 内核: {krn}")
|
||
|
||
# CPU
|
||
cpu = m.get("cpu", 0)
|
||
self._value_label("cpu_card").setText(f"{cpu:.1f}%")
|
||
cores = m.get("cores", 1)
|
||
self._sub_label("cpu_card").setText(f"{cores} 核 CPU")
|
||
|
||
# 内存
|
||
mp = m.get("mem_percent", 0)
|
||
mu = SystemMonitor.format_bytes(m.get("mem_used", 0))
|
||
mt = SystemMonitor.format_bytes(m.get("mem_total", 0))
|
||
self._value_label("mem_card").setText(f"{mp:.1f}%")
|
||
self._sub_label("mem_card").setText(f"{mu} / {mt}")
|
||
|
||
# 负载
|
||
l1, l5, l15 = m.get("load1", 0), m.get("load5", 0), m.get("load15", 0)
|
||
self._value_label("load_card").setText(f"{l1:.2f}")
|
||
self._sub_label("load_card").setText(
|
||
f"5分钟: {l5:.2f} · 15分钟: {l15:.2f} (核心数={cores})"
|
||
)
|
||
|
||
# 启动时间
|
||
ut = SystemMonitor.format_uptime(m.get("uptime", 0))
|
||
self._value_label("uptime_card").setText(ut)
|
||
boot_ts = time.time() - m.get("uptime", 0)
|
||
self._sub_label("uptime_card").setText(
|
||
f"启动于: {time.strftime('%Y-%m-%d %H:%M', time.localtime(boot_ts))}"
|
||
)
|
||
|
||
# 进程概览
|
||
self.proc_total_value.setText(str(m.get("proc_total", 0)))
|
||
self.proc_running_value.setText(str(m.get("proc_running", 0)))
|
||
self.proc_sleep_value.setText(str(m.get("proc_sleep", 0)))
|
||
self.proc_zombie_value.setText(str(m.get("proc_zombie", 0)))
|
||
|
||
# 进程列表
|
||
self._proc_data = m.get("processes", [])
|
||
self._apply_proc_filter()
|
||
|
||
# 磁盘表
|
||
disks = m.get("disks", [])
|
||
disk_table: QTableWidget = self.disk_group.findChild(QTableWidget)
|
||
disk_table.setRowCount(len(disks))
|
||
for i, d in enumerate(disks):
|
||
disk_table.setItem(i, 0, QTableWidgetItem(d["mount"]))
|
||
used_s = SystemMonitor.format_bytes(d["used"])
|
||
total_s = SystemMonitor.format_bytes(d["total"])
|
||
disk_table.setItem(i, 1, QTableWidgetItem(f"{used_s} / {total_s}"))
|
||
pct = d["percent"]
|
||
disk_table.setItem(i, 2, QTableWidgetItem(f"{pct}%"))
|
||
bar = QProgressBar()
|
||
bar.setValue(min(pct, 100))
|
||
bar.setFormat(f"{pct}%")
|
||
disk_table.setCellWidget(i, 3, bar)
|
||
|
||
# 网络表(计算每秒速率)
|
||
nets = m.get("net", [])
|
||
net_table: QTableWidget = self.net_group.findChild(QTableWidget)
|
||
net_table.setRowCount(len(nets))
|
||
now = time.time()
|
||
for i, n in enumerate(nets):
|
||
rx, tx, iface = n["rx"], n["tx"], n["iface"]
|
||
rx_s, tx_s = "-", "-"
|
||
if iface in self._last_net:
|
||
last_rx, last_tx, last_ts = self._last_net[iface]
|
||
dt = max(now - last_ts, 0.001)
|
||
rx_speed = (rx - last_rx) / dt
|
||
tx_speed = (tx - last_tx) / dt
|
||
rx_s = f"{SystemMonitor.format_bytes(rx)} ({SystemMonitor.format_bytes(int(rx_speed))}/s)"
|
||
tx_s = f"{SystemMonitor.format_bytes(tx)} ({SystemMonitor.format_bytes(int(tx_speed))}/s)"
|
||
else:
|
||
rx_s = f"{SystemMonitor.format_bytes(rx)}"
|
||
tx_s = f"{SystemMonitor.format_bytes(tx)}"
|
||
self._last_net[iface] = (rx, tx, now)
|
||
net_table.setItem(i, 0, QTableWidgetItem(iface))
|
||
net_table.setItem(i, 1, QTableWidgetItem(rx_s))
|
||
net_table.setItem(i, 2, QTableWidgetItem(tx_s))
|
||
if iface in self._last_net and len(self._last_net[iface]) == 3:
|
||
last_rx, last_tx, last_ts = self._last_net[iface]
|
||
dt = max(now - last_ts, 0.001)
|
||
rx_speed = (rx - last_rx) / dt
|
||
tx_speed = (tx - last_tx) / dt
|
||
net_table.setItem(i, 3, QTableWidgetItem(
|
||
f"↓{SystemMonitor.format_bytes(int(rx_speed))}/s ↑{SystemMonitor.format_bytes(int(tx_speed))}/s"))
|
||
else:
|
||
net_table.setItem(i, 3, QTableWidgetItem("采样中..."))
|
||
|
||
# ============================================================
|
||
# 进程表:过滤 / 排序 / 树形 / TOP 5 / 详情 / 杀进程
|
||
# ============================================================
|
||
# 表格列定义(树形模式下第 1 列是缩进/连接线,普通模式第 1 列是 PID)
|
||
PROC_COLS = [
|
||
("PID", "pid"), # 扁平
|
||
("PPID", "ppid"),
|
||
("USER", "user"),
|
||
("CPU%", "pcpu"),
|
||
("MEM%", "pmem"),
|
||
("RSS", "rss"),
|
||
("STAT", "stat"),
|
||
("PRI", "pri"),
|
||
("NI", "nice"),
|
||
("启动", "etime"),
|
||
("CPU时间", "time"),
|
||
("命令", "comm"),
|
||
]
|
||
|
||
def _apply_proc_filter(self):
|
||
"""根据搜索框 + 排序 + 视图模式 + CPU 过滤,刷新进程表和 TOP 5"""
|
||
if not hasattr(self, "proc_table"):
|
||
return
|
||
query = self.proc_search.text().strip().lower() if hasattr(self, "proc_search") else ""
|
||
terms = [t for t in query.split() if t]
|
||
sort_idx = self.proc_sort_combo.currentIndex() if hasattr(self, "proc_sort_combo") else 0
|
||
view_idx = self.proc_view_combo.currentIndex() if hasattr(self, "proc_view_combo") else 0
|
||
is_tree = (view_idx == 1)
|
||
min_cpu = self.proc_min_cpu.value() if hasattr(self, "proc_min_cpu") else 0
|
||
|
||
data = list(self._proc_data)
|
||
# 过滤
|
||
if terms or min_cpu > 0:
|
||
def match(p):
|
||
if min_cpu > 0 and p.get("pcpu", 0) < min_cpu:
|
||
return False
|
||
if terms:
|
||
hay = f"{p.get('pid','')} {p.get('ppid','')} {p.get('user','')} {p.get('stat','')} {p.get('comm','')}".lower()
|
||
if not all(t in hay for t in terms):
|
||
return False
|
||
return True
|
||
data = [p for p in data if match(p)]
|
||
# 排序
|
||
if sort_idx == 0: # CPU
|
||
data.sort(key=lambda p: p.get("pcpu", 0), reverse=True)
|
||
elif sort_idx == 1: # MEM
|
||
data.sort(key=lambda p: p.get("pmem", 0), reverse=True)
|
||
elif sort_idx == 2: # PID
|
||
data.sort(key=lambda p: p.get("pid", 0))
|
||
elif sort_idx == 3: # 启动时间(新→旧)
|
||
data.sort(key=lambda p: p.get("etime", 0), reverse=True)
|
||
elif sort_idx == 4: # 用户
|
||
data.sort(key=lambda p: (p.get("user", ""), -p.get("pcpu", 0)))
|
||
elif sort_idx == 5: # 命令名
|
||
data.sort(key=lambda p: (p.get("comm", "").split()[0] if p.get("comm") else "", -p.get("pcpu", 0)))
|
||
|
||
# 树形视图:按 PPID 排序 + 缩进
|
||
if is_tree:
|
||
data = self._build_tree_view(data)
|
||
else:
|
||
# 扁平:按 PPID 隐藏列不显示;直接就是排序后列表
|
||
pass
|
||
|
||
# 重置表格
|
||
n_cols = len(self.PROC_COLS) + (1 if is_tree else 0)
|
||
self.proc_table.setColumnCount(n_cols)
|
||
headers = (["▾"] if is_tree else []) + [h[0] for h in self.PROC_COLS]
|
||
self.proc_table.setHorizontalHeaderLabels(headers)
|
||
h = self.proc_table.horizontalHeader()
|
||
if is_tree:
|
||
h.setSectionResizeMode(0, QHeaderView.ResizeToContents) # 树形缩进
|
||
for i, (name, _) in enumerate(self.PROC_COLS):
|
||
offset = 1 if is_tree else 0
|
||
if name == "命令":
|
||
h.setSectionResizeMode(i + offset, QHeaderView.Stretch)
|
||
else:
|
||
h.setSectionResizeMode(i + offset, QHeaderView.ResizeToContents)
|
||
|
||
self.proc_table.setRowCount(len(data))
|
||
for row, item in enumerate(data):
|
||
if is_tree:
|
||
p, depth, is_last = item["proc"], item["depth"], item["is_last"]
|
||
# 树形缩进列
|
||
if depth == 0:
|
||
indent = ""
|
||
else:
|
||
indent = ("│ " * (depth - 1)) + ("└─ " if is_last else "├─ ")
|
||
tree_item = QTableWidgetItem(indent)
|
||
tree_item.setData(Qt.UserRole, p["pid"])
|
||
tree_item.setData(Qt.UserRole + 1, p["comm"])
|
||
if depth == 0:
|
||
tree_item.setForeground(QColor("#42a5f5"))
|
||
self.proc_table.setItem(row, 0, tree_item)
|
||
col_offset = 1
|
||
else:
|
||
p = item
|
||
col_offset = 0
|
||
self._fill_proc_row(row, p, col_offset)
|
||
|
||
# 更新标题 + 状态行
|
||
total = len(self._proc_data)
|
||
shown = len(data)
|
||
mode_label = "树形" if is_tree else "扁平"
|
||
if terms or min_cpu > 0 or sort_idx != 0 or is_tree:
|
||
self.proc_group.setTitle(f"进程列表 · {mode_label} · 显示 {shown}/{total}")
|
||
else:
|
||
self.proc_group.setTitle(f"进程列表 · {mode_label} · {total} 条")
|
||
|
||
# TOP 5
|
||
self._refresh_top5()
|
||
|
||
def _build_tree_view(self, data: List[dict]) -> List[dict]:
|
||
"""按 PPID 构建树视图(保持 CPU 排序,只调整子进程位置)"""
|
||
if not data:
|
||
return []
|
||
# 把所有可见 PID 做集合
|
||
visible_pids = {p["pid"] for p in data}
|
||
# PPID 索引
|
||
children_of: Dict[int, List[dict]] = {}
|
||
for p in data:
|
||
ppid = p.get("ppid", 0)
|
||
children_of.setdefault(ppid, []).append(p)
|
||
# 排序子进程
|
||
for ppid in children_of:
|
||
children_of[ppid].sort(key=lambda p: p.get("pcpu", 0), reverse=True)
|
||
# 从 PID 0 / 1 出发(init / kthreadd),BFS 展开
|
||
result: List[dict] = []
|
||
roots = children_of.get(0, []) + children_of.get(1, [])
|
||
# 也加入那些 PPID 不在可见集合的(孤儿)
|
||
orphan_ppids = set(children_of.keys()) - {0, 1}
|
||
for ppid in sorted(orphan_ppids):
|
||
for p in children_of[ppid]:
|
||
if p.get("ppid") not in visible_pids:
|
||
roots.append(p)
|
||
def walk(node, depth, is_last):
|
||
result.append({"proc": node, "depth": depth, "is_last": is_last})
|
||
# 找子进程
|
||
kids = children_of.get(node["pid"], [])
|
||
for i, k in enumerate(kids):
|
||
walk(k, depth + 1, i == len(kids) - 1)
|
||
for i, r in enumerate(roots):
|
||
walk(r, 0, i == len(roots) - 1)
|
||
return result
|
||
|
||
def _fill_proc_row(self, row: int, p: dict, col_offset: int):
|
||
"""填充一行单元格。col_offset 决定从第几列开始(树形=1,扁平=0)"""
|
||
pid = p.get("pid", 0)
|
||
ppid = p.get("ppid", 0)
|
||
user = p.get("user", "")
|
||
pcpu = p.get("pcpu", 0.0)
|
||
pmem = p.get("pmem", 0.0)
|
||
rss = p.get("rss", 0)
|
||
stat = p.get("stat", "")
|
||
pri = p.get("pri", 0)
|
||
nice = p.get("nice", 0)
|
||
etime = p.get("etime", 0)
|
||
ptime = p.get("time", 0)
|
||
comm = p.get("comm", "")
|
||
|
||
# PID
|
||
pid_item = QTableWidgetItem(str(pid))
|
||
pid_item.setData(Qt.UserRole, pid)
|
||
pid_item.setData(Qt.UserRole + 1, comm)
|
||
pid_item.setData(Qt.UserRole + 2, ppid) # 给详情面板用
|
||
pid_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||
self.proc_table.setItem(row, col_offset, pid_item)
|
||
# PPID
|
||
ppid_item = QTableWidgetItem(str(ppid) if ppid else "-")
|
||
ppid_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||
self.proc_table.setItem(row, col_offset + 1, ppid_item)
|
||
# USER(截断到 12 字符)
|
||
user_display = user if len(user) <= 12 else user[:11] + "…"
|
||
user_item = QTableWidgetItem(user_display)
|
||
user_item.setToolTip(user)
|
||
self.proc_table.setItem(row, col_offset + 2, user_item)
|
||
# CPU%
|
||
cpu_item = QTableWidgetItem(f"{pcpu:.1f}")
|
||
cpu_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||
if pcpu >= 80:
|
||
cpu_item.setBackground(QColor(0xef, 0x53, 0x50, 60))
|
||
cpu_item.setForeground(QColor("#ef5350"))
|
||
font = cpu_item.font()
|
||
font.setBold(True)
|
||
cpu_item.setFont(font)
|
||
elif pcpu >= 50:
|
||
cpu_item.setForeground(QColor("#ef5350"))
|
||
font = cpu_item.font()
|
||
font.setBold(True)
|
||
cpu_item.setFont(font)
|
||
elif pcpu >= 20:
|
||
cpu_item.setForeground(QColor("#ffa726"))
|
||
self.proc_table.setItem(row, col_offset + 3, cpu_item)
|
||
# MEM%
|
||
mem_item = QTableWidgetItem(f"{pmem:.1f}")
|
||
mem_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||
if pmem >= 15:
|
||
mem_item.setBackground(QColor(0xff, 0xa7, 0x26, 60))
|
||
mem_item.setForeground(QColor("#ffa726"))
|
||
font = mem_item.font()
|
||
font.setBold(True)
|
||
mem_item.setFont(font)
|
||
elif pmem >= 5:
|
||
mem_item.setForeground(QColor("#ffa726"))
|
||
self.proc_table.setItem(row, col_offset + 4, mem_item)
|
||
# RSS
|
||
rss_item = QTableWidgetItem(SystemMonitor.format_bytes(rss * 1024))
|
||
rss_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||
self.proc_table.setItem(row, col_offset + 5, rss_item)
|
||
# STAT(含 Z 用红字 + 整行加黄底)
|
||
stat_item = QTableWidgetItem(stat)
|
||
if stat.startswith("Z"):
|
||
stat_item.setBackground(QColor(0xff, 0xc1, 0x07, 80))
|
||
stat_item.setForeground(QColor("#ff6f00"))
|
||
font = stat_item.font()
|
||
font.setBold(True)
|
||
stat_item.setFont(font)
|
||
elif stat.startswith("R"):
|
||
stat_item.setForeground(QColor("#66bb6a"))
|
||
elif "D" in stat:
|
||
stat_item.setForeground(QColor("#ab47bc"))
|
||
self.proc_table.setItem(row, col_offset + 6, stat_item)
|
||
# PRI
|
||
pri_item = QTableWidgetItem(str(pri))
|
||
pri_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||
self.proc_table.setItem(row, col_offset + 7, pri_item)
|
||
# NI
|
||
ni_item = QTableWidgetItem(str(nice))
|
||
ni_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||
if nice < 0:
|
||
ni_item.setForeground(QColor("#66bb6a"))
|
||
elif nice > 0:
|
||
ni_item.setForeground(QColor("#ffa726"))
|
||
self.proc_table.setItem(row, col_offset + 8, ni_item)
|
||
# 启动时长
|
||
self.proc_table.setItem(row, col_offset + 9, QTableWidgetItem(self._fmt_duration(etime)))
|
||
# CPU 时间
|
||
self.proc_table.setItem(row, col_offset + 10, QTableWidgetItem(self._fmt_duration(ptime)))
|
||
# 命令(截断显示 + tooltip 全内容)
|
||
comm_display = comm if len(comm) <= 200 else comm[:200] + "…"
|
||
comm_item = QTableWidgetItem(comm_display)
|
||
comm_item.setToolTip(comm)
|
||
self.proc_table.setItem(row, col_offset + 11, comm_item)
|
||
|
||
def _refresh_top5(self):
|
||
"""刷新 TOP 5 CPU / 内存 列表"""
|
||
if not hasattr(self, "proc_top_cpu"):
|
||
return
|
||
top_cpu = sorted(self._proc_data, key=lambda p: p.get("pcpu", 0), reverse=True)[:5]
|
||
top_mem = sorted(self._proc_data, key=lambda p: p.get("pmem", 0), reverse=True)[:5]
|
||
self.proc_top_cpu.clear()
|
||
for i, p in enumerate(top_cpu, 1):
|
||
txt = f"{i}. PID {p['pid']:<7} {p.get('pcpu', 0):>5.1f}% {p.get('comm','')[:30]}"
|
||
item = QListWidgetItem(txt)
|
||
item.setData(Qt.UserRole, p["pid"])
|
||
self.proc_top_cpu.addItem(item)
|
||
self.proc_top_mem.clear()
|
||
for i, p in enumerate(top_mem, 1):
|
||
txt = f"{i}. PID {p['pid']:<7} {p.get('pmem', 0):>5.1f}% {SystemMonitor.format_bytes(p.get('rss', 0) * 1024):>7} {p.get('comm','')[:25]}"
|
||
item = QListWidgetItem(txt)
|
||
item.setData(Qt.UserRole, p["pid"])
|
||
self.proc_top_mem.addItem(item)
|
||
|
||
def _on_proc_row_selected(self):
|
||
"""点行 -> 详情面板"""
|
||
rows = self.proc_table.selectionModel().selectedRows()
|
||
if not rows:
|
||
return
|
||
row = rows[0].row()
|
||
col_offset = 1 if self.proc_view_combo.currentIndex() == 1 else 0
|
||
pid_item = self.proc_table.item(row, col_offset)
|
||
if not pid_item:
|
||
return
|
||
pid = pid_item.data(Qt.UserRole)
|
||
# 找到进程
|
||
p = next((x for x in self._proc_data if x["pid"] == pid), None)
|
||
if p:
|
||
self._show_proc_detail(p)
|
||
|
||
def _show_proc_detail(self, p: dict):
|
||
"""显示一个进程的完整信息到右侧详情面板"""
|
||
comm = p.get("comm", "")
|
||
comm_first = comm.split()[0] if comm else ""
|
||
lines = [
|
||
f"PID : {p.get('pid')} PPID : {p.get('ppid', 0)} USER : {p.get('user','')}",
|
||
f"STATE : {p.get('stat','')} PRI : {p.get('pri',0)} NICE: {p.get('nice',0)}",
|
||
f"CPU : {p.get('pcpu', 0):.1f}% MEM : {p.get('pmem', 0):.1f}%",
|
||
f"RSS : {SystemMonitor.format_bytes(p.get('rss', 0) * 1024)} VSZ : {SystemMonitor.format_bytes(p.get('vsz', 0) * 1024)}",
|
||
f"运行 : {self._fmt_duration(p.get('etime', 0))} 累计CPU: {self._fmt_duration(p.get('time', 0))}",
|
||
"-" * 50,
|
||
f"COMMAND:",
|
||
f" {comm}",
|
||
]
|
||
# 如果有 PPID,找父进程名
|
||
ppid = p.get("ppid", 0)
|
||
if ppid:
|
||
parent = next((x for x in self._proc_data if x["pid"] == ppid), None)
|
||
if parent:
|
||
pcomm = parent.get("comm", "").split()[0] if parent.get("comm") else "?"
|
||
lines.insert(5, f"父进程 : {ppid} ({pcomm})")
|
||
else:
|
||
lines.insert(5, f"父进程 : {ppid} (未在列表中,可能已退出)")
|
||
# 找子进程
|
||
kids = [x for x in self._proc_data if x.get("ppid") == p.get("pid")]
|
||
if kids:
|
||
lines.append("-" * 50)
|
||
lines.append(f"子进程 ({len(kids)}):")
|
||
for k in kids[:10]:
|
||
kcomm = k.get("comm", "").split()[0] if k.get("comm") else "?"
|
||
lines.append(f" PID {k.get('pid',0):<6} {kcomm}")
|
||
if len(kids) > 10:
|
||
lines.append(f" ... 还有 {len(kids) - 10} 个")
|
||
self.proc_detail.setPlainText("\n".join(lines))
|
||
|
||
def _on_top_item_clicked(self, item: QListWidgetItem):
|
||
"""点 TOP 5 行 -> 滚动到主表对应行 + 选中"""
|
||
pid = item.data(Qt.UserRole)
|
||
col_offset = 1 if self.proc_view_combo.currentIndex() == 1 else 0
|
||
for row in range(self.proc_table.rowCount()):
|
||
pid_item = self.proc_table.item(row, col_offset)
|
||
if pid_item and pid_item.data(Qt.UserRole) == pid:
|
||
self.proc_table.selectRow(row)
|
||
self.proc_table.scrollToItem(pid_item, QAbstractItemView.PositionAtCenter)
|
||
return
|
||
|
||
@staticmethod
|
||
def _fmt_duration(seconds: int) -> str:
|
||
"""把秒数格式化成 5d3h / 2h15m / 45s"""
|
||
s = int(seconds)
|
||
if s < 0:
|
||
s = 0
|
||
days, rem = divmod(s, 86400)
|
||
hours, rem = divmod(rem, 3600)
|
||
mins, secs = divmod(rem, 60)
|
||
if days:
|
||
return f"{days}d{hours}h"
|
||
if hours:
|
||
return f"{hours}h{mins}m"
|
||
if mins:
|
||
return f"{mins}m{secs}s"
|
||
return f"{secs}s"
|
||
|
||
def _proc_context_menu(self, pos):
|
||
idx = self.proc_table.indexAt(pos)
|
||
if not idx.isValid():
|
||
return
|
||
row = idx.row()
|
||
col_offset = 1 if self.proc_view_combo.currentIndex() == 1 else 0
|
||
pid_item = self.proc_table.item(row, col_offset)
|
||
if not pid_item:
|
||
return
|
||
pid = pid_item.data(Qt.UserRole)
|
||
comm = pid_item.data(Qt.UserRole + 1) or ""
|
||
# 选中的行数
|
||
sel_count = len(self.proc_table.selectionModel().selectedRows())
|
||
menu = QMenu(self.proc_table)
|
||
a_soft = menu.addAction(f"🔪 kill {pid} (SIGTERM)")
|
||
a_hard = menu.addAction(f"☠ kill -9 {pid} (SIGKILL)")
|
||
if sel_count > 1:
|
||
menu.addSeparator()
|
||
a_batch = menu.addAction(f"☠ 批量杀选中 {sel_count} 个 (SIGTERM)")
|
||
else:
|
||
a_batch = None
|
||
menu.addSeparator()
|
||
a_copy = menu.addAction("复制 PID")
|
||
a_copy_comm = menu.addAction("复制命令行")
|
||
a_filter = menu.addAction("按此命令过滤")
|
||
a_detail = menu.addAction("查看详情")
|
||
chosen = menu.exec_(self.proc_table.viewport().mapToGlobal(pos))
|
||
if chosen == a_soft:
|
||
self._proc_kill(pid, comm, force=False)
|
||
elif chosen == a_hard:
|
||
self._proc_kill(pid, comm, force=True)
|
||
elif chosen == a_batch:
|
||
self._proc_batch_kill()
|
||
elif chosen == a_copy:
|
||
QApplication.clipboard().setText(str(pid))
|
||
elif chosen == a_copy_comm:
|
||
QApplication.clipboard().setText(comm)
|
||
elif chosen == a_filter:
|
||
# 取 comm 的第一段(命令名)
|
||
base = comm.split()[0] if comm else ""
|
||
if base:
|
||
self.proc_search.setText(base)
|
||
elif chosen == a_detail:
|
||
self._show_proc_detail(next((x for x in self._proc_data if x["pid"] == pid), {"pid": pid}))
|
||
|
||
def _proc_kill_selected(self):
|
||
"""双击行:杀进程(弹确认)"""
|
||
rows = self.proc_table.selectionModel().selectedRows()
|
||
if not rows:
|
||
return
|
||
row = rows[0].row()
|
||
col_offset = 1 if self.proc_view_combo.currentIndex() == 1 else 0
|
||
pid_item = self.proc_table.item(row, col_offset)
|
||
if not pid_item:
|
||
return
|
||
pid = pid_item.data(Qt.UserRole)
|
||
comm = pid_item.data(Qt.UserRole + 1) or ""
|
||
self._proc_kill(pid, comm, force=False)
|
||
|
||
def _proc_batch_kill(self):
|
||
"""批量杀所有选中行"""
|
||
rows = self.proc_table.selectionModel().selectedRows()
|
||
if not rows:
|
||
QMessageBox.information(self, "提示", "请先在表格里选中要杀的多行(Ctrl/Shift 多选)")
|
||
return
|
||
col_offset = 1 if self.proc_view_combo.currentIndex() == 1 else 0
|
||
targets = [] # [(pid, comm), ...]
|
||
for r in rows:
|
||
pid_item = self.proc_table.item(r.row(), col_offset)
|
||
if pid_item:
|
||
pid = pid_item.data(Qt.UserRole)
|
||
comm = pid_item.data(Qt.UserRole + 1) or ""
|
||
if pid:
|
||
targets.append((pid, comm))
|
||
if not targets:
|
||
return
|
||
# 确认
|
||
preview = "\n".join(f" PID {p:<7} {c[:60]}" for p, c in targets[:10])
|
||
if len(targets) > 10:
|
||
preview += f"\n ... 还有 {len(targets) - 10} 个"
|
||
reply = QMessageBox.question(
|
||
self, f"批量杀 {len(targets)} 个进程",
|
||
f"将向以下进程发送 SIGTERM:\n\n{preview}\n\n"
|
||
f"建议先 SIGTERM,等几秒不响应再 SIGKILL。\n继续吗?",
|
||
QMessageBox.Yes | QMessageBox.No,
|
||
)
|
||
if reply != QMessageBox.Yes:
|
||
return
|
||
if not self.current_host_id:
|
||
return
|
||
conn = self.manager.get_connection(self.current_host_id)
|
||
if not conn or not conn.connected:
|
||
QMessageBox.warning(self, "未连接", "主机未连接")
|
||
return
|
||
# 一次 ssh 跑全部 kill(避免循环 ssh 开销)
|
||
pids = " ".join(str(p) for p, _ in targets)
|
||
code, out, err = conn.exec_command(
|
||
f"kill {pids} 2>&1 ; sleep 0.5 ; for p in {pids}; do "
|
||
f"if kill -0 $p 2>/dev/null; then echo \"$p alive\"; fi; done",
|
||
timeout=15,
|
||
)
|
||
killed = len(targets) - (out.count("alive") if "alive" in out else 0)
|
||
QMessageBox.information(
|
||
self, "批量杀完成",
|
||
f"共 {len(targets)} 个,已确认退出 {killed} 个\n"
|
||
f"残留(未响应 SIGTERM,建议 SIGKILL): {out.count('alive')}\n\n"
|
||
f"远程输出:\n{out.strip()[:500]}")
|
||
|
||
def _proc_kill(self, pid: int, comm: str, force: bool = False):
|
||
"""在远程主机上执行 kill [pid]"""
|
||
if not self.current_host_id:
|
||
return
|
||
conn = self.manager.get_connection(self.current_host_id)
|
||
if not conn or not conn.connected:
|
||
QMessageBox.warning(self, "未连接", "主机未连接")
|
||
return
|
||
sig = "SIGKILL" if force else "SIGTERM"
|
||
flag = "-9" if force else ""
|
||
short_comm = (comm[:60] + "…") if len(comm) > 60 else comm
|
||
reply = QMessageBox.question(
|
||
self, f"杀进程 {pid}",
|
||
f"确定要 {sig} 进程 {pid} 吗?\n\n"
|
||
f"命令: {short_comm}\n\n"
|
||
f"建议: 先 SIGTERM 让进程清理;不响应再 SIGKILL。",
|
||
QMessageBox.Yes | QMessageBox.No,
|
||
)
|
||
if reply != QMessageBox.Yes:
|
||
return
|
||
code, out, err = conn.exec_command(f"kill {flag} {pid} 2>&1; echo \"exit=$?\"", timeout=10)
|
||
if code == 0:
|
||
QMessageBox.information(self, "完成",
|
||
f"已对 PID {pid} 发送 {sig}\n远程返回:\n{out.strip()}")
|
||
else:
|
||
QMessageBox.critical(self, "失败",
|
||
f"kill {pid} 失败\n退出码: {code}\nstderr: {err.strip()}\nstdout: {out.strip()}")
|
||
|
||
|
||
# ============================================================
|
||
# AI Agent 对话面板
|
||
# ============================================================
|
||
class AIChatPanel(QWidget):
|
||
"""AI Agent 对话面板"""
|
||
|
||
def __init__(self, manager: ConnectionManager, agent, parent=None):
|
||
super().__init__(parent)
|
||
self.manager = manager
|
||
self.agent = agent
|
||
self.worker: Optional[AIWorker] = None
|
||
self.current_host_id: Optional[str] = None
|
||
self._build_ui()
|
||
|
||
def _build_ui(self):
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(8, 8, 8, 8)
|
||
|
||
# 顶部状态
|
||
head = QHBoxLayout()
|
||
self.status_label = QLabel("AI Agent: 未配置")
|
||
self.status_label.setStyleSheet("color: #888;")
|
||
head.addWidget(self.status_label)
|
||
head.addStretch(1)
|
||
self.btn_config = QPushButton("⚙ AI 设置")
|
||
self.btn_config.clicked.connect(self._show_config)
|
||
head.addWidget(self.btn_config)
|
||
self.btn_clear = QPushButton("清空对话")
|
||
self.btn_clear.clicked.connect(self._clear)
|
||
head.addWidget(self.btn_clear)
|
||
layout.addLayout(head)
|
||
|
||
# 快捷指令
|
||
quick = QHBoxLayout()
|
||
quick.addWidget(QLabel("快捷:"))
|
||
for label, prompt in [
|
||
("检查状态", "帮我检查当前主机的运行状态,包括 CPU/内存/磁盘/网络"),
|
||
("查日志", "查看最近 100 行系统日志,重点关注 error 和 warning"),
|
||
("找大文件", "列出 /var/log 目录下最大的 10 个文件"),
|
||
("查端口", "查看当前监听的端口以及对应进程"),
|
||
]:
|
||
b = QPushButton(label)
|
||
b.clicked.connect(lambda _, p=prompt: self.input_edit.setText(p))
|
||
quick.addWidget(b)
|
||
quick.addStretch(1)
|
||
layout.addLayout(quick)
|
||
|
||
# 对话显示
|
||
self.chat_view = QTextEdit()
|
||
self.chat_view.setReadOnly(True)
|
||
self.chat_view.setStyleSheet("""
|
||
QTextEdit {
|
||
background: #1e1e1e;
|
||
color: #e0e0e0;
|
||
font-family: Consolas, 'Courier New', monospace;
|
||
font-size: 10pt;
|
||
}
|
||
""")
|
||
layout.addWidget(self.chat_view, 1)
|
||
|
||
# 输入区
|
||
input_layout = QHBoxLayout()
|
||
self.input_edit = QLineEdit()
|
||
self.input_edit.setPlaceholderText("输入问题,回车发送(Shift+回车换行)...")
|
||
self.input_edit.returnPressed.connect(self._send)
|
||
self.btn_send = QPushButton("发送")
|
||
self.btn_send.clicked.connect(self._send)
|
||
input_layout.addWidget(self.input_edit, 1)
|
||
input_layout.addWidget(self.btn_send)
|
||
layout.addLayout(input_layout)
|
||
|
||
self._append("系统", "AI Agent 已就绪。请先点击「⚙ AI 设置」配置 API Key。")
|
||
|
||
def set_host(self, host_id: str):
|
||
self.current_host_id = host_id
|
||
if host_id:
|
||
h = self.manager.get_host(host_id)
|
||
if h:
|
||
self._append("系统", f"已切换到主机: {h.get('name', h.get('host'))}")
|
||
|
||
def _append(self, role: str, content: str, color: str = ""):
|
||
ts = datetime.now().strftime("%H:%M:%S")
|
||
color_map = {
|
||
"user": "#4fc3f7", "assistant": "#aed581", "assistant_thinking": "#ffb74d",
|
||
"tool_call": "#ba68c8", "tool_result": "#90a4ae", "error": "#ef5350",
|
||
"system": "#888",
|
||
}
|
||
c = color or color_map.get(role, "#e0e0e0")
|
||
safe = (content or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||
safe = safe.replace("\n", "<br>")
|
||
role_label_map = {
|
||
"user": "我", "assistant": "AI", "assistant_thinking": "AI 思考",
|
||
"tool_call": "调用", "tool_result": "结果", "error": "错误", "system": "系统",
|
||
}
|
||
rl = role_label_map.get(role, role)
|
||
self.chat_view.append(
|
||
f'<span style="color:#666;">[{ts}]</span> '
|
||
f'<b style="color:{c};">{rl}</b>: '
|
||
f'<span style="color:{c};">{safe}</span>'
|
||
)
|
||
# 滚动到底
|
||
sb = self.chat_view.verticalScrollBar()
|
||
sb.setValue(sb.maximum())
|
||
|
||
def _send(self):
|
||
if self.worker and self.worker.isRunning():
|
||
return
|
||
text = self.input_edit.text().strip()
|
||
if not text:
|
||
return
|
||
if not self.agent.api_key:
|
||
QMessageBox.warning(self, "未配置", "请先在「⚙ AI 设置」中配置 API Key")
|
||
return
|
||
self._append("user", text)
|
||
self.input_edit.clear()
|
||
self.btn_send.setEnabled(False)
|
||
self.btn_send.setText("思考中...")
|
||
conn = self.manager.get_connection(self.current_host_id) if self.current_host_id else None
|
||
self.worker = AIWorker(self.agent, text, conn)
|
||
self.worker.step.connect(self._append)
|
||
self.worker.finished_with.connect(self._on_done)
|
||
self.worker.error.connect(self._on_error)
|
||
self.worker.start()
|
||
|
||
def _on_done(self, final: str):
|
||
self._append("assistant", final)
|
||
self.btn_send.setEnabled(True)
|
||
self.btn_send.setText("发送")
|
||
|
||
def _on_error(self, msg: str):
|
||
self._append("error", msg)
|
||
self.btn_send.setEnabled(True)
|
||
self.btn_send.setText("发送")
|
||
|
||
def _clear(self):
|
||
self.agent.clear_history()
|
||
self.chat_view.clear()
|
||
self._append("系统", "对话历史已清空")
|
||
|
||
def _show_config(self):
|
||
from .config_dialog import AIConfigDialog
|
||
dlg = AIConfigDialog(self.agent, self)
|
||
if dlg.exec_():
|
||
self.status_label.setText(
|
||
f"AI Agent: {self.agent.model} @ {self.agent.base_url}")
|
||
self._append("系统", "AI 配置已更新")
|