feat(post-install): add optional zram profile and metrics

This commit is contained in:
Gabriel Gonzalez Pineda
2026-10-15 00:28:52 +01:00
parent f3add354e6
commit 51ed84f9b1
18 changed files with 783 additions and 3 deletions
+36 -1
View File
@@ -12,7 +12,7 @@ import { ProcessDetailModal } from "./process-detail-modal"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import { fetchApi } from "../lib/api-config"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { formatStorage } from "../lib/utils"
import { formatBytes as formatByteSize, formatStorage } from "../lib/utils"
import { getCountFormKey, useI18n, useT } from "../lib/i18n/provider"
import { Area, AreaChart, ResponsiveContainer } from "recharts"
@@ -21,6 +21,19 @@ interface TempDataPoint {
value: number
}
interface ZramDevice {
name: string
active: boolean
capacity_bytes: number
swap_used_bytes: number
original_bytes: number
compressed_bytes: number
memory_used_bytes: number
algorithm: string | null
compression_ratio: number | null
saved_bytes: number
}
interface SystemData {
cpu_usage: number
cpu_user?: number // preview restyle
@@ -29,6 +42,7 @@ interface SystemData {
memory_total: number
memory_used: number
memory_cached?: number // preview restyle
zram?: { available: boolean; devices: ZramDevice[] }
temperature: number
temperature_sparkline?: TempDataPoint[]
uptime: string
@@ -489,6 +503,27 @@ export function SystemOverview() {
</div>
</div>
</div>
{systemData.zram?.available && systemData.zram.devices.length > 0 && (
<div className="mt-4 border-t border-border pt-3 space-y-3">
{systemData.zram.devices.map((device) => (
<div key={device.name} className="text-xs">
<div className="font-medium mb-2">{t("overview.zram")} <span className="font-mono text-muted-foreground">{device.name}</span></div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
<span className="text-muted-foreground">{t("overview.zramCapacity")}</span>
<span className="text-right font-mono">{formatByteSize(device.capacity_bytes)}</span>
<span className="text-muted-foreground">{t("overview.zramSwapUsed")}</span>
<span className="text-right font-mono">{formatByteSize(device.swap_used_bytes)}</span>
<span className="text-muted-foreground">{t("overview.zramAlgorithm")}</span>
<span className="text-right font-mono">{device.algorithm ?? "—"}</span>
<span className="text-muted-foreground">{t("overview.zramCompressionRatio")}</span>
<span className="text-right font-mono">{device.compression_ratio === null ? "—" : `${device.compression_ratio.toFixed(2)}:1`}</span>
<span className="text-muted-foreground">{t("overview.zramPhysicalMemory")}</span>
<span className="text-right font-mono">{formatByteSize(device.memory_used_bytes)}</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
+6
View File
@@ -92,6 +92,12 @@
"loadAxis": "Laden",
"memory": "Erinnerung",
"memoryUsage": "Speichernutzung",
"zram": "ZRAM",
"zramCapacity": "Kapazität",
"zramSwapUsed": "Verwendeter Swap",
"zramAlgorithm": "Algorithmus",
"zramCompressionRatio": "Kompressionsverhältnis",
"zramPhysicalMemory": "Verwendeter physischer Speicher",
"user": "Benutzer",
"system": "System",
"cores": "Kerne",
+6
View File
@@ -91,6 +91,12 @@
"loadAxis": "Load",
"memory": "Memory",
"memoryUsage": "Memory Usage",
"zram": "ZRAM",
"zramCapacity": "Capacity",
"zramSwapUsed": "Swap used",
"zramAlgorithm": "Algorithm",
"zramCompressionRatio": "Compression ratio",
"zramPhysicalMemory": "Physical memory used",
"user": "User",
"system": "System",
"cores": "Cores",
+6
View File
@@ -92,6 +92,12 @@
"loadAxis": "Carga",
"memory": "Memoria",
"memoryUsage": "Uso de la memoria",
"zram": "ZRAM",
"zramCapacity": "Capacidad",
"zramSwapUsed": "Swap usado",
"zramAlgorithm": "Algoritmo",
"zramCompressionRatio": "Relación de compresión",
"zramPhysicalMemory": "Memoria física usada",
"user": "Usuario",
"system": "Sistema",
"cores": "Núcleos",
+6
View File
@@ -92,6 +92,12 @@
"loadAxis": "Charger",
"memory": "Mémoire",
"memoryUsage": "Utilisation de la mémoire",
"zram": "ZRAM",
"zramCapacity": "Capacité",
"zramSwapUsed": "Swap utilisé",
"zramAlgorithm": "Algorithme",
"zramCompressionRatio": "Taux de compression",
"zramPhysicalMemory": "Mémoire physique utilisée",
"user": "Utilisateur",
"system": "Système",
"cores": "Noyaux",
+6
View File
@@ -92,6 +92,12 @@
"loadAxis": "Carico",
"memory": "Memoria",
"memoryUsage": "Utilizzo memoria",
"zram": "ZRAM",
"zramCapacity": "Capacità",
"zramSwapUsed": "Swap utilizzato",
"zramAlgorithm": "Algoritmo",
"zramCompressionRatio": "Rapporto di compressione",
"zramPhysicalMemory": "Memoria fisica utilizzata",
"user": "Utente",
"system": "Sistema",
"cores": "Core",
+6
View File
@@ -92,6 +92,12 @@
"loadAxis": "Carregar",
"memory": "Memória",
"memoryUsage": "Uso de memória",
"zram": "ZRAM",
"zramCapacity": "Capacidade",
"zramSwapUsed": "Swap usado",
"zramAlgorithm": "Algoritmo",
"zramCompressionRatio": "Taxa de compressão",
"zramPhysicalMemory": "Memória física usada",
"user": "Usuário",
"system": "Sistema",
"cores": "Núcleos",
+6
View File
@@ -91,6 +91,12 @@
"loadAxis": "Záťaž",
"memory": "Pamäť",
"memoryUsage": "Využitie pamäte",
"zram": "ZRAM",
"zramCapacity": "Kapacita",
"zramSwapUsed": "Použitý swap",
"zramAlgorithm": "Algoritmus",
"zramCompressionRatio": "Pomer kompresie",
"zramPhysicalMemory": "Použitá fyzická pamäť",
"user": "Používateľ",
"system": "Systém",
"cores": "Jadrá",
+6
View File
@@ -92,6 +92,12 @@
"loadAxis": "Belastning",
"memory": "Minne",
"memoryUsage": "Minnesanvändning",
"zram": "ZRAM",
"zramCapacity": "Kapacitet",
"zramSwapUsed": "Använt swaputrymme",
"zramAlgorithm": "Algoritm",
"zramCompressionRatio": "Komprimeringsgrad",
"zramPhysicalMemory": "Använt fysiskt minne",
"user": "Användare",
"system": "System",
"cores": "Kärnor",
+1
View File
@@ -126,6 +126,7 @@ cd "$SCRIPT_DIR"
# Copy Flask server
echo "📋 Copying Flask server..."
cp "$SCRIPT_DIR/flask_server.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/zram_metrics.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/flask_auth_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_auth_routes.py not found"
cp "$SCRIPT_DIR/auth_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ auth_manager.py not found"
cp "$SCRIPT_DIR/jwt_middleware.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ jwt_middleware.py not found"
@@ -26,6 +26,7 @@ TOOL_METADATA = {
'system_limits': {'name': 'System Limits Increase', 'function': 'increase_system_limits', 'version': '1.1'},
# entropy removed — modern kernels 5.6+ have built-in entropy generation, haveged no longer needed
'memory_settings': {'name': 'Memory Settings Optimization', 'function': 'optimize_memory_settings', 'version': '1.1'},
'zram_swap': {'name': 'ZRAM-only Swap Profile', 'function': 'configure_zram_swap', 'version': '1.0'},
'kernel_panic': {'name': 'Kernel Panic Configuration', 'function': 'configure_kernel_panic', 'version': '1.0'},
'apt_ipv4': {'name': 'APT IPv4 Force', 'function': 'force_apt_ipv4', 'version': '1.0'},
'kexec': {'name': 'kexec for quick reboots', 'function': 'enable_kexec', 'version': '1.0'},
+7
View File
@@ -78,6 +78,7 @@ from smartctl_resolver import ( # noqa: E402
smartctl_type_args,
)
from temperature_sensor_resolver import get_storage_temperatures # noqa: E402
from zram_metrics import read_zram_metrics # noqa: E402
from flask_script_runner import script_runner
import threading
from proxmox_storage_monitor import classify_storage_state, proxmox_storage_monitor
@@ -9827,6 +9828,11 @@ def api_system():
# Get temperature sparkline (last 1h) for overview mini chart
temp_sparkline = get_temperature_sparkline(60)
try:
zram = read_zram_metrics()
except Exception:
zram = {'available': False, 'devices': []}
return jsonify({
'cpu_usage': round(cpu_usage, 1),
'cpu_user': cpu_user_pct,
@@ -9835,6 +9841,7 @@ def api_system():
'memory_total': round(memory_total_gb, 1),
'memory_used': round(memory_used_gb, 1),
'memory_cached': memory_cached_gb,
'zram': zram,
'temperature': temp,
'temperature_sparkline': temp_sparkline,
'uptime': uptime,
+110
View File
@@ -0,0 +1,110 @@
"""Tests for reading zram metrics from injectable proc and sys paths."""
import importlib
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
class ZramMetricsTests(unittest.TestCase):
def test_reader_module_is_available(self):
module_spec = importlib.util.find_spec("zram_metrics")
self.assertIsNotNone(module_spec)
module = importlib.import_module("zram_metrics")
self.assertTrue(callable(getattr(module, "read_zram_metrics", None)))
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.block_root = self.root / "sys" / "block"
self.block_root.mkdir(parents=True)
self.swaps_path = self.root / "proc" / "swaps"
self.swaps_path.parent.mkdir(parents=True)
self.swaps_path.write_text(
"Filename\tType\tSize\tUsed\tPriority\n", encoding="utf-8"
)
def add_device(self, name="zram0", mm_stat="2097152 1048576 1179648 0"):
device = self.block_root / name
device.mkdir()
(device / "disksize").write_text("1073741824\n", encoding="utf-8")
(device / "mm_stat").write_text(mm_stat + "\n", encoding="utf-8")
(device / "comp_algorithm").write_text(
"lzo lzo-rle [zstd] lz4\n", encoding="utf-8"
)
return device
def read_metrics(self):
from zram_metrics import read_zram_metrics
return read_zram_metrics(self.block_root, self.swaps_path)
def test_no_zram_devices_reports_unavailable(self):
(self.block_root / "sda").mkdir()
self.assertEqual(self.read_metrics(), {"available": False, "devices": []})
def test_reads_capacity_swap_usage_and_compression_counters(self):
self.add_device()
self.swaps_path.write_text(
"Filename\tType\tSize\tUsed\tPriority\n"
"/dev/zram0\tpartition\t1048576\t1024\t100\n",
encoding="utf-8",
)
self.assertEqual(
self.read_metrics(),
{
"available": True,
"devices": [
{
"name": "zram0",
"active": True,
"capacity_bytes": 1073741824,
"swap_used_bytes": 1048576,
"original_bytes": 2097152,
"compressed_bytes": 1048576,
"memory_used_bytes": 1179648,
"algorithm": "zstd",
"compression_ratio": 2.0,
"saved_bytes": 917504,
}
],
},
)
def test_zero_compressed_bytes_has_null_ratio(self):
self.add_device(mm_stat="2097152 0 0 0")
result = self.read_metrics()
self.assertTrue(result["available"])
self.assertEqual(len(result["devices"]), 1)
self.assertFalse(result["devices"][0]["active"])
self.assertEqual(result["devices"][0]["swap_used_bytes"], 0)
self.assertIsNone(result["devices"][0]["compression_ratio"])
def test_malformed_or_short_mm_stat_omits_device(self):
for mm_stat in ("not-a-number 1048576 1179648", "2097152 1048576"):
with self.subTest(mm_stat=mm_stat):
device = self.block_root / "zram0"
if device.exists():
(device / "mm_stat").write_text(mm_stat + "\n", encoding="utf-8")
else:
self.add_device(mm_stat=mm_stat)
result = self.read_metrics()
self.assertFalse(result["available"])
self.assertEqual(result["devices"], [])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,166 @@
"""Contract tests for the opt-in ZRAM profile's pure Bash helpers."""
import subprocess
import tempfile
import unittest
from pathlib import Path
HELPERS = Path(__file__).resolve().parents[3] / "scripts/post_install/zram_profile_helpers.sh"
SWAPS_HEADER = "Filename\tType\tSize\tUsed\tPriority\n"
class HelperAvailabilityTests(unittest.TestCase):
def test_helper_file_is_available(self):
self.assertTrue(HELPERS.is_file(), f"ZRAM helper is missing: {HELPERS}")
def call_helper(function, *arguments):
return subprocess.run(
[
"bash",
"-c",
'source "$1" || exit; shift; "$@"',
"bash",
str(HELPERS),
function,
*(str(argument) for argument in arguments),
],
capture_output=True,
text=True,
check=False,
)
class ZramGeneratorConfigTests(unittest.TestCase):
def assert_config(self, supports_algorithm_params, expected_algorithm):
result = call_helper("zram_render_generator_config", supports_algorithm_params)
self.assertEqual(result.returncode, 0, result.stderr)
settings = {
key.strip(): value.strip()
for line in result.stdout.splitlines()
if "=" in line
for key, value in [line.split("=", 1)]
}
self.assertIn("[zram0]", result.stdout.splitlines())
self.assertEqual(settings.get("zram-size", "").strip(), "ram / 10")
self.assertEqual(settings.get("swap-priority", "").strip(), "100")
self.assertEqual(settings.get("compression-algorithm", "").strip(), expected_algorithm)
self.assertNotIn("writeback", result.stdout.lower())
def test_generator_1_1_2_2_uses_plain_zstd(self):
self.assert_config("false", "zstd")
def test_generator_1_2_1_2_uses_zstd_level_nine(self):
self.assert_config("true", "zstd(level=9)")
class NonZramSwapDetectionTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
directory = Path(self.temporary.name)
self.swaps_file = directory / "swaps"
self.fstab_file = directory / "fstab"
def detect(self, swaps, fstab):
self.swaps_file.write_text(swaps, encoding="utf-8")
self.fstab_file.write_text(fstab, encoding="utf-8")
return call_helper("zram_has_non_zram_swap", self.swaps_file, self.fstab_file)
def test_active_disk_swap_is_detected(self):
result = self.detect(
SWAPS_HEADER + "/dev/nvme0n1p3\tpartition\t1048576\t0\t-2\n",
"",
)
self.assertEqual(result.returncode, 0, result.stderr)
def test_persistent_swapfile_is_detected(self):
result = self.detect(SWAPS_HEADER, "/swapfile none swap sw 0 0\n")
self.assertEqual(result.returncode, 0, result.stderr)
def test_commented_disk_swap_is_ignored(self):
result = self.detect(SWAPS_HEADER, " # /swapfile none swap sw 0 0\n")
self.assertEqual(result.returncode, 1, result.stderr)
def test_active_and_persistent_zram_are_ignored(self):
result = self.detect(
SWAPS_HEADER + "/dev/zram0\tpartition\t1048576\t0\t100\n",
"/dev/zram0 none swap defaults 0 0\n",
)
self.assertEqual(result.returncode, 1, result.stderr)
def test_regular_fstab_entry_does_not_count_as_swap(self):
result = self.detect(SWAPS_HEADER, "/dev/nvme0n1p2 / ext4 defaults 0 1\n")
self.assertEqual(result.returncode, 1, result.stderr)
class ForeignZramConfigTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.etc = self.root / "etc/systemd"
(self.etc / "zram-generator.conf.d").mkdir(parents=True)
self.owned = self.etc / "zram-generator.conf.d/90-proxmenux.conf"
def foreign_configs(self):
return call_helper(
"zram_foreign_generator_config", self.owned, self.root
).stdout.splitlines()
def test_empty_packaged_default_does_not_block_profile(self):
(self.etc / "zram-generator.conf").write_text(
"# package default\n[zram0]\n; no active directives\n",
encoding="utf-8",
)
self.assertEqual(self.foreign_configs(), [])
def test_active_foreign_directive_is_reported(self):
foreign = self.etc / "zram-generator.conf"
foreign.write_text("[zram1]\nzram-size = ram / 4\n", encoding="utf-8")
self.assertEqual(self.foreign_configs(), [str(foreign)])
def test_owned_drop_in_is_not_reported(self):
self.owned.write_text(
"# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n",
encoding="utf-8",
)
self.assertEqual(self.foreign_configs(), [])
class SystemdSwapDetectionTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.system_dir = self.root / "etc/systemd/system"
self.system_dir.mkdir(parents=True)
def test_administrator_defined_zram_and_other_swap_units_are_reported(self):
(self.system_dir / "dev-zram0.swap").write_text("", encoding="utf-8")
(self.system_dir / "custom.swap").write_text("", encoding="utf-8")
result = call_helper("zram_list_defined_systemd_swap_units", self.root)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
set(result.stdout.splitlines()),
{
f"systemd: {self.system_dir / 'dev-zram0.swap'}",
f"systemd: {self.system_dir / 'custom.swap'}",
},
)
def test_no_non_zram_systemd_swap_has_empty_output(self):
result = call_helper("zram_list_defined_systemd_swap_units", self.root)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "")
if __name__ == "__main__":
unittest.main()
+68
View File
@@ -0,0 +1,68 @@
"""Read zram swap metrics from procfs and sysfs."""
import re
from pathlib import Path
def _swap_usage(swaps_path):
usage = {}
try:
lines = Path(swaps_path).read_text(encoding="utf-8").splitlines()
except OSError:
return usage
for line in lines[1:]:
fields = line.split()
if len(fields) < 4:
continue
name = Path(fields[0]).name
if not re.fullmatch(r"zram\d+", name):
continue
try:
usage[name] = int(fields[3]) * 1024
except ValueError:
continue
return usage
def read_zram_metrics(sys_block_root="/sys/block", swaps_path="/proc/swaps"):
"""Return zram device counters with byte units and active swap usage."""
root = Path(sys_block_root)
try:
device_paths = sorted(
path for path in root.iterdir()
if path.is_dir() and re.fullmatch(r"zram\d+", path.name)
)
except OSError:
device_paths = []
swap_usage = _swap_usage(swaps_path)
devices = []
for path in device_paths:
try:
capacity = int((path / "disksize").read_text(encoding="utf-8").strip())
counters = (path / "mm_stat").read_text(encoding="utf-8").split()
if len(counters) < 3:
continue
original, compressed, memory_used = map(int, counters[:3])
if min(capacity, original, compressed, memory_used) < 0:
continue
algorithms = (path / "comp_algorithm").read_text(encoding="utf-8")
except (OSError, ValueError):
continue
selected = re.search(r"\[([^\]]+)\]", algorithms)
devices.append({
"name": path.name,
"active": path.name in swap_usage,
"capacity_bytes": capacity,
"swap_used_bytes": swap_usage.get(path.name, 0),
"original_bytes": original,
"compressed_bytes": compressed,
"memory_used_bytes": memory_used,
"algorithm": selected.group(1) if selected else None,
"compression_ratio": original / compressed if compressed else None,
"saved_bytes": original - memory_used,
})
return {"available": bool(devices), "devices": devices}