B1: migrate UI wiring — /api/storage/migrate{,-app,/status} + settings & app pages
ServeStorageAPI gains POST /api/storage/migrate (whole-namespace), POST /api/storage/migrate-app (single app), GET /api/storage/migrate/status (poll). settings.html: the greyed migrate-all span becomes a real target-select + button + shared progress panel; app_info.html gains a per-app 'Áthelyezés másik tárhelyre' control. Both poll the shared status endpoint and render Hungarian phase progress. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -471,6 +471,23 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
|
||||
data["HasAppInfo"] = found.Meta.HasAppInfo()
|
||||
data["EffectiveSubdomain"] = effectiveSubdomain
|
||||
|
||||
// Per-app migration (B1): offer to move this app's data to another connected drive (≠ current).
|
||||
if found.Deployed {
|
||||
current := ""
|
||||
if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
|
||||
current = appCfg.Env["HDD_PATH"]
|
||||
}
|
||||
var targets []settings.StoragePath
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path == current || sp.Decommissioned || sp.Disconnected || !sp.Schedulable {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, sp)
|
||||
}
|
||||
data["MigrateTargets"] = targets
|
||||
data["MigrateCurrent"] = current
|
||||
}
|
||||
|
||||
s.executeTemplate(w, r, "app_info", data)
|
||||
}
|
||||
|
||||
|
||||
@@ -245,11 +245,61 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleStorageRegister(w, r)
|
||||
case r.URL.Path == "/api/storage/activate" && r.Method == http.MethodPost:
|
||||
s.handleStorageActivate(w, r)
|
||||
case r.URL.Path == "/api/storage/migrate" && r.Method == http.MethodPost:
|
||||
s.handleStorageMigrate(w, r)
|
||||
case r.URL.Path == "/api/storage/migrate-app" && r.Method == http.MethodPost:
|
||||
s.handleStorageMigrateApp(w, r)
|
||||
case r.URL.Path == "/api/storage/migrate/status" && r.Method == http.MethodGet:
|
||||
s.handleStorageMigrateStatus(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStorageMigrate starts a whole-namespace migration (all apps + non-app content) off the source
|
||||
// drive onto the chosen target. Async: VALIDATE runs synchronously (a refusal is returned here and
|
||||
// changes nothing); the copy/redeploy/cleanup run in the background and the UI polls migrate/status.
|
||||
func (s *Server) handleStorageMigrate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
||||
return
|
||||
}
|
||||
id, err := s.stackMgr.MigrateAll(r.Context(), strings.TrimSpace(req.Source), strings.TrimSpace(req.Target))
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id})
|
||||
}
|
||||
|
||||
// handleStorageMigrateApp starts a single-app migration onto the chosen target.
|
||||
func (s *Server) handleStorageMigrateApp(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
App string `json:"app"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
||||
return
|
||||
}
|
||||
id, err := s.stackMgr.MigrateApp(r.Context(), strings.TrimSpace(req.App), strings.TrimSpace(req.Target))
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id})
|
||||
}
|
||||
|
||||
// handleStorageMigrateStatus returns the live migration job (nil/idle when none is running) for the
|
||||
// progress panel poll.
|
||||
func (s *Server) handleStorageMigrateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"job": s.stackMgr.MigrationStatus()})
|
||||
}
|
||||
|
||||
type storageProvReq struct {
|
||||
Device string `json:"device"`
|
||||
FSType string `json:"fstype"`
|
||||
|
||||
@@ -55,6 +55,55 @@
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
|
||||
{{if and .Stack.Deployed .MigrateTargets}}
|
||||
<div class="app-info-card" style="margin-top:1rem">
|
||||
<h3>Áthelyezés másik tárhelyre</h3>
|
||||
<p class="form-hint">Ennek az alkalmazásnak az adatait másik csatlakoztatott tárhelyre helyezheted át. Az alkalmazás az áthelyezés alatt rövid időre leáll, az adatok pedig csak az ellenőrzés és a sikeres újraindítás után törlődnek a régi helyről.</p>
|
||||
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-top:.5rem">
|
||||
<select id="app-migrate-target" class="btn btn-sm btn-outline">
|
||||
<option value="">Válassz céltárhelyet…</option>
|
||||
{{range .MigrateTargets}}<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>{{end}}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline" onclick="appMigrate('{{.Stack.Name}}','{{.Meta.DisplayName}}')">Áthelyezés</button>
|
||||
</div>
|
||||
<div id="app-migrate-progress" style="display:none;margin-top:.75rem;padding:.75rem;border:1px solid var(--accent);border-radius:6px;background:rgba(0,136,204,0.06)"></div>
|
||||
</div>
|
||||
<script>
|
||||
function appMigFmtGB(b){ return (Number(b||0)/1e9).toFixed(1)+' GB'; }
|
||||
function appMigRender(job){
|
||||
var names={stop:'Leállítás',copy:'Adatok másolása',verify:'Ellenőrzés',flip:'Újratelepítés',redeploy:'Újratelepítés',cleanup:'Régi adatok törlése'};
|
||||
var s=names[job.phase]||job.phase;
|
||||
if(job.phase==='copy'&&job.bytes_total>0){ s+=' ('+Math.floor(100*job.bytes_done/job.bytes_total)+'% — '+appMigFmtGB(job.bytes_done)+'/'+appMigFmtGB(job.bytes_total)+')'; }
|
||||
return s+'…';
|
||||
}
|
||||
function appMigWatch(){
|
||||
var panel=document.getElementById('app-migrate-progress');
|
||||
if(panel) panel.style.display='block';
|
||||
function tick(){
|
||||
fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){
|
||||
var job=d.data&&d.data.job;
|
||||
if(!job){ if(panel) panel.textContent='Nincs folyamatban áthelyezés.'; return; }
|
||||
if(panel) panel.innerHTML=appMigRender(job);
|
||||
if(job.phase==='done'){ if(panel) panel.innerHTML+='<br><strong>Kész ✓</strong>'; setTimeout(function(){location.reload();},1500); return; }
|
||||
if(job.phase==='aborted'){ if(panel) panel.innerHTML+='<br><strong style="color:var(--danger,#c0392b)">Megszakadt: '+(job.error||'')+'</strong><br>A régi adatok érintetlenek.'; return; }
|
||||
setTimeout(tick,1500);
|
||||
}).catch(function(){ setTimeout(tick,2000); });
|
||||
}
|
||||
tick();
|
||||
}
|
||||
function appMigrate(app,label){
|
||||
var sel=document.getElementById('app-migrate-target');
|
||||
var target=sel?sel.value:'';
|
||||
if(!target){ alert('Válassz céltárhelyet.'); return; }
|
||||
if(!confirm('Áthelyezed a(z) '+label+' adatait ide: '+target+'?\n\nAz alkalmazás rövid időre leáll. A régi adatok csak sikeres áthelyezés után törlődnek.')) return;
|
||||
fetch('/api/storage/migrate-app',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({app:app,target:target})})
|
||||
.then(function(r){return r.json();}).then(function(d){ if(d.ok){ appMigWatch(); } else { alert('Hiba: '+(d.error||'ismeretlen')); } })
|
||||
.catch(function(e){ alert('Hiba: '+e); });
|
||||
}
|
||||
(function(){ fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){ if(d.data&&d.data.job){ appMigWatch(); } }).catch(function(){}); })();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{if .HasAppInfo}}
|
||||
<div class="app-info-grid">
|
||||
{{if .AppInfo.UseCases}}
|
||||
|
||||
@@ -334,13 +334,63 @@ function pollUntilBack() {
|
||||
</form>
|
||||
{{end}}
|
||||
{{if and (gt .AppCount 0) .HasOtherPaths}}
|
||||
<span class="btn btn-xs btn-outline" style="opacity:.45;cursor:not-allowed" title="Hamarosan">📦 Összes adat átköltöztetése</span>
|
||||
{{$src := .Path}}
|
||||
<span class="migrate-inline" style="display:inline-flex;gap:.35rem;align-items:center">
|
||||
<select id="migrate-target-{{.Path}}" class="btn btn-xs btn-outline">
|
||||
<option value="">📦 Áthelyezés ide…</option>
|
||||
{{range $.StoragePaths}}{{if and (ne .Path $src) .Schedulable (not .Disconnected) (not .Decommissioned)}}<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>{{end}}{{end}}
|
||||
</select>
|
||||
<button class="btn btn-xs btn-outline" onclick="storageMigrateAll('{{.Path}}','{{.Label}}')">Összes adat áthelyezése</button>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div id="migrate-progress" style="display:none;margin-top:1rem;padding:1rem;border:1px solid var(--accent);border-radius:6px;background:rgba(0,136,204,0.06)">
|
||||
<strong>Adatok áthelyezése</strong>
|
||||
<div id="migrate-progress-body" style="margin-top:.5rem">…</div>
|
||||
</div>
|
||||
<script>
|
||||
// Shared migration progress (used by both migrate-all here and per-app migrate on the app page).
|
||||
function migFmtGB(b){ return (Number(b||0)/1e9).toFixed(1)+' GB'; }
|
||||
function migRender(job){
|
||||
var names={stop:'Alkalmazások leállítása',copy:'Adatok másolása',verify:'Ellenőrzés',flip:'Újratelepítés',redeploy:'Újratelepítés',cleanup:'Forrás törlése'};
|
||||
var s=names[job.phase]||job.phase;
|
||||
if(job.current_app) s+=': '+job.current_app;
|
||||
if(job.phase==='copy'&&job.bytes_total>0){ s+=' ('+Math.floor(100*job.bytes_done/job.bytes_total)+'% — '+migFmtGB(job.bytes_done)+'/'+migFmtGB(job.bytes_total)+')'; }
|
||||
return s+'…';
|
||||
}
|
||||
function migWatch(){
|
||||
var panel=document.getElementById('migrate-progress');
|
||||
var body=document.getElementById('migrate-progress-body');
|
||||
if(panel) panel.style.display='block';
|
||||
function tick(){
|
||||
fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){
|
||||
var job=d.data&&d.data.job;
|
||||
if(!job){ if(body) body.textContent='Nincs folyamatban migráció.'; return; }
|
||||
if(body) body.innerHTML=migRender(job);
|
||||
if(job.phase==='done'){ if(body) body.innerHTML+='<br><strong>Kész ✓</strong>'; setTimeout(function(){location.reload();},1500); return; }
|
||||
if(job.phase==='aborted'){ if(body) body.innerHTML+='<br><strong style="color:var(--danger,#c0392b)">Megszakadt: '+(job.error||'')+'</strong><br>A forrás adatai érintetlenek.'; return; }
|
||||
setTimeout(tick,1500);
|
||||
}).catch(function(){ setTimeout(tick,2000); });
|
||||
}
|
||||
tick();
|
||||
}
|
||||
function storageMigrateAll(source,label){
|
||||
var sel=document.getElementById('migrate-target-'+source);
|
||||
var target=sel?sel.value:'';
|
||||
if(!target){ alert('Válassz céltárolót a legördülő menüből.'); return; }
|
||||
if(!confirm('Áthelyezed a(z) '+label+' ÖSSZES adatát ide: '+target+'?\n\nAz alkalmazások az áthelyezés alatt rövid időre leállnak. A forrás adatai csak az ellenőrzés és a sikeres újraindítás után törlődnek.')) return;
|
||||
fetch('/api/storage/migrate',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({source:source,target:target})})
|
||||
.then(function(r){return r.json();}).then(function(d){ if(d.ok){ migWatch(); } else { alert('Hiba: '+(d.error||'ismeretlen')); } })
|
||||
.catch(function(e){ alert('Hiba: '+e); });
|
||||
}
|
||||
// Resume view: if a migration is already running when the page loads, show the panel.
|
||||
(function(){ fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){ if(d.data&&d.data.job){ migWatch(); } }).catch(function(){}); })();
|
||||
</script>
|
||||
{{else}}
|
||||
<div class="empty-state" style="padding:1.5rem">
|
||||
Nincs regisztrált adattároló. Adjon hozzá egyet az alábbi űrlappal.
|
||||
|
||||
Reference in New Issue
Block a user