Kaynağa Gözat

Add device picker and UUID-based mounting (experimental)

Add a Linux-only device listing endpoint and UI device picker, plus UUID-aware mounting.

- Expose GET /system/disk/diskmg/devices (diskmg.HandleListDevicesWithInfo) which runs lsblk --json and returns disks/partitions with name, size, model, fstype, uuid and mountpoint.
- Add ResolveDeviceByUUID (uses blkid -U) in filesystem/static.go to resolve partition UUID -> /dev/* path (Linux only) and wire it into NewFileSystemHandler so stored DiskUUID is tried before the raw device path when automounting.
- Add DiskUUID field to FileSystemOption to persist partition UUIDs.
- Register new admin route in src/disk.go for the devices endpoint.
- Update storage editor UI (fshedit.html): add Browse button, Partition UUID input, a modal device picker, and JS to fetch/select partitions (fills mountdev and diskuuid, maps common filesystems).

Also include minor logging and safe string conversions when parsing lsblk output. This improves UX for selecting storage (survives device renames) and prefers UUID-based mounts with device-path fallback.
Toby Chui 3 hafta önce
ebeveyn
işleme
9c200da07c

+ 1 - 0
src/disk.go

@@ -189,6 +189,7 @@ func DiskServiceInit() {
 		if *allow_hardware_management {
 			authRouter.HandleFunc("/system/disk/diskmg/view", diskmg.HandleView)
 			adminRouter.HandleFunc("/system/disk/diskmg/platform", diskmg.HandlePlatform)
+			adminRouter.HandleFunc("/system/disk/diskmg/devices", diskmg.HandleListDevicesWithInfo)
 			adminRouter.HandleFunc("/system/disk/diskmg/mount", func(w http.ResponseWriter, r *http.Request) {
 				//Mount option require passing in all filesystem handlers
 				allFsh := GetAllLoadedFsh()

+ 121 - 0
src/mod/disk/diskmg/diskmg.go

@@ -3,6 +3,7 @@ package diskmg
 import (
 	"encoding/json"
 	"errors"
+	"fmt"
 	"log"
 	"net/http"
 	"os/exec"
@@ -460,3 +461,123 @@ func HandlePlatform(w http.ResponseWriter, r *http.Request) {
 	js, _ := json.Marshal(runtime.GOOS)
 	utils.SendJSONResponse(w, string(js))
 }
+
+/*
+HandleListDevicesWithInfo returns all block devices with their partitions,
+partition UUID (from blkid / lsblk -f), filesystem type, size and mount point.
+Used by the storage pool editor UI so the user can pick a partition by name
+instead of having to know the raw /dev path.
+
+GET /system/disk/diskmg/devices
+*/
+
+// lsblkFull is used to parse a single lsblk -b --json -o NAME,SIZE,TYPE,FSTYPE,UUID,LABEL,MOUNTPOINT,MODEL call.
+type lsblkFull struct {
+	Blockdevices []lsblkFullDev `json:"blockdevices"`
+}
+
+type lsblkFullDev struct {
+	Name       string         `json:"name"`
+	Size       int64          `json:"size"`
+	Type       string         `json:"type"`
+	Fstype     interface{}    `json:"fstype"`
+	UUID       interface{}    `json:"uuid"`
+	Label      interface{}    `json:"label"`
+	Mountpoint interface{}    `json:"mountpoint"`
+	Model      interface{}    `json:"model"`
+	Children   []lsblkFullDev `json:"children"`
+}
+
+// PartitionDeviceInfo is the per-partition record returned to the frontend.
+type PartitionDeviceInfo struct {
+	Name       string `json:"name"`
+	DevPath    string `json:"devpath"`
+	Fstype     string `json:"fstype"`
+	UUID       string `json:"uuid"`
+	Label      string `json:"label"`
+	Size       int64  `json:"size"`
+	Mountpoint string `json:"mountpoint"`
+}
+
+// BlockDeviceInfo is the per-disk record returned to the frontend.
+type BlockDeviceInfo struct {
+	Name       string                `json:"name"`
+	Model      string                `json:"model"`
+	Size       int64                 `json:"size"`
+	Partitions []PartitionDeviceInfo `json:"partitions"`
+}
+
+func HandleListDevicesWithInfo(w http.ResponseWriter, r *http.Request) {
+	if runtime.GOOS != "linux" {
+		utils.SendErrorResponse(w, "This function is Linux only")
+		return
+	}
+
+	cmd := exec.Command("lsblk", "-b", "--json", "-o", "NAME,SIZE,TYPE,FSTYPE,UUID,LABEL,MOUNTPOINT,MODEL")
+	o, err := cmd.CombinedOutput()
+	if err != nil {
+		utils.SendErrorResponse(w, "lsblk error: "+err.Error())
+		return
+	}
+
+	var raw lsblkFull
+	if err := json.Unmarshal(o, &raw); err != nil {
+		utils.SendErrorResponse(w, "parse error: "+err.Error())
+		return
+	}
+
+	// helper: safely convert interface{} to string
+	ifaceStr := func(v interface{}) string {
+		if v == nil {
+			return ""
+		}
+		return strings.TrimSpace(fmt.Sprintf("%v", v))
+	}
+
+	result := []BlockDeviceInfo{}
+	for _, dev := range raw.Blockdevices {
+		// Only show disk/loop/md types; skip rom, etc.
+		if dev.Type != "disk" && dev.Type != "md" && dev.Type != "loop" {
+			continue
+		}
+
+		diskInfo := BlockDeviceInfo{
+			Name:       dev.Name,
+			Model:      ifaceStr(dev.Model),
+			Size:       dev.Size,
+			Partitions: []PartitionDeviceInfo{},
+		}
+
+		for _, part := range dev.Children {
+			partInfo := PartitionDeviceInfo{
+				Name:       part.Name,
+				DevPath:    "/dev/" + part.Name,
+				Fstype:     ifaceStr(part.Fstype),
+				UUID:       ifaceStr(part.UUID),
+				Label:      ifaceStr(part.Label),
+				Size:       part.Size,
+				Mountpoint: ifaceStr(part.Mountpoint),
+			}
+			diskInfo.Partitions = append(diskInfo.Partitions, partInfo)
+		}
+
+		// If a disk has no children (e.g. unpartitioned), expose the disk itself as
+		// a single entry so it can still be selected.
+		if len(diskInfo.Partitions) == 0 {
+			diskInfo.Partitions = append(diskInfo.Partitions, PartitionDeviceInfo{
+				Name:       dev.Name,
+				DevPath:    "/dev/" + dev.Name,
+				Fstype:     ifaceStr(dev.Fstype),
+				UUID:       ifaceStr(dev.UUID),
+				Label:      ifaceStr(dev.Label),
+				Size:       dev.Size,
+				Mountpoint: ifaceStr(dev.Mountpoint),
+			})
+		}
+
+		result = append(result, diskInfo)
+	}
+
+	js, _ := json.Marshal(result)
+	utils.SendJSONResponse(w, string(js))
+}

+ 1 - 0
src/mod/filesystem/config.go

@@ -18,6 +18,7 @@ type FileSystemOption struct {
 	Filesystem string `json:"filesystem,omitempty"` //Support {"ext4","ext2", "ext3", "fat", "vfat", "ntfs"}
 	Mountdev   string `json:"mountdev,omitempty"`   //Device file (e.g. /dev/sda1)
 	Mountpt    string `json:"mountpt,omitempty"`    //Device mount point (e.g. /media/storage1)
+	DiskUUID   string `json:"diskuuid,omitempty"`   //Partition UUID for UUID-based mounting (preferred over device path, survives device renames across reboots)
 
 	Username string `json:"username,omitempty"` //Username if the storage require auth
 	Password string `json:"password,omitempty"` //Password if the storage require auth

+ 13 - 1
src/mod/filesystem/filesystem.go

@@ -142,7 +142,19 @@ func NewFileSystemHandler(option FileSystemOption, RuntimePersistenceConfig Runt
 	if inSlice([]string{"ext4", "ext2", "ext3", "fat", "vfat", "ntfs"}, fstype) || fstype == "" {
 		//Check if the target fs require mounting
 		if option.Automount == true {
-			err := MountDevice(option.Mountpt, option.Mountdev, option.Filesystem)
+			// If a partition UUID is stored, try to resolve the actual device path from
+			// the UUID first (survives USB device renames across reboots). Fall back to
+			// the stored device path if UUID lookup fails.
+			actualMountDev := option.Mountdev
+			if option.DiskUUID != "" {
+				if resolvedPath, err := ResolveDeviceByUUID(option.DiskUUID); err == nil {
+					log.Println("[Storage] Resolved device by UUID " + option.DiskUUID + " -> " + resolvedPath)
+					actualMountDev = resolvedPath
+				} else {
+					log.Println("[Storage] UUID lookup failed for " + option.DiskUUID + ", falling back to device path " + option.Mountdev)
+				}
+			}
+			err := MountDevice(option.Mountpt, actualMountDev, option.Filesystem)
 			if err != nil {
 				return &FileSystemHandler{}, err
 			}

+ 23 - 0
src/mod/filesystem/static.go

@@ -1,6 +1,7 @@
 package filesystem
 
 import (
+	"bytes"
 	"crypto/md5"
 	"crypto/sha256"
 	"encoding/hex"
@@ -215,6 +216,28 @@ func MountDevice(mountpt string, mountdev string, filesystem string) error {
 	return nil
 }
 
+// ResolveDeviceByUUID tries to find a block device path (e.g. /dev/sdb1) by its
+// partition UUID using blkid. Linux only. Returns an error if the UUID is not found.
+func ResolveDeviceByUUID(partUUID string) (string, error) {
+	if runtime.GOOS != "linux" {
+		return "", errors.New("UUID-based device lookup is only supported on Linux")
+	}
+	if partUUID == "" {
+		return "", errors.New("empty UUID given")
+	}
+	cmd := exec.Command("blkid", "-U", partUUID)
+	var out bytes.Buffer
+	cmd.Stdout = &out
+	if err := cmd.Run(); err != nil {
+		return "", fmt.Errorf("device with UUID %s not found: %v", partUUID, err)
+	}
+	devPath := strings.TrimSpace(out.String())
+	if devPath == "" {
+		return "", fmt.Errorf("UUID %s did not resolve to any device", partUUID)
+	}
+	return devPath, nil
+}
+
 func GetFileSize(filename string) int64 {
 	fi, err := os.Stat(filename)
 	if err != nil {

+ 104 - 3
src/web/SystemAO/storage/fshedit.html

@@ -152,7 +152,14 @@
             <div class="localfs">
                 <div class="field">
                     <label>Mount Device</label>
-                    <input type="text" name="mountdev" placeholder="e.g. /dev/sda1">
+                    <div class="ui action input">
+                        <input type="text" name="mountdev" placeholder="e.g. /dev/sda1">
+                        <button class="ui button" type="button" onclick="showDevicePicker();"><i class="search icon"></i> Browse</button>
+                    </div>
+                </div>
+                <div class="field">
+                    <label>Partition UUID <small style="font-weight:normal;color:#888;">(recommended for USB drives &mdash; survives device renames across reboots)</small></label>
+                    <input type="text" name="diskuuid" id="diskuuidInput" placeholder="Auto-filled when using Browse, or paste manually">
                 </div>
                 <div class="field">
                     <label>Mount Point</label>
@@ -202,6 +209,19 @@
             <br><br><br><br>
         </form>
     </div>
+    <!-- Device Picker Modal -->
+    <div class="ui modal" id="devicePickerModal">
+        <div class="header"><i class="hdd icon"></i> Select a Partition</div>
+        <div class="scrolling content" style="max-height:60vh;">
+            <div id="devicePickerContent">
+                <div class="ui active centered inline loader"></div>
+            </div>
+        </div>
+        <div class="actions">
+            <div class="ui deny button"><i class="times icon"></i> Cancel</div>
+        </div>
+    </div>
+
     <script>
         //Get target fsh uuid and group from hash
         var targetFSH = "";
@@ -384,10 +404,91 @@
             handleFileSystemTypeChange(option.filesystem);
             $("input[name=mountdev]").val(option.mountdev);
             $("input[name=mountpt]").val(option.mountpt);
+            if (option.diskuuid){
+                $('#diskuuidInput').val(option.diskuuid);
+            }
             if (option.automount == true){
-                //$("input[name=automount]")[0].checked = true;
-                $("#automount").parent().checkbox("set checked");
+                //$('input[name=automount]')[0].checked = true;
+                $('#automount').parent().checkbox('set checked');
+            }
+        }
+
+        /* ── Device Picker ──────────────────────────────────────────────── */
+
+        function _bytesToSize(bytes) {
+            if (!bytes || bytes === 0) return '0 B';
+            var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+            var i = Math.floor(Math.log(bytes) / Math.log(1024));
+            return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
+        }
+
+        function showDevicePicker() {
+            $('#devicePickerContent').html('<div class="ui active centered inline loader" style="margin:2em auto;"></div>');
+            $('#devicePickerModal').modal('show');
+
+            $.get('../../system/disk/diskmg/devices', function(data) {
+                if (!data || data.error) {
+                    $('#devicePickerContent').html('<div class="ui error message">' + (data && data.error ? data.error : 'Failed to load devices') + '</div>');
+                    return;
+                }
+                if (data.length === 0) {
+                    $('#devicePickerContent').html('<div class="ui warning message">No block devices found.</div>');
+                    return;
+                }
+
+                var html = '<div class="ui middle aligned divided list">';
+                data.forEach(function(disk) {
+                    html += '<div class="item" style="padding:10px 0;">';
+                    html += '<div style="font-weight:bold; margin-bottom:6px;"><i class="hdd icon"></i>' + disk.name;
+                    if (disk.model) html += ' <span style="color:#888; font-weight:normal;">(' + disk.model + ')</span>';
+                    html += ' &mdash; ' + _bytesToSize(disk.size) + '</div>';
+
+                    if (disk.partitions && disk.partitions.length > 0) {
+                        disk.partitions.forEach(function(part) {
+                            var escapedDev  = part.devpath.replace(/'/g, "\\'");
+                            var escapedUUID = (part.uuid  || '').replace(/'/g, "\\'");
+                            var escapedFs   = (part.fstype || '').replace(/'/g, "\\'");
+                            html += '<div class="ui segment" style="cursor:pointer; margin:3px 0; padding:8px 12px;" '
+                                  + 'onclick="selectPartition(\'' + escapedDev + '\',\'' + escapedUUID + '\',\'' + escapedFs + '\')">';
+                            html += '<div><i class="file outline icon"></i><b>' + part.name + '</b> (' + _bytesToSize(part.size) + ')';
+                            if (part.label) html += ' <span style="color:#666;">\u201c' + part.label + '\u201d</span>';
+                            html += '</div>';
+                            html += '<div style="margin-top:4px; font-size:90%; color:#666;">';
+                            if (part.fstype)     html += '<span><i class="database icon"></i>' + part.fstype + '</span>&nbsp;&nbsp;';
+                            if (part.uuid)       html += '<span style="font-family:monospace;"><i class="key icon"></i>' + part.uuid + '</span>';
+                            if (part.mountpoint) html += '&nbsp;&nbsp;<span><i class="map marker alternate icon"></i>' + part.mountpoint + '</span>';
+                            if (!part.fstype && !part.uuid) html += '<span style="color:#aaa;">Unformatted / no filesystem detected</span>';
+                            html += '</div></div>';
+                        });
+                    }
+                    html += '</div>';
+                });
+                html += '</div>';
+                $('#devicePickerContent').html(html);
+            }).fail(function() {
+                $('#devicePickerContent').html('<div class="ui error message">Could not reach the server. Is hardware management enabled?</div>');
+            });
+        }
+
+        function selectPartition(devpath, uuid, fstype) {
+            $('input[name=mountdev]').val(devpath);
+            if (uuid) {
+                $('#diskuuidInput').val(uuid);
+            }
+            if (fstype && fstype !== '') {
+                var fstypeMap = {
+                    'ntfs': 'ntfs', 'ntfs-3g': 'ntfs',
+                    'ext4': 'ext4', 'ext3': 'ext3', 'ext2': 'ext4',
+                    'vfat': 'vfat', 'fat': 'vfat', 'fat32': 'vfat',
+                    'btrfs': 'btrfs'
+                };
+                var mapped = fstypeMap[fstype.toLowerCase()];
+                if (mapped) {
+                    $('#fstype').dropdown('set selected', mapped);
+                    handleFileSystemTypeChange(mapped);
+                }
             }
+            $('#devicePickerModal').modal('hide');
         }
 
         function handleCancel(){