neoLegacy/tools/performance-monitor/widgets.py
itsRevela be17d4028f feat: add performance monitor tool
DLL injector + Python GUI for real-time server metrics.
Hooks into tick loop, chunk generation, and player chunk map
to collect timing data over TCP, displayed in a tkinter dashboard.
Includes bot spawner for load testing.
2026-04-05 15:49:52 -05:00

495 lines
17 KiB
Python

"""
Custom widgets for the performance monitor GUI.
- TickBreakdownBar: stacked horizontal bar showing per-phase timing
- TPSGraph: real-time TPS line chart (adapted from server-monitor)
- EntityChunkPanel: per-dimension entity/chunk counts
- MemoryBar: process memory usage bar
- LagSpikePanel: recent lag spikes with root cause
"""
import time
from collections import deque
from PySide6.QtCore import Qt
from PySide6.QtGui import QPainter, QColor, QPen, QFont, QBrush, QLinearGradient
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
QLabel, QGroupBox, QFrame, QScrollArea, QSizePolicy,
)
from models import TickSnapshot, AutosaveSnapshot
# -----------------------------------------------------------------------
# Color palette for tick phases
# -----------------------------------------------------------------------
PHASE_COLORS = {
"pool_reset": QColor(100, 100, 120),
"level_tick_0": QColor(70, 130, 220), # Overworld level tick
"entity_tick_0": QColor(230, 120, 50), # Overworld entity tick
"tracker_tick_0": QColor(200, 200, 70), # Overworld tracker
"level_tick_1": QColor(150, 60, 180), # Nether level tick
"entity_tick_1": QColor(220, 60, 80), # Nether entity tick
"tracker_tick_1": QColor(180, 180, 50), # Nether tracker
"level_tick_2": QColor(50, 180, 170), # End level tick
"entity_tick_2": QColor(220, 100, 140), # End entity tick
"tracker_tick_2": QColor(160, 160, 50), # End tracker
"players_tick": QColor(80, 200, 120),
"connection_tick": QColor(80, 180, 220),
"console_tick": QColor(120, 120, 140),
"other": QColor(60, 60, 80),
}
DIMENSION_NAMES = {0: "Overworld", -1: "Nether", 1: "End"}
# -----------------------------------------------------------------------
# Tick Breakdown Bar
# -----------------------------------------------------------------------
class TickBreakdownBar(QWidget):
"""Horizontal stacked bar showing which phase consumed time."""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(40)
self.setMaximumHeight(50)
self._segments: list[tuple[str, int, QColor]] = [] # (label, us, color)
self._total_us: int = 0
self._bg = QColor(24, 24, 32)
def update_from_snapshot(self, snap: TickSnapshot):
segments = []
if snap.pool_reset_us > 0:
segments.append(("Pool Reset", snap.pool_reset_us, PHASE_COLORS["pool_reset"]))
for i, lv in enumerate(snap.levels):
suffix = str(i)
dim_name = DIMENSION_NAMES.get(lv.dimension, f"Dim{lv.dimension}")
if lv.level_tick_us > 0:
segments.append(
(f"{dim_name} Weather", lv.level_tick_us,
PHASE_COLORS.get(f"level_tick_{suffix}", PHASE_COLORS["other"])))
if not lv.entity_tick_skipped and lv.entity_tick_us > 0:
segments.append(
(f"{dim_name} Entities", lv.entity_tick_us,
PHASE_COLORS.get(f"entity_tick_{suffix}", PHASE_COLORS["other"])))
if lv.tracker_tick_us > 0:
segments.append(
(f"{dim_name} Tracker", lv.tracker_tick_us,
PHASE_COLORS.get(f"tracker_tick_{suffix}", PHASE_COLORS["other"])))
if snap.players_tick_us > 0:
segments.append(("Players", snap.players_tick_us, PHASE_COLORS["players_tick"]))
if snap.connection_tick_us > 0:
segments.append(("Network", snap.connection_tick_us, PHASE_COLORS["connection_tick"]))
if snap.console_tick_us > 0:
segments.append(("Console", snap.console_tick_us, PHASE_COLORS["console_tick"]))
# "Other" time = total - sum of known phases
accounted = sum(s[1] for s in segments)
other = snap.total_us - accounted
if other > 0:
segments.append(("Other", other, PHASE_COLORS["other"]))
self._segments = segments
self._total_us = snap.total_us
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
painter.fillRect(0, 0, w, h, self._bg)
if self._total_us <= 0 or not self._segments:
painter.setPen(QPen(QColor(140, 140, 160), 1))
painter.setFont(QFont("Consolas", 10))
painter.drawText(0, 0, w, h, Qt.AlignCenter, "No data")
painter.end()
return
# Draw the 50ms budget line
budget_x = min(w * (50000 / max(self._total_us, 50000)), w)
# Draw segments
x = 0.0
painter.setFont(QFont("Consolas", 8))
for label, us, color in self._segments:
seg_w = (us / self._total_us) * w
if seg_w < 1:
continue
painter.fillRect(int(x), 2, int(seg_w), h - 4, color)
# Label if wide enough
if seg_w > 60:
painter.setPen(QPen(QColor(255, 255, 255), 1))
text = f"{label} {us / 1000:.1f}ms"
painter.drawText(int(x) + 4, 2, int(seg_w) - 8, h - 4,
Qt.AlignVCenter | Qt.AlignLeft, text)
x += seg_w
# Budget line (50ms = 1 tick at 20 TPS)
if self._total_us > 50000:
bx = int(w * 50000 / self._total_us)
painter.setPen(QPen(QColor(255, 80, 80, 180), 2, Qt.DashLine))
painter.drawLine(bx, 0, bx, h)
# Total label on the right
painter.setPen(QPen(QColor(200, 200, 220), 1))
painter.setFont(QFont("Consolas", 9))
total_text = f"{self._total_us / 1000:.1f}ms"
painter.drawText(w - 80, 0, 75, h, Qt.AlignVCenter | Qt.AlignRight, total_text)
painter.end()
# -----------------------------------------------------------------------
# TPS Graph
# -----------------------------------------------------------------------
class TPSGraph(QWidget):
"""Real-time TPS line chart."""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(200)
self.setMinimumWidth(400)
self._data: deque[tuple[float, float]] = deque(maxlen=6000) # 5 min at 20 TPS
self._bg = QColor(24, 24, 32)
self._grid = QColor(48, 48, 64)
self._line_good = QColor(80, 200, 120)
self._line_warn = QColor(255, 200, 60)
self._line_bad = QColor(255, 80, 80)
def add_point(self, timestamp: float, tps: float):
self._data.append((timestamp, tps))
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
ml, mb, mt, mr = 50, 30, 20, 20
painter.fillRect(0, 0, w, h, self._bg)
gw = w - ml - mr
gh = h - mt - mb
if gw < 10 or gh < 10:
painter.end()
return
# Grid
painter.setPen(QPen(self._grid, 1))
painter.setFont(QFont("Consolas", 8))
for tps_val in [0, 5, 10, 15, 20]:
y = mt + gh - (tps_val / 20.0) * gh
painter.drawLine(ml, int(y), w - mr, int(y))
painter.setPen(QPen(QColor(140, 140, 160), 1))
painter.drawText(5, int(y) + 4, f"{tps_val}")
painter.setPen(QPen(self._grid, 1))
# 15 TPS warning line
painter.setPen(QPen(QColor(255, 200, 60, 80), 1, Qt.DashLine))
y15 = mt + gh - (15.0 / 20.0) * gh
painter.drawLine(ml, int(y15), w - mr, int(y15))
if len(self._data) < 2:
painter.setPen(QPen(QColor(140, 140, 160), 1))
painter.setFont(QFont("Consolas", 12))
painter.drawText(ml, mt, gw, gh, Qt.AlignCenter, "Waiting for data...")
painter.end()
return
data_list = list(self._data)
now = data_list[-1][0]
time_window = 300.0
points = []
for t, tps in data_list:
age = now - t
if age > time_window:
continue
x = ml + (1.0 - age / time_window) * gw
y = mt + gh - (min(tps, 20.0) / 20.0) * gh
points.append((x, y, tps))
for i in range(1, len(points)):
x1, y1, t1 = points[i - 1]
x2, y2, t2 = points[i]
avg = (t1 + t2) / 2
if avg >= 18:
color = self._line_good
elif avg >= 15:
color = self._line_warn
else:
color = self._line_bad
painter.setPen(QPen(color, 2))
painter.drawLine(int(x1), int(y1), int(x2), int(y2))
# Time axis
painter.setPen(QPen(QColor(140, 140, 160), 1))
painter.setFont(QFont("Consolas", 8))
for sec in [0, 60, 120, 180, 240, 300]:
x = ml + (1.0 - sec / time_window) * gw
if x >= ml:
label = "now" if sec == 0 else f"-{sec}s"
painter.drawText(int(x) - 15, h - 8, label)
painter.end()
# -----------------------------------------------------------------------
# Stat Card (reused from server-monitor)
# -----------------------------------------------------------------------
class StatCard(QFrame):
"""Compact stat display card."""
def __init__(self, title: str, parent=None):
super().__init__(parent)
self.setFrameStyle(QFrame.Box | QFrame.Raised)
self.setStyleSheet(
"StatCard { background: #1a1a24; border: 1px solid #333348; border-radius: 6px; }"
)
layout = QVBoxLayout(self)
layout.setContentsMargins(12, 8, 12, 8)
self._title = QLabel(title)
self._title.setStyleSheet("color: #8888aa; font-size: 11px;")
self._title.setAlignment(Qt.AlignCenter)
layout.addWidget(self._title)
self._value = QLabel("--")
self._value.setStyleSheet("color: #ffffff; font-size: 24px; font-weight: bold;")
self._value.setAlignment(Qt.AlignCenter)
layout.addWidget(self._value)
def set_value(self, text: str, color: str = "#ffffff"):
self._value.setText(text)
self._value.setStyleSheet(
f"color: {color}; font-size: 24px; font-weight: bold;"
)
# -----------------------------------------------------------------------
# Entity / Chunk Panel
# -----------------------------------------------------------------------
class EntityChunkPanel(QGroupBox):
"""Per-dimension entity and chunk counts."""
def __init__(self, parent=None):
super().__init__("World Stats", parent)
self.setStyleSheet(
"QGroupBox { color: #aaaacc; border: 1px solid #333348; "
"border-radius: 6px; margin-top: 8px; padding-top: 14px; }"
"QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }"
)
layout = QGridLayout(self)
layout.setSpacing(4)
# Headers
headers = ["", "Overworld", "Nether", "End"]
for col, h in enumerate(headers):
lbl = QLabel(h)
lbl.setStyleSheet("color: #8888aa; font-size: 10px; font-weight: bold;")
lbl.setAlignment(Qt.AlignCenter)
layout.addWidget(lbl, 0, col)
# Rows
row_labels = ["Entities", "Global", "Players", "Chunks", "Tile Ent.", "To Remove"]
self._cells: dict[tuple[str, int], QLabel] = {}
for row, label in enumerate(row_labels, start=1):
lbl = QLabel(label)
lbl.setStyleSheet("color: #8888aa; font-size: 10px;")
layout.addWidget(lbl, row, 0)
for col in range(3):
cell = QLabel("--")
cell.setStyleSheet("color: #ccccdd; font-size: 11px; font-family: Consolas;")
cell.setAlignment(Qt.AlignCenter)
layout.addWidget(cell, row, col + 1)
self._cells[(label, col)] = cell
def update_from_snapshot(self, snap: TickSnapshot):
dim_order = [0, -1, 1] # Overworld, Nether, End
dim_to_col = {0: 0, -1: 1, 1: 2}
level_map = {lv.dimension: lv for lv in snap.levels}
for dim in dim_order:
col = dim_to_col[dim]
lv = level_map.get(dim)
if lv:
self._set_cell("Entities", col, str(lv.entity_count))
self._set_cell("Global", col, str(lv.global_entity_count))
self._set_cell("Players", col, str(lv.player_count))
self._set_cell("Chunks", col, str(lv.loaded_chunks))
self._set_cell("Tile Ent.", col, str(lv.tile_entity_count))
self._set_cell("To Remove", col, str(lv.entities_to_remove))
else:
for label in ["Entities", "Global", "Players", "Chunks", "Tile Ent.", "To Remove"]:
self._set_cell(label, col, "--")
def _set_cell(self, label: str, col: int, text: str):
cell = self._cells.get((label, col))
if cell:
cell.setText(text)
# -----------------------------------------------------------------------
# Memory Bar
# -----------------------------------------------------------------------
class MemoryBar(QWidget):
"""Simple horizontal bar showing memory usage."""
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumHeight(24)
self.setMaximumHeight(30)
self._used_mb = 0.0
self._total_mb = 1.0
self._bg = QColor(24, 24, 32)
def update_memory(self, used_mb: float, total_mb: float):
self._used_mb = used_mb
self._total_mb = max(total_mb, 1.0)
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
w, h = self.width(), self.height()
painter.fillRect(0, 0, w, h, self._bg)
ratio = min(self._used_mb / self._total_mb, 1.0)
bar_w = int(w * ratio)
if ratio < 0.6:
color = QColor(80, 200, 120)
elif ratio < 0.8:
color = QColor(255, 200, 60)
else:
color = QColor(255, 80, 80)
painter.fillRect(0, 2, bar_w, h - 4, color)
painter.setPen(QPen(QColor(220, 220, 240), 1))
painter.setFont(QFont("Consolas", 9))
text = f"Memory: {self._used_mb:.0f} / {self._total_mb:.0f} MB ({ratio * 100:.0f}%)"
painter.drawText(8, 0, w - 16, h, Qt.AlignVCenter, text)
painter.end()
# -----------------------------------------------------------------------
# Lag Spike Panel
# -----------------------------------------------------------------------
class LagSpikePanel(QGroupBox):
"""Shows recent lag spikes with the exact causal phase."""
def __init__(self, parent=None):
super().__init__("Lag Spikes", parent)
self.setStyleSheet(
"QGroupBox { color: #aaaacc; border: 1px solid #333348; "
"border-radius: 6px; margin-top: 8px; padding-top: 14px; }"
"QGroupBox::title { subcontrol-position: top left; padding: 2px 8px; }"
)
self._layout = QVBoxLayout(self)
self._layout.setSpacing(2)
self._entries: deque[QLabel] = deque(maxlen=50)
self._placeholder = QLabel("No lag spikes detected")
self._placeholder.setStyleSheet("color: #50c878; font-size: 11px;")
self._placeholder.setAlignment(Qt.AlignCenter)
self._layout.addWidget(self._placeholder)
self._layout.addStretch()
def add_spike(self, snap: TickSnapshot):
"""Legacy - called without reason."""
self.add_spike_with_reason(snap, "Unknown")
def add_spike_with_reason(self, snap: TickSnapshot, reason: str):
"""Add a spike entry with a classified reason."""
if self._placeholder.isVisible():
self._placeholder.setVisible(False)
interval_ms = snap.total_us / 1000.0
if interval_ms >= 1000:
dur_str = f"{interval_ms / 1000:.1f}s"
else:
dur_str = f"{interval_ms:.0f}ms"
ts = time.strftime("%H:%M:%S")
text = f"[{ts}] {dur_str} - {reason}"
# Color: red for major (>500ms), orange for moderate
if interval_ms >= 500:
color = "#ff5050"
else:
color = "#ffc83d"
lbl = QLabel(text)
lbl.setStyleSheet(f"color: {color}; font-size: 10px; font-family: Consolas;")
lbl.setWordWrap(True)
# Insert at top
self._layout.insertWidget(0, lbl)
self._entries.appendleft(lbl)
# Remove old entries if over limit
while len(self._entries) > 50:
old = self._entries.pop()
self._layout.removeWidget(old)
old.deleteLater()
def add_autosave(self, snap: AutosaveSnapshot):
"""Add an autosave event."""
if self._placeholder.isVisible():
self._placeholder.setVisible(False)
# Find dominant sub-phase
parts = [
("Players", snap.players_us),
("Levels", snap.levels_us),
("Rules", snap.rules_us),
("Flush", snap.flush_us),
]
cause, cause_us = max(parts, key=lambda x: x[1])
ts = time.strftime("%H:%M:%S")
text = (f"[{ts}] AUTOSAVE {snap.total_ms:.0f}ms "
f"- {cause} ({cause_us / 1000:.1f}ms)")
lbl = QLabel(text)
lbl.setStyleSheet("color: #ffc83d; font-size: 10px; font-family: Consolas;")
self._layout.insertWidget(0, lbl)
self._entries.appendleft(lbl)
while len(self._entries) > 50:
old = self._entries.pop()
self._layout.removeWidget(old)
old.deleteLater()