diff --git a/AppImage/components/system-overview.tsx b/AppImage/components/system-overview.tsx
index 744d62f9..ab443d42 100644
--- a/AppImage/components/system-overview.tsx
+++ b/AppImage/components/system-overview.tsx
@@ -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() {
+ {systemData.zram?.available && systemData.zram.devices.length > 0 && (
+
+ {systemData.zram.devices.map((device) => (
+
+
{t("overview.zram")} {device.name}
+
+ {t("overview.zramCapacity")}
+ {formatByteSize(device.capacity_bytes)}
+ {t("overview.zramSwapUsed")}
+ {formatByteSize(device.swap_used_bytes)}
+ {t("overview.zramAlgorithm")}
+ {device.algorithm ?? "—"}
+ {t("overview.zramCompressionRatio")}
+ {device.compression_ratio === null ? "—" : `${device.compression_ratio.toFixed(2)}:1`}
+ {t("overview.zramPhysicalMemory")}
+ {formatByteSize(device.memory_used_bytes)}
+
+
+ ))}
+
+ )}
diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json
index e41bf44d..4093529a 100644
--- a/AppImage/messages/de/common.json
+++ b/AppImage/messages/de/common.json
@@ -92,6 +92,12 @@
"loadAxis": "Laden",
"memory": "Arbeitsspeicher",
"memoryUsage": "Arbeitsspeichernutzung",
+ "zram": "ZRAM",
+ "zramCapacity": "Kapazität",
+ "zramSwapUsed": "Verwendeter Swap",
+ "zramAlgorithm": "Algorithmus",
+ "zramCompressionRatio": "Kompressionsverhältnis",
+ "zramPhysicalMemory": "Verwendeter Arbeitsspeicher",
"user": "Benutzer",
"system": "System",
"cores": "Kerne",
diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json
index 7bb3c7a8..ca7e536d 100644
--- a/AppImage/messages/en/common.json
+++ b/AppImage/messages/en/common.json
@@ -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",
diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json
index dc261e22..1d5308a1 100644
--- a/AppImage/messages/es/common.json
+++ b/AppImage/messages/es/common.json
@@ -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",
diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json
index 427d9cc2..80582c89 100644
--- a/AppImage/messages/fr/common.json
+++ b/AppImage/messages/fr/common.json
@@ -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",
diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json
index c225e21d..27a5a76b 100644
--- a/AppImage/messages/it/common.json
+++ b/AppImage/messages/it/common.json
@@ -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",
diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json
index 5bda578f..2c688625 100644
--- a/AppImage/messages/pt/common.json
+++ b/AppImage/messages/pt/common.json
@@ -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",
diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json
index e1fbaede..dc5b7b80 100644
--- a/AppImage/messages/sk/common.json
+++ b/AppImage/messages/sk/common.json
@@ -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á",
diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json
index 4641e28d..a40378c9 100644
--- a/AppImage/messages/sv/common.json
+++ b/AppImage/messages/sv/common.json
@@ -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",
diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh
index f73f6112..b0df1302 100755
--- a/AppImage/scripts/build_appimage.sh
+++ b/AppImage/scripts/build_appimage.sh
@@ -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"
diff --git a/AppImage/scripts/flask_proxmenux_routes.py b/AppImage/scripts/flask_proxmenux_routes.py
index 04b317f6..48da2fbe 100644
--- a/AppImage/scripts/flask_proxmenux_routes.py
+++ b/AppImage/scripts/flask_proxmenux_routes.py
@@ -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 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'},
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 5fdf48ca..13da0971 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -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
@@ -9837,6 +9838,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,
@@ -9845,6 +9851,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,
diff --git a/AppImage/scripts/tests/test_zram_metrics.py b/AppImage/scripts/tests/test_zram_metrics.py
new file mode 100644
index 00000000..617cdd80
--- /dev/null
+++ b/AppImage/scripts/tests/test_zram_metrics.py
@@ -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()
diff --git a/AppImage/scripts/tests/test_zram_profile_helpers.py b/AppImage/scripts/tests/test_zram_profile_helpers.py
new file mode 100644
index 00000000..584af666
--- /dev/null
+++ b/AppImage/scripts/tests/test_zram_profile_helpers.py
@@ -0,0 +1,356 @@
+"""Contract tests for the opt-in ZRAM profile's pure Bash helpers."""
+
+import os
+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 test_helper_can_be_sourced_twice(self):
+ result = subprocess.run(
+ ["bash", "-c", 'source "$1" && source "$1"', "bash", str(HELPERS)],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+
+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(), "32767")
+ 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 ForeignActiveZramSwapTests(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ directory = Path(self.temporary.name)
+ self.root = directory
+ self.swaps_file = directory / "swaps"
+
+ def detect(self, swaps, owned_device=""):
+ self.swaps_file.write_text(swaps, encoding="utf-8")
+ return call_helper(
+ "zram_list_foreign_active_zram_swap", self.swaps_file, owned_device
+ )
+
+ def test_active_disk_swap_is_allowed(self):
+ result = self.detect(
+ SWAPS_HEADER + "/dev/nvme0n1p3\tpartition\t1048576\t0\t-2\n",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout, "")
+
+ def test_active_unowned_zram0_is_detected(self):
+ result = self.detect(SWAPS_HEADER + "/dev/zram0\tpartition\t1048576\t0\t100\n")
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout, "active zram: /dev/zram0\n")
+
+ def test_active_owned_zram0_is_allowed_alongside_disk_swap(self):
+ result = self.detect(
+ SWAPS_HEADER
+ + "/dev/nvme0n1p3\tpartition\t1048576\t0\t-2\n"
+ + "/dev/zram0\tpartition\t1048576\t0\t100\n",
+ "/dev/zram0",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout, "")
+
+ def test_another_active_zram_device_is_rejected(self):
+ result = self.detect(
+ SWAPS_HEADER
+ + "/dev/zram0\tpartition\t1048576\t0\t100\n"
+ + "/dev/zram1\tpartition\t1048576\t0\t90\n",
+ "/dev/zram0",
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout, "active zram: /dev/zram1\n")
+
+ def test_swap_table_read_failure_is_reported(self):
+ fake_bin = self.root / "bin"
+ fake_bin.mkdir()
+ fake_awk = fake_bin / "awk"
+ fake_awk.write_text("#!/bin/sh\nexit 2\n", encoding="utf-8")
+ fake_awk.chmod(0o755)
+ env = os.environ.copy()
+ env["PATH"] = f"{fake_bin}:{env.get('PATH', '')}"
+
+ result = subprocess.run(
+ [
+ "bash",
+ "-c",
+ 'source "$1" || exit; shift; "$@"',
+ "bash",
+ str(HELPERS),
+ "zram_list_foreign_active_zram_swap",
+ str(self.swaps_file),
+ ],
+ capture_output=True,
+ text=True,
+ check=False,
+ env=env,
+ )
+
+ self.assertEqual(result.returncode, 2, result.stderr)
+
+
+class ForeignFstabZramSwapTests(unittest.TestCase):
+ def setUp(self):
+ self.temporary = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temporary.cleanup)
+ self.fstab_file = Path(self.temporary.name) / "fstab"
+
+ def detect(self, text):
+ self.fstab_file.write_text(text, encoding="utf-8")
+ return call_helper("zram_list_foreign_fstab_zram_swap", self.fstab_file)
+
+ def test_disk_swap_entries_are_allowed(self):
+ result = self.detect(
+ "UUID=swap-partition none swap defaults 0 0\n"
+ "/swapfile none swap sw 0 0\n"
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout, "")
+
+ def test_zram_swap_entry_is_reported(self):
+ result = self.detect(
+ "UUID=swap-partition none swap defaults 0 0\n"
+ "/dev/zram1 none swap pri=100 0 0\n"
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout, "fstab zram: /dev/zram1\n")
+
+
+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 ForeignSystemdZramUnitTests(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_disk_swap_unit_is_allowed_and_zram_units_are_reported(self):
+ (self.system_dir / "pve-swap.swap").write_text(
+ "[Swap]\nWhat=/dev/mapper/pve-swap\n", encoding="utf-8"
+ )
+ (self.system_dir / "dev-zram0.swap").write_text(
+ "[Swap]\nWhat=/dev/zram0\n", encoding="utf-8"
+ )
+ external = self.system_dir / "external.swap"
+ external.write_text("[Swap]\nWhat=/dev/zram1\n", encoding="utf-8")
+ vendor_dir = self.root / "usr/lib/systemd/system"
+ vendor_dir.mkdir(parents=True)
+ vendor = vendor_dir / "vendor-zram.swap"
+ vendor.write_text("[Swap]\nWhat=/dev/zram2\n", encoding="utf-8")
+
+ result = call_helper("zram_list_foreign_systemd_zram_units", self.root)
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(
+ set(result.stdout.splitlines()),
+ {
+ f"systemd zram unit: {self.system_dir / 'dev-zram0.swap'}",
+ f"systemd zram unit: {external}",
+ f"systemd zram unit: {vendor}",
+ },
+ )
+
+ def test_unrelated_custom_swap_unit_is_allowed(self):
+ (self.system_dir / "custom.swap").write_text(
+ "[Swap]\nWhat=/swapfile\n", encoding="utf-8"
+ )
+
+ result = call_helper("zram_list_foreign_systemd_zram_units", self.root)
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout, "")
+
+ def test_reserved_zram0_unit_masked_with_dev_null_is_reported(self):
+ (self.system_dir / "dev-zram0.swap").symlink_to("/dev/null")
+
+ result = call_helper("zram_list_foreign_systemd_zram_units", self.root)
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(
+ result.stdout,
+ f"systemd zram unit masked: {self.system_dir / 'dev-zram0.swap'}\n",
+ )
+
+ def test_zram_swap_drop_in_is_reported(self):
+ dropins = self.system_dir / "custom.swap.d"
+ dropins.mkdir()
+ dropin = dropins / "10-zram.conf"
+ dropin.write_text("[Swap]\nWhat=/dev/zram1\n", encoding="utf-8")
+
+ result = call_helper("zram_list_foreign_systemd_zram_units", self.root)
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout, f"systemd zram unit: {dropin}\n")
+
+ def test_generator_wants_symlink_is_not_a_foreign_unit_file(self):
+ generator = self.root / "run/systemd/generator"
+ generator.mkdir(parents=True)
+ (generator / "dev-zram0.swap").write_text(
+ "[Swap]\nWhat=/dev/zram0\n", encoding="utf-8"
+ )
+ wants = self.root / "run/systemd/system/swap.target.wants"
+ wants.mkdir(parents=True)
+ (wants / "dev-zram0.swap").symlink_to("../../generator/dev-zram0.swap")
+
+ result = call_helper("zram_list_foreign_systemd_zram_units", self.root)
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertEqual(result.stdout, "")
+
+
+class ZramProfileActivationVerifierTests(unittest.TestCase):
+ 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"
+ zram0 = self.block_root / "zram0"
+ zram0.mkdir(parents=True)
+ (zram0 / "disksize").write_text("3435973836\n", encoding="utf-8")
+ (zram0 / "comp_algorithm").write_text(
+ "lzo [zstd] lz4\n", encoding="utf-8"
+ )
+ self.meminfo = self.root / "proc/meminfo"
+ self.meminfo.parent.mkdir(parents=True)
+ self.meminfo.write_text("MemTotal: 33554432 kB\n", encoding="utf-8")
+ self.swaps = self.root / "proc/swaps"
+ self.swaps.write_text(SWAPS_HEADER, encoding="utf-8")
+
+ def verify(self):
+ return call_helper(
+ "zram_profile_verify_active",
+ "zstd",
+ "32767",
+ self.block_root,
+ self.meminfo,
+ self.swaps,
+ )
+
+ def test_profile_is_valid_with_lower_priority_disk_swap_active(self):
+ self.swaps.write_text(
+ SWAPS_HEADER
+ + "/dev/zram0\tpartition\t3355443\t0\t32767\n"
+ + "/dev/nvme0n1p3\tpartition\t1048576\t32\t-2\n",
+ encoding="utf-8",
+ )
+
+ result = self.verify()
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+ def test_profile_rejects_a_second_active_zram_device(self):
+ self.swaps.write_text(
+ SWAPS_HEADER
+ + "/dev/zram0\tpartition\t3355443\t0\t32767\n"
+ + "/dev/zram1\tpartition\t1048576\t0\t90\n",
+ encoding="utf-8",
+ )
+
+ result = self.verify()
+
+ self.assertEqual(result.returncode, 1, result.stderr)
+
+ def test_profile_rejects_zram_device_without_requested_priority(self):
+ self.swaps.write_text(
+ SWAPS_HEADER
+ + "/dev/zram0\tpartition\t3355443\t0\t100\n",
+ encoding="utf-8",
+ )
+
+ result = self.verify()
+
+ self.assertEqual(result.returncode, 1, result.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/AppImage/scripts/zram_metrics.py b/AppImage/scripts/zram_metrics.py
new file mode 100644
index 00000000..8620049b
--- /dev/null
+++ b/AppImage/scripts/zram_metrics.py
@@ -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}
diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh
index 7bf56e55..49ed0f3c 100644
--- a/scripts/post_install/customizable_post_install.sh
+++ b/scripts/post_install/customizable_post_install.sh
@@ -63,6 +63,9 @@ fi
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
fi
+if [[ -f "$LOCAL_SCRIPTS/post_install/zram_profile_helpers.sh" ]]; then
+ source "$LOCAL_SCRIPTS/post_install/zram_profile_helpers.sh"
+fi
# ==========================================================
@@ -2448,8 +2451,182 @@ EOF
}
+configure_zram_swap() {
+ local FUNC_VERSION="1.0"
+ # description: Enable an opt-in ZRAM swap profile sized to one tenth of RAM.
+ pmx_journal_context "configure_zram_swap" "$FUNC_VERSION"
+ local config=/etc/systemd/zram-generator.conf.d/90-proxmenux.conf
+ local marker="$BASE_DIR/zram-swap.state"
+ local package_added=false package_owned=false owned=false previous_active=false state_mutated=false
+ local previous_config="" previous_marker="" desired="" conflicts="" fstab_conflicts="" systemd_conflicts="" version="" algorithm=""
+ local supports_params=false failure="" old_umask allowed_zram=""
+ if ! declare -f zram_render_generator_config >/dev/null ||
+ ! declare -f zram_list_foreign_active_zram_swap >/dev/null ||
+ ! declare -f zram_list_foreign_fstab_zram_swap >/dev/null ||
+ ! declare -f zram_list_foreign_systemd_zram_units >/dev/null ||
+ ! declare -f zram_foreign_generator_config >/dev/null ||
+ ! declare -f zram_profile_verify_active >/dev/null; then
+ msg_error "$(translate "ZRAM profile helpers are missing")"
+ return 1
+ fi
+ if [[ ! -r /proc/swaps || ! -r /etc/fstab ]]; then
+ msg_error "$(translate "Cannot inspect current swap configuration")"
+ return 1
+ fi
+
+ # Validate ownership before allowing the existing /dev/zram0 device.
+ if [[ -e "$marker" ]]; then
+ if [[ ! -f "$config" || -L "$config" || -L "$marker" ]] ||
+ ! grep -qx '# ProxMenux ZRAM profile' "$config" ||
+ ! grep -Eq '^installed_by_proxmenux=(true|false)$' "$marker"; then
+ msg_error "$(translate "ZRAM ownership state is incomplete; inspect it before retrying")"
+ return 1
+ fi
+ owned=true
+ previous_config="$(cat "$config")"
+ previous_marker="$(cat "$marker")"
+ grep -qx 'installed_by_proxmenux=true' "$marker" && package_owned=true
+ allowed_zram=/dev/zram0
+ fi
+
+ if ! conflicts="$(zram_list_foreign_active_zram_swap /proc/swaps "$allowed_zram")" ||
+ ! fstab_conflicts="$(zram_list_foreign_fstab_zram_swap /etc/fstab)" ||
+ ! systemd_conflicts="$(zram_list_foreign_systemd_zram_units)"; then
+ msg_error "$(translate "Cannot inspect existing ZRAM configurations")"
+ return 1
+ fi
+ if [[ -n "$fstab_conflicts" ]]; then
+ [[ -n "$conflicts" ]] && conflicts+=$'\n'
+ conflicts+="$fstab_conflicts"
+ fi
+ if [[ -n "$systemd_conflicts" ]]; then
+ [[ -n "$conflicts" ]] && conflicts+=$'\n'
+ conflicts+="$systemd_conflicts"
+ fi
+ if [[ -n "$conflicts" ]]; then
+ msg_error "$(translate "Another ZRAM swap setup exists; refusing to replace it:") $conflicts"
+ return 1
+ fi
+ if ! conflicts="$(zram_foreign_generator_config "$config")"; then
+ msg_error "$(translate "Cannot inspect existing ZRAM generator configuration")"
+ return 1
+ fi
+ if [[ -n "$conflicts" ]]; then
+ msg_error "$(translate "Another ZRAM generator configuration exists; refusing to overwrite it:") $conflicts"
+ return 1
+ fi
+ if [[ "$owned" == false && ( -e "$config" || -L "$config" ) ]]; then
+ msg_error "$(translate "Existing zram0 configuration is not owned by ProxMenux")"
+ return 1
+ fi
+
+ if awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' /proc/swaps; then
+ previous_active=true
+ fi
+ if ! dpkg-query -W -f='${Status}' systemd-zram-generator 2>/dev/null | grep -q 'install ok installed'; then
+ if ! pmx_install_pkg systemd-zram-generator; then
+ if [[ "$owned" == false ]] &&
+ dpkg-query -W -f='${Status}' systemd-zram-generator 2>/dev/null | grep -q 'install ok installed' &&
+ [[ -z "$(zram_foreign_generator_config "$config")" ]]; then
+ if DEBIAN_FRONTEND=noninteractive apt-get purge -y systemd-zram-generator >/dev/null 2>&1; then
+ pmx_record_uninstall "systemd-zram-generator" "$FUNC_VERSION"
+ fi
+ fi
+ msg_error "$(translate "Could not install systemd-zram-generator")"
+ return 1
+ fi
+ package_added=true
+ package_owned=true
+ fi
+ version="$(dpkg-query -W -f='${Version}' systemd-zram-generator 2>/dev/null)"
+ if [[ -z "$version" ]]; then
+ failure="Cannot determine installed systemd-zram-generator version"
+ else
+ if dpkg --compare-versions "$version" ge '1.2.0'; then
+ supports_params=true
+ fi
+ desired="$(zram_render_generator_config "$supports_params")"
+ algorithm=zstd
+ fi
+ if [[ -z "$failure" && "$owned" == true && "$previous_config" == "$desired" && "$previous_active" == true ]]; then
+ if zram_profile_verify_active "$algorithm"; then
+ register_tool "zram_swap" true "$FUNC_VERSION"
+ msg_success "$(translate "ZRAM profile is already active")"
+ return 0
+ fi
+ failure="Existing ZRAM profile is active but does not match its configuration"
+ fi
+ if [[ -z "$failure" && "$previous_active" == true ]]; then
+ if ! swapoff /dev/zram0; then
+ msg_error "$(translate "Could not release ZRAM pages; existing profile remains active")"
+ return 1
+ fi
+ state_mutated=true
+ if ! systemctl stop dev-zram0.swap >/dev/null 2>&1; then
+ failure="Could not stop existing dev-zram0.swap"
+ fi
+ fi
+ if [[ -z "$failure" ]]; then
+ state_mutated=true
+ if ! install -d -m 755 "$(dirname "$config")"; then
+ failure="Could not create ZRAM generator directory"
+ elif ! printf '%s\n' "$desired" | pmx_write_file "$config"; then
+ failure="Could not write ZRAM generator configuration"
+ fi
+ fi
+ if [[ -z "$failure" ]]; then
+ old_umask="$(umask)"
+ umask 077
+ if ! printf 'installed_by_proxmenux=%s\n' "$package_owned" | pmx_write_file "$marker"; then
+ failure="Could not write ZRAM package ownership state"
+ fi
+ umask "$old_umask"
+ [[ -f "$marker" ]] && chmod 600 "$marker"
+ fi
+ if [[ -z "$failure" ]]; then
+ if ! systemctl daemon-reload || ! systemctl start dev-zram0.swap; then
+ failure="Could not activate dev-zram0.swap"
+ elif ! zram_profile_verify_active "$algorithm"; then
+ failure="Active ZRAM algorithm, capacity or swap sources did not match the profile"
+ fi
+ fi
+ if [[ -z "$failure" ]]; then
+ register_tool "zram_swap" true "$FUNC_VERSION"
+ msg_success "$(translate "ZRAM swap profile enabled")"
+ return 0
+ fi
+
+ msg_error "$(translate "ZRAM profile activation failed; details:") $failure"
+ if [[ "$state_mutated" == true ]] && awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' /proc/swaps; then
+ if ! swapoff /dev/zram0; then
+ # Keep the drop-in and marker so a later uninstall can retry safely.
+ register_tool "zram_swap" true "$FUNC_VERSION"
+ msg_error "$(translate "ZRAM still holds pages; configuration was preserved")"
+ return 1
+ fi
+ fi
+ if [[ "$state_mutated" == true ]]; then
+ systemctl stop dev-zram0.swap >/dev/null 2>&1 || true
+ if [[ "$owned" == true ]]; then
+ printf '%s\n' "$previous_config" | pmx_write_file "$config"
+ printf '%s\n' "$previous_marker" | pmx_write_file "$marker"
+ chmod 600 "$marker"
+ else
+ pmx_remove_file "$config"
+ pmx_remove_file "$marker"
+ fi
+ systemctl daemon-reload >/dev/null 2>&1 || true
+ [[ "$previous_active" == true ]] && systemctl start dev-zram0.swap >/dev/null 2>&1
+ fi
+ if [[ "$package_added" == true && "$owned" == false && ! -e "$config" && ! -e "$marker" && -z "$(zram_foreign_generator_config "$config")" ]]; then
+ if DEBIAN_FRONTEND=noninteractive apt-get purge -y systemd-zram-generator >/dev/null 2>&1; then
+ pmx_record_uninstall "systemd-zram-generator" "$FUNC_VERSION"
+ fi
+ fi
+ return 1
+}
# ==========================================================
@@ -3674,6 +3851,7 @@ custom_post_description_label() {
"Enable High Availability services") translate "Enable High Availability services" ;;
"Install Figurine") translate "Install Figurine" ;;
"Install and configure Log2RAM") translate "Install and configure Log2RAM" ;;
+ "Enable ZRAM swap profile") translate "Enable ZRAM swap profile" ;;
*) echo "$1" ;;
esac
}
@@ -3769,6 +3947,7 @@ main_menu() {
"System|Increase various system limits|LIMITS"
# Entropy (haveged) removed — modern kernels 5.6+ have built-in entropy generation
"System|Optimize Memory|MEMORYFIXES"
+ "Optional|Enable ZRAM swap profile|ZRAM_SWAP"
"System|Enable fast reboots|KEXEC"
"System|Enable restart on kernel panic|KERNELPANIC"
"Optional|Apply AMD CPU fixes|AMDFIXES"
@@ -3912,6 +4091,7 @@ done
LIMITS) increase_system_limits ;;
# ENTROPY removed — modern kernels 5.6+ have built-in entropy
MEMORYFIXES) optimize_memory_settings ;;
+ ZRAM_SWAP) configure_zram_swap ;;
KEXEC) enable_kexec ;;
KERNELPANIC) configure_kernel_panic ;;
AMDFIXES) apply_amd_fixes ;;
diff --git a/scripts/post_install/tests/test_zram_profile_lifecycle.sh b/scripts/post_install/tests/test_zram_profile_lifecycle.sh
new file mode 100644
index 00000000..fbe8d6ea
--- /dev/null
+++ b/scripts/post_install/tests/test_zram_profile_lifecycle.sh
@@ -0,0 +1,286 @@
+#!/bin/bash
+
+set -euo pipefail
+
+SCRIPT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_ROOT/../.." && pwd)"
+TEST_ROOT="$(mktemp -d)"
+trap 'rm -rf "$TEST_ROOT"' EXIT
+
+CONFIG="$TEST_ROOT/etc/systemd/zram-generator.conf.d/90-proxmenux.conf"
+MARKER="$TEST_ROOT/state/zram-swap.state"
+SWAPS="$TEST_ROOT/proc/swaps"
+FSTAB="$TEST_ROOT/etc/fstab"
+EXPECTED_FSTAB="$TEST_ROOT/fstab.expected"
+VERIFY_MODE=0
+START_COUNT=0
+PURGE_COUNT=0
+
+fail() {
+ printf 'FAIL: %s\n' "$*" >&2
+ exit 1
+}
+
+load_language() { :; }
+initialize_cache() { :; }
+translate() { printf '%s' "$1"; }
+msg_info() { :; }
+msg_info2() { :; }
+msg_ok() { :; }
+msg_warn() { :; }
+msg_error() { printf '%s\n' "$*" >&2; }
+msg_success() { :; }
+show_proxmenux_logo() { :; }
+clear() { :; }
+pmx_install_pkg() { return 1; }
+pmx_record_uninstall() { :; }
+apt-get() {
+ if [[ "$*" == "purge -y systemd-zram-generator" ]]; then
+ PURGE_COUNT=$((PURGE_COUNT + 1))
+ fi
+ return 0
+}
+pmx_journal_context() { :; }
+
+dpkg-query() {
+ case "$*" in
+ *'${Status}'*) printf 'install ok installed\n' ;;
+ *'${Version}'*) printf '1.2.0-1\n' ;;
+ *) return 1 ;;
+ esac
+}
+
+dpkg() {
+ [[ "$1" == "--compare-versions" && "$3" == "ge" && "$2" == "1.2.0-1" && "$4" == "1.2.0" ]]
+}
+
+systemctl() {
+ case "$1" in
+ daemon-reload|stop) return 0 ;;
+ start)
+ START_COUNT=$((START_COUNT + 1))
+ if [[ "${2:-}" == "dev-zram0.swap" ]] &&
+ ! awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' "$SWAPS"; then
+ printf '/dev/zram0\tpartition\t3355443\t0\t32767\n' >> "$SWAPS"
+ fi
+ return 0
+ ;;
+ is-active) return 1 ;;
+ *) return 0 ;;
+ esac
+}
+
+swapoff() {
+ local target="${1:-}" next="$TEST_ROOT/swaps.next"
+ [[ -n "$target" ]] || fail "swapoff was called without a target"
+ awk -v target="$target" 'NR == 1 || $1 != target' "$SWAPS" > "$next"
+ mv "$next" "$SWAPS"
+}
+
+PMX_JOURNAL_ROOT="$TEST_ROOT/journal"
+export PMX_JOURNAL_ROOT
+source "$REPO_ROOT/scripts/global/pmx_journal.sh"
+
+mkdir -p "$TEST_ROOT/host/scripts/post_install"
+cp "$SCRIPT_ROOT/zram_profile_helpers.sh" "$TEST_ROOT/host/scripts/post_install/"
+
+# Isolate only host paths; source the real installer function and helper library.
+python3 - "$SCRIPT_ROOT/customizable_post_install.sh" "$TEST_ROOT/customizable.sh" "$TEST_ROOT" <<'PY'
+from pathlib import Path
+import sys
+
+source_path, output_path, root = map(Path, sys.argv[1:])
+text = source_path.read_text(encoding="utf-8")
+
+def replace_exact(old, new, expected=1):
+ global text
+ count = text.count(old)
+ if count != expected:
+ raise SystemExit(f"expected {expected} path substitutions, found {count}: {old}")
+ text = text.replace(old, new)
+
+replace_exact(
+ 'LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts"',
+ f'LOCAL_SCRIPTS="{root}/host/scripts"',
+)
+replace_exact(
+ 'BASE_DIR="/usr/local/share/proxmenux"',
+ f'BASE_DIR="{root}/state"',
+)
+replace_exact(
+ 'UTILS_FILE="$BASE_DIR/utils.sh"',
+ f'UTILS_FILE="{root}/missing-utils.sh"',
+)
+replace_exact(
+ 'TOOLS_JSON="/usr/local/share/proxmenux/installed_tools.json"',
+ f'TOOLS_JSON="{root}/state/installed_tools.json"',
+)
+replace_exact(
+ 'local config=/etc/systemd/zram-generator.conf.d/90-proxmenux.conf',
+ f'local config={root}/etc/systemd/zram-generator.conf.d/90-proxmenux.conf',
+)
+replace_exact(
+ 'if [[ ! -r /proc/swaps || ! -r /etc/fstab ]]; then',
+ f'if [[ ! -r "{root}/proc/swaps" || ! -r "{root}/etc/fstab" ]]; then',
+)
+replace_exact(
+ 'zram_list_foreign_active_zram_swap /proc/swaps "$allowed_zram"',
+ f'zram_list_foreign_active_zram_swap "{root}/proc/swaps" "$allowed_zram"',
+)
+replace_exact(
+ 'zram_list_foreign_fstab_zram_swap /etc/fstab',
+ f'zram_list_foreign_fstab_zram_swap "{root}/etc/fstab"',
+)
+replace_exact(
+ 'zram_list_foreign_systemd_zram_units)',
+ 'zram_list_foreign_systemd_zram_units "$TEST_ROOT")',
+)
+replace_exact(
+ 'zram_foreign_generator_config "$config"',
+ 'zram_foreign_generator_config "$config" "$TEST_ROOT"',
+ expected=3,
+)
+replace_exact(
+ "awk 'NR > 1 && $1 == \"/dev/zram0\" { found=1 } END { exit !found }' /proc/swaps",
+ f"awk 'NR > 1 && $1 == \"/dev/zram0\" {{ found=1 }} END {{ exit !found }}' \"{root}/proc/swaps\"",
+ expected=2,
+)
+output_path.write_text(text, encoding="utf-8")
+PY
+
+# Isolate the real uninstall function and redirect only the paths it inspects.
+python3 - "$SCRIPT_ROOT/uninstall-tools.sh" "$TEST_ROOT/uninstall-zram.sh" "$TEST_ROOT" <<'PY'
+from pathlib import Path
+import sys
+
+source_path, output_path, root = map(Path, sys.argv[1:])
+text = source_path.read_text(encoding="utf-8")
+start = text.index("uninstall_zram_swap() {")
+end = text.index("\n}\n", start) + 3
+function = text[start:end]
+replacements = {
+ "local config=/etc/systemd/zram-generator.conf.d/90-proxmenux.conf":
+ f"local config={root}/etc/systemd/zram-generator.conf.d/90-proxmenux.conf",
+ 'awk \'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }\' /proc/swaps':
+ f'awk \'NR > 1 && $1 == "/dev/zram0" {{ found=1 }} END {{ exit !found }}\' "{root}/proc/swaps"',
+ 'zram_foreign_generator_config "$config"':
+ 'zram_foreign_generator_config "$config" "$TEST_ROOT"',
+ 'zram_list_foreign_active_zram_swap /proc/swaps /dev/zram0':
+ f'zram_list_foreign_active_zram_swap "{root}/proc/swaps" /dev/zram0',
+ 'zram_list_foreign_fstab_zram_swap /etc/fstab':
+ f'zram_list_foreign_fstab_zram_swap "{root}/etc/fstab"',
+ 'zram_list_foreign_systemd_zram_units)':
+ 'zram_list_foreign_systemd_zram_units "$TEST_ROOT")',
+}
+for old, new in replacements.items():
+ if function.count(old) != 1:
+ raise SystemExit(f"expected one uninstall path to rewrite: {old}")
+ function = function.replace(old, new)
+output_path.write_text(function, encoding="utf-8")
+PY
+
+source "$TEST_ROOT/customizable.sh"
+source "$TEST_ROOT/uninstall-zram.sh"
+BASE_DIR="$TEST_ROOT/state"
+TOOLS_JSON="$TEST_ROOT/state/installed_tools.json"
+mkdir -p "$BASE_DIR"
+register_tool() { printf '%s|%s|%s\n' "$1" "$2" "${3:-}" >> "$TEST_ROOT/registry.log"; }
+zram_profile_verify_active() { return "$VERIFY_MODE"; }
+
+reset_fixture() {
+ rm -rf "$TEST_ROOT/etc" "$TEST_ROOT/run" "$TEST_ROOT/proc" "$BASE_DIR" "$TEST_ROOT/journal"
+ mkdir -p "$TEST_ROOT/etc/systemd/system" "$TEST_ROOT/proc" "$BASE_DIR" "$TEST_ROOT/run/systemd/system"
+ printf 'Filename\tType\tSize\tUsed\tPriority\n/dev/nvme0n1p3\tpartition\t1048576\t8\t-2\n' > "$SWAPS"
+ printf '/dev/nvme0n1p3 none swap defaults 0 0\n/swapfile none swap sw 0 0\n' > "$FSTAB"
+ cp "$FSTAB" "$EXPECTED_FSTAB"
+ printf '[Swap]\nWhat=/dev/mapper/pve-swap\n' > "$TEST_ROOT/etc/systemd/system/pve-swap.swap"
+ PMX_JOURNAL_ROOT="$TEST_ROOT/journal"
+ START_COUNT=0
+ PURGE_COUNT=0
+ VERIFY_MODE=0
+ : > "$TEST_ROOT/registry.log"
+}
+
+assert_disk_swap_unchanged() {
+ cmp -s "$FSTAB" "$EXPECTED_FSTAB" || fail "installer changed fstab"
+ grep -Fq '/dev/nvme0n1p3' "$SWAPS" || fail "active disk swap was removed"
+}
+
+reset_fixture
+configure_zram_swap || fail "disk swap prevented ZRAM installation"
+grep -Fq 'swap-priority = 32767' "$CONFIG" || fail "ZRAM priority is not maximal"
+grep -Fq 'installed_by_proxmenux=false' "$MARKER" || fail "package ownership was not preserved"
+grep -Fq '/dev/zram0' "$SWAPS" || fail "ZRAM did not become active"
+assert_disk_swap_unchanged
+
+configure_zram_swap || fail "owned ZRAM profile could not be re-applied with disk swap"
+[[ "$START_COUNT" -eq 1 ]] || fail "matching owned profile was not idempotent"
+assert_disk_swap_unchanged
+printf 'PASS: disk swap coexists and owned profile is idempotent\n'
+
+reset_fixture
+printf '/dev/zram1 none swap defaults 0 0\n' >> "$FSTAB"
+cp "$FSTAB" "$EXPECTED_FSTAB"
+if configure_zram_swap; then
+ fail "foreign fstab ZRAM entry was accepted"
+fi
+[[ ! -e "$CONFIG" && ! -e "$MARKER" ]] || fail "foreign fstab ZRAM entry left partial profile state"
+assert_disk_swap_unchanged
+printf 'PASS: foreign fstab ZRAM setup is refused without changing disk swap\n'
+
+reset_fixture
+printf '%s\n' '[Swap]' 'What=/dev/zram1' > "$TEST_ROOT/etc/systemd/system/external-zram.swap"
+if configure_zram_swap; then
+ fail "foreign systemd ZRAM unit was accepted"
+fi
+[[ ! -e "$CONFIG" && ! -e "$MARKER" ]] || fail "foreign systemd ZRAM unit left partial profile state"
+assert_disk_swap_unchanged
+printf 'PASS: foreign systemd ZRAM unit is refused without changing disk swap\n'
+
+reset_fixture
+VERIFY_MODE=1
+if configure_zram_swap; then
+ fail "activation verification failure was reported as success"
+fi
+[[ ! -e "$CONFIG" && ! -e "$MARKER" ]] || fail "failed activation left partial profile state"
+if awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' "$SWAPS"; then
+ fail "rollback left the failed ZRAM device active"
+fi
+assert_disk_swap_unchanged
+printf 'PASS: verification failure rolls back only the ProxMenux ZRAM state\n'
+
+reset_fixture
+mkdir -p "$(dirname "$CONFIG")"
+printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG"
+printf 'installed_by_proxmenux=true\n' > "$MARKER"
+printf '/dev/zram1 none swap defaults 0 0\n' >> "$FSTAB"
+printf '[Swap]\nWhat=/dev/zram2\n' > "$TEST_ROOT/etc/systemd/system/external-zram.swap"
+cp "$FSTAB" "$EXPECTED_FSTAB"
+uninstall_zram_swap || fail "profile uninstall failed with foreign ZRAM configuration"
+[[ "$PURGE_COUNT" -eq 0 ]] || fail "uninstaller purged generator while foreign ZRAM config exists"
+[[ ! -e "$CONFIG" && ! -e "$MARKER" ]] || fail "uninstaller left ProxMenux profile files behind"
+assert_disk_swap_unchanged
+printf 'PASS: uninstall retains generator when foreign ZRAM config exists\n'
+
+reset_fixture
+mkdir -p "$(dirname "$CONFIG")"
+printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG"
+printf 'installed_by_proxmenux=true\n' > "$MARKER"
+printf '%s\n' '[Swap]' 'What=/dev/zram2' > "$TEST_ROOT/etc/systemd/system/external-zram.swap"
+uninstall_zram_swap || fail "profile uninstall failed with foreign systemd ZRAM unit"
+[[ "$PURGE_COUNT" -eq 0 ]] || fail "uninstaller purged generator while foreign systemd ZRAM unit exists"
+assert_disk_swap_unchanged
+printf 'PASS: uninstall retains generator for foreign systemd ZRAM unit\n'
+
+reset_fixture
+mkdir -p "$(dirname "$CONFIG")"
+printf '# ProxMenux ZRAM profile\n[zram0]\nzram-size = ram / 10\n' > "$CONFIG"
+printf 'installed_by_proxmenux=true\n' > "$MARKER"
+printf '/dev/zram0\tpartition\t3355443\t8\t32767\n' >> "$SWAPS"
+uninstall_zram_swap || fail "owned profile could not be uninstalled"
+[[ "$PURGE_COUNT" -eq 1 ]] || fail "owned generator package was not purged"
+if awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' "$SWAPS"; then
+ fail "uninstaller left ProxMenux ZRAM active"
+fi
+assert_disk_swap_unchanged
+printf 'PASS: uninstall removes owned ZRAM and preserves disk swap\n'
diff --git a/scripts/post_install/uninstall-tools.sh b/scripts/post_install/uninstall-tools.sh
index 10eeda09..63b22fa1 100644
--- a/scripts/post_install/uninstall-tools.sh
+++ b/scripts/post_install/uninstall-tools.sh
@@ -37,6 +37,9 @@ fi
if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then
source "$LOCAL_SCRIPTS/global/pmx_journal.sh"
fi
+if [[ -f "$LOCAL_SCRIPTS/post_install/zram_profile_helpers.sh" ]]; then
+ source "$LOCAL_SCRIPTS/post_install/zram_profile_helpers.sh"
+fi
load_language
initialize_cache
@@ -55,6 +58,83 @@ register_tool() {
################################################################
+uninstall_zram_swap() {
+ local FUNC_VERSION="1.0"
+ pmx_journal_context "uninstall_zram_swap" "$FUNC_VERSION"
+ local config=/etc/systemd/zram-generator.conf.d/90-proxmenux.conf
+ local marker="$BASE_DIR/zram-swap.state"
+ local package_owned=false foreign="" other_active="" fstab_zram="" systemd_zram="" inspection_failed=false old_active=false
+ local old_config="" old_marker=""
+
+ if ! declare -f zram_foreign_generator_config >/dev/null ||
+ ! declare -f zram_list_foreign_active_zram_swap >/dev/null ||
+ ! declare -f zram_list_foreign_fstab_zram_swap >/dev/null ||
+ ! declare -f zram_list_foreign_systemd_zram_units >/dev/null; then
+ msg_error "$(translate "ZRAM profile helpers are missing")"
+ return 1
+ fi
+ if [[ ! -f "$config" || ! -f "$marker" || -L "$config" || -L "$marker" ]] ||
+ ! grep -qx '# ProxMenux ZRAM profile' "$config" ||
+ ! grep -Eq '^installed_by_proxmenux=(true|false)$' "$marker"; then
+ msg_error "$(translate "ZRAM ownership state is incomplete; no files were removed")"
+ return 1
+ fi
+ old_config="$(cat "$config")"
+ old_marker="$(cat "$marker")"
+ [[ "$old_marker" == installed_by_proxmenux=true ]] && package_owned=true
+
+ if awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' /proc/swaps; then
+ old_active=true
+ if ! swapoff /dev/zram0; then
+ msg_error "$(translate "ZRAM still holds pages; profile remains active")"
+ return 1
+ fi
+ fi
+ if systemctl is-active --quiet dev-zram0.swap && ! systemctl stop dev-zram0.swap; then
+ if [[ "$old_active" == true ]]; then
+ systemctl start dev-zram0.swap >/dev/null 2>&1 || true
+ fi
+ msg_error "$(translate "Could not stop ZRAM swap; profile configuration remains")"
+ return 1
+ fi
+ if ! pmx_remove_file "$config" || ! pmx_remove_file "$marker" || ! systemctl daemon-reload; then
+ printf '%s\n' "$old_config" | pmx_write_file "$config"
+ printf '%s\n' "$old_marker" | pmx_write_file "$marker"
+ chmod 600 "$marker"
+ systemctl daemon-reload >/dev/null 2>&1 || true
+ if [[ "$old_active" == true ]] && ! systemctl start dev-zram0.swap >/dev/null 2>&1; then
+ msg_error "$(translate "Could not remove ZRAM profile; configuration was restored but swap could not be restarted")"
+ else
+ msg_error "$(translate "Could not remove ZRAM profile; configuration was restored")"
+ fi
+ return 1
+ fi
+ rmdir "$(dirname "$config")" 2>/dev/null || true
+
+ if [[ "$package_owned" == true ]]; then
+ if ! foreign="$(zram_foreign_generator_config "$config")" ||
+ ! other_active="$(zram_list_foreign_active_zram_swap /proc/swaps /dev/zram0)" ||
+ ! fstab_zram="$(zram_list_foreign_fstab_zram_swap /etc/fstab)" ||
+ ! systemd_zram="$(zram_list_foreign_systemd_zram_units)"; then
+ inspection_failed=true
+ fi
+ if [[ "$inspection_failed" == false && -z "$foreign" && -z "$other_active" && -z "$fstab_zram" && -z "$systemd_zram" ]]; then
+ if DEBIAN_FRONTEND=noninteractive apt-get purge -y systemd-zram-generator >/dev/null 2>&1; then
+ pmx_record_uninstall "systemd-zram-generator" "$FUNC_VERSION"
+ else
+ msg_warn "$(translate "ZRAM profile removed, but package purge failed")"
+ fi
+ else
+ msg_warn "$(translate "Another ZRAM configuration exists or could not be inspected; systemd-zram-generator was retained")"
+ fi
+ fi
+ register_tool "zram_swap" false
+ msg_success "$(translate "ZRAM swap profile removed")"
+ return 0
+}
+
+################################################################
+
uninstall_fastfetch() {
local FUNC_VERSION="1.0"
pmx_journal_context "uninstall_fastfetch" "$FUNC_VERSION"
@@ -1270,6 +1350,7 @@ show_uninstall_menu() {
system_limits) desc="System Limits Increase";;
entropy) desc="Entropy Generation (haveged)";;
memory_settings) desc="Memory Settings Optimization";;
+ zram_swap) desc="ZRAM swap profile";;
kernel_panic) desc="Kernel Panic Configuration";;
apt_ipv4) desc="APT IPv4 Force";;
kexec) desc="kexec for quick reboots";;
@@ -1315,18 +1396,30 @@ show_uninstall_menu() {
fi
# Execute uninstallations
+ local failed=0
for tool in $selected_tools; do
tool=$(echo "$tool" | tr -d '"')
if declare -f "uninstall_$tool" > /dev/null 2>&1; then
clear
show_proxmenux_logo
- "uninstall_$tool"
+ # Preserve legacy return handling for existing uninstallers.
+ # Only ZRAM uses a failure status to signal that its active swap
+ # profile could not be safely removed.
+ if [[ "$tool" == "zram_swap" ]]; then
+ uninstall_zram_swap || failed=1
+ else
+ "uninstall_$tool" || true
+ fi
else
msg_warn "$(translate "No uninstaller found for:") $tool"
fi
done
- msg_success "$(translate "Selected optimizations have been uninstalled.")"
+ if [[ "$failed" -eq 0 ]]; then
+ msg_success "$(translate "Selected optimizations have been uninstalled.")"
+ else
+ msg_error "$(translate "Some optimizations could not be uninstalled; their configuration was preserved.")"
+ fi
msg_warn "$(translate "A system reboot is recommended to ensure all changes take effect.")"
echo -e
msg_success "$(translate "Press Enter to continue...")"
diff --git a/scripts/post_install/zram_profile_helpers.sh b/scripts/post_install/zram_profile_helpers.sh
new file mode 100644
index 00000000..d6e6cfba
--- /dev/null
+++ b/scripts/post_install/zram_profile_helpers.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+# Pure helpers shared by the ZRAM installer and its fixture tests.
+
+# The kernel's maximum priority puts ZRAM ahead of standard Proxmox swap.
+# A disk swap explicitly configured at this same maximum cannot be outranked.
+if ! declare -p ZRAM_SWAP_PRIORITY >/dev/null 2>&1; then
+ readonly ZRAM_SWAP_PRIORITY=32767
+fi
+
+zram_render_generator_config() {
+ local algorithm=zstd
+ [[ "${1:-false}" == true ]] && algorithm='zstd(level=9)'
+ cat </dev/null)" || real_device="$swap_path"
+ if [[ "$real_device" =~ ^/dev/zram[0-9]+$ && "$real_device" != "$owned_device" ]]; then
+ printf 'active zram: %s\n' "$swap_path"
+ fi
+ done <<< "$swap_paths"
+}
+
+zram_list_foreign_fstab_zram_swap() {
+ local fstab_file="$1"
+ [[ -r "$fstab_file" ]] || return 2
+ awk '
+ $0 !~ /^[[:space:]]*#/ && NF >= 3 && $3 == "swap" && $1 ~ /^\/dev\/zram[0-9]+$/ {
+ print "fstab zram: " $1
+ }
+ ' "$fstab_file"
+}
+
+# Print generator configuration outside this feature's dedicated drop-in.
+zram_foreign_generator_config() {
+ local owned_file="$1" fixture_root="${2:-}" root file
+ local roots=(/etc/systemd /run/systemd /usr/local/lib/systemd /usr/lib/systemd /lib/systemd)
+ if [[ -n "$fixture_root" ]]; then
+ roots=("$fixture_root/etc/systemd" "$fixture_root/run/systemd" "$fixture_root/usr/local/lib/systemd" "$fixture_root/usr/lib/systemd" "$fixture_root/lib/systemd")
+ fi
+ for root in "${roots[@]}"; do
+ for file in "$root/zram-generator.conf" "$root"/zram-generator.conf.d/*.conf; do
+ [[ -e "$file" || -L "$file" ]] || continue
+ [[ "$file" == "$owned_file" ]] && continue
+ # Ignore package defaults containing only comments, whitespace and
+ # section headers. Any directive or malformed active line is foreign.
+ local active
+ active="$(awk '
+ {
+ line=$0
+ sub(/^[[:space:]]+/, "", line)
+ sub(/[[:space:]]+$/, "", line)
+ if (line == "" || line ~ /^[#;]/ || line ~ /^\[[^]]+\]$/) next
+ found=1
+ }
+ END { if (found) print "active" }
+ ' "$file")" || return 2
+ if [[ -n "$active" ]]; then
+ printf '%s\n' "$file"
+ fi
+ done
+ done
+}
+
+zram_list_foreign_systemd_zram_units() {
+ local fixture_root="${1:-}" root file unit what unit_files
+ local roots=(/etc/systemd/system /run/systemd/system /usr/local/lib/systemd/system /usr/lib/systemd/system /lib/systemd/system)
+ if [[ -n "$fixture_root" ]]; then
+ roots=("$fixture_root/etc/systemd/system" "$fixture_root/run/systemd/system" "$fixture_root/usr/local/lib/systemd/system" "$fixture_root/usr/lib/systemd/system" "$fixture_root/lib/systemd/system")
+ fi
+ for root in "${roots[@]}"; do
+ [[ -d "$root" ]] || continue
+ # Skip activation links; systemd-generator creates links under
+ # *.wants and they must not look like foreign unit definitions.
+ unit_files="$(find "$root" \( -type d \( -name '*.wants' -o -name '*.requires' \) -prune \) -o \( \( -name '*.swap' -o -path '*.swap.d/*.conf' \) \( -type f -o -type l \) -print \) 2>/dev/null)" || return 2
+ while IFS= read -r file; do
+ [[ -z "$file" ]] && continue
+ unit="$(basename "$file")"
+ if [[ -L "$file" && "$(readlink -f -- "$file" 2>/dev/null || true)" == /dev/null ]]; then
+ if [[ "$unit" == "dev-zram0.swap" ]]; then
+ printf 'systemd zram unit masked: %s\n' "$file"
+ fi
+ continue
+ fi
+ # A persistent unit named for a zram device could shadow the
+ # generated unit, even if it currently points at another device.
+ what="$(awk '
+ /^[[:space:]]*\[Swap\][[:space:]]*$/ { in_swap=1; next }
+ /^[[:space:]]*\[/ { in_swap=0 }
+ in_swap && /^[[:space:]]*What[[:space:]]*=[[:space:]]*\/dev\/zram[0-9]+([[:space:]]|$)/ {
+ found=1
+ }
+ END { if (found) print "zram" }
+ ' "$file")" || return 2
+ if [[ "$unit" =~ ^dev-zram[0-9]+\.swap$ || -n "$what" ]]; then
+ printf 'systemd zram unit: %s\n' "$file"
+ fi
+ done <<< "$unit_files"
+ done
+}
+
+zram_profile_verify_active() {
+ local expected_algorithm="$1" expected_priority="${2:-$ZRAM_SWAP_PRIORITY}"
+ local sys_block_root="${3:-/sys/block}" meminfo_path="${4:-/proc/meminfo}" swaps_path="${5:-/proc/swaps}"
+ local zram0="$sys_block_root/zram0" expected_bytes actual_bytes active_algorithm
+
+ [[ -r "$zram0/disksize" && -r "$zram0/comp_algorithm" && -r "$meminfo_path" && -r "$swaps_path" ]] || return 1
+ expected_bytes="$(awk '/^MemTotal:/ { print int($2 * 1024 / 10) }' "$meminfo_path")"
+ actual_bytes="$(cat "$zram0/disksize")"
+ [[ "$expected_bytes" =~ ^[0-9]+$ && "$actual_bytes" =~ ^[0-9]+$ ]] || return 1
+ (( actual_bytes >= expected_bytes - 1048576 && actual_bytes <= expected_bytes + 1048576 )) || return 1
+ active_algorithm="$(grep -oE '\[[^]]+\]' "$zram0/comp_algorithm" | tr -d '[]')"
+ [[ "$active_algorithm" == "$expected_algorithm" ]] || return 1
+ awk -v priority="$expected_priority" '
+ NR > 1 && $1 == "/dev/zram0" {
+ found=1
+ if ($5 + 0 != priority + 0) bad=1
+ }
+ NR > 1 && $1 ~ /^\/dev\/zram[0-9]+$/ && $1 != "/dev/zram0" { bad=1 }
+ END { exit !(found && !bad) }
+ ' "$swaps_path"
+}