506 lines
26 KiB
Plaintext
506 lines
26 KiB
Plaintext
package templates
|
|
import (
|
|
"backea/templates/layouts"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
"backea/internal/backup/models"
|
|
)
|
|
|
|
// FormatSize formats byte size to human-readable format
|
|
func FormatSize(size int64) string {
|
|
const unit = 1024
|
|
if size < unit {
|
|
return fmt.Sprintf("%d B", size)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := size / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
return fmt.Sprintf("%.2f %cB", float64(size)/float64(div), "KMGTPE"[exp])
|
|
}
|
|
|
|
// FormatTime formats time to a readable format
|
|
func FormatTime(t time.Time) string {
|
|
return t.Format("Jan 02, 2006 15:04:05")
|
|
}
|
|
|
|
// FormatTimeSince formats the duration since a time in a human-readable way
|
|
func FormatTimeSince(t time.Time) string {
|
|
timeSince := time.Since(t)
|
|
hours := int(timeSince.Hours())
|
|
|
|
if hours < 1 {
|
|
return fmt.Sprintf("%d minutes ago", int(timeSince.Minutes()))
|
|
}
|
|
return fmt.Sprintf("%d hours ago", hours)
|
|
}
|
|
|
|
// GetStatusClass returns the appropriate status class based on backup age
|
|
func GetStatusClass(t time.Time) string {
|
|
timeSince := time.Since(t)
|
|
hours := int(timeSince.Hours())
|
|
|
|
if hours > 72 {
|
|
return "Failed"
|
|
} else if hours > 24 {
|
|
return "Warning"
|
|
}
|
|
return "Healthy"
|
|
}
|
|
|
|
// FormatServiceName returns a display-friendly service name
|
|
func FormatServiceName(groupName, serviceIndex string) string {
|
|
if serviceIndex == "" {
|
|
return groupName
|
|
}
|
|
return fmt.Sprintf("%s - %s", groupName, serviceIndex)
|
|
}
|
|
|
|
// CalculateTotalSize calculates total size of all backups
|
|
func CalculateTotalSize(backups []models.BackupInfo) int64 {
|
|
var total int64
|
|
for _, b := range backups {
|
|
total += b.Size
|
|
}
|
|
return total
|
|
}
|
|
|
|
// CalculateGroupTotalSize calculates total size of all backups for a service group
|
|
func CalculateGroupTotalSize(serviceGroup map[string][]models.BackupInfo) int64 {
|
|
var total int64
|
|
for _, backups := range serviceGroup {
|
|
for _, b := range backups {
|
|
total += b.Size
|
|
}
|
|
}
|
|
return total
|
|
}
|
|
|
|
// GetGroupTotalBackupCount returns the total number of backups across all services in a group
|
|
func GetGroupTotalBackupCount(serviceGroup map[string][]models.BackupInfo) int {
|
|
count := 0
|
|
for _, backups := range serviceGroup {
|
|
count += len(backups)
|
|
}
|
|
return count
|
|
}
|
|
|
|
// GetLatestBackupTime returns the most recent backup time for a service group
|
|
func GetLatestBackupTime(serviceGroup map[string][]models.BackupInfo) (time.Time, bool) {
|
|
var latestTime time.Time
|
|
found := false
|
|
|
|
for _, backups := range serviceGroup {
|
|
if len(backups) > 0 && (latestTime.IsZero() || backups[0].CreationTime.After(latestTime)) {
|
|
latestTime = backups[0].CreationTime
|
|
found = true
|
|
}
|
|
}
|
|
|
|
return latestTime, found
|
|
}
|
|
|
|
// GetGroupStatus returns the status of a service group based on the most recent backup
|
|
func GetGroupStatus(serviceGroup map[string][]models.BackupInfo) string {
|
|
latestTime, found := GetLatestBackupTime(serviceGroup)
|
|
if !found {
|
|
return "No Backups"
|
|
}
|
|
return GetStatusClass(latestTime)
|
|
}
|
|
|
|
// ServiceProviderInfo holds the backup strategy info for a service
|
|
type ServiceProviderInfo struct {
|
|
Type string
|
|
Provider string
|
|
Directory string
|
|
}
|
|
|
|
// BackupWithService represents a backup with its service identifier
|
|
type BackupWithService struct {
|
|
ServiceIndex string
|
|
Backup models.BackupInfo
|
|
}
|
|
|
|
// GetSortedBackups collects all backups from a service group and sorts them by time
|
|
func GetSortedBackups(serviceGroup map[string][]models.BackupInfo) []BackupWithService {
|
|
var allBackups []BackupWithService
|
|
|
|
// Collect all backups with their service indices
|
|
for serviceIndex, backups := range serviceGroup {
|
|
for _, b := range backups {
|
|
allBackups = append(allBackups, BackupWithService{
|
|
ServiceIndex: serviceIndex,
|
|
Backup: b,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort by creation time (newest first)
|
|
sort.Slice(allBackups, func(i, j int) bool {
|
|
return allBackups[i].Backup.CreationTime.After(allBackups[j].Backup.CreationTime)
|
|
})
|
|
|
|
return allBackups
|
|
}
|
|
|
|
// Home renders the homepage with lazy-loaded backup information
|
|
templ Home(serviceBackups map[string]map[string][]models.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo, sortedGroupNames []string, groupDirectories map[string]string) {
|
|
@layouts.Base("Backea - Backup Dashboard") {
|
|
<div class="responsive gruvbox-dark">
|
|
<div class="gruvbox-bg-hard round padding margin-bottom">
|
|
<h1 class="extra-large medium gruvbox-aqua">Welcome to Backea</h1>
|
|
<p class="large gruvbox-blue">Unified guardians.</p>
|
|
</div>
|
|
|
|
<h2 class="large medium margin-bottom gruvbox-orange">Latest Backups by Service</h2>
|
|
|
|
<!-- Check if we have any backups -->
|
|
if len(serviceBackups) == 0 {
|
|
<div id="no-backups" class="gruvbox-bg1 gruvbox-yellow-border round border padding">
|
|
<p class="gruvbox-yellow">No backup services configured or no backups found.</p>
|
|
</div>
|
|
} else {
|
|
<div id="has-backups">
|
|
<!-- Loop through groups in alphabetical order -->
|
|
for _, groupName := range sortedGroupNames {
|
|
<div class="margin-bottom-large service-group-container" id={ fmt.Sprintf("group-%s", groupName) }>
|
|
<!-- Header section with loading skeleton - will be replaced with actual header -->
|
|
<div id={ fmt.Sprintf("group-header-%s", groupName) }
|
|
hx-get={ fmt.Sprintf("/api/service-group/%s/header", groupName) }
|
|
hx-trigger="load once"
|
|
hx-swap="outerHTML">
|
|
<div class="gruvbox-bg1 round padding margin-bottom">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<h3 class="medium">
|
|
<!-- Display directory from groupDirectories map -->
|
|
if directory, exists := groupDirectories[groupName]; exists && directory != "" {
|
|
<span class="gruvbox-orange">{ directory }</span>
|
|
} else {
|
|
<span class="gruvbox-green">Unknown Directory</span>
|
|
}
|
|
<span class="gruvbox-green">{ groupName }</span>
|
|
</h3>
|
|
<div>
|
|
<!-- Display backup type and provider based on first service in group -->
|
|
if len(serviceBackups[groupName]) > 0 {
|
|
for serviceIndex := range serviceBackups[groupName] {
|
|
if providerInfo, exists := serviceConfigs[groupName][serviceIndex]; exists {
|
|
<span class="backup-tool-tag gruvbox-blue">{ providerInfo.Type }</span>
|
|
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
|
|
<span class="backup-tool-tag gruvbox-purple">B2 Backblaze</span>
|
|
} else if providerInfo.Provider == "ftp" {
|
|
<span class="backup-tool-tag gruvbox-yellow">FTP</span>
|
|
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
|
|
<span class="backup-tool-tag gruvbox-orange">SSH</span>
|
|
} else if providerInfo.Provider == "s3" {
|
|
<span class="backup-tool-tag gruvbox-red">S3</span>
|
|
} else {
|
|
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Provider }</span>
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
<span class="backup-tool-tag gruvbox-blue">Unknown</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Group Summary stats with skeleton loading -->
|
|
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem;">
|
|
<div>
|
|
<p class="gruvbox-fg-dim">Total Size</p>
|
|
<p class="gruvbox-yellow skeleton-text">--</p>
|
|
</div>
|
|
<div>
|
|
<p class="gruvbox-fg-dim">Backups</p>
|
|
<p class="gruvbox-yellow skeleton-text">--</p>
|
|
</div>
|
|
<div>
|
|
<p class="gruvbox-fg-dim">Last Backup</p>
|
|
<p class="gruvbox-yellow skeleton-text">--</p>
|
|
</div>
|
|
<div>
|
|
<p class="gruvbox-fg-dim">Status</p>
|
|
<p class="gruvbox-yellow skeleton-text">--</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Always render the lazy-loading skeleton for backup table -->
|
|
<div class="table-skeleton"
|
|
id={ fmt.Sprintf("skeleton-%s", groupName) }
|
|
hx-get={ fmt.Sprintf("/api/service-group/%s/backups", groupName) }
|
|
hx-trigger="load once"
|
|
hx-swap="outerHTML"
|
|
hx-indicator={ fmt.Sprintf("#loading-indicator-%s", groupName) }>
|
|
<div class="skeleton-table gruvbox-bg1 round padding">
|
|
<div class="skeleton-header">
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
</div>
|
|
<div class="skeleton-row">
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
</div>
|
|
<div class="skeleton-row">
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
<div class="skeleton-cell"></div>
|
|
</div>
|
|
</div>
|
|
<div id={ fmt.Sprintf("loading-indicator-%s", groupName) } class="htmx-indicator">
|
|
<div class="loading-spinner"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
}
|
|
|
|
// GroupHeaderComponent renders just the group header with up-to-date stats
|
|
templ GroupHeaderComponent(groupName string, serviceBackups map[string][]models.BackupInfo, serviceConfigs map[string]ServiceProviderInfo, directory string) {
|
|
<div id={ fmt.Sprintf("group-header-%s", groupName) } class="gruvbox-bg1 round padding margin-bottom">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<h3 class="medium">
|
|
if directory != "" {
|
|
<span class="gruvbox-orange">{ directory }</span>
|
|
} else {
|
|
<span class="gruvbox-green">Unknown Directory</span>
|
|
}
|
|
<span class="gruvbox-green">{ groupName }</span>
|
|
</h3>
|
|
<div>
|
|
<!-- Display backup type and provider based on first service in group -->
|
|
if len(serviceBackups) > 0 {
|
|
for serviceIndex := range serviceBackups {
|
|
if providerInfo, exists := serviceConfigs[serviceIndex]; exists {
|
|
<span class="backup-tool-tag gruvbox-blue">{ providerInfo.Type }</span>
|
|
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
|
|
<span class="backup-tool-tag gruvbox-purple">B2 Backblaze</span>
|
|
} else if providerInfo.Provider == "ftp" {
|
|
<span class="backup-tool-tag gruvbox-yellow">FTP</span>
|
|
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
|
|
<span class="backup-tool-tag gruvbox-orange">SSH</span>
|
|
} else if providerInfo.Provider == "s3" {
|
|
<span class="backup-tool-tag gruvbox-red">S3</span>
|
|
} else {
|
|
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Provider }</span>
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
<span class="backup-tool-tag gruvbox-blue">Unknown</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Group Summary stats with actual data -->
|
|
<div style="display: flex; flex-wrap: wrap; gap: 1rem; margin-top: 0.5rem;">
|
|
<div>
|
|
<p class="gruvbox-fg-dim">Total Size</p>
|
|
<p class="gruvbox-yellow">{ FormatSize(CalculateGroupTotalSize(serviceBackups)) }</p>
|
|
</div>
|
|
<div>
|
|
<p class="gruvbox-fg-dim">Backups</p>
|
|
<p class="gruvbox-yellow">{ fmt.Sprintf("%d", GetGroupTotalBackupCount(serviceBackups)) }</p>
|
|
</div>
|
|
<div>
|
|
<p class="gruvbox-fg-dim">Last Backup</p>
|
|
if latestTime, found := GetLatestBackupTime(serviceBackups); found {
|
|
<p class="gruvbox-yellow">{ FormatTimeSince(latestTime) }</p>
|
|
} else {
|
|
<p class="gruvbox-yellow">Never</p>
|
|
}
|
|
</div>
|
|
<div>
|
|
<p class="gruvbox-fg-dim">Status</p>
|
|
if status := GetGroupStatus(serviceBackups); status == "No Backups" {
|
|
<p class="gruvbox-red">No Backups</p>
|
|
} else if status == "Failed" {
|
|
<p class="gruvbox-red">Failed</p>
|
|
} else if status == "Warning" {
|
|
<p class="gruvbox-yellow">Warning</p>
|
|
} else {
|
|
<p class="gruvbox-green">Healthy</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
// Updated table templates with action column
|
|
templ ServiceGroupBackupsTable(groupName string, serviceGroup map[string][]models.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
|
<div class="group-backups-table" id={ fmt.Sprintf("backups-%s", groupName) }>
|
|
if GetGroupTotalBackupCount(serviceGroup) > 0 {
|
|
<div class="overflow">
|
|
<table class="gruvbox-table">
|
|
<thead>
|
|
<tr class="gruvbox-bg1">
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Service</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Date</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Size</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Type</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Retention</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Location</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Get all backups from all services in this group, sorted by creation time -->
|
|
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), 5, serviceConfigs)
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- "View all" link - now using HTMX to replace the table content -->
|
|
if GetGroupTotalBackupCount(serviceGroup) > 5 {
|
|
<div class="margin-top right-align">
|
|
<button class="gruvbox-button gruvbox-bg2"
|
|
hx-get={ fmt.Sprintf("/api/service-group/%s/all-backups", groupName) }
|
|
hx-target={ fmt.Sprintf("#backups-%s", groupName) }
|
|
hx-swap="innerHTML"
|
|
hx-indicator={ fmt.Sprintf("#view-all-indicator-%s", groupName) }>
|
|
View all { fmt.Sprintf("%d", GetGroupTotalBackupCount(serviceGroup)) } backups
|
|
<span id={ fmt.Sprintf("view-all-indicator-%s", groupName) } class="htmx-indicator">
|
|
<span class="loading-spinner inline-spinner"></span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
}
|
|
</div>
|
|
} else {
|
|
<div class="gruvbox-bg1 round padding">
|
|
<p class="gruvbox-yellow">No backups found for this service.</p>
|
|
<p class="gruvbox-fg-dim">Backups will appear here when created.</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
// ServiceGroupAllBackupsTable template for showing all backups
|
|
templ ServiceGroupAllBackupsTable(groupName string, serviceGroup map[string][]models.BackupInfo, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
|
<!-- We're returning just the INNER content now, not the whole container -->
|
|
if GetGroupTotalBackupCount(serviceGroup) > 0 {
|
|
<div class="overflow">
|
|
<table class="gruvbox-table">
|
|
<thead>
|
|
<tr class="gruvbox-bg1">
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Service</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Date</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Size</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Type</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Retention</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Location</th>
|
|
<th class="padding-small border-bottom left-align gruvbox-purple">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<!-- Show ALL backups, without limit -->
|
|
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), 9999, serviceConfigs)
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- "Show fewer" button that reloads original table -->
|
|
<div class="margin-top right-align">
|
|
<button class="gruvbox-button gruvbox-bg2"
|
|
hx-get={ fmt.Sprintf("/api/service-group/%s/backups", groupName) }
|
|
hx-target={ fmt.Sprintf("#backups-%s", groupName) }
|
|
hx-swap="outerHTML"
|
|
hx-indicator={ fmt.Sprintf("#show-fewer-indicator-%s", groupName) }>
|
|
Show fewer backups
|
|
<span id={ fmt.Sprintf("show-fewer-indicator-%s", groupName) } class="htmx-indicator">
|
|
<span class="loading-spinner inline-spinner"></span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
} else {
|
|
<div class="gruvbox-bg1 round padding">
|
|
<p class="gruvbox-yellow">No backups found for this service.</p>
|
|
<p class="gruvbox-fg-dim">Backups will appear here when created.</p>
|
|
</div>
|
|
}
|
|
|
|
<!-- Trigger header refresh with out-of-band swap -->
|
|
<div hx-get={ fmt.Sprintf("/api/service-group/%s/header", groupName) }
|
|
hx-trigger="load once"
|
|
hx-target={ fmt.Sprintf("#group-header-%s", groupName) }
|
|
hx-swap="outerHTML"
|
|
style="display:none;"></div>
|
|
}
|
|
|
|
// renderSortedBackups with action buttons
|
|
templ renderSortedBackups(groupName string, sortedBackups []BackupWithService, limit int, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
|
for i := 0; i < len(sortedBackups) && i < limit; i++ {
|
|
<tr class={ templ.Classes(
|
|
templ.KV("gruvbox-bg-hard", i % 2 == 0),
|
|
templ.KV("gruvbox-bg0", i % 2 != 0),
|
|
) }>
|
|
<td class="padding-small border-bottom gruvbox-fg">{ FormatServiceName(groupName, sortedBackups[i].ServiceIndex) }</td>
|
|
<td class="padding-small border-bottom gruvbox-fg">{ FormatTime(sortedBackups[i].Backup.CreationTime) }</td>
|
|
<td class="padding-small border-bottom gruvbox-fg">{ FormatSize(sortedBackups[i].Backup.Size) }</td>
|
|
<td class="padding-small border-bottom gruvbox-fg">{ sortedBackups[i].Backup.Type }</td>
|
|
<td class="padding-small border-bottom gruvbox-fg">{ sortedBackups[i].Backup.RetentionTag }</td>
|
|
<td class="padding-small border-bottom">
|
|
if providerInfo, exists := serviceConfigs[groupName][sortedBackups[i].ServiceIndex]; exists {
|
|
if providerInfo.Provider == "b2" || providerInfo.Provider == "backblaze" {
|
|
<span class="backup-tool-tag gruvbox-purple">{ providerInfo.Type } B2 Backblaze</span>
|
|
} else if providerInfo.Provider == "ftp" {
|
|
<span class="backup-tool-tag gruvbox-yellow">{ providerInfo.Type } FTP</span>
|
|
} else if providerInfo.Provider == "ssh" || providerInfo.Provider == "sftp" {
|
|
<span class="backup-tool-tag gruvbox-orange">{ providerInfo.Type } SSH</span>
|
|
} else if providerInfo.Provider == "s3" {
|
|
<span class="backup-tool-tag gruvbox-red">{ providerInfo.Type } S3</span>
|
|
} else {
|
|
<span class="backup-tool-tag gruvbox-green">{ providerInfo.Type } { providerInfo.Provider }</span>
|
|
}
|
|
} else {
|
|
<span class="backup-tool-tag gruvbox-gray">Unknown</span>
|
|
}
|
|
</td>
|
|
<td class="padding-small border-bottom action-buttons">
|
|
<button class="action-button restore-button"
|
|
hx-get={ fmt.Sprintf("/api/backups/restore-form?backupID=%s&groupName=%s&serviceIndex=%s",
|
|
sortedBackups[i].Backup.ID,
|
|
groupName,
|
|
sortedBackups[i].ServiceIndex) }
|
|
hx-target="body"
|
|
hx-swap="beforeend">
|
|
Restore
|
|
</button>
|
|
<a href={ templ.SafeURL(fmt.Sprintf("/api/backups/download?backupID=%s&groupName=%s&serviceIndex=%s",
|
|
sortedBackups[i].Backup.ID,
|
|
groupName,
|
|
sortedBackups[i].ServiceIndex)) }
|
|
class="action-button download-button">
|
|
Download
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
}
|
|
}
|
|
|
|
|
|
// backupsTableRowsSorted renders backup rows sorted by creation time across all services
|
|
templ backupsTableRowsSorted(groupName string, serviceGroup map[string][]models.BackupInfo, limit int, serviceConfigs map[string]map[string]ServiceProviderInfo) {
|
|
@renderSortedBackups(groupName, GetSortedBackups(serviceGroup), limit, serviceConfigs)
|
|
}
|
|
|