W13 new-drive pages pre-fill the next free hdd_N and answer a bad name visibly; W14 drive operator detail behind Részletek; W29 plain decommission names no move; unreleased
gates / gates (push) Successful in 1m37s

This commit is contained in:
2026-10-21 22:51:08 +02:00
parent 8c1e290f63
commit 28b05d6d68
16 changed files with 1349 additions and 32 deletions
+17
View File
@@ -64,6 +64,23 @@
titkosított adat van. Ez a másolat független a helyi másodpéldánytól és a teljes rendszermentéstől." New parity case
`backups_remote_pinned`; the `backups_remote_*`, `escrow_first`, `escrow_receremony` and `settings_security_*`
fixtures regenerated on purpose (only the lines above changed).
- **W13 — the new-drive pages never refuse in silence.** The mount name is pre-filled with the next free `hdd_N`
(`hdd_1` when none; every registered path counts as taken, a decommissioned one too — `nextFreeMountName`), the hint
adds „Hagyd így, ha nem tudod." (EN „Leave it as it is if you are not sure."), and the form is `novalidate`: an empty
or wrong name answers with a visible sentence, „Írj be egy csatlakoztatási nevet. Csak betű, szám, _ és - lehet
benne, legfeljebb 40 karakter. Ha nem tudod, írd be: hdd_2" (EN „Type a mount name. Only letters, digits, _ and - are
allowed, at most 40 characters. If you are not sure, type: hdd_2"), never only a browser tooltip. The same on the
attach page (same defect). `TestW13_StorageInitSuggestsTheNextFreeNameAndNeverFailsSilently`; `storage_init` and
`storage_attach` fixtures regenerated on purpose.
- **W14 — the storage page's drive cards** keep the type, device, path and durable id („local-lvm lvmthin · pve/data",
„10.77.0.1:felhom-offsite#…") behind „Részletek" / „Details"; the name, purpose line, badges and fill bar stay. The
`storage_full` / `storage_empty` fixtures regenerated on purpose.
- **W29 — a plain decommission no longer shows the move sentence with empty blanks** („Adatok átköltöztetve ide: .
()"). With nothing moved the card reads „Leszerelve. Nem költöztettünk át adatot róla; ami rajta volt, a meghajtón
maradt." (EN „Decommissioned. No data was moved off it; whatever was on it stays on the drive."). NOT the brief's
„Leszerelve, adat nem volt rajta": a decommission-anyway stops the apps on the drive and unmounts it without moving or
formatting, so the data may well still be on it. New parity case `storage_decommissioned_plain`;
`TestW29_PlainDecommissionNamesNoMove`.
## v0.337.0 (2026-10-21) — kept second-drive copies (D25: R-1175, R-1181, R-1179); a `.fab` import keeps the family gate (R-1191); a stale restore temp no longer blocks the retry (R-1188 c); the overcommit sentence made true (R-1192); escrow recorded before the floor (R-1176); a finished off-site abandonment closes on the hub's word (R-1182); storage labels follow the language (W26)
+5 -2
View File
@@ -1409,6 +1409,7 @@
"err.stacks.field_required": "the %q (%s) field has to be filled in",
"err.stacks.needs_drive": "This app needs a storage drive. You can add one on the Storage → Drives page.",
"storage_wizard.resz_n": "part {n}",
"storage_wizard.name_invalid": "Type a mount name. Only letters, digits, _ and - are allowed, at most 40 characters. If you are not sure, type: {name}",
"err.web.meghajto_hasznalatban_formazas": "This disk holds a drive that is in use, so the whole disk cannot be formatted. You can format only its free part.",
"storage.belso_tarhely_magyarazat": "It is on the machine's own disk, but it is not part of the full system backup. A different disk or the off-site backup protects against a disk failure.",
"backup.target.offer_unproven": "You can choose this drive as the place for the system backup. Felhom cannot check that this drive is really a different disk (inside a virtual machine and behind some USB enclosures this is not visible), so we cannot promise that you can restore the system after a disk failure.",
@@ -2277,6 +2278,7 @@
"storage.a_z": "Drive",
"storage.adatok_athelyezese": "Moving data",
"storage.adatok_atkoltoztetve_ide": "Data moved to: <strong>{{.MigratedToLabel}}</strong> ({{.MigratedTo}})",
"storage.leszerelve_nem_koltoztettunk": "Decommissioned. No data was moved off it; whatever was on it stays on the drive.",
"storage.adatok_masolasa": "Copying data",
"storage.adatot_tartalmaz": "Holds data",
"storage.adattarolok": "Data storage",
@@ -2393,6 +2395,7 @@
"storage.regisztracio_sikertelen": "Registration failed:",
"storage.regisztralas": "Register",
"storage.regisztralva": "Registered",
"storage.reszletek": "Details",
"storage.rendszer_vedett": "System — protected",
"storage.rendszermeghajtok": "System drives",
"storage.rendszermeghajton": "On the system drive",
@@ -2412,7 +2415,7 @@
"storage.visszacsatlakoztatja_a_z": "Reconnect the",
"storage_attach.1_meghajto_kivalasztasa": "1. Choose a drive",
"storage_attach.2_konfiguracio": "2. Configuration",
"storage_attach.a_meghajto_a_mnt_nev": "The drive goes to /mnt/&lt;name&gt;.",
"storage_attach.a_meghajto_a_mnt_nev": "The drive goes to /mnt/&lt;name&gt;. Leave it as it is if you are not sure.",
"storage_attach.a_meghajto_sikeresen": "The drive was",
"storage_attach.beallitas_alapertelmezett_adattarolokent": "Use as the default data storage for new installs",
"storage_attach.betoltes": "Loading…",
@@ -2439,7 +2442,7 @@
"storage_init.1_meghajto_kivalasztasa": "1. Choose a drive",
"storage_init.2_konfiguracio": "2. Configuration",
"storage_init.a_kivalasztott_meghajto_adatot_tartalmaz": "The chosen drive <strong>holds data</strong>. Initialising (formatting) deletes all data\n on it — you have to confirm to continue.",
"storage_init.a_meghajto_a_mnt_nev": "The drive goes to /mnt/&lt;name&gt;.",
"storage_init.a_meghajto_a_mnt_nev": "The drive goes to /mnt/&lt;name&gt;. Leave it as it is if you are not sure.",
"storage_init.a_meghajto_adatot_tartalmaz_a": "The drive holds data. Formatting <strong>deletes for good</strong> all data on it.",
"storage_init.a_meghajto_sikeresen_inicializalva_es": "The drive is initialised and registered:",
"storage_init.adatot_tartalmaz_a_formazas_torli": "Holds data — formatting deletes it",
+5 -2
View File
@@ -1402,6 +1402,7 @@
"err.stacks.field_required": "a(z) %q (%s) mező kitöltése kötelező",
"err.stacks.needs_drive": "Ehhez az alkalmazáshoz egy adattároló kell. A Meghajtók oldalon adhatsz hozzá egyet.",
"storage_wizard.resz_n": "{n}. rész",
"storage_wizard.name_invalid": "Írj be egy csatlakoztatási nevet. Csak betű, szám, _ és - lehet benne, legfeljebb 40 karakter. Ha nem tudod, írd be: {name}",
"err.web.meghajto_hasznalatban_formazas": "Ezen a lemezen egy használatban lévő meghajtó van, ezért a teljes lemez nem formázható. Csak a szabad részét formázhatod.",
"storage.belso_tarhely_magyarazat": "A gép saját lemezén van, de nem része a teljes rendszermentésnek. Egy lemezhiba ellen egy másik lemez vagy a távoli mentés véd.",
"backup.target.offer_unproven": "Ezt a meghajtót kijelölheted a rendszermentés helyéül. A Felhom nem tudja ellenőrizni, hogy ez a meghajtó valóban másik lemez-e (virtuális gépben és egyes USB-házakban ez nem látszik), ezért nem ígérhetjük, hogy egy lemezhiba után is vissza tudod állítani a rendszert.",
@@ -2265,6 +2266,7 @@
"storage.a_z": "A(z)",
"storage.adatok_athelyezese": "Adatok áthelyezése",
"storage.adatok_atkoltoztetve_ide": "Adatok átköltöztetve ide: <strong>{{.MigratedToLabel}}</strong> ({{.MigratedTo}})",
"storage.leszerelve_nem_koltoztettunk": "Leszerelve. Nem költöztettünk át adatot róla; ami rajta volt, a meghajtón maradt.",
"storage.adatok_masolasa": "Adatok másolása",
"storage.adatot_tartalmaz": "Adatot tartalmaz",
"storage.adattarolok": "Adattárolók",
@@ -2381,6 +2383,7 @@
"storage.regisztracio_sikertelen": "Regisztráció sikertelen:",
"storage.regisztralas": "Regisztrálás",
"storage.regisztralva": "Regisztrálva",
"storage.reszletek": "Részletek",
"storage.rendszer_vedett": "Rendszer — védett",
"storage.rendszermeghajtok": "Rendszermeghajtók",
"storage.rendszermeghajton": "Rendszermeghajtón",
@@ -2400,7 +2403,7 @@
"storage.visszacsatlakoztatja_a_z": "Visszacsatlakoztatod a(z)",
"storage_attach.1_meghajto_kivalasztasa": "1. Meghajtó kiválasztása",
"storage_attach.2_konfiguracio": "2. Konfiguráció",
"storage_attach.a_meghajto_a_mnt_nev": "A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.",
"storage_attach.a_meghajto_a_mnt_nev": "A meghajtó a /mnt/&lt;név&gt; útvonalra kerül. Hagyd így, ha nem tudod.",
"storage_attach.a_meghajto_sikeresen": "A meghajtó sikeresen",
"storage_attach.beallitas_alapertelmezett_adattarolokent": "Beállítás alapértelmezett adattárolóként új telepítéseknél",
"storage_attach.betoltes": "Betöltés…",
@@ -2427,7 +2430,7 @@
"storage_init.1_meghajto_kivalasztasa": "1. Meghajtó kiválasztása",
"storage_init.2_konfiguracio": "2. Konfiguráció",
"storage_init.a_kivalasztott_meghajto_adatot_tartalmaz": "A kiválasztott meghajtó <strong>adatot tartalmaz</strong>. Az inicializálás (formázás) törli a rajta lévő\n összes adatot — a folytatáshoz meg kell erősítened.",
"storage_init.a_meghajto_a_mnt_nev": "A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.",
"storage_init.a_meghajto_a_mnt_nev": "A meghajtó a /mnt/&lt;név&gt; útvonalra kerül. Hagyd így, ha nem tudod.",
"storage_init.a_meghajto_adatot_tartalmaz_a": "A meghajtó adatot tartalmaz. A formázás <strong>véglegesen törli</strong> a rajta lévő összes adatot.",
"storage_init.a_meghajto_sikeresen_inicializalva_es": "A meghajtó sikeresen inicializálva és regisztrálva:",
"storage_init.adatot_tartalmaz_a_formazas_torli": "Adatot tartalmaz — a formázás törli",
@@ -40,6 +40,12 @@ func i18nCasesC() []i18nCase {
{"storage_empty", "storage", func() map[string]interface{} {
return i18nLayoutData("storage", "Tárhely")
}},
// W29: a plain decommission (nothing moved) says so, never the move sentence with empty blanks.
{"storage_decommissioned_plain", "storage", func() map[string]interface{} {
d := i18nLayoutData("storage", "Tárhely")
d["StoragePaths"] = []m{{"Path": "/mnt/hdd_1", "Label": "hdd_1", "Decommissioned": true, "DecommissionedAt": "2026-10-20 18:00"}}
return d
}},
// `09` §3 decision 36 — the kept-data list, one dated and one leftover row, and the empty page.
{"kept_data_full", "kept_data", func() map[string]interface{} {
d := i18nLayoutData("kept-data", "Megőrzött adatok")
@@ -10,6 +10,7 @@ import (
"path"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -54,6 +55,22 @@ func mountWhere(name string) (string, error) {
return "/mnt/" + name, nil
}
// nextFreeMountName is the first hdd_N (N from 1) whose /mnt/hdd_N is not a registered storage path — a
// decommissioned one counts as taken, so a new drive never reuses an old drive's name (W13). Pinned by
// TestW13_StorageInitSuggestsTheNextFreeNameAndNeverFailsSilently.
func nextFreeMountName(registered []settings.StoragePath) string {
taken := map[string]bool{}
for _, sp := range registered {
taken[path.Clean(sp.Path)] = true
}
for n := 1; ; n++ {
name := "hdd_" + strconv.Itoa(n)
if !taken["/mnt/"+name] {
return name
}
}
}
// fsUUIDForDevice re-lists the agent's disks and returns the fs UUID of the storage backed by `device`
// (the only way the de-privileged controller learns the UUID it must pass to assign). "" if not found.
func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string {
@@ -373,6 +390,12 @@ func (s *Server) storageWizardPageHandler(w http.ResponseWriter, r *http.Request
}
data := s.baseData(tmpl, title)
data["TitleKey"] = titleKey // i18n: the Hungarian title above is what hu renders
// W13 (rehearsal 2026-10-20): the mount-name box was empty and a household did not know what to type.
var registered []settings.StoragePath
if s.settings != nil {
registered = s.settings.GetStoragePaths()
}
data["SuggestedMountName"] = nextFreeMountName(registered)
// executeTemplate, NOT render(): the wizard POSTs /api/storage/* with the csrf-meta token,
// and render() leaves {{.CSRFToken}} empty. Latent until the claim arc — on an unclaimed
// (auth-off) box CsrfProtect is skipped entirely, so the wizard had never run against a
@@ -55,7 +55,9 @@
{{else if .Decommissioned}}
<div class="storage-path-details">
<div class="storage-disconnected-info">
<span class="form-hint">{{T "storage.adatok_atkoltoztetve_ide"}}</span>
{{- /* W29 (rehearsal 2026-10-20): a plain decommission moves nothing; the move sentence rendered
with empty blanks („Adatok átköltöztetve ide: . ()"). It names a move only when there was one. */}}
{{if .MigratedTo}}<span class="form-hint">{{T "storage.adatok_atkoltoztetve_ide"}}</span>{{else}}<span class="form-hint">{{T "storage.leszerelve_nem_koltoztettunk"}}</span>{{end}}
{{if .DecommissionedAt}}<span class="form-hint">{{T "storage.idopont"}}</span>{{end}}
</div>
</div>
@@ -342,11 +344,14 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
var badges = roleTag(d.role)+appBackingTag(d)+classTag(d)+dataTag(d);
if(withActions) badges += regTag(d, registered);
var purpose = purposeDesc(d);
// W14 (rehearsal 2026-10-20): the type, device, path and durable id („local-lvm lvmthin · pve/data",
// „10.77.0.1:felhom-offsite#…") are operator detail; they wait behind „Részletek".
return '<div class="drive-card role-'+esc(d.role||'system')+'">'
+'<div class="drive-card-top"><div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div>'
+'<div class="drive-card-top"><div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span></div>'
+'<div class="drive-badges">'+badges+'</div></div>'
+(purpose?'<div class="drive-purpose">'+esc(purpose)+'</div>':'')
+(d.durable_id?'<div class="drive-sub mono">'+esc(d.durable_id)+'</div>':'')
+'<details class="drive-details"><summary>{{T "storage.reszletek"}}</summary><div class="drive-sub">'+sub+'</div>'
+(d.durable_id?'<div class="drive-sub mono">'+esc(d.durable_id)+'</div>':'')+'</details>'
+capBar(d)
+(withActions?actions(d,registered):'')
+'</div>';
@@ -359,8 +364,8 @@ window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}
var extra='<div class="metarows">'
+'<span class="metarow">'+roleTag(d.role)+'</span>'
+(d.class?'<span class="metarow">'+classTag(d)+'</span>':'')
+(d.durable_id?'<span class="metarow mono" title="{{T "storage.tartos_azonosito"}}">'+esc(d.durable_id)+'</span>':'')
+'</div>'
+(d.durable_id?'<details class="drive-details"><summary>{{T "storage.reszletek"}}</summary><span class="drive-sub mono" title="{{T "storage.tartos_azonosito"}}">'+esc(d.durable_id)+'</span></details>':'')
+actions(d,registered,hasSafeDisconnect);
slot.innerHTML=extra;
return true;
@@ -17,7 +17,7 @@
<div class="settings-card" id="cfg-card" style="display:none">
<h3>{{T "storage_attach.2_konfiguracio"}}</h3>
<form id="attach-form" onsubmit="return submitAttach(event)">
<form id="attach-form" novalidate onsubmit="return submitAttach(event)">
<div class="form-group">
<label>{{T "storage_attach.kivalasztott_eszkoz"}}</label>
<span class="settings-value mono" id="sel-device">—</span>
@@ -28,8 +28,8 @@
<label for="mount-name">{{T "storage_attach.csatlakoztatasi_nev"}} <span class="required">*</span></label>
<div style="display:flex;align-items:center;gap:.25rem">
<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:180px">
<input type="text" id="mount-name" class="form-control" placeholder="hdd_1" value="{{if .SuggestedMountName}}{{.SuggestedMountName}}{{else}}hdd_1{{end}}"
pattern="[a-zA-Z0-9_-]+" maxlength="40" required style="max-width:180px">
</div>
<span class="form-hint">{{T "storage_attach.a_meghajto_a_mnt_nev"}}</span>
</div>
@@ -123,9 +123,20 @@ function pickDisk(radio){
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
}
// W13: the form is novalidate — a browser tooltip was the only answer to an empty or wrong name, and the button
// seemed to do nothing. An already-mounted store has no name to check.
function mountNameProblem(){
if(selMounted){ return ''; }
var v=(document.getElementById('mount-name').value||'').trim();
if(/^[a-zA-Z0-9_-]{1,40}$/.test(v)){ return ''; }
return '{{T "storage_wizard.name_invalid"}}'.replace('{name}', '{{if .SuggestedMountName}}{{.SuggestedMountName}}{{else}}hdd_1{{end}}');
}
async function submitAttach(ev){
ev.preventDefault();
var btn=document.getElementById('attach-btn'); var out=document.getElementById('attach-result');
var bad=mountNameProblem();
if(bad){ out.innerHTML='<div class="alert alert-error" id="mount-name-error">'+esc(bad)+'</div>'; document.getElementById('mount-name').focus(); return false; }
btn.disabled=true; out.innerHTML='<p class="form-hint">{{T "storage_attach.csatlakoztatas_folyamatban"}}</p>';
try{
// R-280: an already-mounted store is REGISTERED in place. Sending it to /api/storage/attach would
@@ -137,7 +148,7 @@ async function submitAttach(ev){
set_default:document.getElementById('set-default').checked};
}else{
url='/api/storage/attach';
body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value,
body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value.trim(),
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)});
@@ -17,7 +17,7 @@
<div class="settings-card" id="cfg-card" style="display:none">
<h3>{{T "storage_init.2_konfiguracio"}}</h3>
<form id="init-form" onsubmit="return submitInit(event)">
<form id="init-form" novalidate onsubmit="return submitInit(event)">
<div class="form-group">
<label>{{T "storage_init.kivalasztott_eszkoz"}}</label>
<span class="settings-value mono" id="sel-device">—</span>
@@ -26,8 +26,8 @@
<label for="mount-name">{{T "storage_init.csatlakoztatasi_nev"}} <span class="required">*</span></label>
<div style="display:flex;align-items:center;gap:.25rem">
<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:180px">
<input type="text" id="mount-name" class="form-control" placeholder="hdd_1" value="{{if .SuggestedMountName}}{{.SuggestedMountName}}{{else}}hdd_1{{end}}"
pattern="[a-zA-Z0-9_-]+" maxlength="40" required style="max-width:180px">
</div>
<span class="form-hint">{{T "storage_init.a_meghajto_a_mnt_nev"}}</span>
</div>
@@ -124,7 +124,7 @@ function pickDisk(radio){
// request context and is followed via pollInitStatus — a closed tab no longer aborts it.
async function postInit(confirmed, durableId){
var body={device:selDevice, fstype:document.getElementById('fstype').value,
mount_name:document.getElementById('mount-name').value, label:document.getElementById('storage-label').value,
mount_name:document.getElementById('mount-name').value.trim(), label:document.getElementById('storage-label').value,
set_default:document.getElementById('set-default').checked, confirmed:!!confirmed, durable_id:durableId||""};
var r=await fetch('/api/storage/init',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
return {status:r.status, j:await r.json()};
@@ -167,9 +167,19 @@ async function pollInitStatus(out, btn){
}
}
// W13: the form is novalidate — a browser tooltip was the only answer to an empty or wrong name, and the button
// seemed to do nothing. mountNameProblem answers with a visible sentence instead (the server checks again).
function mountNameProblem(){
var v=(document.getElementById('mount-name').value||'').trim();
if(/^[a-zA-Z0-9_-]{1,40}$/.test(v)){ return ''; }
return '{{T "storage_wizard.name_invalid"}}'.replace('{name}', '{{if .SuggestedMountName}}{{.SuggestedMountName}}{{else}}hdd_1{{end}}');
}
async function submitInit(ev){
ev.preventDefault();
var btn=document.getElementById('init-btn'); var out=document.getElementById('init-result');
var bad=mountNameProblem();
if(bad){ out.innerHTML='<div class="alert alert-error" id="mount-name-error">'+esc(bad)+'</div>'; document.getElementById('mount-name').focus(); return false; }
btn.disabled=true; out.innerHTML=stepper('formatting');
try{
var res=await postInit(false, "");
@@ -3641,6 +3641,9 @@ a.stat-card:hover {
font-size: .78rem; color: var(--text-3);
font-family: var(--font-data); word-break: break-all;
}
/* W14: a drive's operator detail (type, device, path, durable id) waits behind „Részletek". */
.drive-details summary { cursor: pointer; font-size: .78rem; color: var(--text-3); }
.drive-details[open] summary { margin-bottom: .2rem; }
.drive-badges { display: flex; flex-wrap: wrap; gap: .35rem; align-items: center; justify-content: flex-end; }
.drive-cap { display: flex; flex-direction: column; gap: .3rem; }
.drive-cap .meter-track { height: 3px; }
@@ -189,7 +189,7 @@
<div class="settings-card" id="cfg-card" style="display:none">
<h3>2. Konfiguráció</h3>
<form id="attach-form" onsubmit="return submitAttach(event)">
<form id="attach-form" novalidate onsubmit="return submitAttach(event)">
<div class="form-group">
<label>Kiválasztott eszköz</label>
<span class="settings-value mono" id="sel-device">—</span>
@@ -199,10 +199,10 @@
<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>
<input type="text" id="mount-name" class="form-control" placeholder="hdd_1"
pattern="[a-zA-Z0-9_-]+" required style="max-width:180px">
<input type="text" id="mount-name" class="form-control" placeholder="hdd_1" value="hdd_1"
pattern="[a-zA-Z0-9_-]+" maxlength="40" required style="max-width:180px">
</div>
<span class="form-hint">A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.</span>
<span class="form-hint">A meghajtó a /mnt/&lt;név&gt; útvonalra kerül. Hagyd így, ha nem tudod.</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
@@ -295,9 +295,20 @@ function pickDisk(radio){
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
}
function mountNameProblem(){
if(selMounted){ return ''; }
var v=(document.getElementById('mount-name').value||'').trim();
if(/^[a-zA-Z0-9_-]{1,40}$/.test(v)){ return ''; }
return 'Írj be egy csatlakoztatási nevet. Csak betű, szám, _ és - lehet benne, legfeljebb 40 karakter. Ha nem tudod, írd be: {name}'.replace('{name}', 'hdd_1');
}
async function submitAttach(ev){
ev.preventDefault();
var btn=document.getElementById('attach-btn'); var out=document.getElementById('attach-result');
var bad=mountNameProblem();
if(bad){ out.innerHTML='<div class="alert alert-error" id="mount-name-error">'+esc(bad)+'</div>'; document.getElementById('mount-name').focus(); return false; }
btn.disabled=true; out.innerHTML='<p class="form-hint">Csatlakoztatás folyamatban…</p>';
try{
@@ -309,7 +320,7 @@ async function submitAttach(ev){
set_default:document.getElementById('set-default').checked};
}else{
url='/api/storage/attach';
body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value,
body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value.trim(),
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)});
File diff suppressed because it is too large Load Diff
@@ -421,11 +421,14 @@ window.__registeredPaths=[];
var badges = roleTag(d.role)+appBackingTag(d)+classTag(d)+dataTag(d);
if(withActions) badges += regTag(d, registered);
var purpose = purposeDesc(d);
return '<div class="drive-card role-'+esc(d.role||'system')+'">'
+'<div class="drive-card-top"><div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div>'
+'<div class="drive-card-top"><div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span></div>'
+'<div class="drive-badges">'+badges+'</div></div>'
+(purpose?'<div class="drive-purpose">'+esc(purpose)+'</div>':'')
+(d.durable_id?'<div class="drive-sub mono">'+esc(d.durable_id)+'</div>':'')
+'<details class="drive-details"><summary>Részletek</summary><div class="drive-sub">'+sub+'</div>'
+(d.durable_id?'<div class="drive-sub mono">'+esc(d.durable_id)+'</div>':'')+'</details>'
+capBar(d)
+(withActions?actions(d,registered):'')
+'</div>';
@@ -438,8 +441,8 @@ window.__registeredPaths=[];
var extra='<div class="metarows">'
+'<span class="metarow">'+roleTag(d.role)+'</span>'
+(d.class?'<span class="metarow">'+classTag(d)+'</span>':'')
+(d.durable_id?'<span class="metarow mono" title="Tartós azonosító">'+esc(d.durable_id)+'</span>':'')
+'</div>'
+(d.durable_id?'<details class="drive-details"><summary>Részletek</summary><span class="drive-sub mono" title="Tartós azonosító">'+esc(d.durable_id)+'</span></details>':'')
+actions(d,registered,hasSafeDisconnect);
slot.innerHTML=extra;
return true;
@@ -788,11 +788,14 @@ window.__registeredPaths=["\/mnt\/usb1","\/mnt\/old","\/mnt\/hdd1","\/mnt\/hdd2"
var badges = roleTag(d.role)+appBackingTag(d)+classTag(d)+dataTag(d);
if(withActions) badges += regTag(d, registered);
var purpose = purposeDesc(d);
return '<div class="drive-card role-'+esc(d.role||'system')+'">'
+'<div class="drive-card-top"><div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div>'
+'<div class="drive-card-top"><div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span></div>'
+'<div class="drive-badges">'+badges+'</div></div>'
+(purpose?'<div class="drive-purpose">'+esc(purpose)+'</div>':'')
+(d.durable_id?'<div class="drive-sub mono">'+esc(d.durable_id)+'</div>':'')
+'<details class="drive-details"><summary>Részletek</summary><div class="drive-sub">'+sub+'</div>'
+(d.durable_id?'<div class="drive-sub mono">'+esc(d.durable_id)+'</div>':'')+'</details>'
+capBar(d)
+(withActions?actions(d,registered):'')
+'</div>';
@@ -805,8 +808,8 @@ window.__registeredPaths=["\/mnt\/usb1","\/mnt\/old","\/mnt\/hdd1","\/mnt\/hdd2"
var extra='<div class="metarows">'
+'<span class="metarow">'+roleTag(d.role)+'</span>'
+(d.class?'<span class="metarow">'+classTag(d)+'</span>':'')
+(d.durable_id?'<span class="metarow mono" title="Tartós azonosító">'+esc(d.durable_id)+'</span>':'')
+'</div>'
+(d.durable_id?'<details class="drive-details"><summary>Részletek</summary><span class="drive-sub mono" title="Tartós azonosító">'+esc(d.durable_id)+'</span></details>':'')
+actions(d,registered,hasSafeDisconnect);
slot.innerHTML=extra;
return true;
@@ -190,7 +190,7 @@
<div class="settings-card" id="cfg-card" style="display:none">
<h3>2. Konfiguráció</h3>
<form id="init-form" onsubmit="return submitInit(event)">
<form id="init-form" novalidate onsubmit="return submitInit(event)">
<div class="form-group">
<label>Kiválasztott eszköz</label>
<span class="settings-value mono" id="sel-device">—</span>
@@ -199,10 +199,10 @@
<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>
<input type="text" id="mount-name" class="form-control" placeholder="hdd_1"
pattern="[a-zA-Z0-9_-]+" required style="max-width:180px">
<input type="text" id="mount-name" class="form-control" placeholder="hdd_1" value="hdd_1"
pattern="[a-zA-Z0-9_-]+" maxlength="40" required style="max-width:180px">
</div>
<span class="form-hint">A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.</span>
<span class="form-hint">A meghajtó a /mnt/&lt;név&gt; útvonalra kerül. Hagyd így, ha nem tudod.</span>
</div>
<div class="form-group">
<label for="fstype">Fájlrendszer</label>
@@ -298,7 +298,7 @@ function pickDisk(radio){
async function postInit(confirmed, durableId){
var body={device:selDevice, fstype:document.getElementById('fstype').value,
mount_name:document.getElementById('mount-name').value, label:document.getElementById('storage-label').value,
mount_name:document.getElementById('mount-name').value.trim(), label:document.getElementById('storage-label').value,
set_default:document.getElementById('set-default').checked, confirmed:!!confirmed, durable_id:durableId||""};
var r=await fetch('/api/storage/init',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
return {status:r.status, j:await r.json()};
@@ -341,9 +341,19 @@ async function pollInitStatus(out, btn){
}
}
function mountNameProblem(){
var v=(document.getElementById('mount-name').value||'').trim();
if(/^[a-zA-Z0-9_-]{1,40}$/.test(v)){ return ''; }
return 'Írj be egy csatlakoztatási nevet. Csak betű, szám, _ és - lehet benne, legfeljebb 40 karakter. Ha nem tudod, írd be: {name}'.replace('{name}', 'hdd_1');
}
async function submitInit(ev){
ev.preventDefault();
var btn=document.getElementById('init-btn'); var out=document.getElementById('init-result');
var bad=mountNameProblem();
if(bad){ out.innerHTML='<div class="alert alert-error" id="mount-name-error">'+esc(bad)+'</div>'; document.getElementById('mount-name').focus(); return false; }
btn.disabled=true; out.innerHTML=stepper('formatting');
try{
var res=await postInit(false, "");
@@ -0,0 +1,52 @@
package web
import (
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// W13 (rehearsal 2026-10-20): the init page's mount-name box was empty („a household does not know what to type") and
// a press with an empty box did nothing visible — only the browser's tooltip, from `required`. The box now holds the
// next free hdd_N, the hint says „Hagyd így, ha nem tudod", the form is novalidate and the press answers a bad name
// with a visible sentence (the server checks the name again: mountWhere).
func TestW13_StorageInitSuggestsTheNextFreeNameAndNeverFailsSilently(t *testing.T) {
if got := nextFreeMountName(nil); got != "hdd_1" {
t.Fatalf("no drive: %q", got)
}
reg := []settings.StoragePath{{Path: "/mnt/hdd_1"}, {Path: "/mnt/hdd_3/"}, {Path: "/mnt/felhom-drives/nas"}, {Path: "/mnt/hdd_2", Decommissioned: true}}
if got := nextFreeMountName(reg); got != "hdd_4" {
t.Fatalf("hdd_1, hdd_2 (decommissioned) and hdd_3 taken: %q", got)
}
if got := nextFreeMountName(reg[:2]); got != "hdd_2" {
t.Fatalf("hdd_1 and hdd_3 taken: %q", got)
}
s := testServer(t)
s.loadTemplates()
if err := s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/hdd_1"}); err != nil {
t.Fatal(err)
}
for _, page := range []string{"storage_init", "storage_attach"} {
rr := httptest.NewRecorder()
s.storageWizardPageHandler(rr, httptest.NewRequest("GET", "/storage/init", nil), page)
body := rr.Body.String()
if !strings.Contains(body, `id="mount-name" class="form-control" placeholder="hdd_1" value="hdd_2"`) {
t.Errorf("%s: the mount name is not pre-filled with the next free name", page)
}
if !strings.Contains(body, "Hagyd így, ha nem tudod.") {
t.Errorf("%s: the hint does not say „Hagyd így, ha nem tudod.”", page)
}
// THE CONSEQUENCE: no browser-only refusal — the form never lets the browser swallow the press, and the
// press has a visible sentence for a bad name.
if !strings.Contains(body, `-form" novalidate onsubmit=`) {
t.Errorf("%s: the form is not novalidate — an empty name is refused by a tooltip only", page)
}
if !strings.Contains(body, "var bad=mountNameProblem();") || !strings.Contains(body, `id="mount-name-error"`) ||
!strings.Contains(body, "Írj be egy csatlakoztatási nevet.") {
t.Errorf("%s: a bad name has no visible answer", page)
}
}
}
@@ -0,0 +1,35 @@
package web
import (
"strings"
"testing"
)
// W29 (rehearsal 2026-10-20): after a plain decommission (nothing moved) the storage card read „Kiváltva — Adatok
// átköltöztetve ide: . () Időpont: …" — the move sentence with empty blanks. With no move it says the drive was
// decommissioned and that nothing was moved off it; a real move keeps its sentence.
func TestW29_PlainDecommissionNamesNoMove(t *testing.T) {
s := i18nTestServer(t)
want := map[string][2]string{
"hu": {"Leszerelve. Nem költöztettünk át adatot róla", "Adatok átköltöztetve ide"},
"en": {"Decommissioned. No data was moved off it", "Data moved to"},
}
for _, c := range i18nCases() {
switch c.name {
case "storage_decommissioned_plain":
for lang, w := range want {
page := renderI18nCase(t, s, lang, c)
if !strings.Contains(page, w[0]) || strings.Contains(page, w[1]) {
t.Errorf("%s: a plain decommission must say %q and never %q", lang, w[0], w[1])
}
}
case "storage_full": // a real move keeps its sentence
for lang, w := range want {
page := renderI18nCase(t, s, lang, c)
if !strings.Contains(page, w[1]) || strings.Contains(page, w[0]) {
t.Errorf("%s: a decommission with a move must say %q", lang, w[1])
}
}
}
}
}