R-280: attach list from mounted-but-unregistered filesystems; two-clicks promise made conditional
gates / gates (push) Successful in 17s

After a reinstall the data drive could not be re-attached through any dashboard
route: both candidate lists came from the agent's unclaimed-disk scan, and the
rebuilt box's drives are claimed. The restore page said it was two clicks while
pointing at an empty picker.

The attach list now also carries the controller's own mounted-but-unregistered
filesystems. initialize is untouched, so the format wizard's system/backup
protection is unchanged. The 'two clicks' sentence is conditional on the picker
being non-empty, and says something true and actionable when it is not.
This commit is contained in:
2026-08-10 13:41:32 +02:00
parent c732fe1283
commit b762a37097
9 changed files with 600 additions and 12 deletions
@@ -71,12 +71,24 @@
repository the whole time. Installed-ness is a property OF a row, never a filter on it. -->
<!-- R-252: the precondition a rebuilt box fails, said BEFORE the customer presses a button that
would refuse. Rendered only when it is true — a healthy box sees nothing new here. -->
<!-- R-280: the „két kattintás" promise is conditional on the picker it points at being non-empty.
It was printed unconditionally, and on a rebuilt box the picker had nothing in it — the
sentence sent the customer to an empty page and the wall had no way past it. The false branch
says what is true and names a route, rather than promising a click that does not exist. -->
{{if .NoRestoreDestination}}
<p class="form-hint" style="border-left:2px solid var(--amber);padding-left:.75rem">
<strong>Előbb csatold vissza az adatmeghajtót.</strong> A mentéseid megvannak, és a meghajtók is
megvannak — újratelepítés után viszont a gép még nem ismeri őket, ezért most nincs hová
visszaállítani. Ez két kattintás: <a href="/storage" style="color:var(--blue)">Tárhely →
visszaállítani.
{{if .HasAttachDestination}}
Ez két kattintás: <a href="/storage" style="color:var(--blue)">Tárhely →
Meghajtók</a>, „Meglévő meghajtó csatolása". Utána gyere vissza ide.
{{else}}
Csatolható meghajtót viszont most nem látunk ezen a gépen, ezért ezt innen nem tudod
elindítani. Ha a meghajtó be van kötve, de nincs csatlakoztatva, az üzemeltető tudja
csatlakoztatni — jelezd neki. A <a href="/storage" style="color:var(--blue)">Tárhely →
Meghajtók</a> oldalon látod, mit ismer most a gép.
{{end}}
</p>
{{end}}
{{if eq .OffsiteStoreState "unreadable"}}
@@ -23,7 +23,9 @@
<label>Kiválasztott eszköz</label>
<span class="settings-value mono" id="sel-device"></span>
</div>
<div class="form-group">
<!-- R-280: an already-mounted store has no mount name to choose — it is already at its path,
and the action is to register that path. The group is hidden for those. -->
<div class="form-group" id="mount-name-group">
<label for="mount-name">Csatlakoztatási név <span class="required">*</span></label>
<div style="display:flex;align-items:center;gap:.25rem">
<span class="mono" style="opacity:.6">/mnt/</span>
@@ -32,6 +34,10 @@
</div>
<span class="form-hint">A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.</span>
</div>
<p class="form-hint" id="mounted-note" style="display:none">
Ez a meghajtó már csatlakoztatva van, csak a gép nem tartja nyilván. A „Csatolás" a
meglévő helyén veszi nyilvántartásba — <strong>semmi nem törlődik</strong>.
</p>
<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">
@@ -49,7 +55,7 @@
</div>
<script>
var selDevice = "", selFSType = "";
var selDevice = "", selFSType = "", selMounted = false;
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function fmtSize(b){
if(!b) return '';
@@ -74,11 +80,15 @@ async function loadDisks(){
if(d.fstype) parts.push(esc(d.fstype));
var sub = parts.join(' · ');
// Attach the FS-bearing node (mount_source, e.g. /dev/sdd1); the agent resolves its UUID.
// R-280: for an already-mounted store mount_source is its MOUNTPOINT (/mnt/sys_drive) and the
// action is register-in-place, so the card is labelled by the path the customer will see.
var dev = d.mount_source || d.device;
var mounted = !!d.already_mounted;
if(mounted){ title = esc(d.mount_source); sub = [esc(d.device), esc(d.fstype)].filter(Boolean).join(' · '); }
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-fs="'+esc(d.fstype)+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-fs="'+esc(d.fstype)+'" data-i="'+i+'" data-mounted="'+(mounted?'1':'')+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges"><span class="badge badge-ok">Fájlrendszer: '+esc(d.fstype)+'</span></div></div></label>';
+'<div class="drive-badges">'+(mounted?'<span class="badge badge-ok">Már csatlakoztatva</span>':'<span class="badge badge-ok">Fájlrendszer: '+esc(d.fstype)+'</span>')+'</div></div></label>';
});
html+='</div>';
document.getElementById('disk-list').innerHTML=html;
@@ -87,7 +97,14 @@ async function loadDisks(){
function pickDisk(radio){
selDevice=radio.value; selFSType=radio.getAttribute('data-fs')||"";
selMounted=!!radio.getAttribute('data-mounted');
document.getElementById('sel-device').textContent=selDevice;
// An already-mounted store keeps its own path; there is no name to pick. Dropping `required` too,
// or the hidden empty field blocks form submission with no visible cause.
var mng=document.getElementById('mount-name-group'), mni=document.getElementById('mount-name');
mng.style.display = selMounted ? 'none' : '';
mni.required = !selMounted;
document.getElementById('mounted-note').style.display = selMounted ? '' : 'none';
document.querySelectorAll('.drive-card').forEach(function(c){c.classList.remove('is-picked');});
var card=document.getElementById('dc-'+radio.getAttribute('data-i')); if(card) card.classList.add('is-picked');
document.getElementById('cfg-card').style.display='block';
@@ -99,12 +116,22 @@ async function submitAttach(ev){
var btn=document.getElementById('attach-btn'); var out=document.getElementById('attach-result');
btn.disabled=true; out.innerHTML='<p class="form-hint">Csatlakoztatás folyamatban…</p>';
try{
var body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value,
label:document.getElementById('storage-label').value, set_default:document.getElementById('set-default').checked};
var r=await fetch('/api/storage/attach',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
// R-280: an already-mounted store is REGISTERED in place. Sending it to /api/storage/attach would
// ask the agent to mount an in-guest path as if it were a raw device.
var url, body;
if(selMounted){
url='/api/storage/register-mounted';
body={path:selDevice, label:document.getElementById('storage-label').value,
set_default:document.getElementById('set-default').checked};
}else{
url='/api/storage/attach';
body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value,
label:document.getElementById('storage-label').value, set_default:document.getElementById('set-default').checked};
}
var r=await fetch(url,{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
var j=await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); }
out.innerHTML='<div class="alert alert-success">A meghajtó sikeresen csatolva és regisztrálva: <strong class="mono">'+(j.data.where||'')+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
out.innerHTML='<div class="alert alert-success">A meghajtó sikeresen '+(selMounted?'nyilvántartásba véve':'csatolva és regisztrálva')+': <strong class="mono">'+(j.data.where||'')+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+e.message+'</div>'; btn.disabled=false; }
return false;
}