#!/usr/bin/python3
"""
LinuxDoors System Status

A live system monitor -- CPU, memory, disks, network (including the actual
open connections, not just a throughput graph), and processes -- in one
window, with real process control (End Process, Change Priority), light and
dark themes both checked against WCAG contrast, and a +/- zoom that scales
the whole window rather than just one panel.

    python3 linuxdoors-system-status.py

Data sources
------------
CPU and memory come straight from /proc/stat and /proc/meminfo -- no extra
dependency, and the same numbers `top` itself reads. Disks come from
/proc/mounts filtered to real filesystems, sized with os.statvfs. Processes
and network connections are read from the real, already-installed `ps` and
`ss` (iproute2) -- well-tested existing tools doing exactly this one job,
the same reasoning this project already applied choosing `wmctrl` over a
hand-rolled X11 client for LinuxDoors PowerToys' Always on Top module.
Killing or renicing a process you don't own, same as any terminal, needs a
privilege you may not have -- if the plain attempt is refused, a password
dialog offers to retry the one action as root, the same su -c pattern every
other LinuxDoors admin tool already uses.

Themes
------
Two palettes, light and dark, and every colour that carries text is checked
against the surface it sits on: 4.5:1 for body text (WCAG 1.4.3), 3:1 for a
control's outline (1.4.11).

    python3 linuxdoors-system-status.py --check-contrast

prints the whole table and exits non-zero if any pair has fallen below, so
a colour can't be nudged for looks without the loss showing up. The two
palettes below are the same ones already proved out and shipped in
FlashScp -- reused rather than re-derived, since they're already measured.

Zoom
----
+/- (or Ctrl+=/Ctrl+-) scales the application's base font size, and every
widget below follows from that -- point sizes, not pixel constants, so a
100% window and a 150% window are laid out the same way, just bigger.

Sections fold
-------------
Each area (CPU, Memory, Disks, Network, Processes) has a (+)/(-) button in
its own header. Collapsing one is purely a display choice -- the section
keeps updating underneath whether it's open or not, so re-opening it never
shows stale numbers.
"""
import os
import re
import sys
import time
import signal
import subprocess
from collections import deque

from PySide6.QtCore import Qt, QTimer, Signal, QPointF
from PySide6.QtGui import QFont, QColor, QPainter, QPen, QPolygonF, QIcon, QPixmap, QBrush
from PySide6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
    QLabel, QPushButton, QScrollArea, QFrame, QProgressBar, QTableWidget,
    QTableWidgetItem, QHeaderView, QAbstractItemView, QDialog, QLineEdit,
    QSpinBox, QMessageBox, QSizePolicy, QToolButton, QTabWidget,
)
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QValueAxis

# ---------------------------------------------------------------------------
# Palettes -- the same two, and the same WCAG discipline, already proved out
# in FlashScp. Reused verbatim rather than re-derived from scratch.
# ---------------------------------------------------------------------------

DARK_PALETTE = {
    "bg": "#161b22", "surface": "#1b222c", "sunken": "#10151c",
    "row": "#141a22", "hover": "#1c2531",
    "border": "#2b333d", "field": "#626e7b", "rule": "#2b333d",
    "text": "#d6dae0", "dim": "#78909c", "title": "#ffffff",
    "info": "#6db3f2", "good": "#7bd88f", "bad": "#ff6b6b",
    "warn": "#ffb86c", "alt": "#c792ea",
    "select": "#1565c0", "onselect": "#ffffff",
    "disabled": "#717a86",
    "green": "#2e7d32", "greenhover": "#2f8434",
    "red": "#c62828", "redhover": "#b02222",
    "blue": "#1565c0", "bluehover": "#1976d2",
    "grey": "#455a64", "greyhover": "#546e7a",
    "onbutton": "#ffffff",
    "chart_cpu": "#6db3f2", "chart_mem": "#c792ea",
    "chart_up": "#7bd88f", "chart_down": "#ffb86c",
    "chart_grid": "#2b333d",
}

LIGHT_PALETTE = {
    "bg": "#eef1f5", "surface": "#ffffff", "sunken": "#ffffff",
    "row": "#f5f7f9", "hover": "#e6ecf2",
    "border": "#d4dce4", "field": "#7e8b99", "rule": "#d4dce4",
    "text": "#16202b", "dim": "#5b6773", "title": "#0f1720",
    "info": "#0b5cad", "good": "#1a7333", "bad": "#c02617",
    "warn": "#8a5200", "alt": "#6a35a8",
    "select": "#1565c0", "onselect": "#ffffff",
    "disabled": "#767f89",
    "green": "#2e7d32", "greenhover": "#276b2b",
    "red": "#c62828", "redhover": "#ab2222",
    "blue": "#1565c0", "bluehover": "#12539f",
    "grey": "#455a64", "greyhover": "#374a52",
    "onbutton": "#ffffff",
    "chart_cpu": "#0b5cad", "chart_mem": "#6a35a8",
    "chart_up": "#1a7333", "chart_down": "#8a5200",
    "chart_grid": "#d4dce4",
}

PALETTES = {"dark": DARK_PALETTE, "light": LIGHT_PALETTE}
THEME_NAME = "light"
ZOOM = 1.0
BASE_PT = 10


def palette():
    return PALETTES[THEME_NAME]


def colour(key):
    return palette().get(key, palette()["text"])


def set_theme(name):
    global THEME_NAME
    THEME_NAME = name if name in PALETTES else "light"


def other_theme():
    return "light" if THEME_NAME == "dark" else "dark"


def pt(size):
    """A point size scaled by the current zoom level."""
    return max(6, round(size * ZOOM))


# ---------------------------------------------------------------------------
# Contrast checking -- the same WCAG 2.1 relative-luminance formula already
# used to verify FlashScp's palettes, applied to this app's own colour list.
# ---------------------------------------------------------------------------

