storage_network: full redesign on the canonical form pattern + protocol-honest guidance (v0.113.0 UI)

Kills the details/summary-button hack and the nonexistent form-row/form-input
classes (the unstyled-look root cause); rebuilds on the storage_attach pattern
(settings-card / form-group / form-control / form-actions / alert-*). SMB listed
first (consumer reality), NFS two-recipe guidance (map-all-users simple recipe +
full-fidelity anonuid=<uid+100000> with a live computed host-id), §3.2 staged
poll progress (Kapcsolódás/Csatolási teszt/Írásteszt/Regisztrálás), categorized
Hungarian errors with a collapsible raw-detail block, orphan rows with an Árva
badge + remove-only action. C8 render smoke guards the class regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 10:05:08 +02:00
parent bb8737a81f
commit a65dcff85a
2 changed files with 252 additions and 95 deletions
@@ -0,0 +1,56 @@
package web
import (
"bytes"
"strings"
"testing"
)
// C8 — template smoke for the redesigned NAS page: it renders through the PRODUCTION template tree
// and uses the canonical form classes; the nonexistent classes (`form-row`/`form-input`) and the
// summary-styled-as-button hack — the root causes of the unstyled look this task fixed — must never
// come back. Companion red-proof: reintroduce `class="form-input"` on an input → FAIL.
func TestStorageNetworkTemplate_CanonicalClasses(t *testing.T) {
s := testServer(t)
s.loadTemplates()
data := map[string]interface{}{
"Page": "storage-network", "Title": "Hálózati tárhely",
"NetworkStoragePaths": []networkStorageItem{
{Name: "media", Label: "NAS media", Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/media", Path: "/mnt/felhom-drives/media", Health: "ok"},
{Name: "ghost", Label: "Árva megosztás: ghost", Protocol: "nfs", Server: "10.0.0.5", Export: "/srv/ghost", Path: "/mnt/felhom-drives/ghost", Health: "idle", Orphan: true},
},
}
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, "storage_network", data); err != nil {
t.Fatalf("render storage_network: %v", err)
}
html := buf.String()
for _, want := range []string{
`class="form-control"`, // the canonical input class
`class="form-group"`, // the canonical field wrapper
"SMB (Synology, QNAP", // protocol-honest ordering: SMB listed first
"NFS (TrueNAS, Linux szerver)", // no bare "ajánlott" claim
"Árva", // the orphan badge renders
"minden felhasználó leképezése", // the Route-A guidance block
"ns-hostid", // the live computed host-id span
"/api/storage/netstorage/add/status", // the poll-driven progress wiring
} {
if !strings.Contains(html, want) {
t.Errorf("rendered page missing %q", want)
}
}
for _, banned := range []string{
"form-row", // nonexistent class — the old unstyled-look root cause
"form-input", // nonexistent class
`<summary class="btn`, // the summary-styled-as-button hack
} {
if strings.Contains(html, banned) {
t.Errorf("rendered page still contains the banned pattern %q", banned)
}
}
// The orphan row renders with ONLY the remove action (no attach/migrate verbs anywhere near it).
if !strings.Contains(html, "Eltávolítás") {
t.Error("remove action missing")
}
}
@@ -2,112 +2,213 @@
{{template "layout_start" .}}
<div class="page-header">
<h2>Tárhely — Hálózati tárhely (NAS)</h2>
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>
<h2>Tárhely — Hálózati tárhely (NAS)</h2>
</div>
</div>
<div class="settings-card">
<!-- NAS network storage (Part A2) — a distinct class from the physical drives above. No
<!-- NAS network storage (Part A2) — a distinct class from the physical drives. No
leválasztás/leszerelés/áthelyezés/törlés; the only lifecycle action is Eltávolítás. -->
<div class="storage-section">
<h3 style="margin-bottom:.25rem">NAS-megosztások</h3>
<p class="form-hint" style="margin-top:0">
Egy NAS-megosztás (NFS vagy SMB) csatlakoztatása nagy méretű médiatartalomhoz (film, fotó, zene).
A megosztás kiválasztható médiaalkalmazás adatkönyvtáraként. A hálózati tárhely nem fizikai
meghajtó — nincs leszerelés/áthelyezés, csak eltávolítás.
</p>
{{if .NetworkStoragePaths}}
<div class="storage-paths-list">
{{range .NetworkStoragePaths}}
<div class="storage-path-item{{if eq .Health "unreachable"}} storage-disconnected{{end}}">
<div class="storage-path-header">
<div class="storage-path-info">
<span class="storage-path-label">{{.Label}}</span>
<span class="storage-path-path mono">{{.Protocol}} · {{.Server}}:{{.Export}} → {{.Path}}</span>
</div>
<div class="storage-path-badges">
{{if eq .Health "ok"}}<span class="badge badge-ok" title="A megosztás elérhető és csatlakoztatva van">Elérhető</span>
{{else if eq .Health "idle"}}<span class="badge badge-neutral" title="Elérhető, jelenleg készenlétben (igény szerint csatlakozik)">Készenlét</span>
{{else if eq .Health "unreachable"}}<span class="badge badge-warn" title="A NAS jelenleg nem érhető el — az érintett alkalmazások átmenetileg nem olvasnak róla">Nem elérhető</span>
{{else}}<span class="badge badge-neutral" title="Az állapot jelenleg nem lekérdezhető">Ismeretlen</span>{{end}}
</div>
<h3>NAS-megosztások</h3>
<p class="settings-card-desc">
Egy NAS-megosztás (NFS vagy SMB) csatlakoztatása nagy méretű médiatartalomhoz (film, fotó, zene).
A megosztás kiválasztható médiaalkalmazás adatkönyvtáraként. A hálózati tárhely nem fizikai
meghaj— nincs leszerelés/áthelyezés, csak eltávolítás.
</p>
{{if .NetworkStoragePaths}}
<div class="storage-paths-list">
{{range .NetworkStoragePaths}}
<div class="storage-path-item{{if eq .Health "unreachable"}} storage-disconnected{{end}}">
<div class="storage-path-header">
<div class="storage-path-info">
<span class="storage-path-label">{{.Label}}</span>
<span class="storage-path-path mono">{{.Protocol}} · {{.Server}}:{{.Export}} → {{.Path}}</span>
{{if .Orphan}}<span class="form-hint">A megosztás be van állítva a gazdagépen, de nincs regisztrálva — egy megszakadt csatlakoztatás maradványa. Távolítsa el, vagy adja hozzá újra ugyanezzel a névvel.</span>{{end}}
</div>
<div class="storage-path-actions">
<button class="btn btn-xs btn-danger-outline" onclick="netStorageRemove('{{.Name}}','{{.Label}}')">Eltávolítás</button>
<div class="storage-path-badges">
{{if .Orphan}}<span class="badge badge-warn" title="Beállítva a gazdagépen, de nincs regisztrálva">Árva</span>
{{else if eq .Health "ok"}}<span class="badge badge-ok" title="A megosztás elérhető és csatlakoztatva van">Elérhető</span>
{{else if eq .Health "idle"}}<span class="badge badge-neutral" title="Elérhető, jelenleg készenlétben (igény szerint csatlakozik)">Készenlét</span>
{{else if eq .Health "unreachable"}}<span class="badge badge-warn" title="A NAS jelenleg nem érhető el — az érintett alkalmazások átmenetileg nem olvasnak róla">Nem elérhető</span>
{{else}}<span class="badge badge-neutral" title="Az állapot jelenleg nem lekérdezhető">Ismeretlen</span>{{end}}
</div>
</div>
{{end}}
<div class="storage-path-actions">
<button class="btn btn-xs btn-danger-outline" onclick="netStorageRemove('{{.Name}}','{{.Label}}')">Eltávolítás</button>
</div>
</div>
{{else}}
<p class="form-hint">Nincs hálózati tárhely beállítva.</p>
{{end}}
<details style="margin-top:.75rem">
<summary class="btn btn-xs btn-primary" style="cursor:pointer;display:inline-block">Hálózati tárhely hozzáadása</summary>
<div style="margin-top:.75rem;padding:1rem;border:1px solid var(--border,#ddd);border-radius:6px;max-width:520px">
<div class="form-row"><label>Név (azonosító)</label>
<input id="ns-name" type="text" placeholder="pl. media" class="form-input"></div>
<div class="form-row"><label>Protokoll</label>
<select id="ns-protocol" class="form-input" onchange="nsToggleSmb()">
<option value="nfs">NFS (ajánlott)</option>
<option value="smb">SMB / CIFS</option>
</select></div>
<div class="form-row"><label>Szerver (IP vagy hosztnév)</label>
<input id="ns-server" type="text" placeholder="pl. 192.168.0.10" class="form-input"></div>
<div class="form-row"><label id="ns-export-label">Megosztás (NFS export útvonal)</label>
<input id="ns-export" type="text" placeholder="pl. /volume1/media" class="form-input"></div>
<div class="form-row"><label>Alkalmazás felhasználói azonosító (uid)</label>
<input id="ns-uid" type="number" value="1000" class="form-input">
<span class="form-hint">A legtöbb médiaalkalmazás 1000-es uid-del fut.</span></div>
<div id="ns-smb-creds" style="display:none">
<div class="form-row"><label>SMB felhasználónév</label>
<input id="ns-username" type="text" autocomplete="off" class="form-input"></div>
<div class="form-row"><label>SMB jelszó</label>
<input id="ns-password" type="password" autocomplete="new-password" class="form-input">
<span class="form-hint">A jelszót a gazda ügynök 0600-as fájlba írja; a vezérlő nem tárolja.</span></div>
</div>
<button class="btn btn-sm btn-primary" style="margin-top:.5rem" onclick="netStorageAdd()">Csatlakoztatás</button>
<div id="ns-add-msg" class="form-hint" style="margin-top:.5rem"></div>
</div>
</details>
</div>
<script>
function nsToggleSmb(){
var smb = document.getElementById('ns-protocol').value === 'smb';
document.getElementById('ns-smb-creds').style.display = smb ? 'block' : 'none';
document.getElementById('ns-export-label').textContent = smb ? 'Megosztás (SMB megosztásnév)' : 'Megosztás (NFS export útvonal)';
document.getElementById('ns-export').placeholder = smb ? 'pl. media' : 'pl. /volume1/media';
}
function netStorageAdd(){
var msg = document.getElementById('ns-add-msg');
var body = {
name: (document.getElementById('ns-name').value||'').trim(),
protocol: document.getElementById('ns-protocol').value,
server: (document.getElementById('ns-server').value||'').trim(),
export: (document.getElementById('ns-export').value||'').trim(),
mapped_uid: parseInt(document.getElementById('ns-uid').value||'1000',10),
mapped_gid: parseInt(document.getElementById('ns-uid').value||'1000',10),
username: (document.getElementById('ns-username').value||''),
password: (document.getElementById('ns-password').value||'')
};
msg.textContent = 'Csatlakoztatás folyamatban…';
fetch('/api/storage/netstorage/add',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)})
.then(function(r){return r.json();}).then(function(d){
if(d.ok){ msg.textContent='Sikeres'; setTimeout(function(){location.reload();},900); }
else { msg.textContent='Hiba: '+(d.error||'ismeretlen'); }
}).catch(function(e){ msg.textContent='Hiba: '+e; });
}
function netStorageRemove(name,label){
openDialog({title:'Hálózati tárhely eltávolítása', confirmLabel:'Eltávolítás',
message:'Biztosan eltávolítja a(z) '+label+' hálózati tárhelyet?\n\nA megosztás leválasztásra kerül; a NAS-on lévő adatok érintetlenek maradnak.',
onConfirm:function(){
fetch('/api/storage/netstorage/remove',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({name:name})})
.then(function(r){return r.json();}).then(function(d){
if(d.ok){ location.reload(); } else { alert('Hiba: '+(d.error||'ismeretlen')); }
}).catch(function(e){ alert('Hiba: '+e); });
}});
}
</script>
{{else}}
<p class="form-hint">Nincs hálózati tárhely beállítva.</p>
{{end}}
</div>
<div class="settings-card">
<h3>Hálózati tárhely hozzáadása</h3>
<p class="settings-card-desc">A csatlakoztatás előtt a rendszer ellenőrzi a megosztást: valóban
csatolható-e, és tudnak-e írni rá az alkalmazások. Hiba esetén semmi nem marad félkészen beállítva.</p>
<form id="ns-add-form" onsubmit="return netStorageAdd(event)">
<div class="form-group">
<label for="ns-protocol">Protokoll</label>
<select id="ns-protocol" class="form-control" onchange="nsToggleSmb()">
<option value="smb">SMB (Synology, QNAP — a legtöbb NAS)</option>
<option value="nfs">NFS (TrueNAS, Linux szerver)</option>
</select>
</div>
<div class="form-group">
<label for="ns-name">Név (azonosító) <span class="required">*</span></label>
<input id="ns-name" type="text" class="form-control" placeholder="pl. media"
pattern="[a-zA-Z0-9_-]+" required style="max-width:220px">
<span class="form-hint">A megosztás a /mnt/felhom-drives/&lt;név&gt; útvonalon lesz elérhető.</span>
</div>
<div class="form-group">
<label for="ns-server">Szerver (IP vagy hosztnév) <span class="required">*</span></label>
<input id="ns-server" type="text" class="form-control" placeholder="pl. 192.168.0.10" required style="max-width:220px">
</div>
<div class="form-group">
<label for="ns-export" id="ns-export-label">Megosztás neve</label>
<input id="ns-export" type="text" class="form-control" placeholder="pl. media" required style="max-width:320px">
</div>
<div class="form-group">
<label for="ns-uid">Alkalmazás felhasználói azonosító (uid)</label>
<input id="ns-uid" type="number" value="1000" class="form-control" style="max-width:120px" oninput="nsUpdateHostID()">
<span class="form-hint">A legtöbb médiaalkalmazás 1000-es azonosítóval fut.</span>
</div>
<div id="ns-smb-creds">
<div class="form-group">
<label for="ns-username">SMB felhasználónév <span class="required">*</span></label>
<input id="ns-username" type="text" autocomplete="off" class="form-control" style="max-width:220px">
</div>
<div class="form-group">
<label for="ns-password">SMB jelszó <span class="required">*</span></label>
<input id="ns-password" type="password" autocomplete="new-password" class="form-control" style="max-width:220px">
<span class="form-hint">A jelszót a gazda ügynök 0600-as fájlba írja; a vezérlő nem tárolja.</span>
</div>
</div>
<div class="form-actions" style="gap:.75rem">
<button type="submit" class="btn btn-primary" id="ns-add-btn">Csatlakoztatás</button>
</div>
<div id="ns-add-result" style="margin-top:1rem"></div>
</form>
</div>
<div class="settings-card">
<h3>Mit kell beállítani a NAS-on?</h3>
<details>
<summary>Útmutató megjelenítése</summary>
<p>Engedélyezze a Felhom gép IP-címét a megosztáson. Proxmox-fürt esetén minden csomópont IP-címét.</p>
<h4>SMB (Synology, QNAP — a legtöbb NAS)</h4>
<p>Hozzon létre (vagy jelöljön ki) egy felhasználót a NAS-on, és adjon neki írás-olvasás jogot a
megosztásra. Más beállítás nem szükséges — a fájlok a NAS-on ennek a felhasználónak a nevében
jönnek létre.</p>
<h4>NFS</h4>
<p>Egyszerű (a legtöbb NAS-hoz): kapcsolja be az exporton a „minden felhasználó leképezése” (map all
users / all squash) opciót írás-olvasás móddal — bármelyik helyi felhasználóra. A fájlok
tulajdonosa a rendszerben „nobody”-ként látszik; az alkalmazások túlnyomó többségének ez megfelelő.</p>
<p>Teljes értékű (TrueNAS / Linux szerver): export a következő opciókkal:
<code class="mono">rw,all_squash,anonuid=<span class="ns-hostid">101000</span>,anongid=<span class="ns-hostid">101000</span></code>
— így a tulajdonos-információk is hibátlanok.</p>
<p class="form-hint">A Synology/QNAP felületére szabott lépésről lépésre útmutató készül.</p>
</details>
</div>
<script>
function nsToggleSmb(){
var smb = document.getElementById('ns-protocol').value === 'smb';
document.getElementById('ns-smb-creds').style.display = smb ? 'block' : 'none';
document.getElementById('ns-export-label').textContent = smb ? 'Megosztás neve' : 'Megosztás (NFS export útvonal)';
document.getElementById('ns-export').placeholder = smb ? 'pl. media' : 'pl. /volume1/media';
document.getElementById('ns-username').required = smb;
document.getElementById('ns-password').required = smb;
}
function nsUpdateHostID(){
var uid = parseInt(document.getElementById('ns-uid').value||'1000',10);
if(isNaN(uid) || uid < 0){ uid = 1000; }
document.querySelectorAll('.ns-hostid').forEach(function(el){ el.textContent = String(uid + 100000); });
}
function nsEsc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
// Poll-driven progress: the add starts a server-side verify job; we poll its status and show the
// staged Hungarian text until a terminal phase.
var nsPhaseText = {
agent_add: 'Kapcsolódás a szerverhez…',
verifying: 'Csatolási teszt…',
probing: 'Írásteszt…',
registering: 'Regisztrálás…'
};
var nsPollTimer = null;
function netStorageAdd(ev){
ev.preventDefault();
var btn = document.getElementById('ns-add-btn');
var out = document.getElementById('ns-add-result');
var uid = parseInt(document.getElementById('ns-uid').value||'1000',10);
var body = {
name: (document.getElementById('ns-name').value||'').trim(),
protocol: document.getElementById('ns-protocol').value,
server: (document.getElementById('ns-server').value||'').trim(),
export: (document.getElementById('ns-export').value||'').trim(),
mapped_uid: uid,
mapped_gid: uid,
username: (document.getElementById('ns-username').value||''),
password: (document.getElementById('ns-password').value||'')
};
btn.disabled = true;
out.innerHTML = '<p class="form-hint">' + nsPhaseText.agent_add + '</p>';
fetch('/api/storage/netstorage/add',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)})
.then(function(r){return r.json();}).then(function(d){
if(!d.ok){ nsShowError((d.error||'ismeretlen hiba'), ''); btn.disabled=false; return; }
nsPollTimer = setInterval(nsPollStatus, 1500);
}).catch(function(e){ nsShowError(String(e), ''); btn.disabled=false; });
return false;
}
function nsPollStatus(){
fetch('/api/storage/netstorage/add/status')
.then(function(r){return r.json();}).then(function(d){
var st = (d.data||{});
var out = document.getElementById('ns-add-result');
if(st.phase === 'done'){
clearInterval(nsPollTimer);
var warn = st.warn ? ' <span class="form-hint">(' + nsEsc(st.warn) + ')</span>' : '';
out.innerHTML = '<div class="alert alert-success">A hálózati tárhely sikeresen csatlakoztatva és ellenőrizve: <strong class="mono">' + nsEsc(st.path||'') + '</strong>.' + warn + '</div>';
setTimeout(function(){ location.reload(); }, 1500);
} else if(st.phase === 'failed'){
clearInterval(nsPollTimer);
nsShowError(st.message || 'A csatolás sikertelen.', st.detail || '');
document.getElementById('ns-add-btn').disabled = false;
} else if(nsPhaseText[st.phase]){
out.innerHTML = '<p class="form-hint">' + nsPhaseText[st.phase] + '</p>';
}
}).catch(function(){ /* transient poll error — keep polling */ });
}
function nsShowError(message, detail){
var out = document.getElementById('ns-add-result');
var html = '<div class="alert alert-error">' + nsEsc(message);
if(detail){
html += '<details class="mono" style="margin-top:.5rem"><summary>Technikai részletek</summary><p style="white-space:pre-wrap">' + nsEsc(detail) + '</p></details>';
}
html += '</div>';
out.innerHTML = html;
}
function netStorageRemove(name,label){
openDialog({title:'Hálózati tárhely eltávolítása', confirmLabel:'Eltávolítás',
message:'Biztosan eltávolítja a(z) '+label+' hálózati tárhelyet?\n\nA megosztás leválasztásra kerül; a NAS-on lévő adatok érintetlenek maradnak.',
onConfirm:function(){
fetch('/api/storage/netstorage/remove',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({name:name})})
.then(function(r){return r.json();}).then(function(d){
if(d.ok){ location.reload(); } else { alert('Hiba: '+(d.error||'ismeretlen')); }
}).catch(function(e){ alert('Hiba: '+e); });
}});
}
nsToggleSmb();
nsUpdateHostID();
</script>
<div id="dialog-root"></div>
<script>
// Light overlay dialog (D1) — replaces the native blocking browser dialogs (same texts).