#!/usr/bin/env python3 """systats - Terminal system monitor (better than glances).""" import curses import os import signal import sys import time from collections import deque from datetime import timedelta import psutil # Optional GPU support try: import warnings with warnings.catch_warnings(): warnings.simplefilter("ignore") import pynvml pynvml.nvmlInit() NVIDIA_AVAILABLE = True except Exception: NVIDIA_AVAILABLE = False AMD_GPU_PATH = "/sys/class/drm" REFRESH_INTERVAL = 1.0 # ─── i18n ─────────────────────────────────────────────────────────── LANGS = ["en", "pl", "de", "es", "fr", "uk", "zh"] LANG_NAMES = { "en": "English", "pl": "Polski", "de": "Deutsch", "es": "Espanol", "fr": "Francais", "uk": "Ukrainska", "zh": "Zhongwen", } current_lang = "en" TRANSLATIONS = { # Header "up": {"en": "Up", "pl": "Czas", "de": "Up", "es": "Arr", "fr": "Up", "uk": "Chas", "zh": "Yunxing"}, "load": {"en": "Load", "pl": "Obc.", "de": "Last", "es": "Carga", "fr": "Charge", "uk": "Nav.", "zh": "Fuzai"}, "procs": {"en": "Procs", "pl": "Proc", "de": "Proz", "es": "Proc", "fr": "Proc", "uk": "Proc", "zh": "Jinch"}, "run": {"en": "run", "pl": "dzia.", "de": "lauf", "es": "ejec", "fr": "exec", "uk": "prac", "zh": "yunx"}, # Sections "memory": {"en": "MEMORY", "pl": "PAMIEC", "de": "SPEICHER", "es": "MEMORIA", "fr": "MEMOIRE", "uk": "PAM'YAT", "zh": "NEICUN"}, "network": {"en": "NETWORK", "pl": "SIEC", "de": "NETZWERK", "es": "RED", "fr": "RESEAU", "uk": "MEREZHA", "zh": "WANGLUO"}, "disk": {"en": "DISK", "pl": "DYSK", "de": "PLATTE", "es": "DISCO", "fr": "DISQUE", "uk": "DYSK", "zh": "CIPAN"}, "gpu": {"en": "GPU", "pl": "GPU", "de": "GPU", "es": "GPU", "fr": "GPU", "uk": "GPU", "zh": "GPU"}, "temps": {"en": "TEMPS", "pl": "TEMP", "de": "TEMP", "es": "TEMP", "fr": "TEMP", "uk": "TEMP", "zh": "WENDU"}, "processes": {"en": "PROCESSES", "pl": "PROCESY", "de": "PROZESSE", "es": "PROCESOS", "fr": "PROCESSUS", "uk": "PROCESY", "zh": "JINCHENG"}, # Disk "read": {"en": "Read", "pl": "Odczyt", "de": "Lesen", "es": "Leer", "fr": "Lire", "uk": "Chyt.", "zh": "Du"}, "write": {"en": "Write", "pl": "Zapis", "de": "Schreib", "es": "Escrib", "fr": "Ecrir", "uk": "Zapys", "zh": "Xie"}, # Network "total": {"en": "Total", "pl": "Suma", "de": "Gesamt", "es": "Total", "fr": "Total", "uk": "Vsogo", "zh": "Zongji"}, # Process table "filter": {"en": "filter", "pl": "filtr", "de": "Filter", "es": "filtro", "fr": "filtre", "uk": "filtr", "zh": "guolv"}, "name": {"en": "NAME", "pl": "NAZWA", "de": "NAME", "es": "NOMBRE", "fr": "NOM", "uk": "NAZVA", "zh": "MINGCH"}, # Process hints "hint_sort": {"en": "sort", "pl": "sort.", "de": "sort.", "es": "orden", "fr": "tri", "uk": "sort.", "zh": "paixu"}, "hint_reverse": {"en": "reverse", "pl": "odwroc", "de": "umkehr", "es": "invert", "fr": "invers", "uk": "zvorot", "zh": "fanzhuan"}, "hint_filter": {"en": "filter", "pl": "filtr", "de": "Filter", "es": "filtro", "fr": "filtre", "uk": "filtr", "zh": "guolv"}, "hint_signals": {"en": "signals", "pl": "sygnaly", "de": "Signale", "es": "senales", "fr": "signaux", "uk": "sygnaly", "zh": "xinhao"}, "hint_details": {"en": "details", "pl": "szczeg.", "de": "Details", "es": "detalle", "fr": "details", "uk": "detal.", "zh": "xiangxi"}, "hint_nice": {"en": "nice", "pl": "priorytet", "de": "Priorit.", "es": "prior.", "fr": "prior.", "uk": "prior.", "zh": "youxian"}, "hint_quit": {"en": "quit", "pl": "wyjscie", "de": "Ende", "es": "salir", "fr": "quitter", "uk": "vyhid", "zh": "tuichu"}, # Kill menu "send_signal_to": {"en": "Send signal to", "pl": "Wyslij sygnal do", "de": "Signal senden an", "es": "Enviar senal a", "fr": "Envoyer signal a", "uk": "Nadislaty sygnal", "zh": "Fasong xinhao"}, "send": {"en": "send", "pl": "wyslij", "de": "senden", "es": "enviar", "fr": "envoyer", "uk": "nadis.", "zh": "fasong"}, "cancel": {"en": "cancel", "pl": "anuluj", "de": "abbrech", "es": "cancel", "fr": "annuler", "uk": "skasuv.", "zh": "quxiao"}, "apply": {"en": "apply", "pl": "zastosuj", "de": "anwend", "es": "aplicar", "fr": "appliqu", "uk": "zastos.", "zh": "yingyong"}, # Details "proc_details": {"en": "Process Details", "pl": "Szczegoly procesu", "de": "Prozessdetails", "es": "Detalles proceso", "fr": "Details processus", "uk": "Detali procesu", "zh": "Jincheng xiangxi"}, "created": {"en": "Created", "pl": "Utworzony", "de": "Erstellt", "es": "Creado", "fr": "Cree", "uk": "Stvoreno", "zh": "Chuangjian"}, "connections": {"en": "Connections", "pl": "Polaczenia", "de": "Verbindungen", "es": "Conexiones", "fr": "Connexions", "uk": "Z'yednannya", "zh": "Lianjie"}, "close": {"en": "close", "pl": "zamknij", "de": "schlies", "es": "cerrar", "fr": "fermer", "uk": "zakryty", "zh": "guanbi"}, # Renice "nice_value": {"en": "Nice value (-20..19)", "pl": "Priorytet (-20..19)", "de": "Nice-Wert (-20..19)", "es": "Valor nice (-20..19)", "fr": "Valeur nice (-20..19)", "uk": "Priorytet (-20..19)", "zh": "Nice zhi (-20..19)"}, # Filter bar "filter_label": {"en": "Filter", "pl": "Filtr", "de": "Filter", "es": "Filtro", "fr": "Filtre", "uk": "Filtr", "zh": "Guolv"}, # Status messages "no_proc_selected": {"en": "No process selected", "pl": "Nie wybrano procesu", "de": "Kein Prozess gewaehlt", "es": "Ningun proceso seleccionado", "fr": "Aucun processus selectionne", "uk": "Proces ne obrano", "zh": "Wei xuanze jincheng"}, "sent_to": {"en": "Sent {sig} to {name} (PID {pid})", "pl": "Wyslano {sig} do {name} (PID {pid})", "de": "{sig} an {name} gesendet (PID {pid})", "es": "{sig} enviado a {name} (PID {pid})", "fr": "{sig} envoye a {name} (PID {pid})", "uk": "Nadislano {sig} do {name} (PID {pid})", "zh": "Yi fasong {sig} dao {name} (PID {pid})"}, "proc_not_found": {"en": "Process {pid} not found", "pl": "Proces {pid} nie znaleziony", "de": "Prozess {pid} nicht gefunden", "es": "Proceso {pid} no encontrado", "fr": "Processus {pid} introuvable", "uk": "Proces {pid} ne znajdeno", "zh": "Jincheng {pid} wei zhaodao"}, "perm_denied": {"en": "Permission denied: {name} (PID {pid})", "pl": "Brak uprawnien: {name} (PID {pid})", "de": "Zugriff verweigert: {name} (PID {pid})", "es": "Permiso denegado: {name} (PID {pid})", "fr": "Permission refusee: {name} (PID {pid})", "uk": "Dostup zaboroneno: {name} (PID {pid})", "zh": "Quanxian jujue: {name} (PID {pid})"}, "set_nice": {"en": "Set nice {val} for {name} (PID {pid})", "pl": "Ustawiono priorytet {val} dla {name} (PID {pid})", "de": "Nice {val} fuer {name} gesetzt (PID {pid})", "es": "Nice {val} para {name} (PID {pid})", "fr": "Nice {val} pour {name} (PID {pid})", "uk": "Vstanovleno priorytet {val} dlya {name} (PID {pid})", "zh": "Yi shezhi nice {val} {name} (PID {pid})"}, "invalid_nice": {"en": "Invalid nice value", "pl": "Nieprawidlowy priorytet", "de": "Ungueltiger Nice-Wert", "es": "Valor nice invalido", "fr": "Valeur nice invalide", "uk": "Nepravyl'nyj priorytet", "zh": "Wuxiao nice zhi"}, # Help "help_title": {"en": "SYSTATS - Keyboard Shortcuts", "pl": "SYSTATS - Skroty klawiszowe", "de": "SYSTATS - Tastenkuerzel", "es": "SYSTATS - Atajos de teclado", "fr": "SYSTATS - Raccourcis clavier", "uk": "SYSTATS - Klavishni skroty", "zh": "SYSTATS - Jianpan kuaijiejian"}, "help_close": {"en": "Press h, F1 or ? to close", "pl": "Nacisnij h, F1 lub ? aby zamknac", "de": "h, F1 oder ? zum Schliessen", "es": "Pulsa h, F1 o ? para cerrar", "fr": "Appuyez h, F1 ou ? pour fermer", "uk": "Natysnit' h, F1 abo ? shchob zakryty", "zh": "An h, F1 huo ? guanbi"}, "help_nav": {"en": "NAVIGATION", "pl": "NAWIGACJA", "de": "NAVIGATION", "es": "NAVEGACION", "fr": "NAVIGATION", "uk": "NAVIGACIYA", "zh": "DAOHANG"}, "help_down": {"en": "Move cursor down", "pl": "Kursor w dol", "de": "Cursor nach unten", "es": "Mover cursor abajo", "fr": "Curseur vers le bas", "uk": "Kursor vnyz", "zh": "Guangbiao xia yi"}, "help_up": {"en": "Move cursor up", "pl": "Kursor w gore", "de": "Cursor nach oben", "es": "Mover cursor arriba", "fr": "Curseur vers le haut", "uk": "Kursor vgoru", "zh": "Guangbiao shang yi"}, "help_pgdn": {"en": "Scroll down 20", "pl": "Przewin 20 w dol", "de": "20 nach unten", "es": "Bajar 20", "fr": "Defiler 20 bas", "uk": "Prokrutyty 20 vnyz", "zh": "Xiang xia gundon 20"}, "help_pgup": {"en": "Scroll up 20", "pl": "Przewin 20 w gore", "de": "20 nach oben", "es": "Subir 20", "fr": "Defiler 20 haut", "uk": "Prokrutyty 20 vgoru", "zh": "Xiang shang gundon 20"}, "help_home": {"en": "Go to top", "pl": "Na poczatek", "de": "Zum Anfang", "es": "Ir al inicio", "fr": "Aller en haut", "uk": "Na pochatok", "zh": "Dao dingbu"}, "help_end": {"en": "Go to bottom", "pl": "Na koniec", "de": "Zum Ende", "es": "Ir al final", "fr": "Aller en bas", "uk": "Na kinec'", "zh": "Dao dibu"}, "help_sorting": {"en": "SORTING", "pl": "SORTOWANIE", "de": "SORTIERUNG", "es": "ORDENAR", "fr": "TRI", "uk": "SORTUVANNYA", "zh": "PAIXU"}, "help_sort_cpu": {"en": "Sort by CPU%", "pl": "Sortuj wg CPU%", "de": "Nach CPU% sortieren", "es": "Ordenar por CPU%", "fr": "Trier par CPU%", "uk": "Sort. za CPU%", "zh": "An CPU% paixu"}, "help_sort_mem": {"en": "Sort by MEM%", "pl": "Sortuj wg MEM%", "de": "Nach MEM% sortieren", "es": "Ordenar por MEM%", "fr": "Trier par MEM%", "uk": "Sort. za MEM%", "zh": "An MEM% paixu"}, "help_sort_pid": {"en": "Sort by PID", "pl": "Sortuj wg PID", "de": "Nach PID sortieren", "es": "Ordenar por PID", "fr": "Trier par PID", "uk": "Sort. za PID", "zh": "An PID paixu"}, "help_sort_name": {"en": "Sort by name", "pl": "Sortuj wg nazwy", "de": "Nach Name sortieren", "es": "Ordenar por nombre", "fr": "Trier par nom", "uk": "Sort. za nazvoyu", "zh": "An mingcheng paixu"}, "help_reverse": {"en": "Reverse sort order", "pl": "Odwroc sortowanie", "de": "Sortierung umkehren", "es": "Invertir orden", "fr": "Inverser le tri", "uk": "Zvorotne sortuvannya", "zh": "Fanzhuan paixu"}, "help_proc": {"en": "PROCESS MGMT", "pl": "ZARZ. PROCESAMI", "de": "PROZESSVERWALT.", "es": "GEST. PROCESOS", "fr": "GEST. PROCESSUS", "uk": "KER. PROCESAMY", "zh": "JINCHENG GUANLI"}, "help_sigterm": {"en": "Send SIGTERM to process", "pl": "Wyslij SIGTERM", "de": "SIGTERM senden", "es": "Enviar SIGTERM", "fr": "Envoyer SIGTERM", "uk": "Nadislaty SIGTERM", "zh": "Fasong SIGTERM"}, "help_sigkill": {"en": "Send SIGKILL to process", "pl": "Wyslij SIGKILL", "de": "SIGKILL senden", "es": "Enviar SIGKILL", "fr": "Envoyer SIGKILL", "uk": "Nadislaty SIGKILL", "zh": "Fasong SIGKILL"}, "help_sigmenu": {"en": "Signal menu (choose signal)", "pl": "Menu sygnalow", "de": "Signalmenue", "es": "Menu de senales", "fr": "Menu signaux", "uk": "Menyu sygnaliv", "zh": "Xinhao caidan"}, "help_nice_pos": {"en": "Renice process", "pl": "Zmien priorytet", "de": "Prioritaet aendern", "es": "Cambiar prioridad", "fr": "Changer priorite", "uk": "Zminyty priorytet", "zh": "Gaibian youxianji"}, "help_nice_neg": {"en": "Renice process (negative)", "pl": "Zmien priorytet (ujemny)", "de": "Prioritaet aendern (neg)", "es": "Cambiar prioridad (neg)", "fr": "Changer priorite (neg)", "uk": "Zminyty priorytet (vid'yem.)", "zh": "Gaibian youxianji (fu)"}, "help_details": {"en": "Process details", "pl": "Szczegoly procesu", "de": "Prozessdetails", "es": "Detalles proceso", "fr": "Details processus", "uk": "Detali procesu", "zh": "Jincheng xiangxi"}, "help_filter": {"en": "Filter processes", "pl": "Filtruj procesy", "de": "Prozesse filtern", "es": "Filtrar procesos", "fr": "Filtrer processus", "uk": "Filtruvannya procesiv", "zh": "Guolv jincheng"}, "help_general": {"en": "GENERAL", "pl": "OGOLNE", "de": "ALLGEMEIN", "es": "GENERAL", "fr": "GENERAL", "uk": "ZAGAL'NE", "zh": "TONGYONG"}, "help_help": {"en": "Toggle this help", "pl": "Pokaz/ukryj pomoc", "de": "Hilfe ein/aus", "es": "Mostrar/ocultar ayuda", "fr": "Afficher/masquer aide", "uk": "Pokaz./skhov. dovidku", "zh": "Xianshi/yincang bangzhu"}, "help_lang": {"en": "Switch language", "pl": "Zmien jezyk", "de": "Sprache wechseln", "es": "Cambiar idioma", "fr": "Changer langue", "uk": "Zminyty movu", "zh": "Qiehuan yuyan"}, "help_quit": {"en": "Quit", "pl": "Wyjscie", "de": "Beenden", "es": "Salir", "fr": "Quitter", "uk": "Vyhid", "zh": "Tuichu"}, } def t(key, **kwargs): """Get translated string.""" s = TRANSLATIONS.get(key, {}).get(current_lang, key) if kwargs: s = s.format(**kwargs) return s def get_help_lines(): return [ (t("help_nav"), ""), ("j / Down", t("help_down")), ("k / Up", t("help_up")), ("PgDn", t("help_pgdn")), ("PgUp", t("help_pgup")), ("Home", t("help_home")), ("End", t("help_end")), ("", ""), (t("help_sorting"), ""), ("c", t("help_sort_cpu")), ("m", t("help_sort_mem")), ("p", t("help_sort_pid")), ("n", t("help_sort_name")), ("r", t("help_reverse")), ("", ""), (t("help_proc"), ""), ("T", t("help_sigterm")), ("K", t("help_sigkill")), ("F9", t("help_sigmenu")), ("+ / F8", t("help_nice_pos")), ("- / F7", t("help_nice_neg")), ("d / F5 / Enter", t("help_details")), ("/ / f", t("help_filter")), ("", ""), (t("help_general"), ""), ("h / F1 / ?", t("help_help")), ("L", t("help_lang")), ("q / Q / Esc", t("help_quit")), ] # History length for sparklines HIST_LEN = 60 # Color pair IDs C_NORMAL = 0 C_TITLE = 1 C_LOW = 2 C_MED = 3 C_HIGH = 4 C_CRIT = 5 C_BORDER = 6 C_HEADER = 7 C_ACCENT = 8 C_DIM = 9 C_BAR_BG = 10 def init_colors(): curses.start_color() curses.use_default_colors() curses.init_pair(C_TITLE, curses.COLOR_CYAN, -1) curses.init_pair(C_LOW, curses.COLOR_GREEN, -1) curses.init_pair(C_MED, curses.COLOR_YELLOW, -1) curses.init_pair(C_HIGH, curses.COLOR_RED, -1) curses.init_pair(C_CRIT, curses.COLOR_WHITE, curses.COLOR_RED) curses.init_pair(C_BORDER, curses.COLOR_BLUE, -1) curses.init_pair(C_HEADER, curses.COLOR_BLACK, curses.COLOR_CYAN) curses.init_pair(C_ACCENT, curses.COLOR_MAGENTA, -1) curses.init_pair(C_DIM, curses.COLOR_WHITE, -1) curses.init_pair(C_BAR_BG, curses.COLOR_BLACK, -1) curses.init_pair(C_SELECT, curses.COLOR_BLACK, curses.COLOR_WHITE) curses.init_pair(C_POPUP_BG, curses.COLOR_WHITE, curses.COLOR_BLUE) curses.init_pair(C_POPUP_HL, curses.COLOR_YELLOW, curses.COLOR_BLUE) def pct_color(pct): if pct < 50: return C_LOW elif pct < 75: return C_MED elif pct < 90: return C_HIGH return C_CRIT def fmt_bytes(n): for unit in ("B", "K", "M", "G", "T"): if abs(n) < 1024: return f"{n:6.1f}{unit}" n /= 1024 return f"{n:6.1f}P" def fmt_bytes_short(n): for unit in ("B", "K", "M", "G", "T"): if abs(n) < 1024: return f"{n:.1f}{unit}" n /= 1024 return f"{n:.1f}P" def draw_bar(win, y, x, width, pct, label="", show_pct=True): """Draw a colored progress bar.""" if width < 4: return filled = int(width * pct / 100) filled = min(filled, width) col = pct_color(pct) try: # Filled part bar_str = "\u2588" * filled empty_str = "\u2591" * (width - filled) win.addstr(y, x, bar_str, curses.color_pair(col)) win.addstr(y, x + filled, empty_str, curses.color_pair(C_DIM) | curses.A_DIM) if show_pct: pct_str = f"{pct:5.1f}%" px = x + width - len(pct_str) if px > x: win.addstr(y, px, pct_str, curses.color_pair(col) | curses.A_BOLD) if label: win.addstr(y, x + 1, label[:width - 8], curses.color_pair(C_NORMAL) | curses.A_BOLD) except curses.error: pass def draw_sparkline(win, y, x, width, data, max_val=100): """Draw a sparkline from historical data.""" if width < 2 or not data: return chars = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588" samples = list(data)[-width:] if max_val == 0: max_val = 1 try: for i, val in enumerate(samples): idx = int(min(val / max_val, 1.0) * (len(chars) - 1)) col = pct_color(val / max_val * 100 if max_val else 0) win.addstr(y, x + i, chars[idx], curses.color_pair(col)) except curses.error: pass def draw_box(win, y, x, h, w, title=""): """Draw a bordered box with optional title.""" try: # Corners win.addstr(y, x, "\u250c", curses.color_pair(C_BORDER)) win.addstr(y, x + w - 1, "\u2510", curses.color_pair(C_BORDER)) win.addstr(y + h - 1, x, "\u2514", curses.color_pair(C_BORDER)) try: win.addstr(y + h - 1, x + w - 1, "\u2518", curses.color_pair(C_BORDER)) except curses.error: pass # Horizontal lines for cx in range(x + 1, x + w - 1): win.addstr(y, cx, "\u2500", curses.color_pair(C_BORDER)) try: win.addstr(y + h - 1, cx, "\u2500", curses.color_pair(C_BORDER)) except curses.error: pass # Vertical lines for cy in range(y + 1, y + h - 1): win.addstr(cy, x, "\u2502", curses.color_pair(C_BORDER)) try: win.addstr(cy, x + w - 1, "\u2502", curses.color_pair(C_BORDER)) except curses.error: pass # Title if title: t = f" {title} " tx = x + 2 win.addstr(y, tx, t, curses.color_pair(C_TITLE) | curses.A_BOLD) except curses.error: pass def safe_addstr(win, y, x, text, attr=0): try: max_y, max_x = win.getmaxyx() if y < 0 or y >= max_y or x < 0 or x >= max_x: return avail = max_x - x win.addstr(y, x, text[:avail], attr) except curses.error: pass # ─── Data collectors ──────────────────────────────────────────────── class SystemData: def __init__(self): self.cpu_history = deque(maxlen=HIST_LEN) self.mem_history = deque(maxlen=HIST_LEN) self.net_rx_history = deque(maxlen=HIST_LEN) self.net_tx_history = deque(maxlen=HIST_LEN) self.per_cpu_history = [] self.prev_net = psutil.net_io_counters() self.prev_disk_io = psutil.disk_io_counters() self.prev_time = time.time() self.net_rx_rate = 0 self.net_tx_rate = 0 self.disk_read_rate = 0 self.disk_write_rate = 0 self.process_sort = "cpu" # cpu, mem, pid, name self.process_sort_reverse = True def update(self): now = time.time() dt = now - self.prev_time if dt < 0.01: dt = 0.01 self.prev_time = now # CPU self.cpu_pct = psutil.cpu_percent(interval=0) self.per_cpu = psutil.cpu_percent(interval=0, percpu=True) self.cpu_history.append(self.cpu_pct) # Initialize per-cpu histories while len(self.per_cpu_history) < len(self.per_cpu): self.per_cpu_history.append(deque(maxlen=HIST_LEN)) for i, pct in enumerate(self.per_cpu): self.per_cpu_history[i].append(pct) self.cpu_freq = psutil.cpu_freq() self.load_avg = os.getloadavg() self.cpu_count = psutil.cpu_count() self.cpu_count_phys = psutil.cpu_count(logical=False) # Memory self.mem = psutil.virtual_memory() self.swap = psutil.swap_memory() self.mem_history.append(self.mem.percent) # Disk self.partitions = [] for p in psutil.disk_partitions(all=False): try: usage = psutil.disk_usage(p.mountpoint) self.partitions.append((p, usage)) except (PermissionError, OSError): pass # Disk IO try: dio = psutil.disk_io_counters() if dio and self.prev_disk_io: self.disk_read_rate = (dio.read_bytes - self.prev_disk_io.read_bytes) / dt self.disk_write_rate = (dio.write_bytes - self.prev_disk_io.write_bytes) / dt self.prev_disk_io = dio except Exception: pass # Network try: net = psutil.net_io_counters() self.net_rx_rate = (net.bytes_recv - self.prev_net.bytes_recv) / dt self.net_tx_rate = (net.bytes_sent - self.prev_net.bytes_sent) / dt self.net_rx_history.append(self.net_rx_rate) self.net_tx_history.append(self.net_tx_rate) self.prev_net = net except Exception: pass # Network interfaces self.net_addrs = psutil.net_if_addrs() self.net_stats = psutil.net_if_stats() # Temperatures try: self.temps = psutil.sensors_temperatures() except Exception: self.temps = {} # Fans try: self.fans = psutil.sensors_fans() except Exception: self.fans = {} # Battery try: self.battery = psutil.sensors_battery() except Exception: self.battery = None # Uptime self.boot_time = psutil.boot_time() self.uptime = timedelta(seconds=int(now - self.boot_time)) # Processes self._update_processes() # GPU self.gpus = [] self._update_nvidia() self._update_amd() def _update_processes(self): procs = [] for p in psutil.process_iter(["pid", "name", "cpu_percent", "memory_percent", "status", "username", "num_threads", "memory_info"]): try: info = p.info if info["pid"] == 0: continue procs.append(info) except (psutil.NoSuchProcess, psutil.AccessDenied): pass sort_key = { "cpu": lambda p: p.get("cpu_percent", 0) or 0, "mem": lambda p: p.get("memory_percent", 0) or 0, "pid": lambda p: p.get("pid", 0), "name": lambda p: (p.get("name") or "").lower(), }.get(self.process_sort, lambda p: p.get("cpu_percent", 0) or 0) self.processes = sorted(procs, key=sort_key, reverse=self.process_sort_reverse) self.proc_count = len(procs) self.proc_running = sum(1 for p in procs if p.get("status") == psutil.STATUS_RUNNING) def _update_nvidia(self): if not NVIDIA_AVAILABLE: return try: count = pynvml.nvmlDeviceGetCount() for i in range(count): handle = pynvml.nvmlDeviceGetHandleByIndex(i) name = pynvml.nvmlDeviceGetName(handle) if isinstance(name, bytes): name = name.decode() try: util = pynvml.nvmlDeviceGetUtilizationRates(handle) gpu_util = util.gpu mem_util = util.memory except Exception: gpu_util = 0 mem_util = 0 try: mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle) mem_used = mem_info.used mem_total = mem_info.total except Exception: mem_used = 0 mem_total = 0 try: temp = pynvml.nvmlDeviceGetTemperature(handle, pynvml.NVML_TEMPERATURE_GPU) except Exception: temp = 0 try: power = pynvml.nvmlDeviceGetPowerUsage(handle) / 1000 except Exception: power = 0 try: fan = pynvml.nvmlDeviceGetFanSpeed(handle) except Exception: fan = 0 try: clk_gpu = pynvml.nvmlDeviceGetClockInfo(handle, pynvml.NVML_CLOCK_GRAPHICS) clk_mem = pynvml.nvmlDeviceGetClockInfo(handle, pynvml.NVML_CLOCK_MEM) except Exception: clk_gpu = 0 clk_mem = 0 self.gpus.append({ "vendor": "NVIDIA", "name": name, "gpu_util": gpu_util, "mem_util": mem_util, "mem_used": mem_used, "mem_total": mem_total, "temp": temp, "power": power, "fan": fan, "clk_gpu": clk_gpu, "clk_mem": clk_mem, }) except Exception: pass def _update_amd(self): if not os.path.exists(AMD_GPU_PATH): return try: for card in sorted(os.listdir(AMD_GPU_PATH)): if not card.startswith("card") or "-" in card: continue base = os.path.join(AMD_GPU_PATH, card, "device") hwmon_dir = os.path.join(base, "hwmon") if not os.path.isdir(hwmon_dir): continue hwmons = os.listdir(hwmon_dir) if not hwmons: continue hwmon = os.path.join(hwmon_dir, hwmons[0]) def read_val(path, default=0): try: with open(path) as f: return int(f.read().strip()) except Exception: return default def read_str(path, default=""): try: with open(path) as f: return f.read().strip() except Exception: return default name = read_str(os.path.join(base, "product_name"), f"AMD GPU ({card})") if not name: name = f"AMD GPU ({card})" gpu_util = read_val(os.path.join(base, "gpu_busy_percent")) mem_used = read_val(os.path.join(base, "mem_info_vram_used")) mem_total = read_val(os.path.join(base, "mem_info_vram_total")) mem_util = int(mem_used / mem_total * 100) if mem_total else 0 temp = read_val(os.path.join(hwmon, "temp1_input")) // 1000 power = read_val(os.path.join(hwmon, "power1_average")) / 1_000_000 fan = read_val(os.path.join(hwmon, "pwm1")) fan_pct = int(fan / 255 * 100) if fan else 0 clk_gpu = read_val(os.path.join(base, "pp_dpm_sclk")) clk_mem = read_val(os.path.join(base, "pp_dpm_mclk")) self.gpus.append({ "vendor": "AMD", "name": name, "gpu_util": gpu_util, "mem_util": mem_util, "mem_used": mem_used, "mem_total": mem_total, "temp": temp, "power": power, "fan": fan_pct, "clk_gpu": clk_gpu, "clk_mem": clk_mem, }) except Exception: pass # ─── Rendering ────────────────────────────────────────────────────── C_SELECT = 11 C_POPUP_BG = 12 C_POPUP_HL = 13 class ProcessManager: """Interactive process management: kill, nice, filter, details.""" SIGNALS = [ ("SIGTERM (15)", signal.SIGTERM), ("SIGKILL (9)", signal.SIGKILL), ("SIGHUP (1)", signal.SIGHUP), ("SIGINT (2)", signal.SIGINT), ("SIGUSR1 (10)", signal.SIGUSR1), ("SIGUSR2 (12)", signal.SIGUSR2), ("SIGSTOP (19)", signal.SIGSTOP), ("SIGCONT (18)", signal.SIGCONT), ] def __init__(self): self.selected_idx = 0 self.filter_text = "" self.filter_mode = False self.show_kill_menu = False self.kill_menu_idx = 0 self.show_details = False self.show_nice_dialog = False self.nice_value = "" self.status_msg = "" self.status_time = 0 def get_selected_proc(self, processes): filtered = self._filtered(processes) if 0 <= self.selected_idx < len(filtered): return filtered[self.selected_idx] return None def _filtered(self, processes): if not self.filter_text: return processes ft = self.filter_text.lower() return [p for p in processes if ft in (p.get("name") or "").lower() or ft in str(p.get("pid", "")) or ft in (p.get("username") or "").lower()] def filtered_processes(self, processes): return self._filtered(processes) def kill_selected(self, processes, sig): proc = self.get_selected_proc(processes) if not proc: self._set_status(t("no_proc_selected")) return pid = proc.get("pid") name = proc.get("name", "?") try: os.kill(pid, sig) sig_name = signal.Signals(sig).name self._set_status(t("sent_to", sig=sig_name, name=name, pid=pid)) except ProcessLookupError: self._set_status(t("proc_not_found", pid=pid)) except PermissionError: self._set_status(t("perm_denied", name=name, pid=pid)) except Exception as e: self._set_status(f"Error: {e}") def renice_selected(self, processes, nice_val): proc = self.get_selected_proc(processes) if not proc: self._set_status(t("no_proc_selected")) return pid = proc.get("pid") name = proc.get("name", "?") try: p = psutil.Process(pid) p.nice(nice_val) self._set_status(t("set_nice", val=nice_val, name=name, pid=pid)) except psutil.NoSuchProcess: self._set_status(t("proc_not_found", pid=pid)) except psutil.AccessDenied: self._set_status(t("perm_denied", name=name, pid=pid)) except Exception as e: self._set_status(f"Error: {e}") def get_proc_details(self, processes): proc = self.get_selected_proc(processes) if not proc: return None pid = proc.get("pid") try: p = psutil.Process(pid) info = { "pid": pid, "name": p.name(), "exe": p.exe() if hasattr(p, "exe") else "?", "cmdline": " ".join(p.cmdline()) if p.cmdline() else "?", "status": p.status(), "username": p.username(), "create_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(p.create_time())), "cpu_percent": p.cpu_percent(), "memory_percent": p.memory_percent(), "num_threads": p.num_threads(), "nice": p.nice(), "ppid": p.ppid(), } try: mem = p.memory_info() info["rss"] = fmt_bytes_short(mem.rss) info["vms"] = fmt_bytes_short(mem.vms) except Exception: info["rss"] = "?" info["vms"] = "?" try: io = p.io_counters() info["io_read"] = fmt_bytes_short(io.read_bytes) info["io_write"] = fmt_bytes_short(io.write_bytes) except Exception: info["io_read"] = "?" info["io_write"] = "?" try: info["num_fds"] = p.num_fds() except Exception: info["num_fds"] = "?" try: conns = p.net_connections() info["connections"] = len(conns) except Exception: info["connections"] = "?" try: info["cwd"] = p.cwd() except Exception: info["cwd"] = "?" return info except (psutil.NoSuchProcess, psutil.AccessDenied): return None def _set_status(self, msg): self.status_msg = msg self.status_time = time.time() def get_status(self): if self.status_msg and time.time() - self.status_time < 3: return self.status_msg self.status_msg = "" return "" class Renderer: def __init__(self, stdscr, data): self.scr = stdscr self.data = data self.scroll_offset = 0 self.proc_mgr = ProcessManager() self.show_help = False def draw(self): self.scr.erase() h, w = self.scr.getmaxyx() if h < 10 or w < 40: safe_addstr(self.scr, 0, 0, "Terminal too small!", curses.color_pair(C_CRIT)) self.scr.refresh() return y = 0 y = self._draw_header(y, w) y += 1 # Split layout: left panel (CPU+MEM+SWAP), right panel (NET+DISK) left_w = w // 2 right_w = w - left_w panel_y = y left_y = self._draw_cpu(panel_y, 0, left_w) left_y = self._draw_memory(left_y, 0, left_w) right_y = self._draw_network(panel_y, left_w, right_w) right_y = self._draw_disk(right_y, left_w, right_w) y = max(left_y, right_y) # GPU (full width) if self.data.gpus: y = self._draw_gpu(y, 0, w) # Temperatures (full width) if self.data.temps: y = self._draw_temps(y, 0, w) # Processes (rest of screen) self._draw_processes(y, 0, w, h - y) # Help overlay if self.show_help: self._draw_help(h, w) self.scr.refresh() def _draw_header(self, y, w): d = self.data hostname = os.uname().nodename kernel = os.uname().release header = f" SYSTATS | {hostname} | Linux {kernel} | {t('up')}: {d.uptime} " header += f"| {t('load')}: {d.load_avg[0]:.2f} {d.load_avg[1]:.2f} {d.load_avg[2]:.2f} " header += f"| {t('procs')}: {d.proc_count} ({d.proc_running} {t('run')}) " brand = " Electric Entropy Lab " header = header.ljust(w - len(brand))[:w - len(brand)] safe_addstr(self.scr, y, 0, header, curses.color_pair(C_HEADER) | curses.A_BOLD) safe_addstr(self.scr, y, w - len(brand), brand, curses.color_pair(C_ACCENT) | curses.A_BOLD) return y + 1 def _draw_cpu(self, y, x, w): d = self.data ncpu = len(d.per_cpu) freq_str = "" if d.cpu_freq: freq_str = f" {d.cpu_freq.current:.0f}MHz" title = f"CPU [{d.cpu_count_phys}C/{d.cpu_count}T]{freq_str}" # Calculate height: 1 header + overall bar + sparkline + per-core bars cores_per_col = (ncpu + 1) // 2 box_h = 2 + 1 + 1 + cores_per_col + 1 # border + overall + spark + cores + border draw_box(self.scr, y, x, box_h, w, title) iy = y + 1 bar_w = w - 4 # Overall CPU bar safe_addstr(self.scr, iy, x + 2, "ALL", curses.color_pair(C_ACCENT) | curses.A_BOLD) draw_bar(self.scr, iy, x + 6, bar_w - 4, d.cpu_pct) iy += 1 # Sparkline draw_sparkline(self.scr, iy, x + 2, bar_w, d.cpu_history) iy += 1 # Per-core bars (two columns) col_w = (w - 4) // 2 for i, pct in enumerate(d.per_cpu): col = i // cores_per_col row = i % cores_per_col cx = x + 2 + col * col_w cy = iy + row label = f"{i:2d}" safe_addstr(self.scr, cy, cx, label, curses.color_pair(C_DIM)) draw_bar(self.scr, cy, cx + 3, col_w - 4, pct, show_pct=True) return y + box_h def _draw_memory(self, y, x, w): d = self.data box_h = 5 draw_box(self.scr, y, x, box_h, w, t("memory")) bar_w = w - 4 iy = y + 1 # RAM ram_label = f"RAM {fmt_bytes_short(d.mem.used)}/{fmt_bytes_short(d.mem.total)}" safe_addstr(self.scr, iy, x + 2, ram_label, curses.color_pair(C_ACCENT)) iy += 1 draw_bar(self.scr, iy, x + 2, bar_w, d.mem.percent) iy += 1 # Swap swap_label = f"SWP {fmt_bytes_short(d.swap.used)}/{fmt_bytes_short(d.swap.total)}" safe_addstr(self.scr, iy, x + 2, swap_label, curses.color_pair(C_ACCENT)) if d.swap.total > 0: # Show swap bar inline sw_bar_x = x + 2 + len(swap_label) + 1 sw_bar_w = w - 4 - len(swap_label) - 1 if sw_bar_w > 5: draw_bar(self.scr, iy, sw_bar_x, sw_bar_w, d.swap.percent) return y + box_h def _draw_network(self, y, x, w): d = self.data box_h = 6 draw_box(self.scr, y, x, box_h, w, t("network")) iy = y + 1 bar_w = w - 4 rx_str = f"RX: {fmt_bytes_short(d.net_rx_rate)}/s" tx_str = f"TX: {fmt_bytes_short(d.net_tx_rate)}/s" safe_addstr(self.scr, iy, x + 2, rx_str, curses.color_pair(C_LOW)) safe_addstr(self.scr, iy, x + 2 + len(rx_str) + 2, tx_str, curses.color_pair(C_MED)) iy += 1 # Sparklines for RX/TX half_w = (bar_w - 1) // 2 max_net = max(max(d.net_rx_history, default=1), max(d.net_tx_history, default=1), 1) safe_addstr(self.scr, iy, x + 2, "RX", curses.color_pair(C_DIM)) draw_sparkline(self.scr, iy, x + 5, half_w - 3, d.net_rx_history, max_net) safe_addstr(self.scr, iy, x + 2 + half_w + 1, "TX", curses.color_pair(C_DIM)) draw_sparkline(self.scr, iy, x + 2 + half_w + 4, half_w - 3, d.net_tx_history, max_net) iy += 1 # Net total total = d.prev_net safe_addstr(self.scr, iy, x + 2, f"{t('total')} RX: {fmt_bytes_short(total.bytes_recv)} TX: {fmt_bytes_short(total.bytes_sent)}", curses.color_pair(C_DIM)) iy += 1 # Active interfaces ifaces = [] for name, stats in d.net_stats.items(): if stats.isup and name != "lo": addrs = d.net_addrs.get(name, []) ip = "" for a in addrs: if a.family.name == "AF_INET": ip = a.address break if ip: ifaces.append(f"{name}:{ip}") if ifaces: iface_str = " ".join(ifaces) safe_addstr(self.scr, iy, x + 2, iface_str[:w - 4], curses.color_pair(C_DIM)) return y + box_h def _draw_disk(self, y, x, w): d = self.data num_parts = min(len(d.partitions), 4) box_h = 3 + num_parts * 2 draw_box(self.scr, y, x, box_h, w, t("disk")) iy = y + 1 bar_w = w - 4 # IO rates io_str = f"{t('read')}: {fmt_bytes_short(d.disk_read_rate)}/s {t('write')}: {fmt_bytes_short(d.disk_write_rate)}/s" safe_addstr(self.scr, iy, x + 2, io_str, curses.color_pair(C_ACCENT)) iy += 1 for part, usage in d.partitions[:4]: mp = part.mountpoint if len(mp) > 15: mp = "..." + mp[-12:] label = f"{mp} {fmt_bytes_short(usage.used)}/{fmt_bytes_short(usage.total)}" safe_addstr(self.scr, iy, x + 2, label[:bar_w], curses.color_pair(C_DIM)) iy += 1 draw_bar(self.scr, iy, x + 2, bar_w, usage.percent) iy += 1 return y + box_h def _draw_gpu(self, y, x, w): d = self.data box_h = 2 + len(d.gpus) * 4 draw_box(self.scr, y, x, box_h, w, t("gpu")) iy = y + 1 bar_w = w - 4 for gpu in d.gpus: # Name line name_str = f"{gpu['vendor']} {gpu['name']}" safe_addstr(self.scr, iy, x + 2, name_str[:bar_w], curses.color_pair(C_ACCENT) | curses.A_BOLD) iy += 1 # GPU utilization bar safe_addstr(self.scr, iy, x + 2, "GPU", curses.color_pair(C_DIM)) draw_bar(self.scr, iy, x + 6, bar_w - 4, gpu["gpu_util"]) iy += 1 # Memory bar mem_label = f"MEM {fmt_bytes_short(gpu['mem_used'])}/{fmt_bytes_short(gpu['mem_total'])}" safe_addstr(self.scr, iy, x + 2, "MEM", curses.color_pair(C_DIM)) draw_bar(self.scr, iy, x + 6, bar_w - 4, gpu["mem_util"]) iy += 1 # Stats line stats = f"Temp:{gpu['temp']}C Pwr:{gpu['power']:.0f}W Fan:{gpu['fan']}%" if gpu["clk_gpu"]: stats += f" GPU:{gpu['clk_gpu']}MHz" if gpu["clk_mem"]: stats += f" MEM:{gpu['clk_mem']}MHz" safe_addstr(self.scr, iy, x + 2, stats[:bar_w], curses.color_pair(C_DIM)) iy += 1 return y + box_h def _draw_temps(self, y, x, w): d = self.data entries = [] for chip, sensors in d.temps.items(): for s in sensors: label = s.label or chip entries.append((label, s.current, s.high or 100, s.critical or 110)) if not entries: return y box_h = min(2 + len(entries), 8) draw_box(self.scr, y, x, box_h, w, t("temps")) iy = y + 1 cols = max(1, w // 30) col_w = (w - 2) // cols for idx, (label, current, high, critical) in enumerate(entries[:((box_h - 2) * cols)]): col = idx % cols row = idx // cols cy = iy + row cx = x + 1 + col * col_w if cy >= y + box_h - 1: break col_attr = C_LOW if current > high * 0.8: col_attr = C_MED if current > high: col_attr = C_HIGH if critical and current > critical: col_attr = C_CRIT text = f"{label[:12]:12s} {current:5.1f}C" safe_addstr(self.scr, cy, cx, text, curses.color_pair(col_attr)) return y + box_h def _draw_processes(self, y, x, w, avail_h): d = self.data pm = self.proc_mgr if avail_h < 4: return box_h = avail_h processes = pm.filtered_processes(d.processes) # Title with keybindings filter_info = f" {t('filter')}: '{pm.filter_text}'" if pm.filter_text else "" title = f"{t('processes')} [{d.process_sort}] F9:kill F7/F8:{t('hint_nice')} F5:{t('hint_details')} /:{t('hint_filter')}{filter_info}" draw_box(self.scr, y, x, box_h, w, title) iy = y + 1 # Header hdr = f"{'PID':>7s} {'USER':<10s} {'CPU%':>6s} {'MEM%':>6s} {'THR':>4s} {'RES':>8s} {'S':1s} {t('name')}" safe_addstr(self.scr, iy, x + 1, hdr[:w - 2], curses.color_pair(C_HEADER)) iy += 1 max_procs = box_h - 4 # leave room for status bar # Adjust scroll so selected is visible if pm.selected_idx < self.scroll_offset: self.scroll_offset = pm.selected_idx elif pm.selected_idx >= self.scroll_offset + max_procs: self.scroll_offset = pm.selected_idx - max_procs + 1 self.scroll_offset = max(0, min(self.scroll_offset, max(0, len(processes) - max_procs))) visible = processes[self.scroll_offset:self.scroll_offset + max_procs] for row_idx, proc in enumerate(visible): abs_idx = self.scroll_offset + row_idx pid = proc.get("pid", 0) name = proc.get("name", "?") or "?" cpu = proc.get("cpu_percent", 0) or 0 mem = proc.get("memory_percent", 0) or 0 user = (proc.get("username") or "?")[:10] threads = proc.get("num_threads", 0) or 0 mem_info = proc.get("memory_info") rss = mem_info.rss if mem_info else 0 status = (proc.get("status") or "?")[0].upper() line = f"{pid:>7d} {user:<10s} {cpu:>6.1f} {mem:>6.1f} {threads:>4d} {fmt_bytes(rss)} {status} {name}" line = line[:w - 2].ljust(w - 2) if abs_idx == pm.selected_idx: safe_addstr(self.scr, iy, x + 1, line, curses.color_pair(C_SELECT) | curses.A_BOLD) else: col = C_NORMAL if cpu > 50: col = C_HIGH elif cpu > 10: col = C_MED safe_addstr(self.scr, iy, x + 1, line, curses.color_pair(col)) iy += 1 # Status bar at bottom of process box status_y = y + box_h - 2 status = pm.get_status() if status: safe_addstr(self.scr, status_y, x + 2, status[:w - 4], curses.color_pair(C_CRIT) | curses.A_BOLD) else: hint = f"q:{t('hint_quit')} c/m/p/n:{t('hint_sort')} r:{t('hint_reverse')} /:{t('hint_filter')} K:KILL T:TERM F9:{t('hint_signals')} d:{t('hint_details')} +/-:{t('hint_nice')}" safe_addstr(self.scr, status_y, x + 2, hint[:w - 4], curses.color_pair(C_DIM)) # Scrollbar indicator if len(processes) > max_procs: total = len(processes) pct_str = f" [{self.scroll_offset + 1}-{min(self.scroll_offset + max_procs, total)}/{total}] " safe_addstr(self.scr, y + box_h - 1, x + w - len(pct_str) - 2, pct_str, curses.color_pair(C_BORDER)) # Draw popups on top if pm.show_kill_menu: self._draw_kill_menu(y, x, w, avail_h) if pm.show_details: self._draw_details_popup(y, x, w, avail_h) if pm.show_nice_dialog: self._draw_nice_dialog(y, x, w, avail_h) if pm.filter_mode: self._draw_filter_bar(y + box_h - 2, x, w) def _draw_kill_menu(self, y, x, w, avail_h): pm = self.proc_mgr proc = pm.get_selected_proc(self.data.processes) proc_name = proc.get("name", "?") if proc else "?" proc_pid = proc.get("pid", 0) if proc else 0 menu_w = 36 menu_h = len(ProcessManager.SIGNALS) + 4 mx = (w - menu_w) // 2 my = y + 2 # Background for row in range(menu_h): safe_addstr(self.scr, my + row, mx, " " * menu_w, curses.color_pair(C_POPUP_BG)) # Title title = f" {t('send_signal_to')} {proc_name}({proc_pid}) " safe_addstr(self.scr, my, mx, title[:menu_w].center(menu_w), curses.color_pair(C_POPUP_HL) | curses.A_BOLD) safe_addstr(self.scr, my + 1, mx, "\u2500" * menu_w, curses.color_pair(C_POPUP_BG)) for i, (name, _sig) in enumerate(ProcessManager.SIGNALS): row_y = my + 2 + i if i == pm.kill_menu_idx: safe_addstr(self.scr, row_y, mx + 1, f" > {name} ".ljust(menu_w - 2), curses.color_pair(C_POPUP_HL) | curses.A_BOLD) else: safe_addstr(self.scr, row_y, mx + 1, f" {name} ".ljust(menu_w - 2), curses.color_pair(C_POPUP_BG)) safe_addstr(self.scr, my + menu_h - 1, mx, f" Enter:{t('send')} Esc:{t('cancel')} ".center(menu_w), curses.color_pair(C_POPUP_BG) | curses.A_DIM) def _draw_details_popup(self, y, x, w, avail_h): pm = self.proc_mgr details = pm.get_proc_details(self.data.processes) if not details: pm.show_details = False return popup_w = min(w - 4, 70) popup_h = 18 mx = (w - popup_w) // 2 my = y + 1 # Background for row in range(popup_h): safe_addstr(self.scr, my + row, mx, " " * popup_w, curses.color_pair(C_POPUP_BG)) title = f" {t('proc_details')}: {details['name']} (PID {details['pid']}) " safe_addstr(self.scr, my, mx, title[:popup_w].center(popup_w), curses.color_pair(C_POPUP_HL) | curses.A_BOLD) safe_addstr(self.scr, my + 1, mx, "\u2500" * popup_w, curses.color_pair(C_POPUP_BG)) lines = [ f" PID: {details['pid']} PPID: {details['ppid']}", f" User: {details['username']} Status: {details['status']}", f" Nice: {details['nice']} Threads: {details['num_threads']}", f" CPU: {details['cpu_percent']:.1f}% MEM: {details['memory_percent']:.1f}%", f" RSS: {details['rss']} VMS: {details['vms']}", f" IO R: {details['io_read']} IO W: {details['io_write']}", f" FDs: {details['num_fds']} {t('connections')}: {details['connections']}", f" {t('created')}: {details['create_time']}", f" Exe: {details.get('exe', '?')}", f" CWD: {details.get('cwd', '?')}", f" Cmd: {details.get('cmdline', '?')}", ] for i, line in enumerate(lines): safe_addstr(self.scr, my + 2 + i, mx, line[:popup_w].ljust(popup_w), curses.color_pair(C_POPUP_BG)) safe_addstr(self.scr, my + popup_h - 1, mx, f" Esc/d: {t('close')} ".center(popup_w), curses.color_pair(C_POPUP_BG) | curses.A_DIM) def _draw_nice_dialog(self, y, x, w, avail_h): pm = self.proc_mgr proc = pm.get_selected_proc(self.data.processes) proc_name = proc.get("name", "?") if proc else "?" popup_w = 40 popup_h = 5 mx = (w - popup_w) // 2 my = y + 4 for row in range(popup_h): safe_addstr(self.scr, my + row, mx, " " * popup_w, curses.color_pair(C_POPUP_BG)) title = f" Renice: {proc_name} " safe_addstr(self.scr, my, mx, title[:popup_w].center(popup_w), curses.color_pair(C_POPUP_HL) | curses.A_BOLD) safe_addstr(self.scr, my + 1, mx, "\u2500" * popup_w, curses.color_pair(C_POPUP_BG)) safe_addstr(self.scr, my + 2, mx + 2, f"{t('nice_value')}: {pm.nice_value}_".ljust(popup_w - 4), curses.color_pair(C_POPUP_BG)) safe_addstr(self.scr, my + 4, mx, f" Enter:{t('apply')} Esc:{t('cancel')} ".center(popup_w), curses.color_pair(C_POPUP_BG) | curses.A_DIM) def _draw_help(self, h, w): help_lines = get_help_lines() popup_w = min(w - 4, 52) popup_h = min(h - 2, len(help_lines) + 4) mx = (w - popup_w) // 2 my = (h - popup_h) // 2 # Background for row in range(popup_h): safe_addstr(self.scr, my + row, mx, " " * popup_w, curses.color_pair(C_POPUP_BG)) # Title title = f" {t('help_title')} " safe_addstr(self.scr, my, mx, title.center(popup_w), curses.color_pair(C_POPUP_HL) | curses.A_BOLD) safe_addstr(self.scr, my + 1, mx, "\u2500" * popup_w, curses.color_pair(C_POPUP_BG)) for i, (key, desc) in enumerate(help_lines): row_y = my + 2 + i if row_y >= my + popup_h - 1: break if not key and not desc: continue if not desc: # Section header safe_addstr(self.scr, row_y, mx + 2, key, curses.color_pair(C_POPUP_HL) | curses.A_BOLD) else: line = f" {key:<20s} {desc}" safe_addstr(self.scr, row_y, mx, line[:popup_w].ljust(popup_w), curses.color_pair(C_POPUP_BG)) safe_addstr(self.scr, my + popup_h - 1, mx, f" {t('help_close')} ".center(popup_w), curses.color_pair(C_POPUP_BG) | curses.A_DIM) def _draw_filter_bar(self, y, x, w): pm = self.proc_mgr bar = f" {t('filter_label')}: {pm.filter_text}_ " safe_addstr(self.scr, y, x + 1, bar.ljust(w - 2)[:w - 2], curses.color_pair(C_POPUP_HL) | curses.A_BOLD) # ─── Intro ────────────────────────────────────────────────────────── VERSION = "1.0.0" LOGO_ART = [ " ███████╗██╗ ██╗███████╗████████╗ █████╗ ████████╗███████╗", " ██╔════╝╚██╗ ██╔╝██╔════╝╚══██╔══╝██╔══██╗╚══██╔══╝██╔════╝", " ███████╗ ╚████╔╝ ███████╗ ██║ ███████║ ██║ ███████╗", " ╚════██║ ╚██╔╝ ╚════██║ ██║ ██╔══██║ ██║ ╚════██║", " ███████║ ██║ ███████║ ██║ ██║ ██║ ██║ ███████║", " ╚══════╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝", ] BOOT_LINES = [ ("> SYSTATS v{ver}", 0.0), ("> initializing system probes... OK", 0.3), ("> scanning CPU cores... {cores} detected", 0.6), ("> GPU subsystem... {gpu}", 0.9), ("> network interfaces... ONLINE", 1.1), ("> thermal sensors... {temps}", 1.3), ("> process table... READY", 1.5), ("> Electric Entropy Lab // SYSTATS", 1.8), ] import random as _random def intro_splash(stdscr): """Animated boot splash inspired by Spectre/Pulse.""" curses.curs_set(0) stdscr.nodelay(False) stdscr.timeout(30) init_colors() h, w = stdscr.getmaxyx() if h < 15 or w < 60: return # skip intro on tiny terminals # Gather system info for boot lines cores = psutil.cpu_count() gpu_str = "NVIDIA" if NVIDIA_AVAILABLE else ("AMD" if os.path.exists(AMD_GPU_PATH) else "NONE") try: temps_count = sum(len(v) for v in psutil.sensors_temperatures().values()) temps_str = f"{temps_count} sensors" except Exception: temps_str = "N/A" glitch_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*!?<>/\\|{}[]~^" # Phase 1: Boot lines boot_start = time.time() shown_lines = [] for line_tpl, delay in BOOT_LINES: line = line_tpl.format(ver=VERSION, cores=cores, gpu=gpu_str, temps=temps_str) target_time = boot_start + delay while time.time() < target_time: stdscr.getch() # drain input time.sleep(0.02) shown_lines.append(line) stdscr.erase() start_y = (h - len(BOOT_LINES) - len(LOGO_ART) - 4) // 2 for i, sl in enumerate(shown_lines): col = C_LOW if "OK" in sl or "READY" in sl or "ONLINE" in sl or "detected" in sl else C_DIM if "Electric Entropy" in sl: col = C_ACCENT safe_addstr(stdscr, start_y + i, (w - len(sl)) // 2, sl, curses.color_pair(col)) stdscr.refresh() time.sleep(0.3) # Phase 2: Decrypt logo animation logo_start_y = (h - len(LOGO_ART)) // 2 # Find max logo width max_logo_w = max(len(l) for l in LOGO_ART) # Initialize with random chars for non-space positions logo_state = [] for line in LOGO_ART: row = [] for ch in line: if ch == ' ' or ch == '\u2500': row.append(ch) else: row.append(_random.choice(glitch_chars)) logo_state.append(row) # Decrypt columns left to right total_cols = max_logo_w decrypt_steps = 20 # frames for full decrypt chars_per_step = max(1, total_cols // decrypt_steps) revealed = [set() for _ in LOGO_ART] for step in range(decrypt_steps + 5): stdscr.erase() # Reveal columns up to this step reveal_up_to = min(step * chars_per_step, total_cols) for row_idx, line in enumerate(LOGO_ART): lx = (w - max_logo_w) // 2 for col_idx, ch in enumerate(line): if col_idx < reveal_up_to: revealed[row_idx].add(col_idx) display = [] for col_idx in range(len(line)): if col_idx in revealed[row_idx]: display.append(line[col_idx]) elif line[col_idx] == ' ': display.append(' ') else: display.append(_random.choice(glitch_chars)) text = "".join(display) # Color: revealed chars in cyan, glitch in dim for col_idx, ch in enumerate(text): if col_idx >= w - lx - 1: break if ch == ' ': continue if col_idx in revealed[row_idx]: attr = curses.color_pair(C_TITLE) | curses.A_BOLD else: attr = curses.color_pair(C_DIM) | curses.A_DIM try: stdscr.addch(logo_start_y + row_idx, lx + col_idx, ch, attr) except curses.error: pass # Version + brand below logo if step > decrypt_steps - 3: ver_str = f"v{VERSION} // Electric Entropy Lab" safe_addstr(stdscr, logo_start_y + len(LOGO_ART) + 1, (w - len(ver_str)) // 2, ver_str, curses.color_pair(C_ACCENT) | curses.A_BOLD) stdscr.refresh() stdscr.getch() # drain time.sleep(0.05) time.sleep(0.5) # Phase 3: Flash and clear stdscr.erase() stdscr.refresh() time.sleep(0.1) # ─── Main ─────────────────────────────────────────────────────────── show_intro = True def main(stdscr): if show_intro: intro_splash(stdscr) curses.curs_set(0) stdscr.nodelay(True) stdscr.timeout(int(REFRESH_INTERVAL * 1000)) init_colors() data = SystemData() renderer = Renderer(stdscr, data) # First collection to initialize rates data.update() time.sleep(0.1) while True: data.update() renderer.draw() key = stdscr.getch() pm = renderer.proc_mgr processes = pm.filtered_processes(data.processes) max_idx = max(0, len(processes) - 1) # ── Help overlay ── if renderer.show_help: if key in (ord("h"), ord("?"), curses.KEY_F1, 27): renderer.show_help = False continue # ── Filter mode input ── if pm.filter_mode: if key == 27: # ESC pm.filter_mode = False pm.filter_text = "" pm.selected_idx = 0 elif key == 10 or key == 13: # Enter pm.filter_mode = False pm.selected_idx = 0 elif key in (curses.KEY_BACKSPACE, 127, 8): pm.filter_text = pm.filter_text[:-1] pm.selected_idx = 0 elif 32 <= key <= 126: pm.filter_text += chr(key) pm.selected_idx = 0 continue # ── Nice dialog input ── if pm.show_nice_dialog: if key == 27: pm.show_nice_dialog = False pm.nice_value = "" elif key == 10 or key == 13: try: val = int(pm.nice_value) val = max(-20, min(19, val)) pm.renice_selected(processes, val) except ValueError: pm._set_status(t("invalid_nice")) pm.show_nice_dialog = False pm.nice_value = "" elif key in (curses.KEY_BACKSPACE, 127, 8): pm.nice_value = pm.nice_value[:-1] elif chr(key) in "0123456789-" if 0 <= key <= 127 else False: pm.nice_value += chr(key) continue # ── Kill menu input ── if pm.show_kill_menu: if key == 27: pm.show_kill_menu = False elif key == curses.KEY_UP or key == ord("k"): pm.kill_menu_idx = max(0, pm.kill_menu_idx - 1) elif key == curses.KEY_DOWN or key == ord("j"): pm.kill_menu_idx = min(len(ProcessManager.SIGNALS) - 1, pm.kill_menu_idx + 1) elif key == 10 or key == 13: _name, sig = ProcessManager.SIGNALS[pm.kill_menu_idx] pm.kill_selected(processes, sig) pm.show_kill_menu = False continue # ── Details popup ── if pm.show_details: if key == 27 or key == ord("d") or key == ord("q"): pm.show_details = False continue # ── Normal mode ── if key == ord("q") or key == ord("Q"): break elif key == 27: # ESC break elif key in (ord("h"), ord("?"), curses.KEY_F1): renderer.show_help = True elif key == ord("L"): global current_lang idx = LANGS.index(current_lang) current_lang = LANGS[(idx + 1) % len(LANGS)] save_lang(current_lang) elif key == ord("c"): data.process_sort = "cpu" data.process_sort_reverse = True pm.selected_idx = 0 elif key == ord("m"): data.process_sort = "mem" data.process_sort_reverse = True pm.selected_idx = 0 elif key == ord("p"): data.process_sort = "pid" data.process_sort_reverse = False pm.selected_idx = 0 elif key == ord("n"): data.process_sort = "name" data.process_sort_reverse = False pm.selected_idx = 0 elif key == ord("r"): data.process_sort_reverse = not data.process_sort_reverse elif key == curses.KEY_DOWN or key == ord("j"): pm.selected_idx = min(pm.selected_idx + 1, max_idx) elif key == curses.KEY_UP or key == ord("k"): pm.selected_idx = max(pm.selected_idx - 1, 0) elif key == curses.KEY_NPAGE: pm.selected_idx = min(pm.selected_idx + 20, max_idx) elif key == curses.KEY_PPAGE: pm.selected_idx = max(pm.selected_idx - 20, 0) elif key == curses.KEY_HOME: pm.selected_idx = 0 elif key == curses.KEY_END: pm.selected_idx = max_idx # Kill shortcuts elif key == curses.KEY_F9: pm.show_kill_menu = True pm.kill_menu_idx = 0 elif key == ord("T"): pm.kill_selected(processes, signal.SIGTERM) elif key == ord("K"): pm.kill_selected(processes, signal.SIGKILL) # Nice elif key == ord("+") or key == curses.KEY_F8: pm.show_nice_dialog = True pm.nice_value = "" elif key == ord("-") or key == curses.KEY_F7: pm.show_nice_dialog = True pm.nice_value = "-" # Details elif key == ord("d") or key == curses.KEY_F5: pm.show_details = True # Filter elif key == ord("/") or key == ord("f"): pm.filter_mode = True pm.filter_text = "" pm.selected_idx = 0 # Enter on process = details elif key == 10 or key == 13: pm.show_details = True def get_config_path(): config_dir = os.path.join(os.path.expanduser("~"), ".config", "systats") os.makedirs(config_dir, exist_ok=True) return os.path.join(config_dir, "config") def load_saved_lang(): path = get_config_path() try: with open(path) as f: for line in f: if line.startswith("lang="): lang = line.strip().split("=", 1)[1] if lang in LANGS: return lang except FileNotFoundError: pass return None def save_lang(lang): path = get_config_path() with open(path, "w") as f: f.write(f"lang={lang}\n") def ask_lang(): print(" \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510") print(" \u2502 SYSTATS - Electric Entropy Lab \u2502") print(" \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524") print(" \u2502 Choose your language: \u2502") print(" \u2502 \u2502") for i, code in enumerate(LANGS, 1): name = LANG_NAMES.get(code, code) print(f" \u2502 {i}) {name:<28s} \u2502") print(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518") while True: try: choice = input(" > ").strip().lower() except (EOFError, KeyboardInterrupt): print() sys.exit(0) # Accept number if choice.isdigit() and 1 <= int(choice) <= len(LANGS): return LANGS[int(choice) - 1] # Accept lang code if choice in LANGS: return choice print(f" 1-{len(LANGS)} or lang code ({'/'.join(LANGS)})") if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="systats - Terminal system monitor | Electric Entropy Lab") lang_help = ", ".join(f"{c} ({n})" for c, n in LANG_NAMES.items()) parser.add_argument("--lang", choices=LANGS, default=None, help=f"UI language: {lang_help}") parser.add_argument("--reset-lang", action="store_true", help="Reset saved language preference and ask again") parser.add_argument("--no-intro", action="store_true", help="Skip the boot intro animation") args = parser.parse_args() if args.reset_lang: path = get_config_path() try: os.remove(path) print("Language preference reset.") except FileNotFoundError: print("No saved preference found.") if not args.lang: lang = ask_lang() save_lang(lang) current_lang = lang else: save_lang(args.lang) current_lang = args.lang elif args.lang: save_lang(args.lang) current_lang = args.lang else: saved = load_saved_lang() if saved: current_lang = saved else: lang = ask_lang() save_lang(lang) current_lang = lang if args.no_intro: show_intro = False try: curses.wrapper(main) except KeyboardInterrupt: pass