def _relative_luminance(hex_colour):
    parts = hex_colour.lstrip("#")
    channels = []
    for index in (0, 2, 4):
        value = int(parts[index:index + 2], 16) / 255.0
        channels.append(value / 12.92 if value <= 0.03928
                        else ((value + 0.055) / 1.055) ** 2.4)
    red, green, blue = channels
    return 0.2126 * red + 0.7152 * green + 0.0722 * blue


def contrast_ratio(one, two):
    first, second = _relative_luminance(one), _relative_luminance(two)
    lighter, darker = max(first, second), min(first, second)
    return (lighter + 0.05) / (darker + 0.05)


CONTRAST_RULES = [
    ("text", ["bg", "surface", "sunken", "row", "hover"], 4.5),
    ("dim", ["bg", "surface", "sunken", "row"], 4.5),
    ("info", ["bg", "surface", "sunken", "row"], 4.5),
    ("good", ["bg", "surface", "sunken", "row"], 4.5),
    ("bad", ["bg", "surface", "sunken", "row"], 4.5),
    ("warn", ["bg", "surface", "sunken", "row"], 4.5),
    ("alt", ["bg", "surface", "sunken", "row"], 4.5),
    ("title", ["bg"], 4.5),
    ("onselect", ["select"], 4.5),
    ("onbutton", ["green", "red", "blue", "grey",
                  "greenhover", "redhover", "bluehover", "greyhover"], 4.5),
    ("disabled", ["bg", "surface"], 3.0),
    ("field", ["bg", "surface", "sunken"], 3.0),
    ("chart_cpu", ["bg", "surface"], 3.0),
    ("chart_mem", ["bg", "surface"], 3.0),
    ("chart_up", ["bg", "surface"], 3.0),
    ("chart_down", ["bg", "surface"], 3.0),
]


def check_contrast(verbose=True):
    failures = []
    for name in ("light", "dark"):
        current = PALETTES[name]
        if verbose:
            print("\n%s theme" % name.upper())
            print("  %-12s %-10s %8s  %s" % ("ink", "on", "ratio", "needs"))
        for ink, grounds, needed in CONTRAST_RULES:
            for ground in grounds:
                measured = contrast_ratio(current[ink], current[ground])
                ok = measured >= needed
                if not ok:
                    failures.append((name, ink, ground, measured, needed))
                if verbose:
                    print("  %-12s %-10s %8.2f  %s%s" % (
                        ink, ground, measured, needed,
                        "" if ok else "   *** FAIL ***"))
    return failures


# ---------------------------------------------------------------------------
# Stylesheet
# ---------------------------------------------------------------------------

def build_style():
    p = dict(palette())
    p["fs"] = pt(BASE_PT)
    p["fs_title"] = pt(BASE_PT + 6)
    p["fs_small"] = pt(BASE_PT - 2)
    return """
    QWidget { background-color: %(bg)s; color: %(text)s; font-size: %(fs)spt; }
    QMainWindow { background-color: %(bg)s; }
    QScrollArea { border: none; background-color: %(bg)s; }
    QLabel { background: transparent; }
    QLabel[role="title"] { color: %(title)s; font-size: %(fs_title)spt; font-weight: bold; }
    QLabel[role="dim"] { color: %(dim)s; font-size: %(fs_small)spt; }
    QLabel[role="good"] { color: %(good)s; }
    QLabel[role="bad"] { color: %(bad)s; }
    QLabel[role="warn"] { color: %(warn)s; }
    QFrame[role="section"] { background-color: %(surface)s; border: 1px solid %(border)s;
        border-radius: 10px; }
    QFrame[role="rule"] { color: %(rule)s; background-color: %(rule)s; max-height: 1px; }
    QToolButton[role="fold"] { background-color: transparent; color: %(info)s;
        border: 1px solid %(border)s; border-radius: 4px; font-weight: bold;
        font-size: %(fs_title)spt; min-width: 26px; min-height: 26px; }
    QToolButton[role="fold"]:hover { background-color: %(hover)s; }
    QPushButton { background-color: %(blue)s; color: %(onbutton)s; border-radius: 8px;
        padding: 6px 14px; font-weight: bold; }
    QPushButton:hover { background-color: %(bluehover)s; }
    QPushButton:disabled { background-color: %(disabled)s; color: %(surface)s; }
    QPushButton[role="danger"] { background-color: %(red)s; }
    QPushButton[role="danger"]:hover { background-color: %(redhover)s; }
    QPushButton[role="ghost"] { background-color: %(surface)s; color: %(text)s;
        border: 1px solid %(border)s; }
    QPushButton[role="ghost"]:hover { background-color: %(hover)s; }
    QProgressBar { background-color: %(sunken)s; border: 1px solid %(border)s;
        border-radius: 6px; text-align: center; color: %(text)s; }
    QProgressBar::chunk { background-color: %(info)s; border-radius: 5px; }
    QProgressBar[role="mem"]::chunk { background-color: %(alt)s; border-radius: 5px; }
    QTableWidget { background-color: %(sunken)s; alternate-background-color: %(row)s;
        gridline-color: %(border)s; border: 1px solid %(border)s; border-radius: 6px; }
    QTableWidget::item:selected { background-color: %(select)s; color: %(onselect)s; }
    QHeaderView::section { background-color: %(surface)s; color: %(dim)s;
        border: none; border-bottom: 1px solid %(border)s; padding: 4px; font-weight: bold; }
    QLineEdit, QSpinBox { background-color: %(sunken)s; color: %(text)s;
        border: 1px solid %(field)s; border-radius: 6px; padding: 4px; }
    QScrollBar:vertical { background: %(bg)s; width: 12px; }
    QScrollBar::handle:vertical { background: %(field)s; border-radius: 6px; min-height: 24px; }
    """ % p


