feat: infra backup GFS retention + version history
New infra_backup_versions table with GFS pruning (~14 versions per customer). Recovery endpoint supports ?version=ID. New /versions API. Dashboard shows collapsible backup history with app names and disk count. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,17 @@
|
|||||||
# Felhom Hub — Changelog
|
# Felhom Hub — Changelog
|
||||||
|
|
||||||
|
## v0.6.2 (2026-02-26)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Infra backup GFS retention** — New `infra_backup_versions` table stores multiple backups per customer. GFS pruning keeps: all from last 24h, latest per day (7 days), latest per week (4 weeks), latest per month (3 months) — ~14 versions max per customer
|
||||||
|
- **`GET /api/v1/infra-backup/{id}/versions`** — Returns metadata list of all retained backup versions (date, stack names, disk count) for a customer. Bearer auth.
|
||||||
|
- **Recovery version selection** — `GET /api/v1/recovery/{id}?version=ID` fetches a specific backup version instead of latest. Response now includes `backup_versions` array with all available versions.
|
||||||
|
- **Dashboard backup history** — Customer detail page "Infra Backup" card shows version count and collapsible history table (date, apps, disks)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`SaveInfraBackup()`** — Now INSERTs a new row instead of upserting, preserving history. Automatically prunes old versions via GFS algorithm.
|
||||||
|
- **One-time migration** — Existing data from `infra_backups` table is copied to `infra_backup_versions` on first startup
|
||||||
|
|
||||||
## v0.6.1 (2026-02-25)
|
## v0.6.1 (2026-02-25)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.handleNotify(w, r)
|
h.handleNotify(w, r)
|
||||||
case r.Method == http.MethodPost && path == "/infra-backup":
|
case r.Method == http.MethodPost && path == "/infra-backup":
|
||||||
h.handleInfraBackupPush(w, r)
|
h.handleInfraBackupPush(w, r)
|
||||||
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/") && strings.HasSuffix(path, "/versions"):
|
||||||
|
customerID := strings.TrimPrefix(path, "/infra-backup/")
|
||||||
|
customerID = strings.TrimSuffix(customerID, "/versions")
|
||||||
|
h.handleInfraBackupVersions(w, r, customerID)
|
||||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/"):
|
case r.Method == http.MethodGet && strings.HasPrefix(path, "/infra-backup/"):
|
||||||
h.handleInfraBackupGet(w, r, strings.TrimPrefix(path, "/infra-backup/"))
|
h.handleInfraBackupGet(w, r, strings.TrimPrefix(path, "/infra-backup/"))
|
||||||
case r.Method == http.MethodPost && path == "/preferences":
|
case r.Method == http.MethodPost && path == "/preferences":
|
||||||
@@ -586,6 +590,33 @@ func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, c
|
|||||||
w.Write(data)
|
w.Write(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleInfraBackupVersions returns a list of backup versions for a customer.
|
||||||
|
// Auth: Bearer token.
|
||||||
|
func (h *Handler) handleInfraBackupVersions(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||||
|
if !h.checkAuth(r) {
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if customerID == "" {
|
||||||
|
http.Error(w, "Missing customer_id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versions, err := h.store.ListInfraBackupVersions(customerID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Printf("[ERROR] Failed to list infra backup versions for %s: %v", customerID, err)
|
||||||
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if versions == nil {
|
||||||
|
versions = []store.InfraBackupVersion{}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(versions)
|
||||||
|
}
|
||||||
|
|
||||||
// handleRecovery returns both the generated controller.yaml and the infra backup for disaster recovery.
|
// handleRecovery returns both the generated controller.yaml and the infra backup for disaster recovery.
|
||||||
// Auth: X-Retrieval-Password header (same as config retrieval).
|
// Auth: X-Retrieval-Password header (same as config retrieval).
|
||||||
func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, customerID string) {
|
func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||||
@@ -631,24 +662,44 @@ func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, custome
|
|||||||
// Fetch infra backup (optional — may not exist for new customers)
|
// Fetch infra backup (optional — may not exist for new customers)
|
||||||
var infraBackup json.RawMessage
|
var infraBackup json.RawMessage
|
||||||
hasInfraBackup := false
|
hasInfraBackup := false
|
||||||
|
|
||||||
|
// Support ?version=ID for selecting a specific backup version
|
||||||
|
if versionStr := r.URL.Query().Get("version"); versionStr != "" {
|
||||||
|
var versionID int64
|
||||||
|
if _, err := fmt.Sscanf(versionStr, "%d", &versionID); err == nil {
|
||||||
|
if data, err := h.store.GetInfraBackupByID(versionID); err == nil && data != nil {
|
||||||
|
infraBackup = data
|
||||||
|
hasInfraBackup = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
if data, err := h.store.GetInfraBackup(customerID); err == nil && data != nil {
|
if data, err := h.store.GetInfraBackup(customerID); err == nil && data != nil {
|
||||||
infraBackup = data
|
infraBackup = data
|
||||||
hasInfraBackup = true
|
hasInfraBackup = true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include version list for version picker
|
||||||
|
var backupVersions []store.InfraBackupVersion
|
||||||
|
if versions, err := h.store.ListInfraBackupVersions(customerID); err == nil {
|
||||||
|
backupVersions = versions
|
||||||
|
}
|
||||||
|
|
||||||
resp := struct {
|
resp := struct {
|
||||||
CustomerID string `json:"customer_id"`
|
CustomerID string `json:"customer_id"`
|
||||||
ConfigYAML string `json:"config_yaml"`
|
ConfigYAML string `json:"config_yaml"`
|
||||||
InfraBackup json.RawMessage `json:"infra_backup"`
|
InfraBackup json.RawMessage `json:"infra_backup"`
|
||||||
HasInfraBackup bool `json:"has_infra_backup"`
|
HasInfraBackup bool `json:"has_infra_backup"`
|
||||||
|
BackupVersions []store.InfraBackupVersion `json:"backup_versions,omitempty"`
|
||||||
}{
|
}{
|
||||||
CustomerID: customerID,
|
CustomerID: customerID,
|
||||||
ConfigYAML: configYAML,
|
ConfigYAML: configYAML,
|
||||||
InfraBackup: infraBackup,
|
InfraBackup: infraBackup,
|
||||||
HasInfraBackup: hasInfraBackup,
|
HasInfraBackup: hasInfraBackup,
|
||||||
|
BackupVersions: backupVersions,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Printf("[INFO] Recovery data downloaded for customer %s (has_infra_backup=%v)", customerID, hasInfraBackup)
|
h.logger.Printf("[INFO] Recovery data downloaded for customer %s (has_infra_backup=%v, versions=%d)", customerID, hasInfraBackup, len(backupVersions))
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(resp)
|
json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|||||||
+235
-18
@@ -195,6 +195,27 @@ func (s *Store) migrate() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v0.7.0: versioned infra backups with GFS retention
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS infra_backup_versions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
customer_id TEXT NOT NULL,
|
||||||
|
backup_json TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ibv_customer_time
|
||||||
|
ON infra_backup_versions(customer_id, created_at DESC);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time migration: copy existing single-row backups to versioned table
|
||||||
|
s.db.Exec(`INSERT INTO infra_backup_versions (customer_id, backup_json, created_at)
|
||||||
|
SELECT customer_id, backup_json, updated_at FROM infra_backups
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM infra_backup_versions
|
||||||
|
WHERE infra_backup_versions.customer_id = infra_backups.customer_id)`)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,24 +538,34 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu
|
|||||||
return history, rows.Err()
|
return history, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveInfraBackup upserts the infrastructure backup for a customer.
|
// SaveInfraBackup inserts a new infra backup version and prunes old ones (GFS retention).
|
||||||
func (s *Store) SaveInfraBackup(customerID string, backupJSON []byte) error {
|
func (s *Store) SaveInfraBackup(customerID string, backupJSON []byte) error {
|
||||||
_, err := s.db.Exec(`
|
_, err := s.db.Exec(`
|
||||||
INSERT INTO infra_backups (customer_id, backup_json, updated_at)
|
INSERT INTO infra_backup_versions (customer_id, backup_json, created_at)
|
||||||
|
VALUES (?, ?, datetime('now'))`,
|
||||||
|
customerID, string(backupJSON),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also maintain legacy table for backward compatibility during rollback window
|
||||||
|
s.db.Exec(`INSERT INTO infra_backups (customer_id, backup_json, updated_at)
|
||||||
VALUES (?, ?, datetime('now'))
|
VALUES (?, ?, datetime('now'))
|
||||||
ON CONFLICT(customer_id) DO UPDATE SET
|
ON CONFLICT(customer_id) DO UPDATE SET
|
||||||
backup_json = excluded.backup_json,
|
backup_json = excluded.backup_json,
|
||||||
updated_at = datetime('now')`,
|
updated_at = datetime('now')`,
|
||||||
customerID, string(backupJSON),
|
customerID, string(backupJSON))
|
||||||
)
|
|
||||||
return err
|
s.pruneInfraBackups(customerID)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInfraBackup returns the raw infra backup JSON for a customer, or nil if not found.
|
// GetInfraBackup returns the latest infra backup JSON for a customer, or nil if not found.
|
||||||
func (s *Store) GetInfraBackup(customerID string) ([]byte, error) {
|
func (s *Store) GetInfraBackup(customerID string) ([]byte, error) {
|
||||||
var data string
|
var data string
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
"SELECT backup_json FROM infra_backups WHERE customer_id = ?",
|
"SELECT backup_json FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||||
customerID,
|
customerID,
|
||||||
).Scan(&data)
|
).Scan(&data)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -546,20 +577,36 @@ func (s *Store) GetInfraBackup(customerID string) ([]byte, error) {
|
|||||||
return []byte(data), nil
|
return []byte(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInfraBackupByID returns the infra backup JSON for a specific version ID, or nil if not found.
|
||||||
|
func (s *Store) GetInfraBackupByID(id int64) ([]byte, error) {
|
||||||
|
var data string
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
"SELECT backup_json FROM infra_backup_versions WHERE id = ?", id,
|
||||||
|
).Scan(&data)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []byte(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
// InfraBackupMeta holds summary info for the dashboard (avoids parsing full JSON).
|
// InfraBackupMeta holds summary info for the dashboard (avoids parsing full JSON).
|
||||||
type InfraBackupMeta struct {
|
type InfraBackupMeta struct {
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
StackCount int
|
StackCount int
|
||||||
DiskCount int
|
DiskCount int
|
||||||
|
VersionCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInfraBackupMeta returns summary metadata for a customer's infra backup.
|
// GetInfraBackupMeta returns summary metadata for a customer's latest infra backup.
|
||||||
func (s *Store) GetInfraBackupMeta(customerID string) (*InfraBackupMeta, error) {
|
func (s *Store) GetInfraBackupMeta(customerID string) (*InfraBackupMeta, error) {
|
||||||
var backupJSON, updatedAt string
|
var backupJSON, createdAt string
|
||||||
err := s.db.QueryRow(
|
err := s.db.QueryRow(
|
||||||
"SELECT backup_json, updated_at FROM infra_backups WHERE customer_id = ?",
|
"SELECT backup_json, created_at FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||||
customerID,
|
customerID,
|
||||||
).Scan(&backupJSON, &updatedAt)
|
).Scan(&backupJSON, &createdAt)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -568,24 +615,194 @@ func (s *Store) GetInfraBackupMeta(customerID string) (*InfraBackupMeta, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
meta := &InfraBackupMeta{
|
meta := &InfraBackupMeta{
|
||||||
UpdatedAt: parseSQLiteTime(updatedAt),
|
UpdatedAt: parseSQLiteTime(createdAt),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Count total versions
|
||||||
|
s.db.QueryRow("SELECT COUNT(*) FROM infra_backup_versions WHERE customer_id = ?", customerID).Scan(&meta.VersionCount)
|
||||||
|
|
||||||
// Parse just the fields we need
|
// Parse just the fields we need
|
||||||
|
parseInfraBackupCounts(backupJSON, &meta.StackCount, &meta.DiskCount, nil, s.logger, customerID)
|
||||||
|
|
||||||
|
return meta, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfraBackupVersion holds summary info for a single backup version.
|
||||||
|
type InfraBackupVersion struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
StackCount int `json:"stack_count"`
|
||||||
|
DiskCount int `json:"disk_count"`
|
||||||
|
StackNames []string `json:"stack_names,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInfraBackupVersions returns metadata for all retained versions of a customer's backup.
|
||||||
|
func (s *Store) ListInfraBackupVersions(customerID string) ([]InfraBackupVersion, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
"SELECT id, backup_json, created_at FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC",
|
||||||
|
customerID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var versions []InfraBackupVersion
|
||||||
|
for rows.Next() {
|
||||||
|
var v InfraBackupVersion
|
||||||
|
var backupJSON, createdAt string
|
||||||
|
if err := rows.Scan(&v.ID, &backupJSON, &createdAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v.CreatedAt = parseSQLiteTime(createdAt)
|
||||||
|
parseInfraBackupCounts(backupJSON, &v.StackCount, &v.DiskCount, &v.StackNames, nil, "")
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
return versions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneInfraBackups applies GFS retention: keep all from last 24h, latest per day (7d),
|
||||||
|
// latest per week (4w), latest per month (3mo). Delete everything else.
|
||||||
|
func (s *Store) pruneInfraBackups(customerID string) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
"SELECT id, created_at FROM infra_backup_versions WHERE customer_id = ? ORDER BY created_at DESC",
|
||||||
|
customerID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type entry struct {
|
||||||
|
id int64
|
||||||
|
createdAt time.Time
|
||||||
|
}
|
||||||
|
var all []entry
|
||||||
|
for rows.Next() {
|
||||||
|
var e entry
|
||||||
|
var ts string
|
||||||
|
if err := rows.Scan(&e.id, &ts); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.createdAt = parseSQLiteTime(ts)
|
||||||
|
all = append(all, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(all) <= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
keep := make(map[int64]bool)
|
||||||
|
seenDays := make(map[string]bool)
|
||||||
|
seenWeeks := make(map[string]bool)
|
||||||
|
seenMonths := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, e := range all {
|
||||||
|
age := now.Sub(e.createdAt)
|
||||||
|
|
||||||
|
// Keep all from last 24h
|
||||||
|
if age < 24*time.Hour {
|
||||||
|
keep[e.id] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest per calendar day for last 7 days
|
||||||
|
if age < 7*24*time.Hour {
|
||||||
|
day := e.createdAt.Format("2006-01-02")
|
||||||
|
if !seenDays[day] {
|
||||||
|
seenDays[day] = true
|
||||||
|
keep[e.id] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest per ISO week for last 4 weeks
|
||||||
|
if age < 28*24*time.Hour {
|
||||||
|
year, week := e.createdAt.ISOWeek()
|
||||||
|
wk := fmt.Sprintf("%d-W%02d", year, week)
|
||||||
|
if !seenWeeks[wk] {
|
||||||
|
seenWeeks[wk] = true
|
||||||
|
keep[e.id] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest per calendar month for last 3 months
|
||||||
|
if age < 90*24*time.Hour {
|
||||||
|
month := e.createdAt.Format("2006-01")
|
||||||
|
if !seenMonths[month] {
|
||||||
|
seenMonths[month] = true
|
||||||
|
keep[e.id] = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older than 3 months — don't keep
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build delete list
|
||||||
|
var deleteIDs []interface{}
|
||||||
|
var placeholders []string
|
||||||
|
for _, e := range all {
|
||||||
|
if !keep[e.id] {
|
||||||
|
deleteIDs = append(deleteIDs, e.id)
|
||||||
|
placeholders = append(placeholders, "?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deleteIDs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "DELETE FROM infra_backup_versions WHERE id IN (" + joinStrings(placeholders, ",") + ")"
|
||||||
|
_, err = s.db.Exec(query, deleteIDs...)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("[WARN] Failed to prune infra backup versions for %s: %v", customerID, err)
|
||||||
|
} else {
|
||||||
|
s.logger.Printf("[INFO] Pruned %d old infra backup version(s) for %s (kept %d)", len(deleteIDs), customerID, len(keep))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseInfraBackupCounts extracts stack/disk counts and optionally stack names from backup JSON.
|
||||||
|
func parseInfraBackupCounts(backupJSON string, stackCount, diskCount *int, stackNames *[]string, logger *log.Logger, customerID string) {
|
||||||
var parsed struct {
|
var parsed struct {
|
||||||
DeployedStacks []json.RawMessage `json:"deployed_stacks"`
|
DeployedStacks []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
} `json:"deployed_stacks"`
|
||||||
DiskLayout struct {
|
DiskLayout struct {
|
||||||
Mounts []json.RawMessage `json:"mounts"`
|
Mounts []json.RawMessage `json:"mounts"`
|
||||||
} `json:"disk_layout"`
|
} `json:"disk_layout"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(backupJSON), &parsed); err != nil {
|
if err := json.Unmarshal([]byte(backupJSON), &parsed); err != nil {
|
||||||
s.logger.Printf("[WARN] Failed to parse infra backup metadata for %s: %v", customerID, err)
|
if logger != nil {
|
||||||
} else {
|
logger.Printf("[WARN] Failed to parse infra backup metadata for %s: %v", customerID, err)
|
||||||
meta.StackCount = len(parsed.DeployedStacks)
|
|
||||||
meta.DiskCount = len(parsed.DiskLayout.Mounts)
|
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return meta, nil
|
func joinStrings(ss []string, sep string) string {
|
||||||
|
if len(ss) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := ss[0]
|
||||||
|
for _, s := range ss[1:] {
|
||||||
|
result += sep + s
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune deletes reports older than the given number of days.
|
// Prune deletes reports older than the given number of days.
|
||||||
|
|||||||
@@ -231,6 +231,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
|||||||
var recentNotifs []store.NotificationLogEntry
|
var recentNotifs []store.NotificationLogEntry
|
||||||
var infraMeta *store.InfraBackupMeta
|
var infraMeta *store.InfraBackupMeta
|
||||||
var infraBackupAge string
|
var infraBackupAge string
|
||||||
|
var infraBackupVersions []store.InfraBackupVersion
|
||||||
var events []store.Event
|
var events []store.Event
|
||||||
var eventCounts map[string]int
|
var eventCounts map[string]int
|
||||||
|
|
||||||
@@ -244,6 +245,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
|||||||
if infraMeta != nil {
|
if infraMeta != nil {
|
||||||
infraBackupAge = timeAgo(infraMeta.UpdatedAt)
|
infraBackupAge = timeAgo(infraMeta.UpdatedAt)
|
||||||
}
|
}
|
||||||
|
infraBackupVersions, _ = s.store.ListInfraBackupVersions(customerID)
|
||||||
events, _ = s.store.GetRecentEvents(customerID, 50)
|
events, _ = s.store.GetRecentEvents(customerID, 50)
|
||||||
eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour))
|
eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour))
|
||||||
appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour))
|
appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour))
|
||||||
@@ -274,6 +276,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
|||||||
|
|
||||||
InfraBackup *store.InfraBackupMeta
|
InfraBackup *store.InfraBackupMeta
|
||||||
InfraBackupAge string
|
InfraBackupAge string
|
||||||
|
InfraBackupVersions []store.InfraBackupVersion
|
||||||
NotifPrefs *store.NotificationPrefs
|
NotifPrefs *store.NotificationPrefs
|
||||||
RecentNotifications []store.NotificationLogEntry
|
RecentNotifications []store.NotificationLogEntry
|
||||||
History []store.CustomerSummary
|
History []store.CustomerSummary
|
||||||
@@ -315,6 +318,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
|||||||
|
|
||||||
InfraBackup: infraMeta,
|
InfraBackup: infraMeta,
|
||||||
InfraBackupAge: infraBackupAge,
|
InfraBackupAge: infraBackupAge,
|
||||||
|
InfraBackupVersions: infraBackupVersions,
|
||||||
NotifPrefs: notifPrefs,
|
NotifPrefs: notifPrefs,
|
||||||
RecentNotifications: recentNotifs,
|
RecentNotifications: recentNotifs,
|
||||||
History: history,
|
History: history,
|
||||||
|
|||||||
@@ -313,7 +313,34 @@
|
|||||||
<span class="label">Disks</span>
|
<span class="label">Disks</span>
|
||||||
<span class="value">{{.InfraBackup.DiskCount}}</span>
|
<span class="value">{{.InfraBackup.DiskCount}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">Versions</span>
|
||||||
|
<span class="value">{{.InfraBackup.VersionCount}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .InfraBackupVersions}}
|
||||||
|
<details style="margin-top: 0.75rem;">
|
||||||
|
<summary style="cursor: pointer; color: var(--text-secondary, #94a3b8); font-size: 0.85em;">Backup History ({{len .InfraBackupVersions}} versions)</summary>
|
||||||
|
<table style="width: 100%; margin-top: 0.5rem; font-size: 0.85em;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="text-align: left; padding: 0.25rem 0.5rem;">Date</th>
|
||||||
|
<th style="text-align: left; padding: 0.25rem 0.5rem;">Apps</th>
|
||||||
|
<th style="text-align: right; padding: 0.25rem 0.5rem;">Disks</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .InfraBackupVersions}}
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 0.25rem 0.5rem;">{{.CreatedAt.Format "2006-01-02 15:04"}}</td>
|
||||||
|
<td style="padding: 0.25rem 0.5rem;">{{.StackCount}}{{if .StackNames}}: {{range $i, $n := .StackNames}}{{if $i}}, {{end}}{{$n}}{{end}}{{end}}</td>
|
||||||
|
<td style="text-align: right; padding: 0.25rem 0.5rem;">{{.DiskCount}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<p style="color: #facc15">No infra backup received yet</p>
|
<p style="color: #facc15">No infra backup received yet</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user