feat: overhaul process monitoring UI - more info, tree view, details, batch ops
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.
This commit is contained in:
+20
-16
@@ -54,13 +54,14 @@ echo "===PROC==="
|
||||
ps -e -o stat= 2>/dev/null | awk '{r+=($1~/^R/); s+=($1~/^S/); d+=($1~/^D/); z+=($1~/^Z/); t++} END{printf "PROC_TOTAL=%d\nPROC_RUNNING=%d\nPROC_SLEEP=%d\nPROC_DISK=%d\nPROC_ZOMBIE=%d\n", t, r, s, d, z}'
|
||||
# 进程列表:etimes/times 都是纯数字,避免 start 字段含 "Jul 21" 多列错位
|
||||
# comm 字段可能含空格,前 9 列用 \t 拼,comm 用换行做记录结束
|
||||
ps -eo pid,user,pcpu,pmem,vsz,rss,stat,etimes,times,args --sort=-pcpu --no-headers 2>/dev/null \
|
||||
| head -200 \
|
||||
# 字段:pid ppid user pcpu pmem vsz rss stat pri nice etimes times args
|
||||
ps -eo pid,ppid,user,pcpu,pmem,vsz,rss,stat,pri,nice,etimes,times,args --sort=-pcpu --no-headers 2>/dev/null \
|
||||
| head -500 \
|
||||
| awk '{
|
||||
out="PROC\t";
|
||||
for(i=1;i<=9;i++) out=out $i "\t";
|
||||
for(i=1;i<=12;i++) out=out $i "\t";
|
||||
rest="";
|
||||
for(i=10;i<=NF;i++) rest=(i==10?$i:rest " " $i);
|
||||
for(i=13;i<=NF;i++) rest=(i==13?$i:rest " " $i);
|
||||
print out rest
|
||||
}'
|
||||
echo "===HOST==="
|
||||
@@ -167,29 +168,32 @@ echo "OS=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME" || uname -s)"
|
||||
result["proc_disk"] = 0
|
||||
result["proc_zombie"] = 0
|
||||
|
||||
# 进程列表(远程用 \t 分隔前 9 列,第 10 列开始是 args/comm,可能含空格)
|
||||
# 进程列表(远程用 \t 分隔前 12 列,第 13 列开始是 args/comm,可能含空格)
|
||||
result["processes"] = []
|
||||
for line in out.splitlines():
|
||||
if not line.startswith("PROC\t"):
|
||||
continue
|
||||
payload = line[5:] # 去掉 "PROC\t" 前缀
|
||||
fields = payload.split("\t", 9) # 只切前 9 次
|
||||
if len(fields) < 10:
|
||||
fields = payload.split("\t", 12) # 只切前 12 次
|
||||
if len(fields) < 13:
|
||||
continue
|
||||
try:
|
||||
result["processes"].append({
|
||||
"pid": int(fields[0]),
|
||||
"user": fields[1],
|
||||
"pcpu": float(fields[2]),
|
||||
"pmem": float(fields[3]),
|
||||
"vsz": int(fields[4]),
|
||||
"rss": int(fields[5]),
|
||||
"stat": fields[6],
|
||||
"ppid": int(fields[1]) if fields[1].isdigit() else 0,
|
||||
"user": fields[2],
|
||||
"pcpu": float(fields[3]),
|
||||
"pmem": float(fields[4]),
|
||||
"vsz": int(fields[5]),
|
||||
"rss": int(fields[6]),
|
||||
"stat": fields[7],
|
||||
"pri": int(fields[8]) if fields[8].lstrip("-").isdigit() else 0,
|
||||
"nice": int(fields[9]) if fields[9].lstrip("-").isdigit() else 0,
|
||||
# etimes: 自启动以来的秒数(整数)
|
||||
"etime": int(fields[7]) if fields[7].isdigit() else 0,
|
||||
"etime": int(fields[10]) if fields[10].isdigit() else 0,
|
||||
# times: 累计 CPU 时间秒数
|
||||
"time": int(fields[8]) if fields[8].isdigit() else 0,
|
||||
"comm": fields[9].strip(),
|
||||
"time": int(fields[11]) if fields[11].isdigit() else 0,
|
||||
"comm": fields[12].strip(),
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
+46
-1
@@ -65,8 +65,53 @@ def main():
|
||||
# 验证第一列 (PID) 是数字
|
||||
pid_text = panel.proc_table.item(0, 0).text()
|
||||
assert pid_text.isdigit(), f"PID 列不是数字: {pid_text!r}"
|
||||
# 验证 PPID 列存在
|
||||
ppid_text = panel.proc_table.item(0, 1).text()
|
||||
assert ppid_text.lstrip("-").isdigit() or ppid_text == "-", f"PPID 错: {ppid_text!r}"
|
||||
# 验证 PRI/NI 列
|
||||
pri_text = panel.proc_table.item(0, 7).text()
|
||||
assert pri_text.lstrip("-").isdigit(), f"PRI 错: {pri_text!r}"
|
||||
print(f" ✓ 进程表行数: {panel.proc_table.rowCount()}")
|
||||
print(f" ✓ 第 1 行 PID={pid_text} CPU={panel.proc_table.item(0, 2).text()}%")
|
||||
print(f" ✓ 第 1 行 PID={pid_text} PPID={ppid_text} CPU={panel.proc_table.item(0, 3).text()}% PRI={pri_text}")
|
||||
|
||||
# TOP 5
|
||||
assert panel.proc_top_cpu.count() == 5
|
||||
assert panel.proc_top_mem.count() == 5
|
||||
top_cpu_first = panel.proc_top_cpu.item(0).text()
|
||||
top_mem_first = panel.proc_top_mem.item(0).text()
|
||||
assert "%" in top_cpu_first and "%" in top_mem_first
|
||||
print(f" ✓ TOP CPU: {top_cpu_first[:60]}")
|
||||
print(f" ✓ TOP MEM: {top_mem_first[:60]}")
|
||||
|
||||
# 树形切换
|
||||
panel.proc_view_combo.setCurrentIndex(1)
|
||||
app.processEvents()
|
||||
# 第一行应该有缩进符号(树根没有符号,但深度=0)
|
||||
first_cell = panel.proc_table.item(0, 0).text()
|
||||
assert "├─" in first_cell or first_cell == "", f"树形第 1 行异常: {first_cell!r}"
|
||||
# 列数应该 13(树形多 1 列)
|
||||
assert panel.proc_table.columnCount() == 13
|
||||
print(f" ✓ 树形视图生效 (第 1 行: {first_cell!r}, 13 列)")
|
||||
# 切回扁平
|
||||
panel.proc_view_combo.setCurrentIndex(0)
|
||||
app.processEvents()
|
||||
assert panel.proc_table.columnCount() == 12
|
||||
print(f" ✓ 切回扁平 (12 列)")
|
||||
|
||||
# CPU 过滤
|
||||
panel.proc_min_cpu.setValue(10)
|
||||
app.processEvents()
|
||||
after_min = panel.proc_table.rowCount()
|
||||
if after_min > 0:
|
||||
# 验证所有行的 CPU 列都 >= 10
|
||||
for r in range(min(after_min, 10)):
|
||||
cpu_val = float(panel.proc_table.item(r, 3).text())
|
||||
assert cpu_val >= 10, f"CPU 过滤失败: 行 {r} CPU={cpu_val}"
|
||||
print(f" ✓ CPU>=10% 过滤后剩 {after_min} 行,全部 CPU≥10%")
|
||||
panel.proc_min_cpu.setValue(0)
|
||||
app.processEvents()
|
||||
assert panel.proc_table.rowCount() == len(m["processes"])
|
||||
print(f" ✓ CPU 过滤清空恢复 {panel.proc_table.rowCount()} 行")
|
||||
|
||||
print("[4/5] 搜索过滤")
|
||||
# 找一个常见的进程名
|
||||
|
||||
+458
-91
@@ -18,7 +18,7 @@ from PyQt5.QtWidgets import (
|
||||
QFileDialog, QMessageBox, QProgressBar, QTreeWidget, QTreeWidgetItem,
|
||||
QTextEdit, QSplitter, QFrame, QSizePolicy, QGroupBox, QFormLayout,
|
||||
QComboBox, QToolButton, QStyle, QApplication, QInputDialog,
|
||||
QListWidget, QListWidgetItem, QTabWidget, QMenu, QShortcut,
|
||||
QListWidget, QListWidgetItem, QTabWidget, QMenu, QShortcut, QSpinBox,
|
||||
)
|
||||
|
||||
from core.ssh_client import SSHConnection
|
||||
@@ -429,9 +429,14 @@ class MonitorPanel(QWidget):
|
||||
|
||||
splitter.addWidget(upper)
|
||||
|
||||
# ---- 下半:进程表 ----
|
||||
# ---- 下半:进程区(左侧表格 + 右侧详情/TOP) ----
|
||||
self.proc_group = self._build_proc_table()
|
||||
splitter.addWidget(self.proc_group)
|
||||
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:
|
||||
@@ -499,60 +504,121 @@ class MonitorPanel(QWidget):
|
||||
return w
|
||||
|
||||
def _build_proc_table(self) -> QGroupBox:
|
||||
"""进程列表(搜索 + 排序 + 右键杀进程)"""
|
||||
box = QGroupBox("进程列表(前 200,按 CPU 降序,双击/右键杀进程)")
|
||||
"""进程列表:搜索 + 排序 + 树形切换 + 紧凑行 + 高亮 + 多选"""
|
||||
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/用户/命令行,含空格时拆分多关键字)...")
|
||||
self.proc_search.setPlaceholderText("🔍 过滤 (PID/USER/命令) 空格分隔多个关键字 AND 匹配")
|
||||
self.proc_search.textChanged.connect(self._apply_proc_filter)
|
||||
toolbar.addWidget(self.proc_search, 1)
|
||||
self.proc_search.setClearButtonEnabled(True)
|
||||
toolbar.addWidget(self.proc_search, 2)
|
||||
self.proc_sort_combo = QComboBox()
|
||||
self.proc_sort_combo.addItems([
|
||||
"按 CPU 降序", "按内存降序", "按 PID 升序", "按启动时间降序", "按用户",
|
||||
"按 CPU 降序", "按内存降序", "按 PID 升序",
|
||||
"按启动时间(新→旧)", "按用户", "按命令名",
|
||||
])
|
||||
self.proc_sort_combo.currentIndexChanged.connect(self._apply_proc_filter)
|
||||
toolbar.addWidget(QLabel("排序:"))
|
||||
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)
|
||||
|
||||
# 表格
|
||||
headers = ["PID", "用户", "CPU%", "MEM%", "RSS", "STAT", "启动", "CPU时间", "命令"]
|
||||
self.proc_table = QTableWidget(0, len(headers))
|
||||
self.proc_table.setHorizontalHeaderLabels(headers)
|
||||
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)
|
||||
# 列宽策略
|
||||
h = self.proc_table.horizontalHeader()
|
||||
h.setSectionResizeMode(0, QHeaderView.ResizeToContents) # PID
|
||||
h.setSectionResizeMode(1, QHeaderView.ResizeToContents) # USER
|
||||
h.setSectionResizeMode(2, QHeaderView.ResizeToContents) # CPU%
|
||||
h.setSectionResizeMode(3, QHeaderView.ResizeToContents) # MEM%
|
||||
h.setSectionResizeMode(4, QHeaderView.ResizeToContents) # RSS
|
||||
h.setSectionResizeMode(5, QHeaderView.ResizeToContents) # STAT
|
||||
h.setSectionResizeMode(6, QHeaderView.ResizeToContents) # ETIME
|
||||
h.setSectionResizeMode(7, QHeaderView.ResizeToContents) # TIME
|
||||
h.setSectionResizeMode(8, QHeaderView.Stretch) # COMMAND
|
||||
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)
|
||||
|
||||
# 提示
|
||||
hint = QLabel("提示: 右键/双击进程可执行 kill / kill -9。危险操作,请确认 PID。")
|
||||
hint.setStyleSheet("color: #888; font-size: 9pt;")
|
||||
v.addWidget(hint)
|
||||
# 底部状态行
|
||||
self.proc_status_label = QLabel("提示: Ctrl+A 全选, Shift+点击多选, 右键批量操作")
|
||||
self.proc_status_label.setStyleSheet("color: #888; font-size: 9pt;")
|
||||
v.addWidget(self.proc_status_label)
|
||||
|
||||
# 当前缓存的进程数据(worker 回调里更新)
|
||||
# 当前缓存的进程数据
|
||||
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")
|
||||
|
||||
@@ -700,90 +766,328 @@ class MonitorPanel(QWidget):
|
||||
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:
|
||||
if terms or min_cpu > 0:
|
||||
def match(p):
|
||||
hay = f"{p.get('pid','')} {p.get('user','')} {p.get('stat','')} {p.get('comm','')}".lower()
|
||||
return all(t in hay for t in terms)
|
||||
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 降序
|
||||
if sort_idx == 0: # CPU
|
||||
data.sort(key=lambda p: p.get("pcpu", 0), reverse=True)
|
||||
elif sort_idx == 1: # MEM 降序
|
||||
elif sort_idx == 1: # MEM
|
||||
data.sort(key=lambda p: p.get("pmem", 0), reverse=True)
|
||||
elif sort_idx == 2: # PID 升序
|
||||
elif sort_idx == 2: # PID
|
||||
data.sort(key=lambda p: p.get("pid", 0))
|
||||
elif sort_idx == 3: # 启动时间降序(etime 大 = 新)
|
||||
elif sort_idx == 3: # 启动时间(新→旧)
|
||||
data.sort(key=lambda p: p.get("etime", 0), reverse=True)
|
||||
elif sort_idx == 4: # 按用户,再 CPU
|
||||
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, p in enumerate(data):
|
||||
pid = p.get("pid", 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", "")
|
||||
etime = p.get("etime", 0)
|
||||
ptime = p.get("time", 0)
|
||||
comm = p.get("comm", "")
|
||||
# PID 列把 pid 存到 UserRole,方便右键菜单拿到
|
||||
pid_item = QTableWidgetItem(str(pid))
|
||||
pid_item.setData(Qt.UserRole, pid)
|
||||
pid_item.setData(Qt.UserRole + 1, comm)
|
||||
pid_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.proc_table.setItem(row, 0, pid_item)
|
||||
self.proc_table.setItem(row, 1, QTableWidgetItem(user))
|
||||
cpu_item = QTableWidgetItem(f"{pcpu:.1f}")
|
||||
cpu_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
# 高的 CPU/MEM 标色
|
||||
if pcpu >= 50:
|
||||
cpu_item.setForeground(QColor("#ef5350"))
|
||||
elif pcpu >= 20:
|
||||
cpu_item.setForeground(QColor("#ffa726"))
|
||||
self.proc_table.setItem(row, 2, cpu_item)
|
||||
mem_item = QTableWidgetItem(f"{pmem:.1f}")
|
||||
mem_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
if pmem >= 10:
|
||||
mem_item.setForeground(QColor("#ef5350"))
|
||||
elif pmem >= 5:
|
||||
mem_item.setForeground(QColor("#ffa726"))
|
||||
self.proc_table.setItem(row, 3, mem_item)
|
||||
rss_item = QTableWidgetItem(SystemMonitor.format_bytes(rss * 1024))
|
||||
rss_item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.proc_table.setItem(row, 4, rss_item)
|
||||
# STAT 含 Z 用红色
|
||||
stat_item = QTableWidgetItem(stat)
|
||||
if stat.startswith("Z"):
|
||||
stat_item.setForeground(QColor("#ef5350"))
|
||||
self.proc_table.setItem(row, 5, stat_item)
|
||||
self.proc_table.setItem(row, 6, QTableWidgetItem(self._fmt_duration(etime)))
|
||||
self.proc_table.setItem(row, 7, QTableWidgetItem(self._fmt_duration(ptime)))
|
||||
comm_item = QTableWidgetItem(comm)
|
||||
comm_item.setToolTip(comm)
|
||||
self.proc_table.setItem(row, 8, comm_item)
|
||||
# 更新标题栏显示当前过滤后条数
|
||||
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)
|
||||
title = "进程列表"
|
||||
if terms or sort_idx != 0:
|
||||
title += f" (显示 {shown}/{total})"
|
||||
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:
|
||||
title += f" (前 {total})"
|
||||
self.proc_group.setTitle(title)
|
||||
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:
|
||||
@@ -807,23 +1111,34 @@ class MonitorPanel(QWidget):
|
||||
if not idx.isValid():
|
||||
return
|
||||
row = idx.row()
|
||||
pid_item = self.proc_table.item(row, 0)
|
||||
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:
|
||||
@@ -833,6 +1148,8 @@ class MonitorPanel(QWidget):
|
||||
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):
|
||||
"""双击行:杀进程(弹确认)"""
|
||||
@@ -840,13 +1157,63 @@ class MonitorPanel(QWidget):
|
||||
if not rows:
|
||||
return
|
||||
row = rows[0].row()
|
||||
pid_item = self.proc_table.item(row, 0)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user