)}
SMART Health
@@ -636,6 +883,25 @@ function DiskSection() {
onClose={() => setDetailsOpen(false)}
/>
+
+
+ {
+ setUnmountConfirmOpen(false);
+ setUnmountTarget(null);
+ }}
+ onConfirm={handleUnmountConfirm}
+ confirmButton='Unmount'
+ />
+
Refresh
diff --git a/controller/api/disks.py b/controller/api/disks.py
index b1a1de3f..cacd820e 100644
--- a/controller/api/disks.py
+++ b/controller/api/disks.py
@@ -7,7 +7,12 @@ from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
-from controller.lib.config import is_docker_mode
+from controller.lib.config import (
+ get_config_value,
+ is_docker_mode,
+ save_config,
+ update_config,
+)
from controller.lib.disks import (
get_block_devices,
get_mounts,
@@ -112,6 +117,30 @@ async def disk_mount(request: MountRequest):
)
result["fstab"] = fstab_result
+ # Save to controller.yaml to track WROLPi-managed mounts
+ if fstab_result.get("success"):
+ mount_entry = {
+ "device": fstab_result.get("device", request.device),
+ "mount_point": request.mount_point,
+ "fstype": request.fstype or "auto",
+ "options": request.options,
+ }
+
+ # Get current mounts, filter out any existing entry for same mount_point or device
+ current_mounts = get_config_value("drives.mounts", [])
+ current_mounts = [
+ m for m in current_mounts
+ if m.get("mount_point") != request.mount_point
+ and m.get("device") != mount_entry["device"]
+ ]
+ current_mounts.append(mount_entry)
+
+ update_config("drives.mounts", current_mounts)
+ try:
+ save_config()
+ except RuntimeError:
+ pass # Primary drive not mounted - can't save yet
+
return result
@@ -169,6 +198,15 @@ async def delete_fstab(mount_point: str):
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
+ # Remove from controller.yaml - mount is no longer persistent
+ current_mounts = get_config_value("drives.mounts", [])
+ current_mounts = [m for m in current_mounts if m.get("mount_point") != mount_point]
+ update_config("drives.mounts", current_mounts)
+ try:
+ save_config()
+ except RuntimeError:
+ pass # Primary drive not mounted - can't save
+
return result
diff --git a/controller/lib/config.py b/controller/lib/config.py
index d661c498..b0271571 100644
--- a/controller/lib/config.py
+++ b/controller/lib/config.py
@@ -103,6 +103,7 @@ def save_config() -> None:
"""
Save current runtime config to the WROLPi drive.
Only saves the diff from defaults to keep the file clean.
+ If config matches defaults, removes the config file.
"""
if not is_primary_drive_mounted():
raise RuntimeError("Cannot save config: primary drive not mounted")
@@ -111,7 +112,9 @@ def save_config() -> None:
diff = _get_config_diff(_runtime_config, DEFAULT_CONFIG)
if not diff:
- # Nothing to save - config matches defaults
+ # Config matches defaults - remove config file if it exists
+ if CONFIG_PATH_ON_DRIVE.exists():
+ CONFIG_PATH_ON_DRIVE.unlink()
return
CONFIG_PATH_ON_DRIVE.parent.mkdir(parents=True, exist_ok=True)
diff --git a/controller/lib/fstab.py b/controller/lib/fstab.py
index 09bf9a6e..e6707f14 100644
--- a/controller/lib/fstab.py
+++ b/controller/lib/fstab.py
@@ -60,23 +60,38 @@ def parse_fstab() -> list[dict]:
return entries
-def _filter_entry_with_comment(entries: list[dict], mount_point: str) -> list[dict]:
+def _filter_entry_with_comment(
+ entries: list[dict],
+ mount_point: str = None,
+ device: str = None,
+) -> list[dict]:
"""
- Remove a mount entry and its preceding WROLPi comment if present.
+ Remove mount entries and their preceding WROLPi comments if present.
Args:
entries: List of parsed fstab entries
- mount_point: Mount point to remove
+ mount_point: Mount point to remove (optional)
+ device: Device spec to remove (e.g., "UUID=1234" or "/dev/sda1") (optional)
Returns:
- Filtered list without the mount entry and its associated WROLPi comment
+ Filtered list without matching mount entries and their associated WROLPi comments
"""
result = []
skip_indices = set()
# First pass: identify which entries to skip
for i, entry in enumerate(entries):
- if entry.get("mount_point") == mount_point:
+ should_skip = False
+
+ # Check mount_point match
+ if mount_point and entry.get("mount_point") == mount_point:
+ should_skip = True
+
+ # Check device match
+ if device and entry.get("device") == device:
+ should_skip = True
+
+ if should_skip:
skip_indices.add(i)
# Check if previous entry is a WROLPi comment
if i > 0:
@@ -141,8 +156,9 @@ def add_fstab_entry(
# Read current fstab
entries = parse_fstab()
- # Remove any existing entry for this mount point (and its WROLPi comment)
- entries = _filter_entry_with_comment(entries, mount_point)
+ # Remove any existing entry for this mount point or device (and its WROLPi comment)
+ # This ensures we don't get duplicates when remounting the same device to a different location
+ entries = _filter_entry_with_comment(entries, mount_point=mount_point, device=device_spec)
# Build new entry line
new_entry = f"{device_spec} {mount_point} {fstype} {options} 0 2\n"
diff --git a/controller/templates/index.html b/controller/templates/index.html
index 078cbd3c..56e14615 100644
--- a/controller/templates/index.html
+++ b/controller/templates/index.html
@@ -1055,6 +1055,12 @@
// Build set of persistent mount points for quick lookup
const persistentMounts = new Set(fstabEntries.map(e => e.mount_point));
+ // Build map of device (UUID) to fstab entry for unmounted drive lookup
+ const persistentByDevice = new Map();
+ fstabEntries.forEach(e => {
+ // e.device is like "UUID=1234-5678" or "/dev/sda1"
+ persistentByDevice.set(e.device, e);
+ });
let html = `
@@ -1090,14 +1096,26 @@
const protectedMounts = ['/', '/boot', '/boot/firmware'];
const isProtected = protectedMounts.includes(disk.mountpoint);
- // Persist column - only show for mounted, non-protected drives
- if (mounted && !isProtected) {
- const isPersistent = persistentMounts.has(disk.mountpoint);
+ // Check if this disk has a persistent entry (by mount point or UUID)
+ let isPersistent = false;
+ let persistentEntry = null;
+
+ if (mounted) {
+ isPersistent = persistentMounts.has(disk.mountpoint);
+ } else if (disk.uuid) {
+ // Check if unmounted drive has fstab entry by UUID
+ persistentEntry = persistentByDevice.get(`UUID=${disk.uuid}`);
+ isPersistent = !!persistentEntry;
+ }
+
+ // Persist column - show for mounted drives OR unmounted persistent drives
+ if ((mounted || isPersistent) && !isProtected) {
+ const mountPoint = mounted ? disk.mountpoint : persistentEntry.mount_point;
html += `
${isPersistent ? 'Enabled' : 'Disabled'}
diff --git a/controller/test/test_config.py b/controller/test/test_config.py
index c19734db..6e97754f 100644
--- a/controller/test/test_config.py
+++ b/controller/test/test_config.py
@@ -205,6 +205,36 @@ class TestSaveConfig:
# Only port should be in the file
assert saved == {"port": 9999}
+ def test_save_config_removes_file_when_matches_defaults(
+ self, reset_runtime_config, mock_config_path, mock_drive_mounted
+ ):
+ """Should remove config file when config matches defaults."""
+ # First create a config file with non-default value
+ update_config("port", 9999)
+ save_config()
+ assert mock_config_path.exists()
+
+ # Reset to default value
+ update_config("port", 8087) # Default port
+ save_config()
+
+ # File should be removed since config matches defaults
+ assert not mock_config_path.exists()
+
+ def test_save_config_removes_stale_file(
+ self, reset_runtime_config, mock_config_path, mock_drive_mounted
+ ):
+ """Should remove existing config file when config is reset to defaults."""
+ # Simulate an existing stale config file
+ mock_config_path.write_text("drives:\n mounts:\n - device: UUID=1234\n")
+ assert mock_config_path.exists()
+
+ # Runtime config matches defaults (no changes made)
+ save_config()
+
+ # File should be removed
+ assert not mock_config_path.exists()
+
class TestUpdateConfig:
"""Tests for update_config function."""
diff --git a/controller/test/test_disks_api.py b/controller/test/test_disks_api.py
index 5df00bd4..8107578c 100644
--- a/controller/test/test_disks_api.py
+++ b/controller/test/test_disks_api.py
@@ -169,6 +169,143 @@ class TestSmartEndpoints:
assert len(data["drives"]) == 1
+class TestMountPersistSavesConfig:
+ """Tests for mount with persist=true saving to controller.yaml."""
+
+ def test_mount_with_persist_saves_config(self, test_client):
+ """Mount with persist=true should save to controller.yaml."""
+ with mock.patch(
+ "controller.api.disks.mount_drive",
+ return_value={"success": True, "mount_point": "/media/test"}
+ ):
+ with mock.patch(
+ "controller.api.disks.add_fstab_entry",
+ return_value={"success": True, "device": "UUID=1234", "mount_point": "/media/test"}
+ ):
+ with mock.patch("controller.api.disks.save_config") as mock_save:
+ with mock.patch("controller.api.disks.get_config_value", return_value=[]):
+ with mock.patch("controller.api.disks.update_config") as mock_update:
+ response = test_client.post(
+ "/api/disks/mount",
+ json={
+ "device": "/dev/sda1",
+ "mount_point": "/media/test",
+ "fstype": "ext4",
+ "options": "defaults",
+ "persist": True
+ }
+ )
+ assert response.status_code == 200
+ # Check that update_config was called with correct mount entry
+ mock_update.assert_called_once()
+ call_args = mock_update.call_args
+ assert call_args[0][0] == "drives.mounts"
+ mounts = call_args[0][1]
+ assert len(mounts) == 1
+ assert mounts[0]["device"] == "UUID=1234"
+ assert mounts[0]["mount_point"] == "/media/test"
+ # Check that save_config was called
+ mock_save.assert_called_once()
+
+ def test_mount_without_persist_does_not_save_config(self, test_client):
+ """Mount without persist=true should not save to controller.yaml."""
+ with mock.patch(
+ "controller.api.disks.mount_drive",
+ return_value={"success": True, "mount_point": "/media/test"}
+ ):
+ with mock.patch("controller.api.disks.save_config") as mock_save:
+ response = test_client.post(
+ "/api/disks/mount",
+ json={
+ "device": "/dev/sda1",
+ "mount_point": "/media/test",
+ "persist": False
+ }
+ )
+ assert response.status_code == 200
+ mock_save.assert_not_called()
+
+ def test_mount_replaces_existing_entry_for_same_device(self, test_client):
+ """Mount should replace existing entry for same device UUID."""
+ existing_mounts = [
+ {"device": "UUID=1234", "mount_point": "/media/old", "fstype": "ext4", "options": "defaults"}
+ ]
+ with mock.patch(
+ "controller.api.disks.mount_drive",
+ return_value={"success": True}
+ ):
+ with mock.patch(
+ "controller.api.disks.add_fstab_entry",
+ return_value={"success": True, "device": "UUID=1234", "mount_point": "/media/new"}
+ ):
+ with mock.patch("controller.api.disks.save_config"):
+ with mock.patch("controller.api.disks.get_config_value", return_value=existing_mounts):
+ with mock.patch("controller.api.disks.update_config") as mock_update:
+ response = test_client.post(
+ "/api/disks/mount",
+ json={
+ "device": "/dev/sda1",
+ "mount_point": "/media/new",
+ "fstype": "ext4",
+ "persist": True
+ }
+ )
+ assert response.status_code == 200
+ # Should have replaced the old entry
+ call_args = mock_update.call_args
+ mounts = call_args[0][1]
+ assert len(mounts) == 1
+ assert mounts[0]["mount_point"] == "/media/new"
+
+
+class TestDeleteFstabRemovesConfig:
+ """Tests for delete_fstab removing from controller.yaml."""
+
+ def test_delete_fstab_removes_from_config(self, test_client):
+ """Delete fstab entry should remove from controller.yaml."""
+ existing_mounts = [
+ {"device": "UUID=1234", "mount_point": "/media/test", "fstype": "ext4", "options": "defaults"}
+ ]
+ with mock.patch(
+ "controller.api.disks.remove_fstab_entry",
+ return_value={"success": True, "mount_point": "/media/test"}
+ ):
+ with mock.patch("controller.api.disks.save_config") as mock_save:
+ with mock.patch("controller.api.disks.get_config_value", return_value=existing_mounts):
+ with mock.patch("controller.api.disks.update_config") as mock_update:
+ response = test_client.delete("/api/disks/fstab/media/test")
+ assert response.status_code == 200
+ # Check that update_config was called with empty list
+ mock_update.assert_called_once()
+ call_args = mock_update.call_args
+ assert call_args[0][0] == "drives.mounts"
+ mounts = call_args[0][1]
+ assert len(mounts) == 0
+ # Check that save_config was called
+ mock_save.assert_called_once()
+
+ def test_delete_fstab_preserves_other_mounts(self, test_client):
+ """Delete fstab should only remove the specified mount."""
+ existing_mounts = [
+ {"device": "UUID=1234", "mount_point": "/media/test", "fstype": "ext4", "options": "defaults"},
+ {"device": "UUID=5678", "mount_point": "/media/other", "fstype": "ext4", "options": "defaults"},
+ ]
+ with mock.patch(
+ "controller.api.disks.remove_fstab_entry",
+ return_value={"success": True, "mount_point": "/media/test"}
+ ):
+ with mock.patch("controller.api.disks.save_config"):
+ with mock.patch("controller.api.disks.get_config_value", return_value=existing_mounts):
+ with mock.patch("controller.api.disks.update_config") as mock_update:
+ response = test_client.delete("/api/disks/fstab/media/test")
+ assert response.status_code == 200
+ # Should preserve the other mount
+ call_args = mock_update.call_args
+ mounts = call_args[0][1]
+ assert len(mounts) == 1
+ assert mounts[0]["mount_point"] == "/media/other"
+
+
class TestOpenAPIIncludesDisksEndpoints:
"""Tests that OpenAPI documentation includes disks endpoints."""
diff --git a/controller/test/test_fstab.py b/controller/test/test_fstab.py
index c3aedaea..892930cc 100644
--- a/controller/test/test_fstab.py
+++ b/controller/test/test_fstab.py
@@ -131,6 +131,45 @@ UUID=5678 /media/test ext4 defaults,nofail 0 2
# The old timestamp should NOT be there
assert "2025-12-15T15:57:37.675757" not in written_str
+ def test_replaces_entry_for_same_uuid_different_mount_point(self):
+ """Should remove old entry when adding same UUID to different mount point.
+
+ Scenario: User mounts /dev/sda1 (UUID=5678) to /media/64GB with persist,
+ then unmounts, then mounts same device to /media/64GB-2 with persist.
+ The old /media/64GB entry should be removed.
+ """
+ fstab_content = """\
+# /etc/fstab
+UUID=1234 / ext4 defaults 0 1
+# WROLPi managed mount - added 2025-12-15T15:57:37.675757
+UUID=5678 /media/64GB exfat defaults,nofail,x-systemd.device-timeout=10s 0 2
+"""
+ written_content = []
+
+ def mock_write(content):
+ written_content.append(content)
+
+ mock_file = mock.mock_open(read_data=fstab_content)
+ mock_file().write = mock_write
+
+ with mock.patch("controller.lib.fstab.require_normal_mode"):
+ with mock.patch("controller.lib.fstab.get_uuid", return_value="5678"):
+ with mock.patch("controller.lib.fstab.backup_fstab"):
+ with mock.patch("builtins.open", mock_file):
+ with mock.patch("subprocess.run"):
+ # Mount same device to different mount point
+ result = add_fstab_entry("/dev/sda1", "/media/64GB-2", "exfat")
+ assert result["success"] is True
+ written_str = "".join(written_content)
+ # Should only have ONE entry for UUID=5678 (the new one)
+ assert written_str.count("UUID=5678") == 1
+ # Should have the new mount point
+ assert "/media/64GB-2" in written_str
+ # Should NOT have the old mount point
+ assert "/media/64GB " not in written_str
+ # Should have exactly ONE WROLPi comment
+ assert written_str.count("WROLPi managed mount") == 1
+
class TestRemoveFstabEntry:
"""Tests for remove_fstab_entry function."""
diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh
index 295df61e..7e708b5d 100755
--- a/scripts/upgrade.sh
+++ b/scripts/upgrade.sh
@@ -39,25 +39,9 @@ upgrade_controller() {
/opt/wrolpi/controller/venv/bin/pip install --upgrade pip
/opt/wrolpi/controller/venv/bin/pip install --upgrade -r /opt/wrolpi/controller/requirements.txt
- # Reload systemd in case service file changed
+ # Start the Controller.
systemctl daemon-reload
-
- # Start Controller
systemctl start wrolpi-controller
-
- # Wait for health check (up to 30 seconds)
- echo "Waiting for Controller health check..."
- for i in {1..30}; do
- if curl -s http://localhost:8087/api/health > /dev/null 2>&1; then
- echo "Controller upgraded successfully"
- return 0
- fi
- sleep 1
- done
-
- echo "WARNING: Controller health check failed after upgrade"
- echo "Check logs: journalctl -u wrolpi-controller -n 50"
- return 1
}
upgrade_controller || echo "Controller upgrade failed, continuing..."