feat: infra backup retention + version picker
Hub: GFS retention (7d/4w/3m, ~14 versions) in new infra_backup_versions table. Recovery endpoint supports ?version=ID. New /versions API endpoint. Dashboard shows backup history. Controller: local drive backups rotated into history/ (last 5 versions). Setup wizard shows version picker for Hub restores when multiple versions exist. Scan results enriched with app names, disk count, history badge. Local restore supports historical versions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+8
-1
@@ -1,15 +1,22 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
### v0.31.7 — Hub mode: setup wizard with infra backup restore (2026-02-26)
|
### v0.31.7 — Infra backup retention + version picker (2026-02-26)
|
||||||
|
|
||||||
#### Changed
|
#### Changed
|
||||||
- **docker-setup.sh hub mode**: `--hub-customer` now generates a minimal `controller.yaml` (no `customer.id`) instead of installing the full hub config — this triggers the setup wizard on first run, giving the user a choice to restore from an infra backup or start fresh
|
- **docker-setup.sh hub mode**: `--hub-customer` now generates a minimal `controller.yaml` (no `customer.id`) instead of installing the full hub config — this triggers the setup wizard on first run, giving the user a choice to restore from an infra backup or start fresh
|
||||||
- **docker-setup.sh**: Hub credentials are passed to the controller via `FELHOM_SETUP_CUSTOMER_ID` and `FELHOM_SETUP_PASSWORD` environment variables so the setup wizard auto-fills them
|
- **docker-setup.sh**: Hub credentials are passed to the controller via `FELHOM_SETUP_CUSTOMER_ID` and `FELHOM_SETUP_PASSWORD` environment variables so the setup wizard auto-fills them
|
||||||
|
- **Local infra backup**: `WriteLocalInfraBackup()` now rotates previous backup into `history/` subdirectory before writing new files (keeps last 5 versions per drive)
|
||||||
|
- **Setup wizard scan results**: Table now shows app names/count, disk count, and "korábbi" badge for historical versions
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
- **Setup wizard hub pre-seeding**: When deployed with `--hub-customer`, the wizard auto-detects pre-seeded credentials and auto-processes Hub API calls (no manual form entry needed)
|
- **Setup wizard hub pre-seeding**: When deployed with `--hub-customer`, the wizard auto-detects pre-seeded credentials and auto-processes Hub API calls (no manual form entry needed)
|
||||||
- **Hub mode welcome page**: Shows three options instead of two — "Visszaállítás a Hub-ról" (auto-connects to Hub), "Helyi mentés keresése" (local drive scan), "Friss telepítés" (fresh config download)
|
- **Hub mode welcome page**: Shows three options instead of two — "Visszaállítás a Hub-ról" (auto-connects to Hub), "Helyi mentés keresése" (local drive scan), "Friss telepítés" (fresh config download)
|
||||||
- **Auto-process fallback**: If Hub auto-connect fails, the wizard clears the pre-seeded password and falls back to the manual form with the error displayed
|
- **Auto-process fallback**: If Hub auto-connect fails, the wizard clears the pre-seeded password and falls back to the manual form with the error displayed
|
||||||
|
- **Hub backup version picker**: When multiple backup versions exist on the Hub, the setup wizard shows a version picker page (date, controller version, app names, disk count) — user selects which version to restore
|
||||||
|
- **Local backup history restore**: Setup wizard can restore from historical versions found in `history/` subdirectory on local drives
|
||||||
|
- **`ReadLocalInfraHistory()`**: Scans `history/` directory for all retained backup versions with rich metadata (stack names, disk count, integrity status)
|
||||||
|
- **`ReadLocalInfraBackupFromHistory()`**: Reads a specific historical version by timestamp prefix
|
||||||
|
- **`PullRecoveryVersion()`**: Fetches a specific backup version from the Hub recovery endpoint via `?version=ID` parameter
|
||||||
|
|
||||||
#### Fixed
|
#### Fixed
|
||||||
- **Bind mount write**: `atomicWriteFile()` now falls back to direct write when rename fails (fixes "device or resource busy" on Docker bind-mounted `controller.yaml`)
|
- **Bind mount write**: `atomicWriteFile()` now falls back to direct write when rename fails (fixes "device or resource busy" on Docker bind-mounted `controller.yaml`)
|
||||||
|
|||||||
+10
-7
@@ -989,19 +989,22 @@ On error, the wizard falls back to the manual form with the error displayed.
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `setup/setup.go` | `NeedsSetup()` detection, `SetupState` persistence to `setup-state.json` |
|
| `setup/setup.go` | `NeedsSetup()` detection, `SetupState` persistence to `setup-state.json` |
|
||||||
| `setup/handlers.go` | HTTP handlers for each wizard step (welcome, scan, hub-restore, fresh, manual) |
|
| `setup/handlers.go` | HTTP handlers for each wizard step (welcome, scan, hub-restore, fresh, manual) |
|
||||||
| `setup/scanner.go` | Scans all block devices for `.felhom-infra-backup/` directories via `lsblk` + temp mounts |
|
| `setup/scanner.go` | Scans all block devices for `.felhom-infra-backup/` directories (current + `history/`) via `lsblk` + temp mounts; returns rich info (app names, disk count) |
|
||||||
| `setup/hub.go` | Hub recovery pull (`GET /api/v1/recovery/{id}`) and config download |
|
| `setup/hub.go` | Hub recovery pull (`GET /api/v1/recovery/{id}`) and config download |
|
||||||
| `setup/csrf.go` | Lightweight CSRF protection (cookie + hidden field, `SameSite=Strict`) |
|
| `setup/csrf.go` | Lightweight CSRF protection (cookie + hidden field, `SameSite=Strict`) |
|
||||||
| `setup/network.go` | Detects local IPs for LAN access URL display |
|
| `setup/network.go` | Detects local IPs for LAN access URL display |
|
||||||
| `setup/templates/` | 7 embedded HTML templates (Hungarian, dark theme matching main UI) |
|
| `setup/templates/` | 8 embedded HTML templates (Hungarian, dark theme matching main UI) — includes `setup_hub_versions.html` for Hub backup version picker |
|
||||||
|
|
||||||
#### Local Infra Backup (`internal/backup/local_infra.go`)
|
#### Local Infra Backup (`internal/backup/local_infra.go`)
|
||||||
|
|
||||||
The controller writes infrastructure snapshots to **every connected drive** after each backup cycle and on startup. Location: `<drive>/.felhom-infra-backup/`. Files:
|
The controller writes infrastructure snapshots to **every connected drive** after each backup cycle and on startup. Location: `<drive>/.felhom-infra-backup/`. Files:
|
||||||
- `backup.json` — full infra backup (config, settings, disk layout, passwords, stacks)
|
- `backup.json` — full infra backup (config, settings, disk layout, passwords, stacks)
|
||||||
- `metadata.json` — schema version, timestamp, customer ID, controller version, SHA256 checksum
|
- `metadata.json` — schema version, timestamp, customer ID, controller version, SHA256 checksum
|
||||||
|
- `history/` — previous backup versions (last 5), rotated automatically before each write
|
||||||
|
- `{timestamp}-backup.json` + `{timestamp}-metadata.json` pairs (timestamp format: `20060102T150405Z`)
|
||||||
|
- Oldest entries pruned when count exceeds 5
|
||||||
|
|
||||||
During setup wizard drive scan, these backups are discovered, integrity-verified, and offered for one-click restore.
|
During setup wizard drive scan, both current and historical backups are discovered, integrity-verified, and offered for one-click restore. The scan results table shows app names/count, disk count, and a "korábbi" badge for historical versions.
|
||||||
|
|
||||||
#### Recovery Info (`internal/recovery/info.go`)
|
#### Recovery Info (`internal/recovery/info.go`)
|
||||||
|
|
||||||
@@ -1019,9 +1022,9 @@ When a system drive fails and is replaced, the recovery flow uses the setup wiza
|
|||||||
3. User opens wizard at http://<LAN-IP>:8081
|
3. User opens wizard at http://<LAN-IP>:8081
|
||||||
4. Hub mode: welcome page shows Hub restore / local scan / fresh install
|
4. Hub mode: welcome page shows Hub restore / local scan / fresh install
|
||||||
Non-hub mode: welcome page shows restore / fresh install
|
Non-hub mode: welcome page shows restore / fresh install
|
||||||
5. Hub restore: auto-connects to Hub, shows infra backup details
|
5. Hub restore: auto-connects to Hub, shows version picker if multiple versions
|
||||||
Local restore: scans all drives for .felhom-infra-backup/ directories
|
Local restore: scans all drives for .felhom-infra-backup/ directories (current + history/)
|
||||||
6. One-click restore: config, settings, passwords, disk layout
|
6. User selects backup version → restore: config, settings, passwords, disk layout
|
||||||
7. Controller restarts into normal mode with full config
|
7. Controller restarts into normal mode with full config
|
||||||
8. Controller auto-mounts surviving drives by UUID from disk layout
|
8. Controller auto-mounts surviving drives by UUID from disk layout
|
||||||
9. Dashboard shows "Visszaállítás" (Restore) page for app-level recovery
|
9. Dashboard shows "Visszaállítás" (Restore) page for app-level recovery
|
||||||
@@ -1030,7 +1033,7 @@ When a system drive fails and is replaced, the recovery flow uses the setup wiza
|
|||||||
|
|
||||||
**Backup sources (priority order):**
|
**Backup sources (priority order):**
|
||||||
1. **Local infra backup** (`.felhom-infra-backup/` on surviving drives) — fastest, no network needed
|
1. **Local infra backup** (`.felhom-infra-backup/` on surviving drives) — fastest, no network needed
|
||||||
2. **Hub recovery endpoint** (`GET /api/v1/recovery/{id}`) — requires retrieval password
|
2. **Hub recovery endpoint** (`GET /api/v1/recovery/{id}`) — requires retrieval password, supports `?version=ID` for specific versions; Hub retains ~14 versions via GFS pruning (7 daily / 4 weekly / 3 monthly)
|
||||||
3. **Manual config** (wizard form) — enter all details manually as last resort
|
3. **Manual config** (wizard form) — enter all details manually as last resort
|
||||||
|
|
||||||
**Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments).
|
**Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments).
|
||||||
|
|||||||
@@ -8,11 +8,17 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxSchemaVersion is the highest infra backup schema version this controller can read.
|
// MaxSchemaVersion is the highest infra backup schema version this controller can read.
|
||||||
const MaxSchemaVersion = 1
|
const MaxSchemaVersion = 1
|
||||||
|
|
||||||
|
// maxLocalHistory is the number of previous backup versions to keep per drive.
|
||||||
|
const maxLocalHistory = 5
|
||||||
|
|
||||||
// InfraMetadata is the lightweight metadata file written alongside backup.json.
|
// InfraMetadata is the lightweight metadata file written alongside backup.json.
|
||||||
type InfraMetadata struct {
|
type InfraMetadata struct {
|
||||||
SchemaVersion int `json:"schema_version"`
|
SchemaVersion int `json:"schema_version"`
|
||||||
@@ -58,7 +64,7 @@ func WriteLocalInfraBackup(backupJSON []byte, customerID, controllerVersion, tim
|
|||||||
if debug {
|
if debug {
|
||||||
logger.Printf("[DEBUG] WriteLocalInfraBackup: writing to drive=%s, dir=%s", drive, dir)
|
logger.Printf("[DEBUG] WriteLocalInfraBackup: writing to drive=%s, dir=%s", drive, dir)
|
||||||
}
|
}
|
||||||
if err := writeInfraToDir(dir, backupJSON, metaJSON); err != nil {
|
if err := writeInfraToDir(dir, backupJSON, metaJSON, logger); err != nil {
|
||||||
logger.Printf("[WARN] Local infra backup: failed to write to %s: %v", drive, err)
|
logger.Printf("[WARN] Local infra backup: failed to write to %s: %v", drive, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -71,12 +77,15 @@ func WriteLocalInfraBackup(backupJSON []byte, customerID, controllerVersion, tim
|
|||||||
logger.Printf("[INFO] Local infra backup written to %d/%d drive(s)", written, len(drives))
|
logger.Printf("[INFO] Local infra backup written to %d/%d drive(s)", written, len(drives))
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeInfraToDir writes backup.json and metadata.json atomically to the given directory.
|
// writeInfraToDir rotates the current backup into history/ then writes new backup.json and metadata.json.
|
||||||
func writeInfraToDir(dir string, backupData, metaData []byte) error {
|
func writeInfraToDir(dir string, backupData, metaData []byte, logger *log.Logger) error {
|
||||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
return fmt.Errorf("creating dir: %w", err)
|
return fmt.Errorf("creating dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rotate current backup to history (best-effort)
|
||||||
|
rotateToHistory(dir, logger)
|
||||||
|
|
||||||
// Write backup.json atomically
|
// Write backup.json atomically
|
||||||
backupPath := filepath.Join(dir, "backup.json")
|
backupPath := filepath.Join(dir, "backup.json")
|
||||||
if err := atomicWrite(backupPath, backupData, 0600); err != nil {
|
if err := atomicWrite(backupPath, backupData, 0600); err != nil {
|
||||||
@@ -92,6 +101,106 @@ func writeInfraToDir(dir string, backupData, metaData []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rotateToHistory moves the current backup.json + metadata.json into history/{timestamp}-*.
|
||||||
|
func rotateToHistory(dir string, logger *log.Logger) {
|
||||||
|
metaPath := filepath.Join(dir, "metadata.json")
|
||||||
|
backupPath := filepath.Join(dir, "backup.json")
|
||||||
|
|
||||||
|
// Read current metadata to get timestamp
|
||||||
|
metaData, err := os.ReadFile(metaPath)
|
||||||
|
if err != nil {
|
||||||
|
return // no existing backup to rotate
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta InfraMetadata
|
||||||
|
if err := json.Unmarshal(metaData, &meta); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timestamp, fall back to file mtime
|
||||||
|
ts := sanitizeTimestamp(meta.Timestamp)
|
||||||
|
if ts == "" {
|
||||||
|
if fi, err := os.Stat(metaPath); err == nil {
|
||||||
|
ts = fi.ModTime().UTC().Format("20060102T150405Z")
|
||||||
|
} else {
|
||||||
|
ts = time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
histDir := filepath.Join(dir, "history")
|
||||||
|
if err := os.MkdirAll(histDir, 0700); err != nil {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Printf("[WARN] Local infra history: cannot create history dir: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move files
|
||||||
|
histBackup := filepath.Join(histDir, ts+"-backup.json")
|
||||||
|
histMeta := filepath.Join(histDir, ts+"-metadata.json")
|
||||||
|
|
||||||
|
// Copy rather than rename to avoid cross-device issues
|
||||||
|
if data, err := os.ReadFile(backupPath); err == nil {
|
||||||
|
os.WriteFile(histBackup, data, 0600) //nolint:errcheck
|
||||||
|
}
|
||||||
|
os.WriteFile(histMeta, metaData, 0600) //nolint:errcheck
|
||||||
|
|
||||||
|
// Prune old history entries
|
||||||
|
pruneLocalHistory(histDir, maxLocalHistory, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLocalHistory keeps at most maxKeep metadata+backup pairs, deleting the oldest.
|
||||||
|
func pruneLocalHistory(histDir string, maxKeep int, logger *log.Logger) {
|
||||||
|
entries, err := os.ReadDir(histDir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique timestamps (each has -backup.json and -metadata.json)
|
||||||
|
timestamps := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if strings.HasSuffix(name, "-metadata.json") {
|
||||||
|
ts := strings.TrimSuffix(name, "-metadata.json")
|
||||||
|
timestamps[ts] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(timestamps) <= maxKeep {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort timestamps ascending (oldest first)
|
||||||
|
sorted := make([]string, 0, len(timestamps))
|
||||||
|
for ts := range timestamps {
|
||||||
|
sorted = append(sorted, ts)
|
||||||
|
}
|
||||||
|
sort.Strings(sorted)
|
||||||
|
|
||||||
|
// Delete oldest entries beyond limit
|
||||||
|
toDelete := len(sorted) - maxKeep
|
||||||
|
for i := 0; i < toDelete; i++ {
|
||||||
|
ts := sorted[i]
|
||||||
|
os.Remove(filepath.Join(histDir, ts+"-backup.json"))
|
||||||
|
os.Remove(filepath.Join(histDir, ts+"-metadata.json"))
|
||||||
|
if logger != nil {
|
||||||
|
logger.Printf("[DEBUG] Local infra history: pruned old version %s", ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeTimestamp converts an RFC3339 timestamp to a filename-safe format.
|
||||||
|
func sanitizeTimestamp(ts string) string {
|
||||||
|
t, err := time.Parse(time.RFC3339, ts)
|
||||||
|
if err != nil {
|
||||||
|
t, err = time.Parse(time.RFC3339Nano, ts)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.UTC().Format("20060102T150405Z")
|
||||||
|
}
|
||||||
|
|
||||||
// atomicWrite writes data to a .tmp file then renames to the target path.
|
// atomicWrite writes data to a .tmp file then renames to the target path.
|
||||||
func atomicWrite(path string, data []byte, perm os.FileMode) error {
|
func atomicWrite(path string, data []byte, perm os.FileMode) error {
|
||||||
tmp := path + ".tmp"
|
tmp := path + ".tmp"
|
||||||
@@ -110,9 +219,120 @@ func atomicWrite(path string, data []byte, perm os.FileMode) error {
|
|||||||
// Returns the raw backup JSON, metadata, and any error.
|
// Returns the raw backup JSON, metadata, and any error.
|
||||||
func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) {
|
func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) {
|
||||||
dir := InfraBackupDir(mountPath)
|
dir := InfraBackupDir(mountPath)
|
||||||
|
return readInfraBackupFromDir(dir)
|
||||||
|
}
|
||||||
|
|
||||||
// Read metadata
|
// ReadLocalInfraBackupFromHistory reads a specific historical version by its timestamp prefix.
|
||||||
|
func ReadLocalInfraBackupFromHistory(mountPath, historyPrefix string) ([]byte, *InfraMetadata, error) {
|
||||||
|
histDir := InfraBackupHistoryDir(mountPath)
|
||||||
|
|
||||||
|
metaPath := filepath.Join(histDir, historyPrefix+"-metadata.json")
|
||||||
|
backupPath := filepath.Join(histDir, historyPrefix+"-backup.json")
|
||||||
|
|
||||||
|
return readInfraBackupFromFiles(backupPath, metaPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalBackupVersion holds summary info for a historical backup version found on a drive.
|
||||||
|
type LocalBackupVersion struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
CustomerID string `json:"customer_id"`
|
||||||
|
ControllerVersion string `json:"controller_version"`
|
||||||
|
IntegrityOK bool `json:"integrity_ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
StackCount int `json:"stack_count"`
|
||||||
|
StackNames []string `json:"stack_names,omitempty"`
|
||||||
|
DiskCount int `json:"disk_count"`
|
||||||
|
HistoryFile string `json:"history_file,omitempty"` // empty = current, timestamp prefix for history
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLocalInfraHistory reads all historical backup versions from a mount point's history/ directory.
|
||||||
|
// Returns newest-first. Does NOT include the current backup (use ReadLocalInfraBackup for that).
|
||||||
|
func ReadLocalInfraHistory(mountPath string) []LocalBackupVersion {
|
||||||
|
histDir := InfraBackupHistoryDir(mountPath)
|
||||||
|
entries, err := os.ReadDir(histDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect unique timestamps
|
||||||
|
var timestamps []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if strings.HasSuffix(name, "-metadata.json") {
|
||||||
|
ts := strings.TrimSuffix(name, "-metadata.json")
|
||||||
|
if !seen[ts] {
|
||||||
|
seen[ts] = true
|
||||||
|
timestamps = append(timestamps, ts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort descending (newest first)
|
||||||
|
sort.Sort(sort.Reverse(sort.StringSlice(timestamps)))
|
||||||
|
|
||||||
|
var versions []LocalBackupVersion
|
||||||
|
for _, ts := range timestamps {
|
||||||
|
v := LocalBackupVersion{HistoryFile: ts}
|
||||||
|
|
||||||
|
backupPath := filepath.Join(histDir, ts+"-backup.json")
|
||||||
|
metaPath := filepath.Join(histDir, ts+"-metadata.json")
|
||||||
|
|
||||||
|
backupData, meta, err := readInfraBackupFromFiles(backupPath, metaPath)
|
||||||
|
if meta != nil {
|
||||||
|
v.Timestamp = meta.Timestamp
|
||||||
|
v.CustomerID = meta.CustomerID
|
||||||
|
v.ControllerVersion = meta.ControllerVersion
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
v.IntegrityOK = false
|
||||||
|
v.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
v.IntegrityOK = true
|
||||||
|
ParseBackupCounts(backupData, &v.StackCount, &v.StackNames, &v.DiskCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBackupCounts extracts stack/disk counts from backup JSON (for display purposes).
|
||||||
|
func ParseBackupCounts(backupJSON []byte, stackCount *int, stackNames *[]string, diskCount *int) {
|
||||||
|
var parsed struct {
|
||||||
|
DeployedStacks []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
} `json:"deployed_stacks"`
|
||||||
|
DiskLayout struct {
|
||||||
|
Mounts []json.RawMessage `json:"mounts"`
|
||||||
|
} `json:"disk_layout"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(backupJSON, &parsed); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*stackCount = len(parsed.DeployedStacks)
|
||||||
|
*diskCount = len(parsed.DiskLayout.Mounts)
|
||||||
|
if stackNames != nil {
|
||||||
|
for _, s := range parsed.DeployedStacks {
|
||||||
|
name := s.DisplayName
|
||||||
|
if name == "" {
|
||||||
|
name = s.Name
|
||||||
|
}
|
||||||
|
*stackNames = append(*stackNames, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInfraBackupFromDir(dir string) ([]byte, *InfraMetadata, error) {
|
||||||
metaPath := filepath.Join(dir, "metadata.json")
|
metaPath := filepath.Join(dir, "metadata.json")
|
||||||
|
backupPath := filepath.Join(dir, "backup.json")
|
||||||
|
return readInfraBackupFromFiles(backupPath, metaPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readInfraBackupFromFiles(backupPath, metaPath string) ([]byte, *InfraMetadata, error) {
|
||||||
|
// Read metadata
|
||||||
metaData, err := os.ReadFile(metaPath)
|
metaData, err := os.ReadFile(metaPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("reading metadata.json: %w", err)
|
return nil, nil, fmt.Errorf("reading metadata.json: %w", err)
|
||||||
@@ -129,7 +349,6 @@ func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read backup data
|
// Read backup data
|
||||||
backupPath := filepath.Join(dir, "backup.json")
|
|
||||||
backupData, err := os.ReadFile(backupPath)
|
backupData, err := os.ReadFile(backupPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &meta, fmt.Errorf("reading backup.json: %w", err)
|
return nil, &meta, fmt.Errorf("reading backup.json: %w", err)
|
||||||
|
|||||||
@@ -49,3 +49,8 @@ func AppDataDir(drivePath, stackName string) string {
|
|||||||
func InfraBackupDir(mountPath string) string {
|
func InfraBackupDir(mountPath string) string {
|
||||||
return filepath.Join(mountPath, ".felhom-infra-backup")
|
return filepath.Join(mountPath, ".felhom-infra-backup")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InfraBackupHistoryDir returns the history subdirectory for versioned infra backups on a drive.
|
||||||
|
func InfraBackupHistoryDir(mountPath string) string {
|
||||||
|
return filepath.Join(mountPath, ".felhom-infra-backup", "history")
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,12 +18,22 @@ var (
|
|||||||
ErrHubError = errors.New("hub error")
|
ErrHubError = errors.New("hub error")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// BackupVersionSummary holds metadata about one backup version (from Hub).
|
||||||
|
type BackupVersionSummary struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
StackCount int `json:"stack_count"`
|
||||||
|
DiskCount int `json:"disk_count"`
|
||||||
|
StackNames []string `json:"stack_names,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// RecoveryResponse is the combined config + infra backup from the Hub recovery endpoint.
|
// RecoveryResponse is the combined config + infra backup from the Hub recovery endpoint.
|
||||||
type RecoveryResponse struct {
|
type RecoveryResponse struct {
|
||||||
CustomerID string `json:"customer_id"`
|
CustomerID string `json:"customer_id"`
|
||||||
ConfigYAML string `json:"config_yaml"`
|
ConfigYAML string `json:"config_yaml"`
|
||||||
InfraBackup *InfraBackup `json:"infra_backup"`
|
InfraBackup *InfraBackup `json:"infra_backup"`
|
||||||
HasInfraBackup bool `json:"has_infra_backup"`
|
HasInfraBackup bool `json:"has_infra_backup"`
|
||||||
|
BackupVersions []BackupVersionSummary `json:"backup_versions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PullRecovery fetches combined recovery data from the Hub (config + infra backup).
|
// PullRecovery fetches combined recovery data from the Hub (config + infra backup).
|
||||||
@@ -69,6 +79,48 @@ func PullRecovery(hubURL, customerID, retrievalPassword string) (*RecoveryRespon
|
|||||||
return &rr, nil
|
return &rr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PullRecoveryVersion fetches recovery data for a specific backup version ID.
|
||||||
|
func PullRecoveryVersion(hubURL, customerID, retrievalPassword string, versionID int64) (*RecoveryResponse, error) {
|
||||||
|
url := strings.TrimRight(hubURL, "/") + "/api/v1/recovery/" + customerID + fmt.Sprintf("?version=%d", versionID)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrHubError, err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Retrieval-Password", retrievalPassword)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: %v", ErrHubUnreachable, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
switch resp.StatusCode {
|
||||||
|
case http.StatusOK:
|
||||||
|
// success
|
||||||
|
case http.StatusUnauthorized:
|
||||||
|
return nil, ErrAuthFailed
|
||||||
|
case http.StatusNotFound:
|
||||||
|
return nil, ErrNotFound
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: reading response: %v", ErrHubError, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rr RecoveryResponse
|
||||||
|
if err := json.Unmarshal(body, &rr); err != nil {
|
||||||
|
return nil, fmt.Errorf("%w: parsing response: %v", ErrHubError, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &rr, nil
|
||||||
|
}
|
||||||
|
|
||||||
// PullConfig fetches a generated controller.yaml from the Hub config endpoint.
|
// PullConfig fetches a generated controller.yaml from the Hub config endpoint.
|
||||||
// Auth: X-Retrieval-Password header.
|
// Auth: X-Retrieval-Password header.
|
||||||
func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) {
|
func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) {
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("/setup/scan", s.handleScan)
|
mux.HandleFunc("/setup/scan", s.handleScan)
|
||||||
mux.HandleFunc("/setup/scan/status", s.handleScanStatus)
|
mux.HandleFunc("/setup/scan/status", s.handleScanStatus)
|
||||||
mux.HandleFunc("/setup/hub-restore", s.handleHubRestore)
|
mux.HandleFunc("/setup/hub-restore", s.handleHubRestore)
|
||||||
|
mux.HandleFunc("/setup/hub-restore/select", s.handleHubVersionSelect)
|
||||||
mux.HandleFunc("/setup/restore", s.handleRestore)
|
mux.HandleFunc("/setup/restore", s.handleRestore)
|
||||||
mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus)
|
mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus)
|
||||||
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
|
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
|
||||||
@@ -233,7 +234,8 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
|||||||
switch source {
|
switch source {
|
||||||
case "local":
|
case "local":
|
||||||
drivePath := r.FormValue("drive_path")
|
drivePath := r.FormValue("drive_path")
|
||||||
go s.executeLocalRestore(drivePath)
|
historyFile := r.FormValue("history_file")
|
||||||
|
go s.executeLocalRestore(drivePath, historyFile)
|
||||||
case "hub":
|
case "hub":
|
||||||
go s.executeHubRestore()
|
go s.executeHubRestore()
|
||||||
default:
|
default:
|
||||||
@@ -372,10 +374,31 @@ func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, c
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.isDebug() {
|
if s.isDebug() {
|
||||||
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML))
|
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store recovery data in state for restore execution
|
// If multiple versions available, show picker instead of auto-restoring
|
||||||
|
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
|
||||||
|
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
|
||||||
|
// Store config for later use after version selection
|
||||||
|
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
||||||
|
s.state.Save()
|
||||||
|
|
||||||
|
csrf := ensureCSRFToken(w, r)
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"CSRF": csrf,
|
||||||
|
"Versions": recovery.BackupVersions,
|
||||||
|
}
|
||||||
|
s.render(w, "setup_hub_versions", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single version or no versions — proceed directly
|
||||||
|
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storeRecoveryAndRestore stores recovery data in state and starts the restore goroutine.
|
||||||
|
func (s *Server) storeRecoveryAndRestore(w http.ResponseWriter, r *http.Request, recovery *report.RecoveryResponse, customerID string) {
|
||||||
s.state.SelectedBackup = &SelectedBackup{
|
s.state.SelectedBackup = &SelectedBackup{
|
||||||
Source: "hub",
|
Source: "hub",
|
||||||
CustomerID: customerID,
|
CustomerID: customerID,
|
||||||
@@ -389,9 +412,8 @@ func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, c
|
|||||||
s.state.SetStep("restore-exec")
|
s.state.SetStep("restore-exec")
|
||||||
s.state.Save()
|
s.state.Save()
|
||||||
|
|
||||||
s.logger.Printf("[INFO] Setup: hub recovery received (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
|
s.logger.Printf("[INFO] Setup: hub recovery stored (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
|
||||||
|
|
||||||
// Start the restore goroutine, then render the progress page
|
|
||||||
go s.executeHubRestore()
|
go s.executeHubRestore()
|
||||||
|
|
||||||
csrf := ensureCSRFToken(w, r)
|
csrf := ensureCSRFToken(w, r)
|
||||||
@@ -476,34 +498,69 @@ func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.isDebug() {
|
if s.isDebug() {
|
||||||
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML))
|
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store recovery data in state for restore execution
|
|
||||||
s.state.SelectedBackup = &SelectedBackup{
|
|
||||||
Source: "hub",
|
|
||||||
CustomerID: customerID,
|
|
||||||
}
|
|
||||||
s.state.SetFormField("retrieval_password", password)
|
s.state.SetFormField("retrieval_password", password)
|
||||||
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
|
||||||
if recovery.HasInfraBackup && recovery.InfraBackup != nil {
|
// If multiple versions available, show picker
|
||||||
ibJSON, _ := json.Marshal(recovery.InfraBackup)
|
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
|
||||||
s.state.SetFormField("hub_infra_backup", string(ibJSON))
|
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
|
||||||
s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp
|
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
||||||
|
s.state.Save()
|
||||||
|
|
||||||
|
csrf := ensureCSRFToken(w, r)
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"CSRF": csrf,
|
||||||
|
"Versions": recovery.BackupVersions,
|
||||||
|
}
|
||||||
|
s.render(w, "setup_hub_versions", data)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
s.state.SetStep("restore-exec")
|
|
||||||
s.state.Save()
|
|
||||||
|
|
||||||
s.logger.Printf("[INFO] Setup: hub recovery received (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
|
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
||||||
|
}
|
||||||
|
|
||||||
// Start the restore goroutine, then render the progress page
|
// handleHubVersionSelect processes the user's version selection from the Hub version picker.
|
||||||
go s.executeHubRestore()
|
func (s *Server) handleHubVersionSelect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
csrf := ensureCSRFToken(w, r)
|
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
|
||||||
data := map[string]interface{}{
|
return
|
||||||
"CSRF": csrf,
|
|
||||||
}
|
}
|
||||||
s.render(w, "setup_restore_exec", data)
|
if !validateCSRF(r) {
|
||||||
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versionStr := r.FormValue("version_id")
|
||||||
|
customerID := s.state.GetFormField("customer_id")
|
||||||
|
password := s.state.GetFormField("retrieval_password")
|
||||||
|
hubURL := DefaultHubURL
|
||||||
|
|
||||||
|
if customerID == "" || password == "" {
|
||||||
|
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var versionID int64
|
||||||
|
fmt.Sscanf(versionStr, "%d", &versionID)
|
||||||
|
|
||||||
|
s.logger.Printf("[INFO] Setup: user selected backup version %d for %s", versionID, customerID)
|
||||||
|
|
||||||
|
// Fetch the specific version
|
||||||
|
recovery, err := report.PullRecoveryVersion(hubURL, customerID, password, versionID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("[ERROR] Setup: failed to fetch version %d: %v", versionID, err)
|
||||||
|
csrf := ensureCSRFToken(w, r)
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"CSRF": csrf,
|
||||||
|
"Error": fmt.Sprintf("Hiba a verzió letöltésekor: %v", err),
|
||||||
|
}
|
||||||
|
s.render(w, "setup_hub_versions", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -618,7 +675,7 @@ func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// --- Restore Execution ---
|
// --- Restore Execution ---
|
||||||
|
|
||||||
func (s *Server) executeLocalRestore(drivePath string) {
|
func (s *Server) executeLocalRestore(drivePath, historyFile string) {
|
||||||
s.restoreMu.Lock()
|
s.restoreMu.Lock()
|
||||||
s.restoreRunning = true
|
s.restoreRunning = true
|
||||||
s.restoreDone = false
|
s.restoreDone = false
|
||||||
@@ -630,8 +687,14 @@ func (s *Server) executeLocalRestore(drivePath string) {
|
|||||||
}
|
}
|
||||||
s.restoreMu.Unlock()
|
s.restoreMu.Unlock()
|
||||||
|
|
||||||
// Step 1: Read backup
|
// Step 1: Read backup (current or historical version)
|
||||||
backupData, _, err := backup.ReadLocalInfraBackup(drivePath)
|
var backupData []byte
|
||||||
|
var err error
|
||||||
|
if historyFile != "" {
|
||||||
|
backupData, _, err = backup.ReadLocalInfraBackupFromHistory(drivePath, historyFile)
|
||||||
|
} else {
|
||||||
|
backupData, _, err = backup.ReadLocalInfraBackup(drivePath)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err))
|
s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,15 +17,20 @@ import (
|
|||||||
|
|
||||||
// DriveBackup represents a found infra backup on a drive.
|
// DriveBackup represents a found infra backup on a drive.
|
||||||
type DriveBackup struct {
|
type DriveBackup struct {
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
MountPoint string `json:"mount_point"`
|
MountPoint string `json:"mount_point"`
|
||||||
CustomerID string `json:"customer_id"`
|
CustomerID string `json:"customer_id"`
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
CtrlVersion string `json:"controller_version"`
|
CtrlVersion string `json:"controller_version"`
|
||||||
IntegrityOK bool `json:"integrity_ok"`
|
IntegrityOK bool `json:"integrity_ok"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
WasTempMounted bool `json:"-"`
|
StackCount int `json:"stack_count"`
|
||||||
|
StackNames []string `json:"stack_names,omitempty"`
|
||||||
|
DiskCount int `json:"disk_count"`
|
||||||
|
IsHistory bool `json:"is_history"`
|
||||||
|
HistoryFile string `json:"history_file,omitempty"`
|
||||||
|
WasTempMounted bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// lsblkOutput represents the JSON output of lsblk.
|
// lsblkOutput represents the JSON output of lsblk.
|
||||||
@@ -114,10 +119,8 @@ func ScanDrivesForInfraBackups(logger *log.Logger, debug bool) ([]DriveBackup, e
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
result := scanPartition(part, mountedFS, logger)
|
partResults := scanPartition(part, mountedFS, logger)
|
||||||
if result != nil {
|
results = append(results, partResults...)
|
||||||
results = append(results, *result)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Printf("[INFO] Setup: drive scan complete — found %d backup(s)", countValid(results))
|
logger.Printf("[INFO] Setup: drive scan complete — found %d backup(s)", countValid(results))
|
||||||
@@ -137,7 +140,7 @@ func CleanupTempMounts(results []DriveBackup, logger *log.Logger) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Logger) *DriveBackup {
|
func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Logger) []DriveBackup {
|
||||||
label := ""
|
label := ""
|
||||||
if part.Label != nil {
|
if part.Label != nil {
|
||||||
label = *part.Label
|
label = *part.Label
|
||||||
@@ -187,10 +190,12 @@ func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Lo
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Found backup — read and validate
|
var results []DriveBackup
|
||||||
_, meta, err := backup.ReadLocalInfraBackup(mountPoint)
|
|
||||||
|
|
||||||
result := &DriveBackup{
|
// Read current backup
|
||||||
|
backupData, meta, err := backup.ReadLocalInfraBackup(mountPoint)
|
||||||
|
|
||||||
|
current := DriveBackup{
|
||||||
Device: part.Path,
|
Device: part.Path,
|
||||||
Label: label,
|
Label: label,
|
||||||
MountPoint: mountPoint,
|
MountPoint: mountPoint,
|
||||||
@@ -198,24 +203,52 @@ func scanPartition(part lsblkDevice, mountedFS map[string]string, logger *log.Lo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.IntegrityOK = false
|
current.IntegrityOK = false
|
||||||
result.Error = err.Error()
|
current.Error = err.Error()
|
||||||
if meta != nil {
|
if meta != nil {
|
||||||
result.CustomerID = meta.CustomerID
|
current.CustomerID = meta.CustomerID
|
||||||
result.Timestamp = meta.Timestamp
|
current.Timestamp = meta.Timestamp
|
||||||
result.CtrlVersion = meta.ControllerVersion
|
current.CtrlVersion = meta.ControllerVersion
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result.IntegrityOK = true
|
current.IntegrityOK = true
|
||||||
result.CustomerID = meta.CustomerID
|
current.CustomerID = meta.CustomerID
|
||||||
result.Timestamp = meta.Timestamp
|
current.Timestamp = meta.Timestamp
|
||||||
result.CtrlVersion = meta.ControllerVersion
|
current.CtrlVersion = meta.ControllerVersion
|
||||||
|
backup.ParseBackupCounts(backupData, ¤t.StackCount, ¤t.StackNames, ¤t.DiskCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v",
|
results = append(results, current)
|
||||||
part.Path, label, result.CustomerID, result.IntegrityOK)
|
|
||||||
|
|
||||||
return result
|
logger.Printf("[INFO] Setup: found infra backup on %s (%s) — customer=%s, integrity=%v",
|
||||||
|
part.Path, label, current.CustomerID, current.IntegrityOK)
|
||||||
|
|
||||||
|
// Also scan history directory for older versions
|
||||||
|
history := backup.ReadLocalInfraHistory(mountPoint)
|
||||||
|
for _, hv := range history {
|
||||||
|
hResult := DriveBackup{
|
||||||
|
Device: part.Path,
|
||||||
|
Label: label,
|
||||||
|
MountPoint: mountPoint,
|
||||||
|
CustomerID: hv.CustomerID,
|
||||||
|
Timestamp: hv.Timestamp,
|
||||||
|
CtrlVersion: hv.ControllerVersion,
|
||||||
|
IntegrityOK: hv.IntegrityOK,
|
||||||
|
Error: hv.Error,
|
||||||
|
StackCount: hv.StackCount,
|
||||||
|
StackNames: hv.StackNames,
|
||||||
|
DiskCount: hv.DiskCount,
|
||||||
|
IsHistory: true,
|
||||||
|
HistoryFile: hv.HistoryFile,
|
||||||
|
}
|
||||||
|
results = append(results, hResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(history) > 0 {
|
||||||
|
logger.Printf("[INFO] Setup: found %d historical backup version(s) on %s", len(history), part.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMountedFilesystems() map[string]string {
|
func readMountedFilesystems() map[string]string {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{{define "setup_hub_versions"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="hu">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mentés kiválasztása — Felhom</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<div class="setup-container">
|
||||||
|
<div class="setup-header">
|
||||||
|
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||||
|
<h1>Visszaállítás a Hub-ról</h1>
|
||||||
|
<p style="color: var(--text-secondary, #8b949e);">Válasszon a mentés-verziók közül.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||||
|
|
||||||
|
<div class="setup-card">
|
||||||
|
<form method="POST" action="/setup/hub-restore/select" id="version-form">
|
||||||
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 2rem;"></th>
|
||||||
|
<th style="text-align: left; padding: 0.5rem;">Dátum</th>
|
||||||
|
<th style="text-align: left; padding: 0.5rem;">Alkalmazások</th>
|
||||||
|
<th style="text-align: right; padding: 0.5rem;">Lemezek</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $i, $v := .Versions}}
|
||||||
|
<tr style="border-top: 1px solid var(--border, #30363d);">
|
||||||
|
<td style="padding: 0.5rem;">
|
||||||
|
<input type="radio" name="version_id" value="{{$v.ID}}" {{if eq $i 0}}checked{{end}}>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 0.5rem;">{{$v.CreatedAt}}{{if eq $i 0}} <span class="badge badge-ok" style="font-size: 0.75em;">legújabb</span>{{end}}</td>
|
||||||
|
<td style="padding: 0.5rem;">
|
||||||
|
{{$v.StackCount}}{{if $v.StackNames}}: {{range $j, $n := $v.StackNames}}{{if $j}}, {{end}}{{$n}}{{end}}{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; padding: 0.5rem;">{{$v.DiskCount}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||||
|
<button type="submit" class="btn btn-primary">Visszaállítás</button>
|
||||||
|
<a href="/setup" class="btn btn-outline">Vissza</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -33,6 +33,8 @@
|
|||||||
<th>Ügyfél</th>
|
<th>Ügyfél</th>
|
||||||
<th>Dátum</th>
|
<th>Dátum</th>
|
||||||
<th>Verzió</th>
|
<th>Verzió</th>
|
||||||
|
<th>Alkalmazások</th>
|
||||||
|
<th>Lemezek</th>
|
||||||
<th>Állapot</th>
|
<th>Állapot</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -44,6 +46,7 @@
|
|||||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
<input type="hidden" name="source" value="local">
|
<input type="hidden" name="source" value="local">
|
||||||
<input type="hidden" name="drive_path" id="selected-drive" value="">
|
<input type="hidden" name="drive_path" id="selected-drive" value="">
|
||||||
|
<input type="hidden" name="history_file" id="selected-history" value="">
|
||||||
<button type="submit" class="btn btn-primary" id="restore-btn" disabled>Visszaállítás</button>
|
<button type="submit" class="btn btn-primary" id="restore-btn" disabled>Visszaállítás</button>
|
||||||
</form>
|
</form>
|
||||||
<a href="/setup/hub-restore" class="btn btn-outline">Tovább a Hub-hoz</a>
|
<a href="/setup/hub-restore" class="btn btn-outline">Tovább a Hub-hoz</a>
|
||||||
@@ -69,7 +72,6 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var selectedDrive = '';
|
|
||||||
function poll() {
|
function poll() {
|
||||||
fetch('/setup/scan/status')
|
fetch('/setup/scan/status')
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
@@ -95,13 +97,31 @@
|
|||||||
var validCount = 0;
|
var validCount = 0;
|
||||||
data.results.forEach(function(r, i) {
|
data.results.forEach(function(r, i) {
|
||||||
var tr = document.createElement('tr');
|
var tr = document.createElement('tr');
|
||||||
var radio = r.integrity_ok ? '<input type="radio" name="backup" value="' + r.mount_point + '" onclick="selectDrive(this)">' : '';
|
var driveVal = r.mount_point + '|' + (r.history_file || '');
|
||||||
|
var radio = r.integrity_ok ? '<input type="radio" name="backup" value="' + driveVal + '" onclick="selectDrive(this)">' : '';
|
||||||
|
var apps = '-';
|
||||||
|
if (r.stack_count > 0) {
|
||||||
|
apps = r.stack_count.toString();
|
||||||
|
if (r.stack_names && r.stack_names.length > 0) {
|
||||||
|
var names = r.stack_names.slice(0, 3).join(', ');
|
||||||
|
if (r.stack_names.length > 3) names += ', ...';
|
||||||
|
apps += ': ' + names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var disks = r.disk_count > 0 ? r.disk_count.toString() : '-';
|
||||||
|
var dateBadge = '';
|
||||||
|
if (r.is_history) dateBadge = ' <span class="badge" style="font-size:0.7em;background:#6e4000;color:#ffd080;">korábbi</span>';
|
||||||
|
var statusCol = r.integrity_ok
|
||||||
|
? '<span class="badge badge-ok">OK</span>'
|
||||||
|
: '<span class="badge badge-error">' + (r.error || 'Hiba') + '</span>';
|
||||||
tr.innerHTML = '<td>' + radio + '</td>' +
|
tr.innerHTML = '<td>' + radio + '</td>' +
|
||||||
'<td>' + (r.device || '') + (r.label ? ' (' + r.label + ')' : '') + '</td>' +
|
'<td>' + (r.device || '') + (r.label ? ' (' + r.label + ')' : '') + '</td>' +
|
||||||
'<td>' + (r.customer_id || '-') + '</td>' +
|
'<td>' + (r.customer_id || '-') + '</td>' +
|
||||||
'<td>' + (r.timestamp ? r.timestamp.substring(0, 10) : '-') + '</td>' +
|
'<td>' + (r.timestamp ? r.timestamp.substring(0, 10) : '-') + dateBadge + '</td>' +
|
||||||
'<td>' + (r.controller_version || '-') + '</td>' +
|
'<td>' + (r.controller_version || '-') + '</td>' +
|
||||||
'<td>' + (r.integrity_ok ? '<span class="badge badge-ok">OK</span>' : '<span class="badge badge-error">' + (r.error || 'Hiba') + '</span>') + '</td>';
|
'<td>' + apps + '</td>' +
|
||||||
|
'<td>' + disks + '</td>' +
|
||||||
|
'<td>' + statusCol + '</td>';
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
if (r.integrity_ok) validCount++;
|
if (r.integrity_ok) validCount++;
|
||||||
});
|
});
|
||||||
@@ -112,7 +132,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
window.selectDrive = function(el) {
|
window.selectDrive = function(el) {
|
||||||
document.getElementById('selected-drive').value = el.value;
|
var parts = el.value.split('|');
|
||||||
|
document.getElementById('selected-drive').value = parts[0];
|
||||||
|
document.getElementById('selected-history').value = parts[1] || '';
|
||||||
document.getElementById('restore-btn').disabled = false;
|
document.getElementById('restore-btn').disabled = false;
|
||||||
};
|
};
|
||||||
poll();
|
poll();
|
||||||
|
|||||||
Reference in New Issue
Block a user