v0.15.0: Attach existing drive wizard (bind mount, no format)

New Settings wizard to attach drives with existing filesystems without
formatting. Mounts partition at staging path, lets user browse and pick
a subfolder, then bind-mounts it at /mnt/<name> with fstab entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 21:12:02 +01:00
parent e54e097d02
commit 98834dd7e8
8 changed files with 1311 additions and 0 deletions
+5
View File
@@ -40,6 +40,9 @@ type Server struct {
// Disk operation state (format/migrate jobs)
diskJobMu sync.Mutex
diskJob *activeDiskJob
// Active raw mount for the attach wizard (empty when not in use)
activeRawMount string
}
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server {
@@ -117,6 +120,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.backupRestoreHandler(w, r)
case path == "/settings/storage/init":
s.storageInitHandler(w, r)
case path == "/settings/storage/attach":
s.storageAttachHandler(w, r)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/migrate")
+267
View File
@@ -142,6 +142,18 @@ func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
s.storageMigrateStatusAPIHandler(w, r)
case path == "/api/storage/stale-cleanup" && r.Method == http.MethodPost:
s.staleDataCleanupHandler(w, r)
case path == "/api/storage/attach/mount-raw" && r.Method == http.MethodPost:
s.storageAttachMountRawHandler(w, r)
case path == "/api/storage/attach/browse" && r.Method == http.MethodGet:
s.storageAttachBrowseHandler(w, r)
case path == "/api/storage/attach/mkdir" && r.Method == http.MethodPost:
s.storageAttachMkdirHandler(w, r)
case path == "/api/storage/attach" && r.Method == http.MethodPost:
s.storageAttachAPIHandler(w, r)
case path == "/api/storage/attach/status" && r.Method == http.MethodGet:
s.storageAttachStatusAPIHandler(w, r)
case path == "/api/storage/attach/cancel" && r.Method == http.MethodPost:
s.storageAttachCancelHandler(w, r)
default:
http.NotFound(w, r)
}
@@ -823,3 +835,258 @@ func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request)
"errors": errors,
})
}
// --- Attach Existing Drive Wizard ---
// storageAttachHandler serves the attach wizard page.
func (s *Server) storageAttachHandler(w http.ResponseWriter, r *http.Request) {
data := s.baseData("settings", "Meglévő meghajtó csatolása")
s.render(w, "storage_attach", data)
}
// storageAttachMountRawHandler handles POST /api/storage/attach/mount-raw.
// Temporarily mounts a partition at a staging path for browsing.
func (s *Server) storageAttachMountRawHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
DevicePath string `json:"device_path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
if req.DevicePath == "" {
jsonError(w, "Hiányzó eszközútvonal", http.StatusBadRequest)
return
}
// Clean up any previous raw mount first
s.diskJobMu.Lock()
if s.activeRawMount != "" {
_ = storage.CleanupRawMount(s.activeRawMount)
s.activeRawMount = ""
}
s.diskJobMu.Unlock()
rawPath, err := storage.MountRaw(req.DevicePath)
if err != nil {
s.logger.Printf("[ERROR] storageAttachMountRaw: %v", err)
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.diskJobMu.Lock()
s.activeRawMount = rawPath
s.diskJobMu.Unlock()
s.logger.Printf("[INFO] Raw mount for attach: %s → %s", req.DevicePath, rawPath)
jsonResponse(w, map[string]interface{}{
"ok": true,
"raw_path": rawPath,
})
}
// storageAttachBrowseHandler handles GET /api/storage/attach/browse?path=...
// Lists directories at the given path within the raw mount staging area.
func (s *Server) storageAttachBrowseHandler(w http.ResponseWriter, r *http.Request) {
browsePath := r.URL.Query().Get("path")
if browsePath == "" {
jsonError(w, "Hiányzó útvonal paraméter", http.StatusBadRequest)
return
}
// Security: validate path is under the raw mount staging area
cleanPath := filepath.Clean(browsePath)
if !strings.HasPrefix(cleanPath, storage.RawMountBase) {
jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest)
return
}
dirs, err := storage.ListDirectories(cleanPath)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"path": cleanPath,
"dirs": dirs,
})
}
// storageAttachMkdirHandler handles POST /api/storage/attach/mkdir.
// Creates a new directory in the raw mount staging area.
func (s *Server) storageAttachMkdirHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Path string `json:"path"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
if req.Path == "" || req.Name == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return
}
// Security: validate path is under the raw mount staging area
cleanPath := filepath.Clean(req.Path)
if !strings.HasPrefix(cleanPath, storage.RawMountBase) {
jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest)
return
}
createdPath, err := storage.CreateDirectory(cleanPath, req.Name)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] Created directory for attach: %s", createdPath)
jsonResponse(w, map[string]interface{}{
"ok": true,
"created_path": createdPath,
})
}
// storageAttachAPIHandler handles POST /api/storage/attach — starts the final attach job.
func (s *Server) storageAttachAPIHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
DevicePath string `json:"device_path"`
MountName string `json:"mount_name"`
SubPath string `json:"sub_path"`
Label string `json:"label"`
SetDefault bool `json:"set_default"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
return
}
if req.DevicePath == "" || req.MountName == "" || req.SubPath == "" {
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
return
}
job, ok := s.tryStartDiskJob("attach")
if !ok {
jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict)
return
}
s.logger.Printf("[INFO] Storage attach started: device=%s mountName=%s subPath=%s by %s",
req.DevicePath, req.MountName, req.SubPath, r.RemoteAddr)
attachReq := storage.AttachRequest{
DevicePath: req.DevicePath,
MountName: req.MountName,
SubPath: req.SubPath,
Label: req.Label,
SetDefault: req.SetDefault,
}
go func() {
progressCh := make(chan storage.FormatProgress, 32)
go func() {
for p := range progressCh {
job.appendFmtProg(p)
}
}()
mountPath, err := storage.FinalizeAttach(attachReq, progressCh)
close(progressCh)
if err != nil {
s.logger.Printf("[ERROR] Storage attach failed: %v", err)
return
}
// Clear raw mount tracking (it's now permanent via fstab)
s.diskJobMu.Lock()
s.activeRawMount = ""
s.diskJobMu.Unlock()
// Auto-register the new storage path
label := req.Label
if label == "" {
label = settings.InferStorageLabel(mountPath)
}
sp := settings.StoragePath{
Path: mountPath,
Label: label,
IsDefault: req.SetDefault,
Schedulable: true,
AddedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := s.settings.AddStoragePath(sp); err != nil {
s.logger.Printf("[WARN] Failed to register storage path after attach: %v", err)
} else {
s.logger.Printf("[INFO] Storage path registered: %s (%s)", mountPath, label)
s.syncFileBrowserMounts()
}
}()
jsonResponse(w, map[string]interface{}{
"ok": true,
"msg": "Csatolás elindítva",
})
}
// storageAttachStatusAPIHandler handles GET /api/storage/attach/status.
func (s *Server) storageAttachStatusAPIHandler(w http.ResponseWriter, r *http.Request) {
job := s.currentDiskJob()
if job == nil || job.jobType != "attach" {
jsonResponse(w, map[string]interface{}{
"ok": true,
"active": false,
})
return
}
p, ok := job.lastFmtProg()
if !ok {
jsonResponse(w, map[string]interface{}{
"ok": true,
"active": true,
"step": "starting",
"msg": "Csatolás elindult...",
"pct": 0,
})
return
}
jsonResponse(w, map[string]interface{}{
"ok": true,
"active": !job.isDone(),
"step": p.Step,
"msg": p.Message,
"pct": p.Percent,
"error": p.Error,
"done": job.isDone(),
})
}
// storageAttachCancelHandler handles POST /api/storage/attach/cancel.
// Cleans up the temporary raw mount when the user cancels the wizard.
func (s *Server) storageAttachCancelHandler(w http.ResponseWriter, r *http.Request) {
s.diskJobMu.Lock()
rawMount := s.activeRawMount
s.activeRawMount = ""
s.diskJobMu.Unlock()
if rawMount == "" {
jsonResponse(w, map[string]interface{}{"ok": true, "msg": "Nincs aktív raw mount"})
return
}
if err := storage.CleanupRawMount(rawMount); err != nil {
s.logger.Printf("[WARN] Failed to cleanup raw mount %s: %v", rawMount, err)
} else {
s.logger.Printf("[INFO] Cleaned up raw mount: %s", rawMount)
}
jsonResponse(w, map[string]interface{}{"ok": true, "msg": "Raw mount eltávolítva"})
}
@@ -166,6 +166,7 @@
<div style="margin-top:1rem;display:flex;gap:.75rem;flex-wrap:wrap">
<a href="/settings/storage/init" class="btn btn-sm btn-outline">🔧 Új meghajtó inicializálása</a>
<a href="/settings/storage/attach" class="btn btn-sm btn-outline">🔗 Meglévő meghajtó csatolása</a>
</div>
<details class="storage-add-details">
@@ -0,0 +1,566 @@
{{define "storage_attach"}}
{{template "layout_start" .}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>
<h2>Meglévő meghajtó csatolása</h2>
</div>
</div>
<!-- Step 1: Scan -->
<div class="settings-card" id="wizard-scan">
<h3>1. Meghajtók keresése</h3>
<p class="settings-card-desc">Keresse meg a rendszerhez csatlakoztatott, meglévő fájlrendszerrel rendelkező meghajtókat.</p>
<button class="btn btn-primary" onclick="scanDisks()" id="scan-btn">🔍 Meghajtók keresése</button>
<div id="scan-error" class="alert alert-error" style="display:none;margin-top:1rem"></div>
<div id="scan-result" style="display:none;margin-top:1.5rem">
<div id="available-disks"></div>
<div id="system-disks-note" style="display:none;margin-top:1rem"></div>
</div>
</div>
<!-- Step 2: Browse -->
<div class="settings-card" id="wizard-browse" style="display:none">
<h3>2. Mappa kiválasztása</h3>
<p class="settings-card-desc">Válasszon ki egy mappát a meghajtón, amelyet a controller használni fog. Új mappát is létrehozhat.</p>
<div id="browse-info" class="settings-grid" style="margin-bottom:1rem">
<div class="settings-row">
<span class="settings-label">Partíció</span>
<span class="settings-value mono" id="browse-device"></span>
</div>
<div class="settings-row">
<span class="settings-label">Fájlrendszer</span>
<span class="settings-value mono" id="browse-fstype"></span>
</div>
</div>
<div id="browse-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div>
<div id="dir-browser" style="border:1px solid var(--border);border-radius:6px;padding:1rem;background:var(--card-bg);margin-bottom:1rem">
<div id="dir-breadcrumb" class="form-hint mono" style="margin-bottom:.75rem"></div>
<div id="dir-list" style="min-height:100px"></div>
</div>
<div style="display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem">
<div class="form-group" style="margin-bottom:0">
<label for="new-dir-name">Új mappa neve</label>
<input type="text" id="new-dir-name" class="form-control" placeholder="felhom_data"
pattern="[a-zA-Z0-9_]+" style="max-width:200px">
</div>
<button class="btn btn-outline" onclick="createDir()" id="mkdir-btn">📁 Mappa létrehozása</button>
</div>
<div id="selected-dir-info" class="alert alert-info" style="display:none;margin-bottom:1rem">
Kiválasztott mappa: <strong id="selected-dir-display" class="mono"></strong>
</div>
<div class="form-actions" style="gap:.75rem">
<button class="btn btn-primary" onclick="goToConfigure()" id="browse-next-btn" disabled>Tovább →</button>
<button class="btn btn-outline" onclick="cancelAttach()">Mégsem</button>
</div>
</div>
<!-- Step 3: Configure -->
<div class="settings-card" id="wizard-configure" style="display:none">
<h3>3. Konfiguráció</h3>
<p class="settings-card-desc">Adja meg a csatolás paramétereit.</p>
<form id="attach-form">
<div class="form-group">
<label>Kiválasztott partíció</label>
<span class="settings-value mono" id="config-device-display"></span>
</div>
<div class="form-group">
<label>Kiválasztott mappa</label>
<span class="settings-value mono" id="config-subpath-display"></span>
</div>
<div class="form-group">
<label for="mount-name">Csatlakoztatási név <span class="required">*</span></label>
<div class="form-inline">
<span class="mono" style="opacity:.6">/mnt/</span>
<input type="text" id="mount-name" class="form-control" placeholder="hdd_1"
pattern="[a-zA-Z0-9_]+" required style="max-width:160px">
</div>
<span class="form-hint">Pl. hdd_1 → a mappa a /mnt/hdd_1 útvonalra kerül</span>
</div>
<div class="form-group">
<label for="storage-label">Megnevezés</label>
<input type="text" id="storage-label" class="form-control" placeholder="Külső HDD 1TB" maxlength="50">
</div>
<label class="toggle" style="margin-bottom:1.5rem">
<input type="checkbox" id="set-default" checked>
<span class="toggle-label">Beállítás alapértelmezett adattárolóként új telepítéseknél</span>
</label>
<div class="alert alert-info" style="margin-bottom:1.5rem">
<strong>️ Megjegyzés:</strong> A meghajtón lévő adatok <strong>NEM</strong> törlődnek.
A controller csak a kiválasztott mappában dolgozik.<br>
<strong>⚠️ A csatlakozási pont (/mnt/&lt;név&gt;) a meghajtó lecsatolásáig nem módosítható.</strong>
</div>
<div class="form-actions" style="gap:.75rem">
<button type="submit" class="btn btn-primary" id="attach-btn">Csatolás</button>
<button type="button" class="btn btn-outline" onclick="backToBrowse()">← Vissza</button>
</div>
<div id="attach-error" class="alert alert-error" style="display:none;margin-top:1rem"></div>
</form>
</div>
<!-- Step 4: Progress -->
<div class="settings-card" id="wizard-progress" style="display:none">
<h3>4. Csatolás folyamatban...</h3>
<div class="disk-progress-steps" id="progress-steps">
<div class="disk-step" id="pstep-validating"><span class="disk-step-icon"></span> Ellenőrzés</div>
<div class="disk-step" id="pstep-mounting"><span class="disk-step-icon"></span> Csatlakoztatás</div>
<div class="disk-step" id="pstep-permissions"><span class="disk-step-icon"></span> Mappák és jogosultságok</div>
<div class="disk-step" id="pstep-done"><span class="disk-step-icon"></span> Regisztráció</div>
</div>
<div style="margin-top:1.5rem;display:flex;align-items:center;gap:1rem">
<div class="progress-bar-task" style="flex:1">
<div class="progress-fill" id="progress-fill" style="width:0%"></div>
</div>
<span id="progress-percent" style="font-size:0.9rem;color:var(--text-muted);font-family:'JetBrains Mono',monospace;white-space:nowrap">0%</span>
</div>
<div id="progress-msg" class="form-hint" style="margin-top:.75rem"></div>
<div id="progress-error" class="alert alert-error" style="display:none;margin-top:1rem"></div>
</div>
<!-- Step 5: Done -->
<div class="settings-card" id="wizard-done" style="display:none">
<h3>✅ Meghajtó sikeresen csatolva!</h3>
<div id="done-info" class="settings-grid" style="margin-top:1rem">
<div class="settings-row">
<span class="settings-label">Útvonal</span>
<span class="settings-value mono" id="done-path"></span>
</div>
</div>
<a href="/settings" class="btn btn-primary" style="margin-top:1.5rem">← Vissza a Beállításokhoz</a>
</div>
<script>
var selectedDevice = null;
var selectedPartition = null;
var currentBrowsePath = '';
var rawMountPath = '';
var selectedSubPath = '';
var pollTimer = null;
// --- Step 1: Scan ---
function scanDisks() {
var btn = document.getElementById('scan-btn');
var errEl = document.getElementById('scan-error');
var resultEl = document.getElementById('scan-result');
btn.textContent = 'Keresés...';
btn.disabled = true;
errEl.style.display = 'none';
resultEl.style.display = 'none';
fetch('/api/storage/scan', {method:'POST'})
.then(function(r){ return r.json(); })
.then(function(data) {
btn.textContent = '🔍 Meghajtók keresése';
btn.disabled = false;
if (!data.ok) {
errEl.textContent = data.error || 'Ismeretlen hiba';
errEl.style.display = 'block';
return;
}
renderScanResult(data);
resultEl.style.display = 'block';
})
.catch(function(e) {
btn.textContent = '🔍 Meghajtók keresése';
btn.disabled = false;
errEl.textContent = 'Hálózati hiba: ' + e.message;
errEl.style.display = 'block';
});
}
function renderScanResult(data) {
var availEl = document.getElementById('available-disks');
var sysEl = document.getElementById('system-disks-note');
// Filter: only show disks that have at least one partition with a filesystem
var disksWithFS = [];
if (data.available) {
data.available.forEach(function(disk) {
if (disk.Partitions) {
var fsPartitions = disk.Partitions.filter(function(p) { return p.FSType && p.FSType !== ''; });
if (fsPartitions.length > 0) {
disksWithFS.push({disk: disk, partitions: fsPartitions});
}
}
});
}
if (disksWithFS.length === 0) {
availEl.innerHTML = '<div class="empty-state" style="padding:1rem">Nem található meglévő fájlrendszerrel rendelkező meghajtó.<br>' +
'<span class="form-hint">Ha üres meghajtót szeretne inicializálni, használja az <a href="/settings/storage/init">inicializálás varázslót</a>.</span></div>';
return;
}
var html = '<h4 style="margin-bottom:.75rem">Talált meghajtók csatolható partíciókkal:</h4>';
disksWithFS.forEach(function(item) {
var disk = item.disk;
html += '<div style="margin-bottom:1rem">';
html += '<div class="form-hint" style="margin-bottom:.5rem">' + disk.Path + ' — ' + (disk.Size || '?') +
(disk.Model ? ' — ' + disk.Model : '') + '</div>';
item.partitions.forEach(function(part) {
var info = part.FSType;
if (part.Label) info += ', címke: ' + part.Label;
if (part.UUID) info += ', UUID: ' + part.UUID.substring(0, 8) + '...';
html += '<div class="storage-path-item" style="cursor:pointer;border:2px solid transparent;margin-bottom:.5rem" ' +
'onclick="selectPartition(this, \'' + part.Path + '\', \'' + (part.FSType || '') + '\', \'' + (part.Label || '') + '\')" ' +
'data-path="' + part.Path + '">' +
'<div class="storage-path-header"><div class="storage-path-info">' +
'<span class="storage-path-label">○ ' + part.Path + ' — ' + (part.Size || '?') + '</span>' +
'<span class="form-hint">' + info + '</span>' +
'</div></div></div>';
});
html += '</div>';
});
availEl.innerHTML = html;
if (data.system && data.system.length > 0) {
var sysNames = data.system.map(function(d){ return d.Path + ' (' + (d.Size||'?') + ')'; }).join(', ');
sysEl.innerHTML = '<span class="form-hint">A rendszermeghajtó(k) nem választhatók: ' + sysNames + '</span>';
sysEl.style.display = 'block';
}
}
function selectPartition(el, path, fsType, label) {
// Deselect all
document.querySelectorAll('[data-path]').forEach(function(d) {
d.style.border = '2px solid transparent';
var lbl = d.querySelector('.storage-path-label');
if (lbl) lbl.textContent = lbl.textContent.replace('● ', '○ ');
});
// Select this
el.style.border = '2px solid var(--accent-blue)';
var lbl = el.querySelector('.storage-path-label');
if (lbl) lbl.textContent = lbl.textContent.replace('○ ', '● ');
selectedDevice = path;
selectedPartition = {path: path, fsType: fsType, label: label};
// Mount raw and go to browse
mountRawAndBrowse(path, fsType);
}
// --- Step 2: Browse ---
function mountRawAndBrowse(devicePath, fsType) {
var errEl = document.getElementById('scan-error');
errEl.style.display = 'none';
fetch('/api/storage/attach/mount-raw', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({device_path: devicePath})
}).then(function(r){ return r.json(); })
.then(function(data) {
if (!data.ok) {
errEl.textContent = data.error || 'Raw mount sikertelen';
errEl.style.display = 'block';
return;
}
rawMountPath = data.raw_path;
// Show browse step
document.getElementById('browse-device').textContent = devicePath;
document.getElementById('browse-fstype').textContent = fsType;
document.getElementById('wizard-scan').style.display = 'none';
document.getElementById('wizard-browse').style.display = 'block';
document.getElementById('wizard-browse').scrollIntoView({behavior:'smooth'});
// Browse root
browseDirectory(rawMountPath);
})
.catch(function(e) {
errEl.textContent = 'Hálózati hiba: ' + e.message;
errEl.style.display = 'block';
});
}
function browseDirectory(path) {
currentBrowsePath = path;
var errEl = document.getElementById('browse-error');
errEl.style.display = 'none';
// Update breadcrumb
var rel = path.replace(rawMountPath, '') || '/';
document.getElementById('dir-breadcrumb').textContent = 'Aktuális mappa: ' + rel;
fetch('/api/storage/attach/browse?path=' + encodeURIComponent(path))
.then(function(r){ return r.json(); })
.then(function(data) {
if (!data.ok) {
errEl.textContent = data.error || 'Hiba a mappák listázásakor';
errEl.style.display = 'block';
return;
}
renderDirList(data.dirs || [], path);
})
.catch(function(e) {
errEl.textContent = 'Hálózati hiba: ' + e.message;
errEl.style.display = 'block';
});
}
function renderDirList(dirs, basePath) {
var listEl = document.getElementById('dir-list');
var html = '';
// "Use this directory" option (select the current directory itself)
html += '<div class="storage-path-item" style="cursor:pointer;border:2px solid transparent;margin-bottom:.5rem;padding:.5rem .75rem" ' +
'onclick="selectDir(this, \'' + escapeJS(basePath) + '\')" data-dirpath="' + escapeAttr(basePath) + '">' +
'<span class="storage-path-label">📂 . (ez a mappa)</span></div>';
// Parent directory (if not at root)
if (basePath !== rawMountPath) {
var parentPath = basePath.substring(0, basePath.lastIndexOf('/'));
if (parentPath.length < rawMountPath.length) parentPath = rawMountPath;
html += '<div style="padding:.3rem .75rem;cursor:pointer;opacity:.7" onclick="browseDirectory(\'' + escapeJS(parentPath) + '\')">' +
'📁 .. (szülő mappa)</div>';
}
if (dirs.length === 0) {
html += '<div class="form-hint" style="padding:.5rem .75rem">Üres mappa</div>';
} else {
dirs.forEach(function(dir) {
html += '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.25rem">';
// Clickable to navigate into
if (dir.has_children) {
html += '<div style="padding:.3rem .75rem;cursor:pointer;flex:1" onclick="browseDirectory(\'' + escapeJS(dir.path) + '\')">' +
'📁 ' + dir.name + ' →</div>';
} else {
html += '<div style="padding:.3rem .75rem;flex:1">📁 ' + dir.name + '</div>';
}
// Select button
html += '<button class="btn btn-xs btn-outline" onclick="selectDir(null, \'' + escapeJS(dir.path) + '\')">Kiválasztás</button>';
html += '</div>';
});
}
listEl.innerHTML = html;
}
function selectDir(el, path) {
selectedSubPath = path;
document.getElementById('selected-dir-display').textContent = path.replace(rawMountPath, '') || '/';
document.getElementById('selected-dir-info').style.display = 'block';
document.getElementById('browse-next-btn').disabled = false;
// Highlight selected
document.querySelectorAll('[data-dirpath]').forEach(function(d) {
d.style.border = '2px solid transparent';
});
if (el) {
el.style.border = '2px solid var(--accent-blue)';
}
// Pre-fill mount name from partition label if available
var mountInput = document.getElementById('mount-name');
if (!mountInput.value && selectedPartition && selectedPartition.label) {
mountInput.value = selectedPartition.label.replace(/[^a-zA-Z0-9_]/g, '_');
}
}
function createDir() {
var nameInput = document.getElementById('new-dir-name');
var name = nameInput.value.trim();
if (!name) return;
var errEl = document.getElementById('browse-error');
errEl.style.display = 'none';
fetch('/api/storage/attach/mkdir', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({path: currentBrowsePath, name: name})
}).then(function(r){ return r.json(); })
.then(function(data) {
if (!data.ok) {
errEl.textContent = data.error || 'Mappa létrehozása sikertelen';
errEl.style.display = 'block';
return;
}
nameInput.value = '';
// Auto-select the created directory
selectedSubPath = data.created_path;
document.getElementById('selected-dir-display').textContent = data.created_path.replace(rawMountPath, '');
document.getElementById('selected-dir-info').style.display = 'block';
document.getElementById('browse-next-btn').disabled = false;
// Refresh directory listing
browseDirectory(currentBrowsePath);
})
.catch(function(e) {
errEl.textContent = 'Hálózati hiba: ' + e.message;
errEl.style.display = 'block';
});
}
function goToConfigure() {
if (!selectedSubPath) return;
document.getElementById('config-device-display').textContent = selectedDevice;
document.getElementById('config-subpath-display').textContent = selectedSubPath.replace(rawMountPath, '') || '/ (gyökérmappa)';
document.getElementById('wizard-browse').style.display = 'none';
document.getElementById('wizard-configure').style.display = 'block';
document.getElementById('wizard-configure').scrollIntoView({behavior:'smooth'});
}
function backToBrowse() {
document.getElementById('wizard-configure').style.display = 'none';
document.getElementById('wizard-browse').style.display = 'block';
}
function cancelAttach() {
// Cleanup raw mount
fetch('/api/storage/attach/cancel', {method:'POST'}).catch(function(){});
window.location.href = '/settings';
}
// --- Step 3: Submit ---
document.getElementById('attach-form').addEventListener('submit', function(e) {
e.preventDefault();
var mountName = document.getElementById('mount-name').value.trim();
var label = document.getElementById('storage-label').value.trim();
var setDefault = document.getElementById('set-default').checked;
var errEl = document.getElementById('attach-error');
if (!mountName) {
errEl.textContent = 'A csatlakoztatási nevet meg kell adni.';
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
document.getElementById('wizard-configure').style.display = 'none';
document.getElementById('wizard-progress').style.display = 'block';
document.getElementById('wizard-progress').scrollIntoView({behavior:'smooth'});
var body = {
device_path: selectedDevice,
mount_name: mountName,
sub_path: selectedSubPath,
label: label,
set_default: setDefault
};
fetch('/api/storage/attach', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
}).then(function(r){ return r.json(); })
.then(function(data) {
if (!data.ok) {
showProgressError(data.error || 'Ismeretlen hiba');
return;
}
pollTimer = setInterval(pollProgress, 1500);
})
.catch(function(e) {
showProgressError('Hálózati hiba: ' + e.message);
});
});
// --- Step 4: Progress ---
var stepOrder = ['validating','mounting','permissions','done'];
function pollProgress() {
fetch('/api/storage/attach/status')
.then(function(r){ return r.json(); })
.then(function(data) {
if (!data.ok) return;
updateProgressUI(data);
if (data.done) {
clearInterval(pollTimer);
if (data.step === 'done') {
showDone('/mnt/' + document.getElementById('mount-name').value.trim());
}
}
})
.catch(function(){});
}
function updateProgressUI(data) {
var currentIdx = stepOrder.indexOf(data.step);
stepOrder.forEach(function(s, i) {
var el = document.getElementById('pstep-' + s);
if (!el) return;
var icon = el.querySelector('.disk-step-icon');
if (i < currentIdx) {
el.className = 'disk-step disk-step-done';
icon.textContent = '✅';
} else if (i === currentIdx) {
el.className = 'disk-step disk-step-active';
icon.textContent = data.step === 'error' ? '❌' : '⏳';
} else {
el.className = 'disk-step';
icon.textContent = '○';
}
});
var pct = data.pct || 0;
document.getElementById('progress-fill').style.width = pct + '%';
document.getElementById('progress-percent').textContent = pct + '%';
document.getElementById('progress-msg').textContent = data.msg || '';
if (data.step === 'error' || data.error) {
showProgressError(data.error || data.msg || 'Ismeretlen hiba');
}
}
function showProgressError(msg) {
clearInterval(pollTimer);
document.getElementById('progress-error').textContent = 'Hiba: ' + msg;
document.getElementById('progress-error').style.display = 'block';
document.getElementById('wizard-progress').querySelector('h3').textContent = 'Csatolás sikertelen';
}
function showDone(mountPath) {
document.getElementById('wizard-progress').style.display = 'none';
document.getElementById('wizard-done').style.display = 'block';
document.getElementById('done-path').textContent = mountPath;
document.getElementById('wizard-done').scrollIntoView({behavior:'smooth'});
}
// --- Helpers ---
function escapeJS(s) {
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
function escapeAttr(s) {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Cleanup on page unload (best-effort)
window.addEventListener('beforeunload', function() {
if (rawMountPath && !document.getElementById('wizard-done').style.display !== 'none') {
// Best-effort cleanup via sendBeacon
navigator.sendBeacon('/api/storage/attach/cancel');
}
});
</script>
{{template "layout_end" .}}
{{end}}