backea/templates/home.templ
2025-03-21 17:54:43 +01:00

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)
}