def role(widget, name, value=None):
    widget.setProperty("role" if value is None else name, value if value is not None else name)
    widget.style().unpolish(widget)
    widget.style().polish(widget)


def set_role(widget, value):
    widget.setProperty("role", value)
    widget.style().unpolish(widget)
    widget.style().polish(widget)


# ---------------------------------------------------------------------------
# Door mark -- the same brand icon every other native LinuxDoors app uses.
# ---------------------------------------------------------------------------

def door_icon(size=64):
    pixmap = QPixmap(size, size)
    pixmap.fill(QColor(0, 0, 0, 0))
    painter = QPainter(pixmap)
    painter.setRenderHint(QPainter.Antialiasing)
    bg = QColor("#1c1038")
    frame = QColor("#fbeed2")
    opening = QColor("#E0B44A")
    leaf = QColor("#5b359e")
    gold = QColor("#E0B44A")
    painter.setBrush(QBrush(bg))
    painter.setPen(Qt.NoPen)
    painter.drawRoundedRect(0, 0, size, size, size * 0.12, size * 0.12)
    fx, fy, fw, fh = size * 0.18, size * 0.10, size * 0.64, size * 0.80
    painter.setBrush(QBrush(frame))
    painter.drawRect(int(fx), int(fy), int(fw), int(fh))
    ox, oy, ow, oh = fx + fw * 0.08, fy + fh * 0.06, fw * 0.84, fh * 0.88
    painter.setBrush(QBrush(opening))
    painter.drawRect(int(ox), int(oy), int(ow), int(oh))
    lx = ox + ow * 0.10
    poly = QPolygonF([
        QPointF(lx, oy), QPointF(ox + ow * 0.62, oy + oh * 0.06),
        QPointF(ox + ow * 0.72, oy + oh), QPointF(lx, oy + oh * 0.96)])
    painter.setBrush(QBrush(leaf))
    painter.setPen(QPen(gold, max(1.0, size * 0.02)))
    painter.drawPolygon(poly)
    painter.setPen(Qt.NoPen)
    painter.setBrush(QBrush(gold))
    kx, ky = ox + ow * 0.58, oy + oh * 0.5
    r = ow * 0.045
    painter.drawEllipse(QPointF(kx, ky), r, r)
    painter.end()
    return QIcon(pixmap)


# ---------------------------------------------------------------------------
# Data gathering
# ---------------------------------------------------------------------------

def read_cpu_times():
    """Per-CPU (user+nice+sys+...) and idle jiffies from /proc/stat."""
    times = {}
    with open("/proc/stat") as f:
        for line in f:
            if not line.startswith("cpu"):
                break
            parts = line.split()
            label = parts[0]
            nums = list(map(int, parts[1:]))
            idle = nums[3] + (nums[4] if len(nums) > 4 else 0)
            total = sum(nums)
            times[label] = (total, idle)
    return times


def cpu_percentages(prev, cur):
    """{'cpu': overall%, 'cpu0': core0%, ...} from two read_cpu_times() samples."""
    out = {}
    for label in cur:
        if label not in prev:
            continue
        total_delta = cur[label][0] - prev[label][0]
        idle_delta = cur[label][1] - prev[label][1]
        if total_delta <= 0:
            out[label] = 0.0
        else:
            out[label] = max(0.0, min(100.0, 100.0 * (total_delta - idle_delta) / total_delta))
    return out


def read_meminfo():
    info = {}
    with open("/proc/meminfo") as f:
        for line in f:
            key, _, rest = line.partition(":")
            value = rest.strip().split()[0]
            info[key] = int(value)  # kB
    total = info.get("MemTotal", 1)
    avail = info.get("MemAvailable", info.get("MemFree", 0))
    used = total - avail
    swap_total = info.get("SwapTotal", 0)
    swap_free = info.get("SwapFree", 0)
    swap_used = swap_total - swap_free
    return {
        "mem_total_kb": total, "mem_used_kb": used,
        "mem_pct": 100.0 * used / total if total else 0.0,
        "swap_total_kb": swap_total, "swap_used_kb": swap_used,
        "swap_pct": 100.0 * swap_used / swap_total if swap_total else 0.0,
    }


def human_bytes(n):
    n = float(n)
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if abs(n) < 1024.0:
            return f"{n:,.1f} {unit}" if unit != "B" else f"{int(n)} {unit}"
        n /= 1024.0
    return f"{n:,.1f} PB"


def read_disks():
    rows = []
    seen = set()
    real_fs_skip = {"proc", "sysfs", "devtmpfs", "tmpfs", "devpts", "cgroup",
                     "cgroup2", "pstore", "bpf", "tracefs", "debugfs",
                     "mqueue", "securityfs", "autofs", "hugetlbfs",
                     "configfs", "fusectl", "overlay", "squashfs"}
    try:
        with open("/proc/mounts") as f:
            for line in f:
                parts = line.split()
                if len(parts) < 3:
                    continue
                device, mountpoint, fstype = parts[0], parts[1], parts[2]
                if fstype in real_fs_skip or not device.startswith("/"):
                    continue
                if mountpoint in seen:
                    continue
                seen.add(mountpoint)
                try:
                    st = os.statvfs(mountpoint)
                except OSError:
                    continue
                total = st.f_blocks * st.f_frsize
                free = st.f_bavail * st.f_frsize
                used = total - free
                if total == 0:
                    continue
                rows.append({
                    "device": device, "mount": mountpoint, "fstype": fstype,
                    "total": total, "used": used, "free": free,
                    "pct": 100.0 * used / total,
                })
    except OSError:
        pass
    return rows


