v0.15.5: Disaster recovery — Hub-based infra backup, auto-mount, restore UI
Complete DR implementation (TASK2.md Phases 1-4): - Hub infra-backup push/pull endpoints (controller.yaml, disk layout, stacks) - Fresh-deployment detection pulls config from Hub, auto-mounts drives by UUID - Full-page restore UI with drive status, app table, sequential restore - docker-setup.sh shows DR instructions when customer_id is configured New files: disk_layout.go, restore_scan.go, restore_app_linux.go, restore_drives_linux.go, infra_backup.go, infra_pull.go, handler_restore.go, restore.html Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -305,6 +305,23 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
||||
}
|
||||
return id
|
||||
},
|
||||
// statusText maps DR restore status codes to Hungarian labels.
|
||||
"statusText": func(status string) string {
|
||||
switch status {
|
||||
case "pending":
|
||||
return "Várakozik"
|
||||
case "restoring":
|
||||
return "Visszaállítás..."
|
||||
case "done":
|
||||
return "Kész"
|
||||
case "failed":
|
||||
return "Sikertelen"
|
||||
case "skipped":
|
||||
return "Kihagyva"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
},
|
||||
// pageMatch returns true if currentPage is in the pages slice.
|
||||
// Used to filter page-specific alerts in layout.html.
|
||||
"pageMatch": func(pages []string, currentPage string) bool {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// restorePageHandler renders the full-page DR restore UI.
|
||||
func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.restorePlan == nil {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Title": "Katasztrófa utáni visszaállítás",
|
||||
"CustomerName": s.cfg.Customer.Name,
|
||||
"Domain": s.cfg.Customer.Domain,
|
||||
"Version": s.version,
|
||||
"CustomerID": s.restorePlan.CustomerID,
|
||||
"Timestamp": s.restorePlan.Timestamp,
|
||||
"Apps": s.restorePlan.GetApps(),
|
||||
"Drives": s.restorePlan.Drives,
|
||||
"PlanStatus": s.restorePlan.Status,
|
||||
}
|
||||
|
||||
s.render(w, "restore", data)
|
||||
}
|
||||
|
||||
// apiRestoreStatus returns the current restore plan status as JSON.
|
||||
func (s *Server) apiRestoreStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.restorePlan == nil {
|
||||
jsonError(w, "not in restore mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(s.restorePlan.Snapshot())
|
||||
}
|
||||
|
||||
// apiRestoreAll starts restoring all pending apps sequentially.
|
||||
func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) {
|
||||
if s.restorePlan == nil {
|
||||
jsonError(w, "not in restore mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if s.restorePlan.Status == "restoring" {
|
||||
jsonError(w, "restore already in progress", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
s.restorePlan.Status = "restoring"
|
||||
go s.executeAllRestores()
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Visszaállítás elindítva",
|
||||
})
|
||||
}
|
||||
|
||||
// apiRestoreSkip exits restore mode without restoring.
|
||||
func (s *Server) apiRestoreSkip(w http.ResponseWriter, r *http.Request) {
|
||||
if s.restorePlan == nil {
|
||||
jsonError(w, "not in restore mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Println("[INFO] User skipped DR restore — entering normal mode")
|
||||
s.clearRestoreMode()
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Visszaállítás kihagyva",
|
||||
})
|
||||
}
|
||||
|
||||
// executeAllRestores runs the restore for each pending app sequentially.
|
||||
func (s *Server) executeAllRestores() {
|
||||
s.logger.Println("[INFO] Starting DR restore for all apps")
|
||||
|
||||
for i := range s.restorePlan.Apps {
|
||||
app := &s.restorePlan.Apps[i]
|
||||
if app.Status != "pending" {
|
||||
continue
|
||||
}
|
||||
|
||||
s.restorePlan.UpdateApp(app.Name, "restoring", "")
|
||||
s.logger.Printf("[INFO] Restoring app %s (%s)", app.Name, app.DisplayName)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
err := backup.RestoreAppFromBackup(ctx, app, s.cfg.Paths.StacksDir, s.logger)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
s.restorePlan.UpdateApp(app.Name, "failed", err.Error())
|
||||
s.logger.Printf("[ERROR] Restore failed for %s: %v", app.Name, err)
|
||||
} else {
|
||||
s.restorePlan.UpdateApp(app.Name, "done", "")
|
||||
s.logger.Printf("[INFO] Restore completed for %s", app.Name)
|
||||
}
|
||||
}
|
||||
|
||||
s.restorePlan.Status = "done"
|
||||
s.logger.Println("[INFO] All app restores completed")
|
||||
|
||||
// Re-scan stacks so dashboard picks up restored apps
|
||||
if s.stackMgr != nil {
|
||||
if err := s.stackMgr.ScanStacks(); err != nil {
|
||||
s.logger.Printf("[WARN] Post-restore stack scan failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-clear restore mode after a brief delay so user can see final status
|
||||
go func() {
|
||||
time.Sleep(5 * time.Second)
|
||||
// Only auto-clear if user hasn't already navigated away
|
||||
if s.restorePlan != nil && s.restorePlan.AllDone() {
|
||||
// Keep plan visible — user clicks "continue to dashboard" to clear
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// clearRestoreMode exits restore mode and returns to normal operation.
|
||||
func (s *Server) clearRestoreMode() {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
s.restorePlan = nil
|
||||
}
|
||||
@@ -43,6 +43,10 @@ type Server struct {
|
||||
|
||||
// Active raw mount for the attach wizard (empty when not in use)
|
||||
activeRawMount string
|
||||
|
||||
// DR restore mode state
|
||||
restoreMu sync.RWMutex
|
||||
restorePlan *backup.RestorePlan
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -85,10 +89,48 @@ func (s *Server) loadTemplates() {
|
||||
)
|
||||
}
|
||||
|
||||
// SetRestoreState puts the server into DR restore mode with the given plan.
|
||||
func (s *Server) SetRestoreState(plan *backup.RestorePlan) {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
s.restorePlan = plan
|
||||
}
|
||||
|
||||
// InRestoreMode returns true if the server is in DR restore mode.
|
||||
func (s *Server) InRestoreMode() bool {
|
||||
s.restoreMu.RLock()
|
||||
defer s.restoreMu.RUnlock()
|
||||
return s.restorePlan != nil
|
||||
}
|
||||
|
||||
// ServeHTTP handles all non-API web requests.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
// DR restore mode: intercept all routes except restore page, static, and restore API
|
||||
if s.InRestoreMode() {
|
||||
switch {
|
||||
case path == "/restore":
|
||||
s.restorePageHandler(w, r)
|
||||
return
|
||||
case path == "/api/restore/status":
|
||||
s.apiRestoreStatus(w, r)
|
||||
return
|
||||
case path == "/api/restore/all" && r.Method == http.MethodPost:
|
||||
s.apiRestoreAll(w, r)
|
||||
return
|
||||
case path == "/api/restore/skip" && r.Method == http.MethodPost:
|
||||
s.apiRestoreSkip(w, r)
|
||||
return
|
||||
case strings.HasPrefix(path, "/static/"):
|
||||
// Allow static assets through
|
||||
default:
|
||||
// Redirect everything else to the restore page
|
||||
http.Redirect(w, r, "/restore", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "/" || path == "/dashboard":
|
||||
s.dashboardHandler(w, r)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
{{define "restore"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Katasztrófa utáni visszaállítás — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<style>
|
||||
body { background: var(--bg-darker, #0d1117); margin: 0; padding: 0; }
|
||||
.dr-container { max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||
.dr-header { text-align: center; margin-bottom: 2rem; }
|
||||
.dr-header img { width: 48px; height: 48px; margin-bottom: 0.5rem; }
|
||||
.dr-header h1 { color: var(--warning, #f0ad4e); font-size: 1.5rem; margin: 0.5rem 0; }
|
||||
.dr-header p { color: var(--text-secondary, #8b949e); margin: 0.25rem 0; }
|
||||
.dr-card { background: var(--card-bg, #161b22); border: 1px solid var(--border, #30363d); border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; }
|
||||
.dr-card h3 { margin: 0 0 0.75rem 0; color: var(--text-primary, #e6edf3); font-size: 1rem; }
|
||||
.dr-drives { display: flex; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.dr-drive { background: var(--bg-darker, #0d1117); border: 1px solid var(--border, #30363d); border-radius: 6px; padding: 0.75rem 1rem; flex: 1; min-width: 200px; }
|
||||
.dr-drive-label { font-weight: 600; color: var(--text-primary, #e6edf3); }
|
||||
.dr-drive-path { font-size: 0.85rem; color: var(--text-secondary, #8b949e); font-family: monospace; }
|
||||
.dr-drive-status { font-size: 0.85rem; margin-top: 0.25rem; }
|
||||
.dr-drive-ok { color: var(--success, #3fb950); }
|
||||
.dr-drive-warn { color: var(--warning, #f0ad4e); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; color: var(--text-secondary, #8b949e); font-size: 0.85rem; font-weight: 500; border-bottom: 1px solid var(--border, #30363d); }
|
||||
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border, #30363d); color: var(--text-primary, #e6edf3); font-size: 0.9rem; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; font-weight: 500; }
|
||||
.badge-ok { background: rgba(63,185,80,0.15); color: var(--success, #3fb950); }
|
||||
.badge-warn { background: rgba(240,173,78,0.15); color: var(--warning, #f0ad4e); }
|
||||
.badge-none { background: rgba(139,148,158,0.15); color: var(--text-secondary, #8b949e); }
|
||||
.status-pending { color: var(--text-secondary, #8b949e); }
|
||||
.status-restoring { color: var(--info, #58a6ff); }
|
||||
.status-done { color: var(--success, #3fb950); }
|
||||
.status-failed { color: var(--danger, #f85149); }
|
||||
.status-skipped { color: var(--text-secondary, #8b949e); }
|
||||
.dr-actions { display: flex; gap: 0.75rem; justify-content: center; margin-top: 1.5rem; }
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.6rem 1.5rem; border-radius: 6px; border: 1px solid transparent; font-size: 0.9rem; font-weight: 500; cursor: pointer; text-decoration: none; transition: background 0.2s; }
|
||||
.btn-primary { background: var(--accent, #238636); color: #fff; border-color: var(--accent, #238636); }
|
||||
.btn-primary:hover { background: #2ea043; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-outline { background: transparent; color: var(--text-secondary, #8b949e); border-color: var(--border, #30363d); }
|
||||
.btn-outline:hover { color: var(--text-primary, #e6edf3); border-color: var(--text-secondary, #8b949e); }
|
||||
.btn-success { background: var(--accent, #238636); color: #fff; }
|
||||
.progress-bar { height: 4px; background: var(--border, #30363d); border-radius: 2px; margin-top: 1rem; overflow: hidden; display: none; }
|
||||
.progress-bar-inner { height: 100%; background: var(--accent, #238636); transition: width 0.5s; width: 0%; }
|
||||
.dr-info { display: flex; gap: 2rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
|
||||
.dr-info-item { font-size: 0.9rem; }
|
||||
.dr-info-label { color: var(--text-secondary, #8b949e); }
|
||||
.dr-info-value { color: var(--text-primary, #e6edf3); font-weight: 500; }
|
||||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border, #30363d); border-top-color: var(--info, #58a6ff); border-radius: 50%; animation: spin 0.8s linear infinite; vertical-align: middle; margin-right: 4px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dr-container">
|
||||
<div class="dr-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom">
|
||||
<h1>Korábbi telepítés észlelve</h1>
|
||||
<p>A rendszer biztonsági mentést talált a központi szerveren</p>
|
||||
</div>
|
||||
|
||||
<!-- Info card -->
|
||||
<div class="dr-card">
|
||||
<h3>Rendszer információ</h3>
|
||||
<div class="dr-info">
|
||||
<div class="dr-info-item">
|
||||
<span class="dr-info-label">Ügyfél: </span>
|
||||
<span class="dr-info-value">{{.CustomerName}}</span>
|
||||
</div>
|
||||
<div class="dr-info-item">
|
||||
<span class="dr-info-label">Domain: </span>
|
||||
<span class="dr-info-value">{{.Domain}}</span>
|
||||
</div>
|
||||
<div class="dr-info-item">
|
||||
<span class="dr-info-label">Mentés időpontja: </span>
|
||||
<span class="dr-info-value">{{.Timestamp}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drives card -->
|
||||
<div class="dr-card">
|
||||
<h3>Meghajtók</h3>
|
||||
<div class="dr-drives">
|
||||
{{range .Drives}}
|
||||
<div class="dr-drive">
|
||||
<div class="dr-drive-label">{{.Label}}</div>
|
||||
<div class="dr-drive-path">{{.Path}}</div>
|
||||
<div class="dr-drive-status">
|
||||
{{if .Available}}
|
||||
{{if .HasBackup}}
|
||||
<span class="dr-drive-ok">Elérhető, mentés megtalálva</span>
|
||||
{{else}}
|
||||
<span class="dr-drive-ok">Elérhető</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="dr-drive-warn">Nem elérhető</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if not .Drives}}
|
||||
<p style="color:var(--text-secondary)">Nem találhatók csatlakoztatott meghajtók.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apps table card -->
|
||||
<div class="dr-card">
|
||||
<h3>Visszaállítható alkalmazások</h3>
|
||||
{{if .Apps}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alkalmazás</th>
|
||||
<th>Konfiguráció</th>
|
||||
<th>Adatok</th>
|
||||
<th>DB mentés</th>
|
||||
<th>Állapot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="app-table-body">
|
||||
{{range .Apps}}
|
||||
<tr data-app="{{.Name}}">
|
||||
<td>
|
||||
<strong>{{.DisplayName}}</strong>
|
||||
<div style="font-size:.8rem;color:var(--text-secondary)">{{.Name}}</div>
|
||||
</td>
|
||||
<td>
|
||||
{{if .HasConfig}}
|
||||
<span class="badge badge-ok">Megtalálva</span>
|
||||
{{else}}
|
||||
<span class="badge badge-none">Hiányzik</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .HasData}}
|
||||
<span class="badge badge-ok">Elérhető</span>
|
||||
{{else if .HasRsyncData}}
|
||||
<span class="badge badge-warn">Mentésből</span>
|
||||
{{else if not .NeedsHDD}}
|
||||
<span class="badge badge-none">Nem szükséges</span>
|
||||
{{else}}
|
||||
<span class="badge badge-warn">Hiányzik</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .HasDBDump}}
|
||||
<span class="badge badge-ok">Van</span>
|
||||
{{else}}
|
||||
<span class="badge badge-none">Nincs</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="app-status" data-app="{{.Name}}">
|
||||
<span class="status-{{.Status}}">{{statusText .Status}}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="progress-bar" id="progress-bar">
|
||||
<div class="progress-bar-inner" id="progress-inner"></div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p style="color:var(--text-secondary)">Nem találhatók visszaállítható alkalmazások.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="dr-actions" id="dr-actions">
|
||||
{{if eq .PlanStatus "pending"}}
|
||||
{{if .Apps}}
|
||||
<button class="btn btn-primary" id="btn-restore-all" onclick="startRestoreAll()">
|
||||
Összes visszaállítása ({{len .Apps}} alkalmazás)
|
||||
</button>
|
||||
{{end}}
|
||||
<button class="btn btn-outline" id="btn-skip" onclick="skipRestore()">
|
||||
Kihagyás — tovább a vezérlőpulthoz
|
||||
</button>
|
||||
{{else if eq .PlanStatus "restoring"}}
|
||||
<button class="btn btn-primary" disabled>
|
||||
<span class="spinner"></span> Visszaállítás folyamatban...
|
||||
</button>
|
||||
{{else if eq .PlanStatus "done"}}
|
||||
<a href="/" class="btn btn-success" id="btn-continue" onclick="finishRestore(event)">
|
||||
Tovább a vezérlőpulthoz
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var polling = null;
|
||||
var planStatus = "{{.PlanStatus}}";
|
||||
|
||||
if (planStatus === "restoring") {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function startRestoreAll() {
|
||||
var btn = document.getElementById('btn-restore-all');
|
||||
var skipBtn = document.getElementById('btn-skip');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Visszaállítás indítása...';
|
||||
if (skipBtn) skipBtn.style.display = 'none';
|
||||
|
||||
fetch('/api/restore/all', { method: 'POST' })
|
||||
.then(function(resp) { return resp.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
planStatus = 'restoring';
|
||||
document.getElementById('progress-bar').style.display = 'block';
|
||||
startPolling();
|
||||
} else {
|
||||
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Összes visszaállítása';
|
||||
if (skipBtn) skipBtn.style.display = '';
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Összes visszaállítása';
|
||||
if (skipBtn) skipBtn.style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
function skipRestore() {
|
||||
if (!confirm('Biztosan ki szeretné hagyni a visszaállítást? A vezérlőpult üres alkalmazáslistával fog elindulni.')) return;
|
||||
fetch('/api/restore/skip', { method: 'POST' })
|
||||
.then(function(resp) { return resp.json(); })
|
||||
.then(function(data) {
|
||||
if (data.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
}
|
||||
})
|
||||
.catch(function(err) { alert('Hálózati hiba: ' + err.message); });
|
||||
}
|
||||
|
||||
function finishRestore(e) {
|
||||
e.preventDefault();
|
||||
fetch('/api/restore/skip', { method: 'POST' })
|
||||
.then(function() { window.location.href = '/'; })
|
||||
.catch(function() { window.location.href = '/'; });
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (polling) return;
|
||||
document.getElementById('progress-bar').style.display = 'block';
|
||||
polling = setInterval(pollStatus, 2000);
|
||||
pollStatus();
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
fetch('/api/restore/status')
|
||||
.then(function(resp) { return resp.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.ok) return;
|
||||
updateTable(data.apps || []);
|
||||
updateProgress(data.apps || []);
|
||||
|
||||
if (data.status === 'done') {
|
||||
clearInterval(polling);
|
||||
polling = null;
|
||||
planStatus = 'done';
|
||||
updateActions();
|
||||
}
|
||||
})
|
||||
.catch(function() {});
|
||||
}
|
||||
|
||||
function updateTable(apps) {
|
||||
apps.forEach(function(app) {
|
||||
var cells = document.querySelectorAll('.app-status[data-app="' + app.name + '"]');
|
||||
cells.forEach(function(cell) {
|
||||
var html = '<span class="status-' + app.status + '">';
|
||||
if (app.status === 'restoring') {
|
||||
html += '<span class="spinner"></span> ';
|
||||
}
|
||||
html += statusText(app.status);
|
||||
if (app.error) {
|
||||
html += ' <span style="font-size:.8rem;color:var(--danger)">(' + app.error.substring(0, 60) + ')</span>';
|
||||
}
|
||||
html += '</span>';
|
||||
cell.innerHTML = html;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateProgress(apps) {
|
||||
var total = apps.length;
|
||||
if (total === 0) return;
|
||||
var done = 0;
|
||||
apps.forEach(function(a) {
|
||||
if (a.status === 'done' || a.status === 'failed' || a.status === 'skipped') done++;
|
||||
});
|
||||
var pct = Math.round((done / total) * 100);
|
||||
document.getElementById('progress-inner').style.width = pct + '%';
|
||||
}
|
||||
|
||||
function updateActions() {
|
||||
var actions = document.getElementById('dr-actions');
|
||||
actions.innerHTML = '<a href="/" class="btn btn-success" id="btn-continue" onclick="finishRestore(event)">Tovább a vezérlőpulthoz</a>';
|
||||
}
|
||||
|
||||
function statusText(s) {
|
||||
switch (s) {
|
||||
case 'pending': return 'Várakozik';
|
||||
case 'restoring': return 'Visszaállítás...';
|
||||
case 'done': return 'Kész';
|
||||
case 'failed': return 'Sikertelen';
|
||||
case 'skipped': return 'Kihagyva';
|
||||
default: return s;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user