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 1206f5e4..f93d7a9b 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -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", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 9b590728..0719aeb9 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 205176d4..135107ea 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 beaf6426..f7120796 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 37bdb0e4..44f2c243 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 fbc3414f..7b783e25 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 846469f4..b21c1c45 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 7174ac00..0f9e86c0 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 b9034e2b..cfb1ccb2 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..8f1f0d6c 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-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'}, diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 14d20310..78f79f8c 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 @@ -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, 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..47b14842 --- /dev/null +++ b/AppImage/scripts/tests/test_zram_profile_helpers.py @@ -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() 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..86a8db17 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,6 +2451,187 @@ EOF } +configure_zram_swap() { + local FUNC_VERSION="1.0" + # description: Enable an opt-in ZRAM-only 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="" systemd_conflicts="" version="" algorithm="" + local supports_params=false failure="" old_umask swap_detection_status + + if ! declare -f zram_render_generator_config >/dev/null || + ! declare -f zram_has_non_zram_swap >/dev/null || + ! declare -f zram_list_non_zram_swap >/dev/null || + ! declare -f zram_list_defined_systemd_swap_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 + if zram_has_non_zram_swap /proc/swaps /etc/fstab; then + conflicts="$(zram_list_non_zram_swap /proc/swaps /etc/fstab)" || return 1 + else + swap_detection_status=$? + if [[ "$swap_detection_status" -ne 1 ]]; then + msg_error "$(translate "Cannot inspect current swap configuration")" + return 1 + fi + fi + systemd_conflicts="$(zram_list_defined_systemd_swap_units)" + if [[ -n "$systemd_conflicts" ]]; then + [[ -n "$conflicts" ]] && conflicts+=$'\n' + conflicts+="$systemd_conflicts" + fi + if [[ -n "$conflicts" ]]; then + msg_error "$(translate "ZRAM-only profile requires removing these conflicting swap sources first:") $conflicts" + return 1 + fi + conflicts="$(zram_foreign_generator_config "$config")" + if [[ -n "$conflicts" ]]; then + msg_error "$(translate "Another ZRAM generator configuration exists; refusing to overwrite it:") $conflicts" + return 1 + fi + 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 + elif [[ -e "$config" || -L "$config" ]] || awk 'NR > 1 && $1 == "/dev/zram0" { found=1 } END { exit !found }' /proc/swaps; then + msg_error "$(translate "Existing zram0 configuration or swap 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-only 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 +} + +zram_profile_verify_active() { + local expected_algorithm="$1" expected_bytes actual_bytes active_algorithm + [[ -r /sys/block/zram0/disksize && -r /sys/block/zram0/comp_algorithm ]] || return 1 + expected_bytes="$(awk '/^MemTotal:/ { print int($2 * 1024 / 10) }' /proc/meminfo)" + actual_bytes="$(cat /sys/block/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 '\[[^]]+\]' /sys/block/zram0/comp_algorithm | tr -d '[]')" + [[ "$active_algorithm" == "$expected_algorithm" ]] || return 1 + awk 'NR > 1 { if ($1 !~ /^\/dev\/zram[0-9]+$/) bad=1; if ($1 == "/dev/zram0") found=1 } END { exit !(found && !bad) }' /proc/swaps +} + @@ -3674,6 +3858,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-only swap profile") translate "Enable ZRAM-only swap profile" ;; *) echo "$1" ;; esac } @@ -3769,6 +3954,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-only swap profile|ZRAM_SWAP" "System|Enable fast reboots|KEXEC" "System|Enable restart on kernel panic|KERNELPANIC" "Optional|Apply AMD CPU fixes|AMDFIXES" @@ -3912,6 +4098,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/uninstall-tools.sh b/scripts/post_install/uninstall-tools.sh index 10eeda09..63d337c0 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,76 @@ 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="" old_active=false + local old_config="" old_marker="" + + if ! declare -f zram_foreign_generator_config >/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 + foreign="$(zram_foreign_generator_config "$config")" + other_active="$(awk 'NR > 1 && $1 ~ /^\/dev\/zram[0-9]+$/ { print $1 }' /proc/swaps)" + if [[ -z "$foreign" && -z "$other_active" ]]; 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 "systemd-zram-generator is still used by another configuration; package 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 +1343,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-only swap profile";; kernel_panic) desc="Kernel Panic Configuration";; apt_ipv4) desc="APT IPv4 Force";; kexec) desc="kexec for quick reboots";; @@ -1315,18 +1389,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..947998a1 --- /dev/null +++ b/scripts/post_install/zram_profile_helpers.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Pure helpers shared by the ZRAM installer and its fixture tests. + +zram_render_generator_config() { + local algorithm=zstd + [[ "${1:-false}" == true ]] && algorithm='zstd(level=9)' + cat < 1 && $1 !~ /^\/dev\/zram[0-9]+$/ { print "active: " $1 }' "$swaps_file" + awk '$0 !~ /^[[:space:]]*#/ && NF >= 3 && $3 == "swap" && $1 !~ /^\/dev\/zram[0-9]+$/ { print "fstab: " $1 }' "$fstab_file" +} + +zram_has_non_zram_swap() { + [[ -r "$1" && -r "$2" ]] || return 2 + [[ -n "$(zram_list_non_zram_swap "$1" "$2")" ]] +} + +# 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. + if awk ' + { + line=$0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]+$/, "", line) + if (line == "" || line ~ /^[#;]/ || line ~ /^\[[^]]+\]$/) next + found=1 + } + END { exit !found } + ' "$file"; then + printf '%s\n' "$file" + fi + done + done +} + +zram_list_defined_systemd_swap_units() { + local fixture_root="${1:-}" root file + local roots=(/etc/systemd/system /run/systemd/system) + if [[ -n "$fixture_root" ]]; then + roots=("$fixture_root/etc/systemd/system" "$fixture_root/run/systemd/system") + fi + for root in "${roots[@]}"; do + [[ -d "$root" ]] || continue + while IFS= read -r file; do + # Files in these roots are administrator-defined units. The + # generator's own output lives under /run/systemd/generator and + # is intentionally outside this scan. + printf 'systemd: %s\n' "$file" + done < <(find "$root" -name '*.swap' \( -type f -o -type l \) -print 2>/dev/null) + done +}