def read_net_dev():
    """{iface: (rx_bytes, tx_bytes)}"""
    out = {}
    try:
        with open("/proc/net/dev") as f:
            lines = f.readlines()[2:]
        for line in lines:
            iface, rest = line.split(":", 1)
            iface = iface.strip()
            if iface == "lo":
                continue
            fields = rest.split()
            out[iface] = (int(fields[0]), int(fields[8]))
    except OSError:
        pass
    return out


def read_connections():
    """A live connection table via `ss -tulpn` -- proto/local/remote/state/proc."""
    rows = []
    try:
        out = subprocess.run(["ss", "-tulpn"], capture_output=True, text=True,
                              timeout=3).stdout
    except (OSError, subprocess.TimeoutExpired):
        return rows
    for line in out.splitlines()[1:]:
        parts = line.split()
        if len(parts) < 5:
            continue
        proto = parts[0]
        state = parts[1] if not proto.startswith("udp") else "-"
        offset = 1 if proto.startswith("udp") else 2
        try:
            local = parts[offset + 2]
            remote = parts[offset + 3]
        except IndexError:
            continue
        local_addr, _, local_port = local.rpartition(":")
        remote_addr, _, remote_port = remote.rpartition(":")
        proc_match = re.search(r'users:\(\("([^"]+)",pid=(\d+)', line)
        proc_name = f"{proc_match.group(1)} ({proc_match.group(2)})" if proc_match else "-"
        rows.append({
            "proto": proto, "local_addr": local_addr or "*", "local_port": local_port,
            "remote_addr": remote_addr or "*", "remote_port": remote_port,
            "state": state, "process": proc_name,
        })
    return rows


def read_processes():
    rows = []
    try:
        out = subprocess.run(
            ["ps", "-eo", "pid,user,%cpu,%mem,ni,comm", "--no-headers"],
            capture_output=True, text=True, timeout=3).stdout
    except (OSError, subprocess.TimeoutExpired):
        return rows
    for line in out.splitlines():
        parts = line.split(None, 5)
        if len(parts) < 6:
            continue
        pid, user, cpu, mem, nice, comm = parts
        try:
            nice_val = int(nice)
        except ValueError:
            # some processes (kernel threads, real-time-scheduled ones)
            # report "-" for nice instead of a number.
            nice_val = None
        rows.append({"pid": int(pid), "user": user, "cpu": float(cpu),
                     "mem": float(mem), "nice": nice_val, "comm": comm})
    return rows


# ---------------------------------------------------------------------------
# Root-elevation helper -- the same su -c pattern every other LinuxDoors
# admin tool already uses.
# ---------------------------------------------------------------------------

def ask_password(parent, title):
    dlg = QDialog(parent)
    dlg.setWindowTitle(title)
    layout = QVBoxLayout(dlg)
    layout.addWidget(QLabel("Administrator password required:"))
    entry = QLineEdit()
    entry.setEchoMode(QLineEdit.Password)
    layout.addWidget(entry)
    buttons = QHBoxLayout()
    ok = QPushButton("OK")
    cancel = QPushButton("Cancel")
    set_role(cancel, "ghost")
    buttons.addWidget(cancel)
    buttons.addWidget(ok)
    layout.addLayout(buttons)
    ok.clicked.connect(dlg.accept)
    cancel.clicked.connect(dlg.reject)
    entry.returnPressed.connect(dlg.accept)
    if dlg.exec() == QDialog.Accepted:
        return entry.text()
    return None


def run_as_root(parent, cmd_str, title="LinuxDoors System Status"):
    pw = ask_password(parent, title)
    if pw is None:
        return False, "cancelled"
    try:
        proc = subprocess.run(["su", "-c", cmd_str, "root"],
                               input=(pw + "\n").encode(),
                               capture_output=True, timeout=10)
        out = (proc.stdout + proc.stderr).decode(errors="replace")
        if proc.returncode != 0 or "Authentication failure" in out:
            return False, out.strip()[-200:] or f"exit {proc.returncode}"
        return True, None
    except subprocess.TimeoutExpired:
        return False, "timed out"


# ---------------------------------------------------------------------------
# A folding section: a header with a title, live summary text, and a
# (+)/(-) toggle, plus a body that the toggle shows or hides. The body's own
# widgets keep updating on the normal timer regardless of visibility, so
# re-opening a folded section never shows a stale number.
# ---------------------------------------------------------------------------

class Section(QFrame):
    def __init__(self, title, parent=None):
        super().__init__(parent)
        set_role(self, "section")
        outer = QVBoxLayout(self)
        outer.setContentsMargins(14, 10, 14, 12)
        outer.setSpacing(8)

        header = QHBoxLayout()
        self.title_label = QLabel(title)
        role(self.title_label, "role", "title")
        header.addWidget(self.title_label)
        header.addStretch(1)
        self.summary_label = QLabel("")
        role(self.summary_label, "role", "dim")
        header.addWidget(self.summary_label)
        self.fold_btn = QToolButton()
        self.fold_btn.setProperty("role", "fold")
        self.fold_btn.setText("−")  # minus, since it starts open
        self.fold_btn.clicked.connect(self.toggle)
        header.addWidget(self.fold_btn)
        outer.addLayout(header)

        self.body = QWidget()
        outer.addWidget(self.body)
        self._open = True

    def set_summary(self, text):
        self.summary_label.setText(text)

    def toggle(self):
        self._open = not self._open
        self.body.setVisible(self._open)
        self.fold_btn.setText("−" if self._open else "+")


# ---------------------------------------------------------------------------
# A small live-scrolling chart, used for CPU%, memory%, and network
# throughput history.
# ---------------------------------------------------------------------------

