mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Adding disk mount handling to React UI.
This commit is contained in:
parent
1a02a4eb5b
commit
628b9f3096
10 changed files with 600 additions and 51 deletions
|
|
@ -151,10 +151,10 @@ export async function getMounts() {
|
|||
return controllerFetch('/disks/mounts');
|
||||
}
|
||||
|
||||
export async function mountDisk(device, mountPoint) {
|
||||
export async function mountDisk(device, mountPoint, fstype = null, options = 'defaults', persist = false) {
|
||||
return controllerFetch('/disks/mount', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({device, mount_point: mountPoint}),
|
||||
body: JSON.stringify({device, mount_point: mountPoint, fstype, options, persist}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +173,24 @@ export async function getSmartStatus() {
|
|||
return controllerFetch('/disks/smart');
|
||||
}
|
||||
|
||||
// --- Fstab Endpoints ---
|
||||
|
||||
export async function getFstabEntries() {
|
||||
return controllerFetch('/disks/fstab');
|
||||
}
|
||||
|
||||
export async function addFstabEntry(device, mountPoint, fstype, options = 'defaults,nofail,x-systemd.device-timeout=10s') {
|
||||
return controllerFetch('/disks/fstab', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({device, mount_point: mountPoint, fstype, options}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFstabEntry(mountPoint) {
|
||||
const encodedPath = encodeURIComponent(mountPoint.slice(1));
|
||||
return controllerFetch(`/disks/fstab/${encodedPath}`, {method: 'DELETE'});
|
||||
}
|
||||
|
||||
// --- Health Check ---
|
||||
|
||||
export async function healthCheck() {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import {Container, Dropdown, Icon} from "semantic-ui-react";
|
||||
import {Container, Dropdown, Icon, Confirm, Input, Checkbox, Form} from "semantic-ui-react";
|
||||
import {Button, Header, Loader, Modal, Segment, Table} from "../Theme";
|
||||
import {
|
||||
APIButton,
|
||||
|
|
@ -17,9 +17,13 @@ import {
|
|||
disableService,
|
||||
getServiceLogs,
|
||||
getDisks,
|
||||
getMounts,
|
||||
getSmartStatus,
|
||||
restartServices,
|
||||
getFstabEntries,
|
||||
addFstabEntry,
|
||||
removeFstabEntry,
|
||||
mountDisk,
|
||||
unmountDisk,
|
||||
} from "../../api/controller";
|
||||
import {toast} from "react-semantic-toasts-2";
|
||||
import {RestartButton, ShutdownButton} from "./Settings";
|
||||
|
|
@ -481,32 +485,150 @@ function SmartDetailsModal({drive, open, onClose}) {
|
|||
}
|
||||
|
||||
|
||||
// Protected mount points that should not be unmounted
|
||||
const PROTECTED_MOUNTS = ['/', '/boot', '/boot/firmware'];
|
||||
|
||||
// Helper to format disk size
|
||||
const formatSize = (size) => {
|
||||
if (!size) return '-';
|
||||
// If already a string (e.g., "59.5G" from lsblk), return as-is
|
||||
if (typeof size === 'string') return size;
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
let bytes = size;
|
||||
while (bytes >= 1024 && i < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${bytes.toFixed(1)} ${units[i]}`;
|
||||
};
|
||||
|
||||
|
||||
function MountModal({disk, open, onClose, onMount}) {
|
||||
const [mountPoint, setMountPoint] = React.useState('');
|
||||
const [persist, setPersist] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
// Set default mount point when disk changes
|
||||
React.useEffect(() => {
|
||||
if (disk) {
|
||||
const defaultMount = disk.label ? `/media/${disk.label}` : `/media/${disk.name}`;
|
||||
setMountPoint(defaultMount);
|
||||
setPersist(false);
|
||||
}
|
||||
}, [disk]);
|
||||
|
||||
const handleMount = async () => {
|
||||
if (!mountPoint.trim()) {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Mount point required',
|
||||
description: 'Please enter a mount point.',
|
||||
time: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await mountDisk(disk.path, mountPoint.trim(), disk.fstype, 'defaults', persist);
|
||||
toast({
|
||||
type: 'success',
|
||||
title: 'Disk Mounted',
|
||||
description: `${disk.name} mounted at ${mountPoint}`,
|
||||
time: 3000,
|
||||
});
|
||||
onClose();
|
||||
if (onMount) onMount();
|
||||
} catch (e) {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Mount Failed',
|
||||
description: e.message,
|
||||
time: 5000,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!disk) return null;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} size='small'>
|
||||
<Modal.Header>
|
||||
<Icon name='disk'/>
|
||||
Mount Disk: {disk.name}
|
||||
</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Form>
|
||||
<Form.Field>
|
||||
<label>Mount Point</label>
|
||||
<Input
|
||||
value={mountPoint}
|
||||
onChange={(e) => setMountPoint(e.target.value)}
|
||||
placeholder="/media/..."
|
||||
fluid
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Checkbox
|
||||
label="Persistent (survive reboots)"
|
||||
checked={persist}
|
||||
onChange={(e, {checked}) => setPersist(checked)}
|
||||
/>
|
||||
</Form.Field>
|
||||
{disk.fstype && (
|
||||
<p style={{color: '#888', fontSize: '0.9em'}}>
|
||||
Filesystem: {disk.fstype}
|
||||
{disk.label && ` | Label: ${disk.label}`}
|
||||
{disk.size && ` | Size: ${formatSize(disk.size)}`}
|
||||
</p>
|
||||
)}
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={onClose} disabled={loading}>Cancel</Button>
|
||||
<Button color='green' onClick={handleMount} loading={loading} disabled={loading}>
|
||||
<Icon name='check'/>
|
||||
Mount
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function DiskSection() {
|
||||
const [disks, setDisks] = React.useState([]);
|
||||
const [mounts, setMounts] = React.useState([]);
|
||||
const [fstabEntries, setFstabEntries] = React.useState([]);
|
||||
const [smartDrives, setSmartDrives] = React.useState([]);
|
||||
const [smartAvailable, setSmartAvailable] = React.useState(true);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState(null);
|
||||
const [selectedDrive, setSelectedDrive] = React.useState(null);
|
||||
const [detailsOpen, setDetailsOpen] = React.useState(false);
|
||||
const [mountModalOpen, setMountModalOpen] = React.useState(false);
|
||||
const [selectedDisk, setSelectedDisk] = React.useState(null);
|
||||
const [unmountConfirmOpen, setUnmountConfirmOpen] = React.useState(false);
|
||||
const [unmountTarget, setUnmountTarget] = React.useState(null);
|
||||
const dockerized = useDockerized();
|
||||
|
||||
const fetchDiskInfo = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [disksResult, mountsResult, smartResult] = await Promise.allSettled([
|
||||
const [disksResult, fstabResult, smartResult] = await Promise.allSettled([
|
||||
getDisks(),
|
||||
getMounts(),
|
||||
getFstabEntries(),
|
||||
getSmartStatus(),
|
||||
]);
|
||||
|
||||
if (disksResult.status === 'fulfilled') {
|
||||
setDisks(Array.isArray(disksResult.value) ? disksResult.value : []);
|
||||
}
|
||||
if (mountsResult.status === 'fulfilled') {
|
||||
setMounts(Array.isArray(mountsResult.value) ? mountsResult.value : []);
|
||||
if (fstabResult.status === 'fulfilled') {
|
||||
setFstabEntries(Array.isArray(fstabResult.value) ? fstabResult.value : []);
|
||||
}
|
||||
if (smartResult.status === 'fulfilled' && smartResult.value) {
|
||||
// Handle new API format: {available: bool, drives: [...]}
|
||||
|
|
@ -529,6 +651,81 @@ function DiskSection() {
|
|||
setDetailsOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenMountModal = (disk) => {
|
||||
setSelectedDisk(disk);
|
||||
setMountModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseMountModal = () => {
|
||||
setMountModalOpen(false);
|
||||
setSelectedDisk(null);
|
||||
};
|
||||
|
||||
const handleUnmountClick = (mountPoint) => {
|
||||
setUnmountTarget(mountPoint);
|
||||
setUnmountConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleUnmountConfirm = async () => {
|
||||
if (!unmountTarget) return;
|
||||
|
||||
try {
|
||||
await unmountDisk(unmountTarget);
|
||||
toast({
|
||||
type: 'success',
|
||||
title: 'Disk Unmounted',
|
||||
description: `Unmounted ${unmountTarget}`,
|
||||
time: 3000,
|
||||
});
|
||||
fetchDiskInfo();
|
||||
} catch (e) {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Unmount Failed',
|
||||
description: e.message,
|
||||
time: 5000,
|
||||
});
|
||||
} finally {
|
||||
setUnmountConfirmOpen(false);
|
||||
setUnmountTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePersist = async (disk, enable) => {
|
||||
try {
|
||||
if (enable) {
|
||||
await addFstabEntry(disk.path, disk.mountpoint, disk.fstype || 'auto');
|
||||
toast({
|
||||
type: 'success',
|
||||
title: 'Persistence Enabled',
|
||||
description: `${disk.mountpoint} will persist across reboots`,
|
||||
time: 3000,
|
||||
});
|
||||
} else {
|
||||
await removeFstabEntry(disk.mountpoint);
|
||||
toast({
|
||||
type: 'success',
|
||||
title: 'Persistence Disabled',
|
||||
description: `${disk.mountpoint} will not persist across reboots`,
|
||||
time: 3000,
|
||||
});
|
||||
}
|
||||
fetchDiskInfo();
|
||||
} catch (e) {
|
||||
toast({
|
||||
type: 'error',
|
||||
title: 'Failed to update persistence',
|
||||
description: e.message,
|
||||
time: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Check if a mount point is in fstab (persistent)
|
||||
const isPersistent = (mountpoint) => {
|
||||
return fstabEntries.some(entry => entry.mount_point === mountpoint);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchDiskInfo();
|
||||
}, []);
|
||||
|
|
@ -561,28 +758,78 @@ function DiskSection() {
|
|||
|
||||
{error && <p style={{color: 'orange'}}>Some disk information unavailable: {error}</p>}
|
||||
|
||||
<Header as='h4'>Current Mounts</Header>
|
||||
{mounts.length > 0 ? (
|
||||
<Header as='h4'>Disks</Header>
|
||||
{disks.length > 0 ? (
|
||||
<Table unstackable striped compact>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Device</Table.HeaderCell>
|
||||
<Table.HeaderCell>Name</Table.HeaderCell>
|
||||
<Table.HeaderCell>Size</Table.HeaderCell>
|
||||
<Table.HeaderCell>Type</Table.HeaderCell>
|
||||
<Table.HeaderCell>Label</Table.HeaderCell>
|
||||
<Table.HeaderCell>Mount Point</Table.HeaderCell>
|
||||
<Table.HeaderCell>Filesystem</Table.HeaderCell>
|
||||
<Table.HeaderCell>Persist</Table.HeaderCell>
|
||||
<Table.HeaderCell>Actions</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{mounts.map((mount, idx) => (
|
||||
<Table.Row key={idx}>
|
||||
<Table.Cell>{mount.device}</Table.Cell>
|
||||
<Table.Cell>{mount.mount_point}</Table.Cell>
|
||||
<Table.Cell>{mount.fstype}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
{disks.map((disk) => {
|
||||
const isMounted = disk.mountpoint && disk.mountpoint !== '';
|
||||
const isProtected = PROTECTED_MOUNTS.includes(disk.mountpoint);
|
||||
const persistent = isMounted && isPersistent(disk.mountpoint);
|
||||
|
||||
return (
|
||||
<Table.Row key={disk.path || disk.name}>
|
||||
<Table.Cell>{disk.name}</Table.Cell>
|
||||
<Table.Cell>{formatSize(disk.size)}</Table.Cell>
|
||||
<Table.Cell>{disk.fstype || '-'}</Table.Cell>
|
||||
<Table.Cell>{disk.label || '-'}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{isMounted ? (
|
||||
<span>{disk.mountpoint}</span>
|
||||
) : (
|
||||
<span style={{color: '#888'}}>-</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{isMounted && !isProtected ? (
|
||||
<Toggle
|
||||
checked={persistent}
|
||||
onChange={(checked) => handleTogglePersist(disk, checked)}
|
||||
label={persistent ? 'Enabled' : 'Disabled'}
|
||||
/>
|
||||
) : (
|
||||
<span style={{color: '#888'}}>-</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{isMounted && isProtected ? (
|
||||
<span style={{color: '#888'}}>System</span>
|
||||
) : isMounted ? (
|
||||
<Button
|
||||
size='small'
|
||||
color='red'
|
||||
icon='eject'
|
||||
content='Unmount'
|
||||
onClick={() => handleUnmountClick(disk.mountpoint)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
size='small'
|
||||
color='green'
|
||||
icon='plug'
|
||||
content='Mount'
|
||||
onClick={() => handleOpenMountModal(disk)}
|
||||
/>
|
||||
)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
) : (
|
||||
<p>No mounts found.</p>
|
||||
<p>No disks detected.</p>
|
||||
)}
|
||||
|
||||
<Header as='h4'>SMART Health</Header>
|
||||
|
|
@ -636,6 +883,25 @@ function DiskSection() {
|
|||
onClose={() => setDetailsOpen(false)}
|
||||
/>
|
||||
|
||||
<MountModal
|
||||
disk={selectedDisk}
|
||||
open={mountModalOpen}
|
||||
onClose={handleCloseMountModal}
|
||||
onMount={fetchDiskInfo}
|
||||
/>
|
||||
|
||||
<Confirm
|
||||
open={unmountConfirmOpen}
|
||||
header='Unmount Disk'
|
||||
content={`Are you sure you want to unmount ${unmountTarget}?`}
|
||||
onCancel={() => {
|
||||
setUnmountConfirmOpen(false);
|
||||
setUnmountTarget(null);
|
||||
}}
|
||||
onConfirm={handleUnmountConfirm}
|
||||
confirmButton='Unmount'
|
||||
/>
|
||||
|
||||
<Button onClick={fetchDiskInfo} style={{marginTop: '1em'}}>
|
||||
<Icon name='refresh'/>
|
||||
Refresh
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<table>
|
||||
|
|
@ -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 += `
|
||||
<label class="toggle">
|
||||
<input type="checkbox"
|
||||
${isPersistent ? 'checked' : ''}
|
||||
onchange="togglePersist('${disk.path}', '${disk.mountpoint}', '${disk.fstype}', this.checked)">
|
||||
onchange="togglePersist('${disk.path}', '${mountPoint}', '${disk.fstype}', this.checked)">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<span class="toggle-label">${isPersistent ? 'Enabled' : 'Disabled'}</span>
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue