Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cmd/cloudstic/cmd_tui_activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,13 @@ func (l *tuiActionState) Start(action, target string) {
l.mu.Lock()
defer l.mu.Unlock()
l.panel.Status = tui.ActivityStatusRunning
l.panel.ActionKind = actionKindFromActionLabel(action)
if target != "" {
l.panel.Action = fmt.Sprintf("%s (%s)", action, target)
l.panel.Target = strings.TrimPrefix(target, "profile ")
} else {
l.panel.Action = action
l.panel.Target = ""
}
l.panel.Summary = ""
l.panel.UpdatedAt = ""
Expand Down Expand Up @@ -320,3 +323,14 @@ func tuiStoreFlags(profilesFile string, storeCfg cloudstic.ProfileStore) *global
g.verbose = &verbose
return g
}

func actionKindFromActionLabel(action string) tui.ActionKind {
switch action {
case "Run repository check":
return tui.ActionKindCheck
case "Initialize store":
return tui.ActionKindInit
default:
return tui.ActionKindBackup
}
}
117 changes: 84 additions & 33 deletions internal/tui/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,17 @@ const (
)

type ActivityPanel struct {
Status ActivityStatus
Action string
Phase string
Current int64
Total int64
IsBytes bool
Summary string
UpdatedAt string
Lines []string
Status ActivityStatus
ActionKind ActionKind
Action string
Target string
Phase string
Current int64
Total int64
IsBytes bool
Summary string
UpdatedAt string
Lines []string
}

type ActionKind string
Expand Down Expand Up @@ -119,6 +121,23 @@ const (
StoreHealthUnknown StoreHealth = "unknown"
)

type StoreReachability string

const (
StoreReachabilityUnknown StoreReachability = "unknown"
StoreReachabilityPending StoreReachability = "pending"
StoreReachabilityReachable StoreReachability = "reachable"
StoreReachabilityUnavailable StoreReachability = "unavailable"
)

type RepositoryState string

const (
RepositoryStateUnknown RepositoryState = "unknown"
RepositoryStateInitialized RepositoryState = "initialized"
RepositoryStateNotInitialized RepositoryState = "not_initialized"
)

type BackupFreshness string

const (
Expand All @@ -129,18 +148,20 @@ const (
)

type ProfileCard struct {
Name string
Source string
StoreRef string
AuthRef string
Enabled bool
Status ProfileStatus
StatusNote string
StoreHealth StoreHealth
BackupState BackupFreshness
LastBackup string
LastRef string
Actions []ProfileAction
Name string
Source string
StoreRef string
AuthRef string
Enabled bool
Status ProfileStatus
StatusNote string
StoreHealth StoreHealth
Reachability StoreReachability
Repository RepositoryState
BackupState BackupFreshness
LastBackup string
LastRef string
Actions []ProfileAction
}

type StoreProbe struct {
Expand Down Expand Up @@ -202,28 +223,58 @@ func BuildDashboard(cfg *engine.ProfilesConfig, probes map[string]StoreProbe) Da
status, note := profileStatus(cfg, profile, probes[profile.Store])
lastBackup, lastRef, lastCreated := latestBackup(profile.Source, probes[profile.Store].Snapshots)
storeHealth := deriveStoreHealth(cfg, profile, probes[profile.Store])
reachability := deriveStoreReachability(storeHealth)
repository := deriveRepositoryState(probes[profile.Store], storeHealth)
backupState := deriveBackupState(lastCreated)
if lastBackup == "" {
backupState = BackupFreshnessNever
}
d.Profiles = append(d.Profiles, ProfileCard{
Name: name,
Source: profile.Source,
StoreRef: profile.Store,
AuthRef: profile.AuthRef,
Enabled: profile.IsEnabled(),
Status: status,
StatusNote: note,
StoreHealth: storeHealth,
BackupState: backupState,
LastBackup: lastBackup,
LastRef: lastRef,
Actions: deriveProfileActions(status, storeHealth),
Name: name,
Source: profile.Source,
StoreRef: profile.Store,
AuthRef: profile.AuthRef,
Enabled: profile.IsEnabled(),
Status: status,
StatusNote: note,
StoreHealth: storeHealth,
Reachability: reachability,
Repository: repository,
BackupState: backupState,
LastBackup: lastBackup,
LastRef: lastRef,
Actions: deriveProfileActions(status, storeHealth),
})
}
return d
}

func deriveStoreReachability(health StoreHealth) StoreReachability {
switch health {
case StoreHealthPending:
return StoreReachabilityPending
case StoreHealthUnavailable:
return StoreReachabilityUnavailable
case StoreHealthUnknown:
return StoreReachabilityUnknown
default:
return StoreReachabilityReachable
}
}

func deriveRepositoryState(probe StoreProbe, health StoreHealth) RepositoryState {
switch health {
case StoreHealthNotInitialized:
return RepositoryStateNotInitialized
case StoreHealthUnavailable, StoreHealthUnknown, StoreHealthPending:
return RepositoryStateUnknown
}
if probe.Status == "ok" {
return RepositoryStateInitialized
}
return RepositoryStateUnknown
}

func deriveProfileActions(status ProfileStatus, storeHealth StoreHealth) []ProfileAction {
switch {
case status == ProfileStatusDisabled:
Expand Down
18 changes: 18 additions & 0 deletions internal/tui/dashboard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ func TestBuildDashboard_SortsProfilesAndCountsSections(t *testing.T) {
if got.Profiles[0].StoreHealth != StoreHealthReady {
t.Fatalf("store health = %q want ready", got.Profiles[0].StoreHealth)
}
if got.Profiles[0].Reachability != StoreReachabilityReachable {
t.Fatalf("reachability = %q want reachable", got.Profiles[0].Reachability)
}
if got.Profiles[0].Repository != RepositoryStateInitialized {
t.Fatalf("repository = %q want initialized", got.Profiles[0].Repository)
}
if got.Profiles[0].BackupState != BackupFreshnessRecent {
t.Fatalf("backup state = %q want recent", got.Profiles[0].BackupState)
}
Expand Down Expand Up @@ -118,6 +124,12 @@ func TestBuildDashboard_NormalizesStoreProbeErrors(t *testing.T) {
if got.Profiles[0].StoreHealth != StoreHealthNotInitialized {
t.Fatalf("store health=%q want repository not initialized", got.Profiles[0].StoreHealth)
}
if got.Profiles[0].Reachability != StoreReachabilityReachable {
t.Fatalf("reachability=%q want reachable", got.Profiles[0].Reachability)
}
if got.Profiles[0].Repository != RepositoryStateNotInitialized {
t.Fatalf("repository=%q want not initialized", got.Profiles[0].Repository)
}
if len(got.Profiles[0].Actions) != 2 || got.Profiles[0].Actions[0].Kind != ActionKindInit || !got.Profiles[0].Actions[0].Enabled {
t.Fatalf("unexpected actions: %+v", got.Profiles[0].Actions)
}
Expand Down Expand Up @@ -169,6 +181,12 @@ func TestBuildDashboardFromConfig_StoreErrorBecomesWarning(t *testing.T) {
if got.Profiles[0].Status != ProfileStatusWarning || got.Profiles[0].StatusNote != "unlock failed" {
t.Fatalf("unexpected profile status: %+v", got.Profiles[0])
}
if got.Profiles[0].Reachability != StoreReachabilityUnavailable {
t.Fatalf("reachability=%q want unavailable", got.Profiles[0].Reachability)
}
if got.Profiles[0].Repository != RepositoryStateUnknown {
t.Fatalf("repository=%q want unknown", got.Profiles[0].Repository)
}
}

func mustTime(t *testing.T, raw string) time.Time {
Expand Down
81 changes: 64 additions & 17 deletions internal/tui/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ func renderSelectedProfile(d Dashboard) ([]string, map[int]string) {
profileDetailLine("State", plainProfileStateLabel(profile)),
profileDetailLine("Source", profile.Source),
profileDetailLine("Store", profile.StoreRef),
profileDetailLine("Health", storeHealthLabel(profile.StoreHealth)),
profileDetailLine("Health", profileHealthSummary(profile)),
}
if profile.AuthRef != "" {
lines = append(lines, profileDetailLine("Auth", profile.AuthRef))
Expand All @@ -240,7 +240,13 @@ func renderSelectedProfile(d Dashboard) ([]string, map[int]string) {
if profile.LastRef != "" {
lines = append(lines, profileDetailLine("Ref", trimSnapshotRef(profile.LastRef)))
}
if profile.StatusNote != "" && (profile.Status != ProfileStatusReady || profile.BackupState != BackupFreshnessNever) {
if check := profileCheckSummary(d.Activity, profile); check != "" {
lines = append(lines, profileDetailLine("Check", check))
}
if note := profileStatusSummary(profile); note != "" {
lines = append(lines, profileDetailLine("Status", note))
}
if profile.StatusNote != "" && noteAddsContext(profile) {
lines = append(lines, profileDetailLine("Status", profile.StatusNote))
}
buttons := selectedProfileActionButtons(profile)
Expand Down Expand Up @@ -507,27 +513,46 @@ func equalizePaneHeights(left, right []string) ([]string, []string) {
return left, right
}

func storeHealthLabel(health StoreHealth) string {
switch health {
case StoreHealthReady:
return "ready"
case StoreHealthPending:
return "pending"
case StoreHealthDisabled:
func profileHealthSummary(profile ProfileCard) string {
switch {
case profile.Status == ProfileStatusDisabled:
return "disabled"
case StoreHealthMissingStore:
case profile.Status == ProfileStatusError:
return "configuration error"
case profile.Reachability == StoreReachabilityUnavailable:
return "store unavailable"
case profile.Repository == RepositoryStateNotInitialized:
return "repository not initialized"
case profile.Reachability == StoreReachabilityPending:
return "checking store"
case profile.StoreHealth == StoreHealthMissingStore:
return "missing store"
case StoreHealthMissingAuth:
case profile.StoreHealth == StoreHealthMissingAuth:
return "missing auth"
case StoreHealthProviderMismatch:
case profile.StoreHealth == StoreHealthProviderMismatch:
return "provider mismatch"
case StoreHealthUnavailable:
return "unavailable"
case StoreHealthNotInitialized:
return "repository not initialized"
case profile.BackupState == BackupFreshnessStale:
return "backup stale"
default:
return "unknown"
return "ready"
}
}

func profileStatusSummary(profile ProfileCard) string {
switch {
case profile.Reachability == StoreReachabilityUnknown && profile.Repository == RepositoryStateUnknown:
return "status unknown"
default:
return ""
}
}

func noteAddsContext(profile ProfileCard) bool {
if profile.StatusNote == "" {
return false
}
summary := profileHealthSummary(profile)
return !strings.EqualFold(profile.StatusNote, summary)
}

func backupFreshnessLabel(state BackupFreshness) string {
Expand Down Expand Up @@ -755,6 +780,28 @@ func actionButtonLabel(action ProfileAction) string {
}
}

func profileCheckSummary(activity ActivityPanel, profile ProfileCard) string {
if activity.ActionKind != ActionKindCheck || activity.Target != profile.Name {
return ""
}
switch activity.Status {
case ActivityStatusRunning:
return "running"
case ActivityStatusSuccess:
if activity.UpdatedAt != "" {
return fmt.Sprintf("passed at %s", activity.UpdatedAt)
}
return "passed"
case ActivityStatusError:
if activity.UpdatedAt != "" {
return fmt.Sprintf("failed at %s", activity.UpdatedAt)
}
return "failed"
default:
return ""
}
}

func renderActionButton(button actionButton) string {
key := fmt.Sprintf("%s[%s]%s", ui.Cyan, button.Key, ui.Reset)
label := button.Label
Expand Down
Loading
Loading