class HistoryChart(QChartView):
    def __init__(self, series_specs, y_max=100, y_label="%%", history=90):
        # y_label is fed straight into a printf-style format string
        # ("%d" + y_label) -- a literal "%" character must itself be
        # escaped as "%%" there, or the trailing bare "%" leaves the
        # format ambiguous and QtCharts silently renders every axis
        # label as "..." (a real bug hit and fixed here, not guessed:
        # confirmed via a real screenshot before and after).
        """series_specs: list of (name, colour_hex)."""
        chart = QChart()
        chart.legend().setVisible(len(series_specs) > 1)
        chart.legend().setLabelBrush(QBrush(QColor(colour("text"))))
        chart.setBackgroundVisible(False)
        self.series = []
        self.data = []
        for name, hexcol in series_specs:
            s = QLineSeries()
            s.setName(name)
            pen = QPen(QColor(hexcol))
            pen.setWidth(2)
            s.setPen(pen)
            chart.addSeries(s)
            self.series.append(s)
            self.data.append(deque([0.0] * history, maxlen=history))
        self.history = history
        self.axis_x = QValueAxis()
        self.axis_x.setRange(0, history - 1)
        self.axis_x.setVisible(False)
        self.axis_y = QValueAxis()
        self.axis_y.setRange(0, y_max)
        self.axis_y.setTickCount(5)
        self.axis_y.setLabelFormat(f"%d{y_label}")
        self.axis_y.setLabelsColor(QColor(colour("dim")))
        self.axis_y.setGridLineColor(QColor(colour("chart_grid")))
        chart.addAxis(self.axis_x, Qt.AlignBottom)
        chart.addAxis(self.axis_y, Qt.AlignLeft)
        for s in self.series:
            s.attachAxis(self.axis_x)
            s.attachAxis(self.axis_y)
        super().__init__(chart)
        self.setRenderHint(QPainter.Antialiasing)
        self.setMinimumHeight(180)
        self.setMinimumWidth(300)
        # Real bug hit and fixed here: with only setMinimumHeight() given,
        # this widget's default size policy left its WIDTH unconstrained,
        # and nested inside this app's real layout (Section -> QVBoxLayout
        # -> QScrollArea, several levels deep) Qt's first layout pass can
        # give it a near-zero width before the scroll area settles.
        # QtCharts computes its axis label elision against whatever size
        # it's drawn at during that first pass and never revisits it, so
        # every Y-axis label silently rendered as "..." forever after --
        # confirmed by reproducing the exact same axis setup standalone
        # (a bare QMainWindow with nothing else in the layout), where it
        # rendered correctly, isolating the cause to sizing, not the axis
        # configuration itself. Expanding here (not just a floor width)
        # tells the layout to actually claim real space instead of
        # settling for whatever's left over.
        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
        self.setStyleSheet("background: transparent; border: none;")

    def push(self, values):
        for i, v in enumerate(values):
            self.data[i].append(v)
            self.series[i].clear()
            for x, y in enumerate(self.data[i]):
                self.series[i].append(x, y)

    def repaint_theme(self):
        self.chart().legend().setLabelBrush(QBrush(QColor(colour("text"))))
        self.axis_y.setLabelsColor(QColor(colour("dim")))
        self.axis_y.setGridLineColor(QColor(colour("chart_grid")))


# ---------------------------------------------------------------------------
# Main window
# ---------------------------------------------------------------------------

class SystemStatusWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("LinuxDoors System Status")
        self.setWindowIcon(door_icon())
        self.resize(980, 780)

        self._prev_cpu = read_cpu_times()
        self._prev_net = read_net_dev()
        self._prev_net_t = time.time()

        central = QWidget()
        self.setCentralWidget(central)
        root = QVBoxLayout(central)
        root.setContentsMargins(0, 0, 0, 0)
        root.setSpacing(0)

        root.addWidget(self._build_header())

        self.tabs = QTabWidget()
        root.addWidget(self.tabs)

        # Summary: the same foldable-sections view this app has always had,
        # unchanged, just now living inside its own tab instead of being
        # the whole window.
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        content = QWidget()
        self.content_layout = QVBoxLayout(content)
        self.content_layout.setContentsMargins(16, 16, 16, 16)
        self.content_layout.setSpacing(14)
        scroll.setWidget(content)
        self.tabs.addTab(scroll, "Summary")

        self._build_cpu_section()
        self._build_mem_section()
        self._build_disk_section()
        self._build_net_section()
        self._build_proc_section()
        self.content_layout.addStretch(1)

        # One dedicated tab per area, each a second, independent set of
        # the same kind of widgets given a whole tab's worth of room
        # instead of a folded section's -- Qt widgets can only live in one
        # place at a time, so this app keeps two live copies of each
        # area's display (Summary's, and the tab's) and refresh() updates
        # both from the same single data read every cycle.
        self.tabs.addTab(self._build_cpu_tab(), "CPU")
        self.tabs.addTab(self._build_mem_tab(), "Memory")
        self.tabs.addTab(self._build_disk_tab(), "File Systems")
        self.tabs.addTab(self._build_net_tab(), "Network")
        self.tabs.addTab(self._build_proc_tab(), "Processes")

        self.apply_theme()

        self.timer = QTimer(self)
        self.timer.timeout.connect(self.refresh)
        self.timer.start(2000)
        self.refresh()

    # -- header -----------------------------------------------------------

    def _build_header(self):
        bar = QFrame()
        set_role(bar, "section")
        bar.setStyleSheet("border-radius: 0px; border-left: none; border-right: none; border-top: none;")
        layout = QHBoxLayout(bar)
        layout.setContentsMargins(18, 12, 18, 12)

        icon_label = QLabel()
        icon_label.setPixmap(door_icon(36).pixmap(36, 36))
        layout.addWidget(icon_label)

        title = QLabel("LinuxDoors System Status")
        role(title, "role", "title")
        layout.addWidget(title)
        layout.addStretch(1)

        zoom_out = QPushButton("−")
        set_role(zoom_out, "ghost")
        zoom_out.setFixedWidth(36)
        zoom_out.clicked.connect(lambda: self.set_zoom(ZOOM - 0.1))
        layout.addWidget(zoom_out)

        self.zoom_label = QLabel("100%")
        role(self.zoom_label, "role", "dim")
        self.zoom_label.setFixedWidth(46)
        self.zoom_label.setAlignment(Qt.AlignCenter)
        layout.addWidget(self.zoom_label)

        zoom_in = QPushButton("+")
        set_role(zoom_in, "ghost")
        zoom_in.setFixedWidth(36)
        zoom_in.clicked.connect(lambda: self.set_zoom(ZOOM + 0.1))
        layout.addWidget(zoom_in)

        self.theme_btn = QPushButton()
        set_role(self.theme_btn, "ghost")
        self.theme_btn.clicked.connect(self.toggle_theme)
        layout.addWidget(self.theme_btn)

        return bar

    def set_zoom(self, value):
        global ZOOM
        ZOOM = max(0.7, min(2.0, value))
        self.zoom_label.setText(f"{int(ZOOM * 100)}%")
        self.apply_theme()

    def toggle_theme(self):
        set_theme(other_theme())
        self.apply_theme()

    def apply_theme(self):
        QApplication.instance().setStyleSheet(build_style())
        self.theme_btn.setText("Light mode" if THEME_NAME == "dark" else "Dark mode")
        for chart in (self.cpu_chart, self.mem_chart, self.net_chart,
                      self.cpu_chart_tab, self.mem_chart_tab, self.net_chart_tab):
            chart.repaint_theme()

    # -- CPU ----------------------------------------------------------------

    def _populate_cpu(self, layout, chart_height=180):
        n = os.cpu_count() or 1
        bars_layout = QGridLayout()
        bars = []
        cols = 4 if n > 8 else 2
        for i in range(n):
            lbl = QLabel(f"CPU {i}")
            role(lbl, "role", "dim")
            bar = QProgressBar()
            bar.setRange(0, 100)
            bars_layout.addWidget(lbl, i // cols, (i % cols) * 2)
            bars_layout.addWidget(bar, i // cols, (i % cols) * 2 + 1)
            bars.append(bar)
        layout.addLayout(bars_layout)
        chart = HistoryChart([("Overall CPU %", colour("chart_cpu"))])
        chart.setMinimumHeight(chart_height)
        layout.addWidget(chart)
        return bars, chart

    def _build_cpu_section(self):
        self.cpu_section = Section("CPU")
        self.content_layout.addWidget(self.cpu_section)
        layout = QVBoxLayout(self.cpu_section.body)
        layout.setContentsMargins(0, 0, 0, 0)
        self.cpu_bars, self.cpu_chart = self._populate_cpu(layout)

    def _build_cpu_tab(self):
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(16, 16, 16, 16)
        self.cpu_bars_tab, self.cpu_chart_tab = self._populate_cpu(layout, chart_height=320)
        layout.addStretch(1)
        return tab

    # -- Memory ---------------------------------------------------------

    def _populate_mem(self, layout, chart_height=180):
        row = QGridLayout()
        row.addWidget(QLabel("RAM"), 0, 0)
        ram_bar = QProgressBar()
        ram_bar.setProperty("role", "mem")
        row.addWidget(ram_bar, 0, 1)
        ram_label = QLabel("")
        role(ram_label, "role", "dim")
        row.addWidget(ram_label, 0, 2)

        row.addWidget(QLabel("Swap"), 1, 0)
        swap_bar = QProgressBar()
        swap_bar.setProperty("role", "mem")
        row.addWidget(swap_bar, 1, 1)
        swap_label = QLabel("")
        role(swap_label, "role", "dim")
        row.addWidget(swap_label, 1, 2)
        layout.addLayout(row)

        chart = HistoryChart([("Memory %", colour("chart_mem"))])
        chart.setMinimumHeight(chart_height)
        layout.addWidget(chart)
        return ram_bar, ram_label, swap_bar, swap_label, chart

    def _build_mem_section(self):
        self.mem_section = Section("Memory")
        self.content_layout.addWidget(self.mem_section)
        layout = QVBoxLayout(self.mem_section.body)
        layout.setContentsMargins(0, 0, 0, 0)
        (self.ram_bar, self.ram_label, self.swap_bar,
         self.swap_label, self.mem_chart) = self._populate_mem(layout)

    def _build_mem_tab(self):
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(16, 16, 16, 16)
        (self.ram_bar_tab, self.ram_label_tab, self.swap_bar_tab,
         self.swap_label_tab, self.mem_chart_tab) = self._populate_mem(layout, chart_height=320)
        layout.addStretch(1)
        return tab

    # -- Disks ------------------------------------------------------------

    def _populate_disk_table(self):
        table = QTableWidget(0, 6)
        table.setHorizontalHeaderLabels(
            ["Device", "Mount Point", "Type", "Used", "Total", "Use %"])
        table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
        table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        table.setSelectionBehavior(QAbstractItemView.SelectRows)
        table.setAlternatingRowColors(True)
        table.verticalHeader().setVisible(False)
        return table

    def _build_disk_section(self):
        self.disk_section = Section("File Systems")
        self.content_layout.addWidget(self.disk_section)
        layout = QVBoxLayout(self.disk_section.body)
        layout.setContentsMargins(0, 0, 0, 0)
        self.disk_table = self._populate_disk_table()
        layout.addWidget(self.disk_table)

    def _build_disk_tab(self):
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(16, 16, 16, 16)
        self.disk_table_tab = self._populate_disk_table()
        layout.addWidget(self.disk_table_tab)
        return tab

    # -- Network ----------------------------------------------------------

    def _populate_net(self, layout, chart_height=180, conn_height=180):
        chart = HistoryChart(
            [("Download", colour("chart_down")), ("Upload", colour("chart_up"))],
            y_max=1, y_label=" KB/s")
        chart.setMinimumHeight(chart_height)
        layout.addWidget(chart)

        conn_label = QLabel("Active connections")
        role(conn_label, "role", "dim")
        layout.addWidget(conn_label)

        conn_table = QTableWidget(0, 6)
        conn_table.setHorizontalHeaderLabels(
            ["Proto", "Local Address", "Local Port", "Remote Address", "Remote Port", "State / Process"])
        conn_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        conn_table.setSelectionBehavior(QAbstractItemView.SelectRows)
        conn_table.setAlternatingRowColors(True)
        conn_table.setSortingEnabled(True)
        conn_table.verticalHeader().setVisible(False)
        conn_table.setMinimumHeight(conn_height)
        layout.addWidget(conn_table)
        return chart, conn_table

    def _build_net_section(self):
        self.net_section = Section("Network")
        self.content_layout.addWidget(self.net_section)
        layout = QVBoxLayout(self.net_section.body)
        layout.setContentsMargins(0, 0, 0, 0)
        self.net_chart, self.conn_table = self._populate_net(layout)

    def _build_net_tab(self):
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(16, 16, 16, 16)
        self.net_chart_tab, self.conn_table_tab = self._populate_net(
            layout, chart_height=260, conn_height=400)
        return tab

    # -- Processes --------------------------------------------------------

    def _populate_proc(self, layout, table_height=240):
        table = QTableWidget(0, 6)
        table.setHorizontalHeaderLabels(
            ["PID", "User", "CPU %", "Mem %", "Nice", "Command"])
        table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        table.setSelectionBehavior(QAbstractItemView.SelectRows)
        table.setSelectionMode(QAbstractItemView.SingleSelection)
        table.setAlternatingRowColors(True)
        table.setSortingEnabled(True)
        table.verticalHeader().setVisible(False)
        table.setMinimumHeight(table_height)
        layout.addWidget(table)

        btns = QHBoxLayout()
        end_btn = QPushButton("End Process")
        set_role(end_btn, "danger")
        end_btn.clicked.connect(lambda: self.end_selected_process(table))
        btns.addWidget(end_btn)

        prio_btn = QPushButton("Change Priority...")
        set_role(prio_btn, "ghost")
        prio_btn.clicked.connect(lambda: self.change_priority(table))
        btns.addWidget(prio_btn)
        btns.addStretch(1)
        layout.addLayout(btns)
        return table

    def _build_proc_section(self):
        self.proc_section = Section("Processes")
        self.content_layout.addWidget(self.proc_section)
        layout = QVBoxLayout(self.proc_section.body)
        layout.setContentsMargins(0, 0, 0, 0)
        self.proc_table = self._populate_proc(layout)

    def _build_proc_tab(self):
        tab = QWidget()
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(16, 16, 16, 16)
        self.proc_table_tab = self._populate_proc(layout, table_height=460)
        return tab

    def _selected_pid(self, table):
        row = table.currentRow()
        if row < 0:
            return None
        item = table.item(row, 0)
        return int(item.text()) if item else None

    def end_selected_process(self, table):
        pid = self._selected_pid(table)
        if pid is None:
            QMessageBox.information(self, "End Process", "Select a process first.")
            return
        if QMessageBox.question(
                self, "End Process", f"End process {pid}? This cannot be undone.",
                QMessageBox.Yes | QMessageBox.No) != QMessageBox.Yes:
            return
        try:
            os.kill(pid, signal.SIGTERM)
        except PermissionError:
            ok, err = run_as_root(self, f"kill -TERM {pid}", "End Process")
            if not ok:
                QMessageBox.warning(self, "End Process", f"Could not end process: {err}")
        except ProcessLookupError:
            pass
        self.refresh()

    def change_priority(self, table):
        pid = self._selected_pid(table)
        if pid is None:
            QMessageBox.information(self, "Change Priority", "Select a process first.")
            return
        dlg = QDialog(self)
        dlg.setWindowTitle("Change Priority")
        layout = QVBoxLayout(dlg)
        layout.addWidget(QLabel(f"New nice value for PID {pid} (-20 highest .. 19 lowest):"))
        spin = QSpinBox()
        spin.setRange(-20, 19)
        try:
            spin.setValue(os.getpriority(os.PRIO_PROCESS, pid))
        except (PermissionError, ProcessLookupError):
            pass
        layout.addWidget(spin)
        buttons = QHBoxLayout()
        ok_btn = QPushButton("Apply")
        cancel_btn = QPushButton("Cancel")
        set_role(cancel_btn, "ghost")
        buttons.addWidget(cancel_btn)
        buttons.addWidget(ok_btn)
        layout.addLayout(buttons)
        ok_btn.clicked.connect(dlg.accept)
        cancel_btn.clicked.connect(dlg.reject)
        if dlg.exec() != QDialog.Accepted:
            return
        nice = spin.value()
        try:
            os.setpriority(os.PRIO_PROCESS, pid, nice)
        except PermissionError:
            ok, err = run_as_root(self, f"renice {nice} -p {pid}", "Change Priority")
            if not ok:
                QMessageBox.warning(self, "Change Priority", f"Could not change priority: {err}")
        except ProcessLookupError:
            pass
        self.refresh()

    # -- refresh ------------------------------------------------------------

    @staticmethod
    def _fill_disk_table(table, disks):
        table.setRowCount(len(disks))
        for r, d in enumerate(disks):
            table.setItem(r, 0, QTableWidgetItem(d["device"]))
            table.setItem(r, 1, QTableWidgetItem(d["mount"]))
            table.setItem(r, 2, QTableWidgetItem(d["fstype"]))
            table.setItem(r, 3, QTableWidgetItem(human_bytes(d["used"])))
            table.setItem(r, 4, QTableWidgetItem(human_bytes(d["total"])))
            table.setItem(r, 5, QTableWidgetItem(f"{d['pct']:.0f}%"))

    @staticmethod
    def _fill_conn_table(table, conns):
        table.setSortingEnabled(False)
        table.setRowCount(len(conns))
        for r, c in enumerate(conns):
            table.setItem(r, 0, QTableWidgetItem(c["proto"]))
            table.setItem(r, 1, QTableWidgetItem(c["local_addr"]))
            table.setItem(r, 2, QTableWidgetItem(c["local_port"]))
            table.setItem(r, 3, QTableWidgetItem(c["remote_addr"]))
            table.setItem(r, 4, QTableWidgetItem(c["remote_port"]))
            table.setItem(r, 5, QTableWidgetItem(f"{c['state']} / {c['process']}"))
        table.setSortingEnabled(True)

    def _fill_proc_table(self, table, procs):
        selected_pid = self._selected_pid(table)
        table.setSortingEnabled(False)
        table.setRowCount(len(procs))
        for r, p in enumerate(procs):
            table.setItem(r, 0, QTableWidgetItem(str(p["pid"])))
            table.setItem(r, 1, QTableWidgetItem(p["user"]))
            table.setItem(r, 2, QTableWidgetItem(f"{p['cpu']:.1f}"))
            table.setItem(r, 3, QTableWidgetItem(f"{p['mem']:.1f}"))
            nice_text = str(p["nice"]) if p["nice"] is not None else "-"
            table.setItem(r, 4, QTableWidgetItem(nice_text))
            table.setItem(r, 5, QTableWidgetItem(p["comm"]))
            if selected_pid is not None and p["pid"] == selected_pid:
                table.selectRow(r)
        table.setSortingEnabled(True)

    def refresh(self):
        # CPU
        cur_cpu = read_cpu_times()
        pcts = cpu_percentages(self._prev_cpu, cur_cpu)
        self._prev_cpu = cur_cpu
        overall = pcts.get("cpu", 0.0)
        for i in range(len(self.cpu_bars)):
            v = int(pcts.get(f"cpu{i}", 0.0))
            self.cpu_bars[i].setValue(v)
            self.cpu_bars_tab[i].setValue(v)
        self.cpu_section.set_summary(f"{overall:.0f}% overall")
        self.cpu_chart.push([overall])
        self.cpu_chart_tab.push([overall])

        # Memory
        mem = read_meminfo()
        ram_text = f"{human_bytes(mem['mem_used_kb']*1024)} / {human_bytes(mem['mem_total_kb']*1024)}"
        swap_text = (f"{human_bytes(mem['swap_used_kb']*1024)} / {human_bytes(mem['swap_total_kb']*1024)}"
                     if mem["swap_total_kb"] else "no swap")
        for ram_bar, ram_label, swap_bar, swap_label in (
                (self.ram_bar, self.ram_label, self.swap_bar, self.swap_label),
                (self.ram_bar_tab, self.ram_label_tab, self.swap_bar_tab, self.swap_label_tab)):
            ram_bar.setValue(int(mem["mem_pct"]))
            ram_label.setText(ram_text)
            swap_bar.setValue(int(mem["swap_pct"]))
            swap_label.setText(swap_text)
        self.mem_section.set_summary(f"{mem['mem_pct']:.0f}% RAM used")
        self.mem_chart.push([mem["mem_pct"]])
        self.mem_chart_tab.push([mem["mem_pct"]])

        # Disks
        disks = read_disks()
        self._fill_disk_table(self.disk_table, disks)
        self._fill_disk_table(self.disk_table_tab, disks)
        self.disk_section.set_summary(f"{len(disks)} mounted filesystem(s)")

        # Network throughput
        cur_net = read_net_dev()
        now = time.time()
        dt = max(0.5, now - self._prev_net_t)
        rx_delta = sum(v[0] for v in cur_net.values()) - sum(v[0] for v in self._prev_net.values())
        tx_delta = sum(v[1] for v in cur_net.values()) - sum(v[1] for v in self._prev_net.values())
        down_kbs = max(0.0, rx_delta / dt / 1024.0)
        up_kbs = max(0.0, tx_delta / dt / 1024.0)
        self._prev_net = cur_net
        self._prev_net_t = now
        for chart in (self.net_chart, self.net_chart_tab):
            peak = max(down_kbs, up_kbs, chart.axis_y.max(), 8)
            chart.axis_y.setRange(0, peak)
            chart.push([down_kbs, up_kbs])
        self.net_section.set_summary(f"↓ {down_kbs:.0f} KB/s  ↑ {up_kbs:.0f} KB/s")

        conns = read_connections()
        self._fill_conn_table(self.conn_table, conns)
        self._fill_conn_table(self.conn_table_tab, conns)

        # Processes
        procs = sorted(read_processes(), key=lambda p: -p["cpu"])
        self._fill_proc_table(self.proc_table, procs)
        self._fill_proc_table(self.proc_table_tab, procs)
        self.proc_section.set_summary(f"{len(procs)} processes")


def main():
    if "--check-contrast" in sys.argv:
        failures = check_contrast(verbose=True)
        print(f"\n{len(failures)} failing pair(s).")
        sys.exit(1 if failures else 0)

    app = QApplication(sys.argv)
    app.setStyleSheet(build_style())
    win = SystemStatusWindow()
    win.show()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()
