diff --git a/app/src/api/controller.js b/app/src/api/controller.js index a881d1cc..a93399f0 100644 --- a/app/src/api/controller.js +++ b/app/src/api/controller.js @@ -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() { diff --git a/app/src/components/admin/ControllerPage.js b/app/src/components/admin/ControllerPage.js index e2dda050..9a8a7a34 100644 --- a/app/src/components/admin/ControllerPage.js +++ b/app/src/components/admin/ControllerPage.js @@ -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 ( + + + + Mount Disk: {disk.name} + + +
+ + + setMountPoint(e.target.value)} + placeholder="/media/..." + fluid + /> + + + setPersist(checked)} + /> + + {disk.fstype && ( +

+ Filesystem: {disk.fstype} + {disk.label && ` | Label: ${disk.label}`} + {disk.size && ` | Size: ${formatSize(disk.size)}`} +

+ )} +
+
+ + + + +
+ ); +} + + 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 &&

Some disk information unavailable: {error}

} -
Current Mounts
- {mounts.length > 0 ? ( +
Disks
+ {disks.length > 0 ? ( - Device + Name + Size + Type + Label Mount Point - Filesystem + Persist + Actions - {mounts.map((mount, idx) => ( - - {mount.device} - {mount.mount_point} - {mount.fstype} - - ))} + {disks.map((disk) => { + const isMounted = disk.mountpoint && disk.mountpoint !== ''; + const isProtected = PROTECTED_MOUNTS.includes(disk.mountpoint); + const persistent = isMounted && isPersistent(disk.mountpoint); + + return ( + + {disk.name} + {formatSize(disk.size)} + {disk.fstype || '-'} + {disk.label || '-'} + + {isMounted ? ( + {disk.mountpoint} + ) : ( + - + )} + + + {isMounted && !isProtected ? ( + handleTogglePersist(disk, checked)} + label={persistent ? 'Enabled' : 'Disabled'} + /> + ) : ( + - + )} + + + {isMounted && isProtected ? ( + System + ) : isMounted ? ( +
) : ( -

No mounts found.

+

No disks detected.

)}
SMART Health
@@ -636,6 +883,25 @@ function DiskSection() { onClose={() => setDetailsOpen(false)} /> + + + { + setUnmountConfirmOpen(false); + setUnmountTarget(null); + }} + onConfirm={handleUnmountConfirm} + confirmButton='Unmount' + /> +