feat: format empty partitions on system disk (v0.32.6)
Detect and offer to format empty (no filesystem) partitions on the system disk. Adds IsSystemPartition() for granular per-partition safety checks instead of blocking the entire system disk. Init wizard shows formatable partitions with appropriate warnings. Add felhotest demo node to docs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,17 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.32.6 — Format empty partitions on system disk (2026-02-27)
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **storage/scan.go**: New `FormatablePartition` struct and `FormatablePartitions` field on `ScanResult` — detects empty (no filesystem), unmounted, non-system partitions on system disks
|
||||||
|
- **storage/scan_linux.go**: New `getSystemPartitionPaths()` resolves actual system partition device paths from fstab (more granular than `getSystemDiskNames()` which returns parent disk names). `ScanDisks()` now populates `FormatablePartitions` after enrichment
|
||||||
|
- **storage/safety_linux.go**: New `IsSystemPartition()` — checks if a specific partition is a system partition (/, /boot, /boot/efi, swap) or is currently mounted; more granular than `IsSystemDisk()` which blocks the entire disk
|
||||||
|
- **web/storage_handlers.go**: Scan API response now includes `formatable_partitions` array
|
||||||
|
- **web/templates/storage_init.html**: Init wizard shows formatable system-disk partitions as a separate selectable section with info banner, conditional warning text, and hidden partitioning progress step
|
||||||
|
|
||||||
|
#### Changed
|
||||||
|
- **storage/format_linux.go**: `FormatAndMount()` now uses `IsSystemPartition()` for partition-only operations (`CreatePartition=false`) instead of `IsSystemDisk()` — allows formatting empty data partitions on the system disk while still blocking system partitions
|
||||||
|
|
||||||
### v0.32.5 — USB badge fix + graceful Tier2 backup on disconnected/inactive/removed destinations (2026-02-27)
|
### v0.32.5 — USB badge fix + graceful Tier2 backup on disconnected/inactive/removed destinations (2026-02-27)
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Claude in Chrome extension is available — can be used to test web UI on demo-f
|
|||||||
| **Local (this machine)** | Windows 11 | — | Development, Claude Code runs here. Repos in `E:\git\` |
|
| **Local (this machine)** | Windows 11 | — | Development, Claude Code runs here. Repos in `E:\git\` |
|
||||||
| **Build server (k3s, infra)** | Debian 13 | 192.168.0.180 | Build + push container images, k3s cluster |
|
| **Build server (k3s, infra)** | Debian 13 | 192.168.0.180 | Build + push container images, k3s cluster |
|
||||||
| **Demo node** | Debian 13 | 192.168.0.162 | Test deployment (demo-felhom.eu) |
|
| **Demo node** | Debian 13 | 192.168.0.162 | Test deployment (demo-felhom.eu) |
|
||||||
|
| **Demo node 2** | Debian 13 | router.abonet.hu:33022 | Remote test deployment |
|
||||||
|
|
||||||
## Workspace layout
|
## Workspace layout
|
||||||
|
|
||||||
@@ -101,12 +102,14 @@ substitute the full path manually.
|
|||||||
|------|----|----|------|------|
|
|------|----|----|------|------|
|
||||||
| Build server | Debian 13 | 192.168.0.180 | kisfenyo | Build + push container images |
|
| Build server | Debian 13 | 192.168.0.180 | kisfenyo | Build + push container images |
|
||||||
| Demo node | Debian 13 | 192.168.0.162 | kisfenyo | Test deployment (demo-felhom.eu) |
|
| Demo node | Debian 13 | 192.168.0.162 | kisfenyo | Test deployment (demo-felhom.eu) |
|
||||||
|
| Demo node 2 | Debian 13 | router.abonet.hu (SSH port 33022) | kisfenyo | Remote test deployment |
|
||||||
|
|
||||||
## Test environments
|
## Test environments
|
||||||
|
|
||||||
| Node | OS | Hardware | Domain | IP | Notes |
|
| Node | OS | Hardware | Domain | IP | Notes |
|
||||||
|------|-----|----------|--------|----|-------|
|
|------|-----|----------|--------|----|-------|
|
||||||
| demo-felhom | Debian 13 | Acemagic N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | 192.168.0.162 | Primary test node, Cloudflare Tunnel |
|
| demo-felhom | Debian 13 | Acemagic N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | 192.168.0.162 | Primary test node, Cloudflare Tunnel |
|
||||||
|
| felhotest | Debian 13 | Remote VPS, 200G + 100G disk | — | router.abonet.hu:33022 | Remote test node |
|
||||||
| pi-customer-1 | Debian 13 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | 192.168.0.161 | Secondary test, not yet active |
|
| pi-customer-1 | Debian 13 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | 192.168.0.161 | Secondary test, not yet active |
|
||||||
|
|
||||||
- Pi-hole DNS on local network forwards `*.demo-felhom.eu` → 192.168.0.162
|
- Pi-hole DNS on local network forwards `*.demo-felhom.eu` → 192.168.0.162
|
||||||
@@ -152,21 +155,27 @@ The build script:
|
|||||||
- Pushes to `gitea.dooplex.hu/admin/felhom-controller:<VERSION>`
|
- Pushes to `gitea.dooplex.hu/admin/felhom-controller:<VERSION>`
|
||||||
- Expects the version as first argument (e.g., `0.2.11`)
|
- Expects the version as first argument (e.g., `0.2.11`)
|
||||||
|
|
||||||
### Step 3: Deploy on the demo node
|
### Step 3: Deploy on demo nodes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Demo node 1 (local)
|
||||||
$SSH kisfenyo@192.168.0.162 "cd /opt/docker/felhom-controller && sudo docker pull gitea.dooplex.hu/admin/felhom-controller:<NEW_VERSION> && sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:<NEW_VERSION>|' docker-compose.yml && sudo docker compose up -d"
|
$SSH kisfenyo@192.168.0.162 "cd /opt/docker/felhom-controller && sudo docker pull gitea.dooplex.hu/admin/felhom-controller:<NEW_VERSION> && sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:<NEW_VERSION>|' docker-compose.yml && sudo docker compose up -d"
|
||||||
|
|
||||||
|
# Demo node 2 (remote)
|
||||||
|
$SSH -p 33022 kisfenyo@router.abonet.hu "cd /opt/docker/felhom-controller && sudo docker pull gitea.dooplex.hu/admin/felhom-controller:<NEW_VERSION> && sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:<NEW_VERSION>|' docker-compose.yml && sudo docker compose up -d"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 4: Verify the deployment
|
### Step 4: Verify the deployment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$SSH kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
|
$SSH kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
|
||||||
|
$SSH -p 33022 kisfenyo@router.abonet.hu "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
|
||||||
```
|
```
|
||||||
|
|
||||||
Should show the new version and "Up" status. Also check logs for startup errors:
|
Should show the new version and "Up" status. Also check logs for startup errors:
|
||||||
```bash
|
```bash
|
||||||
$SSH kisfenyo@192.168.0.162 "docker logs felhom-controller --tail 20"
|
$SSH kisfenyo@192.168.0.162 "docker logs felhom-controller --tail 20"
|
||||||
|
$SSH -p 33022 kisfenyo@router.abonet.hu "docker logs felhom-controller --tail 20"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build workflow summary
|
### Build workflow summary
|
||||||
@@ -176,8 +185,9 @@ $SSH kisfenyo@192.168.0.162 "docker logs felhom-controller --tail 20"
|
|||||||
| 0. Set SSH var | `SSH=/c/Windows/System32/OpenSSH/ssh.exe` | Local (once per session) |
|
| 0. Set SSH var | `SSH=/c/Windows/System32/OpenSSH/ssh.exe` | Local (once per session) |
|
||||||
| 1. Commit + push | `git add -A && git commit -m "..." && git push` | Local (this repo) |
|
| 1. Commit + push | `git add -A && git commit -m "..." && git push` | Local (this repo) |
|
||||||
| 2. Build + push image | `$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-controller... ./build.sh <VER> --push"` | Build server |
|
| 2. Build + push image | `$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-controller... ./build.sh <VER> --push"` | Build server |
|
||||||
| 3. Deploy | `$SSH kisfenyo@192.168.0.162 "... docker compose up -d"` | Demo node |
|
| 3. Deploy (node 1) | `$SSH kisfenyo@192.168.0.162 "... docker compose up -d"` | Demo node |
|
||||||
| 4. Verify | `$SSH kisfenyo@192.168.0.162 "docker ps ..."` | Demo node |
|
| 3b. Deploy (node 2) | `$SSH -p 33022 kisfenyo@router.abonet.hu "... docker compose up -d"` | Demo node 2 |
|
||||||
|
| 4. Verify | `$SSH kisfenyo@192.168.0.162 "docker ps ..."` + same for router.abonet.hu | Both nodes |
|
||||||
|
|
||||||
### Build & deploy workflow — Hub (felhom-hub)
|
### Build & deploy workflow — Hub (felhom-hub)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ Last updated: 2026-02-19 (session 59)
|
|||||||
- **v0.11.8:** ✅ COMPLETE — Per-App Cross-Drive Backup (3-2-1 rule): rsync/restic to secondary drive, deploy page UI, backup page summary, scheduler jobs, API endpoints
|
- **v0.11.8:** ✅ COMPLETE — Per-App Cross-Drive Backup (3-2-1 rule): rsync/restic to secondary drive, deploy page UI, backup page summary, scheduler jobs, API endpoints
|
||||||
- **v0.11.9:** ✅ COMPLETE — UI Polish Fixes: spacing, tooltip on "Módszer", status dot instead of disabled checkbox, progressive disclosure, emoji cleanup
|
- **v0.11.9:** ✅ COMPLETE — UI Polish Fixes: spacing, tooltip on "Módszer", status dot instead of disabled checkbox, progressive disclosure, emoji cleanup
|
||||||
- **First app deployed:** Paperless-ngx on demo-felhom.eu (2026-02-13)
|
- **First app deployed:** Paperless-ngx on demo-felhom.eu (2026-02-13)
|
||||||
- **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080
|
- **Running on:** demo-felhom (N100 mini PC) at 192.168.0.162:8080, felhotest (remote VPS) at router.abonet.hu:33022
|
||||||
- **All Phase 1-5 features working:** deploy, start/stop/restart/update, logs, health-aware states, auth, monitoring, backups, backup detail page, system monitoring page, settings page
|
- **All Phase 1-5 features working:** deploy, start/stop/restart/update, logs, health-aware states, auth, monitoring, backups, backup detail page, system monitoring page, settings page
|
||||||
|
|
||||||
## Architecture decisions
|
## Architecture decisions
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
A single, lightweight Go container that replaces Portainer + scattered systemd scripts with a unified, Hungarian-language web dashboard for managing Docker Compose stacks, backups, storage, monitoring, and notifications on customer hardware.
|
A single, lightweight Go container that replaces Portainer + scattered systemd scripts with a unified, Hungarian-language web dashboard for managing Docker Compose stacks, backups, storage, monitoring, and notifications on customer hardware.
|
||||||
|
|
||||||
**Current version: v0.32.5**
|
**Current version: v0.32.6**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ The storage subsystem handles the full lifecycle of external storage: detection,
|
|||||||
- `ScanDisks()` uses `lsblk -J -b` for block device enumeration
|
- `ScanDisks()` uses `lsblk -J -b` for block device enumeration
|
||||||
- System disk detection via host fstab parsing (`/host-fstab`) + UUID resolution via `blkid`
|
- System disk detection via host fstab parsing (`/host-fstab`) + UUID resolution via `blkid`
|
||||||
- Partitions enriched with filesystem type, UUID, and label from direct `blkid` probing (Docker containers have incomplete udev cache)
|
- Partitions enriched with filesystem type, UUID, and label from direct `blkid` probing (Docker containers have incomplete udev cache)
|
||||||
- Returns `AvailableDisks` (non-system, non-loop, non-CDROM) and `SystemDisks` separately
|
- Returns `AvailableDisks` (non-system, non-loop, non-CDROM), `SystemDisks`, and `FormatablePartitions` (empty partitions on system disks that are safe to format)
|
||||||
- Handles NVMe (`nvme0n1p1`), SCSI (`sdb1`), and eMMC (`mmcblk0p1`) naming
|
- Handles NVMe (`nvme0n1p1`), SCSI (`sdb1`), and eMMC (`mmcblk0p1`) naming
|
||||||
|
|
||||||
#### Disk Initialization Wizard (`internal/storage/format.go`)
|
#### Disk Initialization Wizard (`internal/storage/format.go`)
|
||||||
@@ -488,6 +488,8 @@ A step-by-step UI at `/settings/storage/init`:
|
|||||||
|
|
||||||
Safety guards: system disk detection, mount path conflict check, confirmation required, progress channel for real-time UI feedback.
|
Safety guards: system disk detection, mount path conflict check, confirmation required, progress channel for real-time UI feedback.
|
||||||
|
|
||||||
|
**System-disk partition formatting:** When the system disk has an empty partition (no filesystem, not mounted, not used for /, /boot, /boot/efi, or swap), the init wizard detects it via `FormatablePartitions` in the scan result and offers to format just that partition. Uses `IsSystemPartition()` (granular per-partition check via fstab) instead of `IsSystemDisk()` (whole-disk block), so sda1 can be formatted while sda3 (root) remains protected.
|
||||||
|
|
||||||
#### Attach Existing Drive Wizard (`internal/storage/attach.go`)
|
#### Attach Existing Drive Wizard (`internal/storage/attach.go`)
|
||||||
|
|
||||||
A step-by-step UI at `/settings/storage/attach` for drives that already have a filesystem (e.g., a previously used ext4 drive). Unlike the init wizard, this does **not** format the drive — existing data is preserved.
|
A step-by-step UI at `/settings/storage/attach` for drives that already have a filesystem (e.g., a previously used ext4 drive). Unlike the init wizard, this does **not** format the drive — existing data is preserved.
|
||||||
@@ -1785,7 +1787,8 @@ See `docker-compose.yml` for the full volume configuration.
|
|||||||
|
|
||||||
| Node | Hardware | Domain | Status |
|
| Node | Hardware | Domain | Status |
|
||||||
|------|----------|--------|--------|
|
|------|----------|--------|--------|
|
||||||
| demo-felhom | Acemagic GK3PLUS N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | Controller v0.22.3 |
|
| demo-felhom | Acemagic GK3PLUS N100, 16G RAM, 512G SSD + 1TB HDD | demo-felhom.eu | Active |
|
||||||
|
| felhotest | Remote VPS, 200G + 100G disk | router.abonet.hu:33022 | Active |
|
||||||
| pi-customer-1 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | Not yet tested |
|
| pi-customer-1 | Raspberry Pi 3B+, 1G RAM, 32G SD | pi-customer-1.local | Not yet tested |
|
||||||
|
|
||||||
## Related Repositories
|
## Related Repositories
|
||||||
|
|||||||
@@ -61,12 +61,25 @@ func FormatAndMount(req FormatRequest, progress chan<- FormatProgress) (string,
|
|||||||
return "", fail("validating", "Az eszköz nem létezik: "+req.DevicePath, err)
|
return "", fail("validating", "Az eszköz nem létezik: "+req.DevicePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
isSystem, err := IsSystemDisk(req.DevicePath)
|
if req.CreatePartition {
|
||||||
if err != nil {
|
// Whole-disk operation: block if any partition on this disk is a system partition.
|
||||||
return "", fail("validating", "Rendszermeghajtó ellenőrzése sikertelen", err)
|
isSystem, err := IsSystemDisk(req.DevicePath)
|
||||||
}
|
if err != nil {
|
||||||
if isSystem {
|
return "", fail("validating", "Rendszermeghajtó ellenőrzése sikertelen", err)
|
||||||
return "", fail("validating", "Ez a rendszermeghajtó — nem formázható!", fmt.Errorf("device is system disk"))
|
}
|
||||||
|
if isSystem {
|
||||||
|
return "", fail("validating", "Ez a rendszermeghajtó — nem formázható!", fmt.Errorf("device is system disk"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Partition-only operation: block only if THIS specific partition is a system partition.
|
||||||
|
// Allows formatting empty partitions on the system disk (e.g., dedicated data partition).
|
||||||
|
isSysPart, err := IsSystemPartition(req.DevicePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fail("validating", "Rendszerpartíció ellenőrzése sikertelen", err)
|
||||||
|
}
|
||||||
|
if isSysPart {
|
||||||
|
return "", fail("validating", "Ez rendszerpartíció — nem formázható!", fmt.Errorf("partition is a system partition"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mounted, err := IsDeviceMounted(req.DevicePath)
|
mounted, err := IsDeviceMounted(req.DevicePath)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -46,6 +47,68 @@ func IsSystemDisk(devicePath string) (bool, error) {
|
|||||||
return rootDiskGroup == devDiskGroup, nil
|
return rootDiskGroup == devDiskGroup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSystemPartition checks if a specific partition is a system partition
|
||||||
|
// (/, /boot, /boot/efi, swap) or is currently mounted.
|
||||||
|
// Unlike IsSystemDisk() which blocks the entire disk, this checks only the
|
||||||
|
// individual partition — allowing non-system partitions on system disks to be formatted.
|
||||||
|
func IsSystemPartition(partitionPath string) (bool, error) {
|
||||||
|
fstabPath := "/host-fstab"
|
||||||
|
if _, err := os.Stat(fstabPath); err != nil {
|
||||||
|
fstabPath = "/etc/fstab"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(fstabPath)
|
||||||
|
if err != nil {
|
||||||
|
// If we can't read fstab, err on the side of caution
|
||||||
|
return true, fmt.Errorf("cannot read fstab: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
systemMounts := map[string]bool{"/": true, "/boot": true, "/boot/efi": true}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source := fields[0]
|
||||||
|
mountPoint := fields[1]
|
||||||
|
fsType := fields[2]
|
||||||
|
|
||||||
|
if !systemMounts[mountPoint] && fsType != "swap" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var devPath string
|
||||||
|
if strings.HasPrefix(source, "UUID=") {
|
||||||
|
uuid := strings.TrimPrefix(source, "UUID=")
|
||||||
|
if out, err := exec.Command("blkid", "-U", uuid).Output(); err == nil {
|
||||||
|
devPath = strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(source, "/dev/") {
|
||||||
|
devPath = source
|
||||||
|
}
|
||||||
|
|
||||||
|
if devPath == partitionPath {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check if the partition is currently mounted
|
||||||
|
mounted, err := IsDeviceMounted(partitionPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if mounted {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
// IsDeviceMounted checks if a device or any of its partitions is currently mounted.
|
// IsDeviceMounted checks if a device or any of its partitions is currently mounted.
|
||||||
func IsDeviceMounted(devicePath string) (bool, error) {
|
func IsDeviceMounted(devicePath string) (bool, error) {
|
||||||
data, err := os.ReadFile("/proc/mounts")
|
data, err := os.ReadFile("/proc/mounts")
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ func IsSystemDisk(devicePath string) (bool, error) {
|
|||||||
return false, fmt.Errorf("storage init is only supported on Linux")
|
return false, fmt.Errorf("storage init is only supported on Linux")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsSystemPartition(partitionPath string) (bool, error) {
|
||||||
|
return false, fmt.Errorf("storage init is only supported on Linux")
|
||||||
|
}
|
||||||
|
|
||||||
func IsDeviceMounted(devicePath string) (bool, error) {
|
func IsDeviceMounted(devicePath string) (bool, error) {
|
||||||
return false, fmt.Errorf("storage init is only supported on Linux")
|
return false, fmt.Errorf("storage init is only supported on Linux")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,18 @@ type Partition struct {
|
|||||||
MountPoint string // "" if not mounted
|
MountPoint string // "" if not mounted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatablePartition is an empty partition on a system disk that can be formatted.
|
||||||
|
type FormatablePartition struct {
|
||||||
|
Partition
|
||||||
|
ParentDiskName string // "sda"
|
||||||
|
ParentDiskPath string // "/dev/sda"
|
||||||
|
ParentDiskModel string // "Samsung SSD 870"
|
||||||
|
ParentDiskSize string // "500 GB"
|
||||||
|
}
|
||||||
|
|
||||||
// ScanResult from disk detection.
|
// ScanResult from disk detection.
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
AvailableDisks []BlockDevice // Unmounted, non-system disks
|
AvailableDisks []BlockDevice // Unmounted, non-system disks
|
||||||
SystemDisks []BlockDevice // System/mounted disks (display only)
|
SystemDisks []BlockDevice // System/mounted disks (display only)
|
||||||
|
FormatablePartitions []FormatablePartition // Empty partitions on system disks, safe to format
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,58 @@ func getSystemDiskNames() map[string]bool {
|
|||||||
return systemDisks
|
return systemDisks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getSystemPartitionPaths returns the set of partition device paths (e.g., "/dev/sda3")
|
||||||
|
// that are system partitions (/, /boot, /boot/efi, swap).
|
||||||
|
// Unlike getSystemDiskNames() which returns parent disk names, this returns the actual
|
||||||
|
// partition paths for granular checks.
|
||||||
|
func getSystemPartitionPaths() map[string]bool {
|
||||||
|
sysPartitions := map[string]bool{}
|
||||||
|
|
||||||
|
fstabPath := "/host-fstab"
|
||||||
|
if _, err := os.Stat(fstabPath); err != nil {
|
||||||
|
fstabPath = "/etc/fstab"
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(fstabPath)
|
||||||
|
if err != nil {
|
||||||
|
return sysPartitions
|
||||||
|
}
|
||||||
|
|
||||||
|
systemMounts := map[string]bool{"/": true, "/boot": true, "/boot/efi": true}
|
||||||
|
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source := fields[0]
|
||||||
|
mountPoint := fields[1]
|
||||||
|
fsType := fields[2]
|
||||||
|
|
||||||
|
if !systemMounts[mountPoint] && fsType != "swap" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(source, "UUID=") {
|
||||||
|
uuid := strings.TrimPrefix(source, "UUID=")
|
||||||
|
if out, err := exec.Command("blkid", "-U", uuid).Output(); err == nil {
|
||||||
|
devPath := strings.TrimSpace(string(out))
|
||||||
|
if devPath != "" {
|
||||||
|
sysPartitions[devPath] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(source, "/dev/") {
|
||||||
|
sysPartitions[source] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sysPartitions
|
||||||
|
}
|
||||||
|
|
||||||
// partitionToParentDisk extracts the parent disk name from a partition device path.
|
// partitionToParentDisk extracts the parent disk name from a partition device path.
|
||||||
// "/dev/sda2" → "sda", "/dev/nvme0n1p2" → "nvme0n1", "/dev/mmcblk0p1" → "mmcblk0"
|
// "/dev/sda2" → "sda", "/dev/nvme0n1p2" → "nvme0n1", "/dev/mmcblk0p1" → "mmcblk0"
|
||||||
func partitionToParentDisk(devPath string) string {
|
func partitionToParentDisk(devPath string) string {
|
||||||
@@ -344,8 +396,31 @@ func ScanDisks(logger *log.Logger, debug bool) (*ScanResult, error) {
|
|||||||
enrichWithBlkid(result.AvailableDisks, logger, debug)
|
enrichWithBlkid(result.AvailableDisks, logger, debug)
|
||||||
enrichWithBlkid(result.SystemDisks, logger, debug)
|
enrichWithBlkid(result.SystemDisks, logger, debug)
|
||||||
|
|
||||||
|
// Detect formatable partitions on system disks:
|
||||||
|
// empty (no filesystem), unmounted, and NOT a system partition.
|
||||||
|
sysPartitionPaths := getSystemPartitionPaths()
|
||||||
|
for _, sysDisk := range result.SystemDisks {
|
||||||
|
for _, part := range sysDisk.Partitions {
|
||||||
|
if part.FSType != "" || part.MountPoint != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sysPartitionPaths[part.Path] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.FormatablePartitions = append(result.FormatablePartitions, FormatablePartition{
|
||||||
|
Partition: part,
|
||||||
|
ParentDiskName: sysDisk.Name,
|
||||||
|
ParentDiskPath: sysDisk.Path,
|
||||||
|
ParentDiskModel: sysDisk.Model,
|
||||||
|
ParentDiskSize: sysDisk.Size,
|
||||||
|
})
|
||||||
|
dbg("formatable partition found: %s on system disk %s", part.Path, sysDisk.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Printf("[INFO] [storage] Found %d disks", len(result.AvailableDisks)+len(result.SystemDisks))
|
logger.Printf("[INFO] [storage] Found %d disks, %d formatable partitions",
|
||||||
|
len(result.AvailableDisks)+len(result.SystemDisks), len(result.FormatablePartitions))
|
||||||
}
|
}
|
||||||
dbg("disk scan completed in %s", time.Since(scanStart).Round(time.Millisecond))
|
dbg("disk scan completed in %s", time.Since(scanStart).Round(time.Millisecond))
|
||||||
|
|
||||||
|
|||||||
@@ -214,10 +214,11 @@ func (s *Server) storageScanAPIHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.logger.Printf("[DEBUG] [web] storageScan: found %d available disks, %d system disks", len(result.AvailableDisks), len(result.SystemDisks))
|
s.logger.Printf("[DEBUG] [web] storageScan: found %d available disks, %d system disks", len(result.AvailableDisks), len(result.SystemDisks))
|
||||||
}
|
}
|
||||||
jsonResponse(w, map[string]interface{}{
|
jsonResponse(w, map[string]interface{}{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"available": result.AvailableDisks,
|
"available": result.AvailableDisks,
|
||||||
"system": result.SystemDisks,
|
"system": result.SystemDisks,
|
||||||
"available_count": len(result.AvailableDisks),
|
"available_count": len(result.AvailableDisks),
|
||||||
|
"formatable_partitions": result.FormatablePartitions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,27 +146,53 @@ function scanDisks() {
|
|||||||
function renderScanResult(data) {
|
function renderScanResult(data) {
|
||||||
var availEl = document.getElementById('available-disks');
|
var availEl = document.getElementById('available-disks');
|
||||||
var sysEl = document.getElementById('system-disks-note');
|
var sysEl = document.getElementById('system-disks-note');
|
||||||
|
var hasAvail = data.available && data.available.length > 0;
|
||||||
|
var hasFP = data.formatable_partitions && data.formatable_partitions.length > 0;
|
||||||
|
|
||||||
if (!data.available || data.available.length === 0) {
|
if (!hasAvail && !hasFP) {
|
||||||
availEl.innerHTML = '<div class="empty-state" style="padding:1rem">Nem található inicializálható meghajtó.</div>';
|
availEl.innerHTML = '<div class="empty-state" style="padding:1rem">Nem található inicializálható meghajtó vagy partíció.</div>';
|
||||||
} else {
|
} else {
|
||||||
var html = '<h4 style="margin-bottom:.75rem">Talált meghajtók (' + data.available.length + '):</h4>';
|
var html = '';
|
||||||
data.available.forEach(function(disk) {
|
if (hasAvail) {
|
||||||
var partInfo = '';
|
html += '<h4 style="margin-bottom:.75rem">Talált meghajtók (' + data.available.length + '):</h4>';
|
||||||
if (disk.Partitions && disk.Partitions.length > 0) {
|
data.available.forEach(function(disk) {
|
||||||
partInfo = disk.Partitions.map(function(p) {
|
var partInfo = '';
|
||||||
return p.Name + (p.FSType ? ' (' + p.FSType + ')' : ' (nincs fájlrendszer)') + (p.MountPoint ? ' → ' + p.MountPoint : '');
|
if (disk.Partitions && disk.Partitions.length > 0) {
|
||||||
}).join(', ');
|
partInfo = disk.Partitions.map(function(p) {
|
||||||
}
|
return p.Name + (p.FSType ? ' (' + p.FSType + ')' : ' (nincs fájlrendszer)') + (p.MountPoint ? ' → ' + p.MountPoint : '');
|
||||||
html += '<div class="storage-path-item" style="cursor:pointer;border:2px solid transparent" ' +
|
}).join(', ');
|
||||||
'onclick="selectDisk(this, \'' + disk.Path + '\', ' + JSON.stringify(disk.CreatePartition !== false) + ')" ' +
|
}
|
||||||
'data-path="' + disk.Path + '" id="disk-' + disk.Name + '">' +
|
html += '<div class="storage-path-item" style="cursor:pointer;border:2px solid transparent" ' +
|
||||||
'<div class="storage-path-header"><div class="storage-path-info">' +
|
'onclick="selectDisk(this, \'' + disk.Path + '\', true)" ' +
|
||||||
'<span class="storage-path-label">○ ' + disk.Path + ' — ' + (disk.Size || '?') + '</span>' +
|
'data-path="' + disk.Path + '" id="disk-' + disk.Name + '">' +
|
||||||
(disk.Model ? '<span class="storage-path-path">' + disk.Model + '</span>' : '') +
|
'<div class="storage-path-header"><div class="storage-path-info">' +
|
||||||
(partInfo ? '<span class="form-hint">' + partInfo + '</span>' : '<span class="form-hint">Nincs partíció</span>') +
|
'<span class="storage-path-label">○ ' + disk.Path + ' — ' + (disk.Size || '?') + '</span>' +
|
||||||
'</div></div></div>';
|
(disk.Model ? '<span class="storage-path-path">' + disk.Model + '</span>' : '') +
|
||||||
});
|
(partInfo ? '<span class="form-hint">' + partInfo + '</span>' : '<span class="form-hint">Nincs partíció</span>') +
|
||||||
|
'</div></div></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFP) {
|
||||||
|
html += '<h4 style="margin-bottom:.75rem;margin-top:1.5rem">Formázható partíciók a rendszermeghajtón (' +
|
||||||
|
data.formatable_partitions.length + '):</h4>';
|
||||||
|
html += '<div class="alert alert-info" style="margin-bottom:.75rem;font-size:.85rem">' +
|
||||||
|
'Az alábbi partíciók a rendszermeghajtón találhatók, de nincsenek használatban. ' +
|
||||||
|
'Formázás után adattárolóként használhatók.</div>';
|
||||||
|
data.formatable_partitions.forEach(function(fp) {
|
||||||
|
var parentInfo = fp.ParentDiskPath + ' (' + fp.ParentDiskSize + ')';
|
||||||
|
if (fp.ParentDiskModel) parentInfo += ' — ' + fp.ParentDiskModel;
|
||||||
|
html += '<div class="storage-path-item" style="cursor:pointer;border:2px solid transparent" ' +
|
||||||
|
'onclick="selectDisk(this, \'' + fp.Path + '\', false)" ' +
|
||||||
|
'data-path="' + fp.Path + '">' +
|
||||||
|
'<div class="storage-path-header"><div class="storage-path-info">' +
|
||||||
|
'<span class="storage-path-label">○ ' + fp.Path + ' — ' + (fp.Size || '?') + '</span>' +
|
||||||
|
'<span class="form-hint">Rendszermeghajtó partíciója: ' + parentInfo + '</span>' +
|
||||||
|
'<span class="form-hint">Nincs fájlrendszer — formázásra kész</span>' +
|
||||||
|
'</div></div></div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
availEl.innerHTML = html;
|
availEl.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +218,20 @@ function selectDisk(el, path, needsPartition) {
|
|||||||
document.getElementById('create-partition').value = needsPartition ? 'true' : 'false';
|
document.getElementById('create-partition').value = needsPartition ? 'true' : 'false';
|
||||||
document.getElementById('selected-device-display').textContent = path;
|
document.getElementById('selected-device-display').textContent = path;
|
||||||
|
|
||||||
|
// Update warning text based on whole-disk vs partition-only operation
|
||||||
|
var warningEl = document.querySelector('#wizard-configure .alert-error');
|
||||||
|
if (needsPartition) {
|
||||||
|
warningEl.innerHTML = '<strong>⚠️ FIGYELEM:</strong> A meghajtó <strong>ÖSSZES</strong> adata törlődik!<br>' +
|
||||||
|
'Ez a művelet <strong>NEM vonható vissza.</strong>';
|
||||||
|
} else {
|
||||||
|
warningEl.innerHTML = '<strong>⚠️ FIGYELEM:</strong> A partíció formázva lesz, a rajta lévő adatok törlődnek!<br>' +
|
||||||
|
'A rendszermeghajtó többi partíciója <strong>NEM</strong> érintett.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide the partitioning progress step
|
||||||
|
var partStep = document.getElementById('pstep-partitioning');
|
||||||
|
if (partStep) partStep.style.display = needsPartition ? '' : 'none';
|
||||||
|
|
||||||
// Show configure step
|
// Show configure step
|
||||||
document.getElementById('wizard-configure').style.display = 'block';
|
document.getElementById('wizard-configure').style.display = 'block';
|
||||||
document.getElementById('wizard-configure').scrollIntoView({behavior:'smooth'});
|
document.getElementById('wizard-configure').scrollIntoView({behavior:'smooth'});
|
||||||
|
|||||||
Reference in New Issue
Block a user