diff --git a/cmd/cloudstic/cmd_tui_activity.go b/cmd/cloudstic/cmd_tui_activity.go index 2e9d345..b7b60c8 100644 --- a/cmd/cloudstic/cmd_tui_activity.go +++ b/cmd/cloudstic/cmd_tui_activity.go @@ -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 = "" @@ -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 + } +} diff --git a/internal/tui/dashboard.go b/internal/tui/dashboard.go index bf9724b..8a3913c 100644 --- a/internal/tui/dashboard.go +++ b/internal/tui/dashboard.go @@ -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 @@ -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 ( @@ -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 { @@ -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: diff --git a/internal/tui/dashboard_test.go b/internal/tui/dashboard_test.go index 0900a3d..46697cd 100644 --- a/internal/tui/dashboard_test.go +++ b/internal/tui/dashboard_test.go @@ -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) } @@ -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) } @@ -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 { diff --git a/internal/tui/shell.go b/internal/tui/shell.go index 74cd5be..6d1aa59 100644 --- a/internal/tui/shell.go +++ b/internal/tui/shell.go @@ -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)) @@ -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) @@ -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 { @@ -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 diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go index 00ce2f9..71782f4 100644 --- a/internal/tui/shell_test.go +++ b/internal/tui/shell_test.go @@ -13,27 +13,31 @@ func TestRenderDashboard(t *testing.T) { AuthCount: 0, SelectedProfile: "documents", Activity: ActivityPanel{ - Status: ActivityStatusSuccess, - Action: "Run backup (profile documents)", - Phase: "Scanning", - Current: 512, - Total: 1024, - IsBytes: true, - Summary: "completed successfully", - UpdatedAt: "2026-04-03 15:05:00", - Lines: []string{"Snapshot abc123 saved"}, + Status: ActivityStatusSuccess, + ActionKind: ActionKindCheck, + Action: "Run backup (profile documents)", + Target: "documents", + Phase: "Scanning", + Current: 512, + Total: 1024, + IsBytes: true, + Summary: "completed successfully", + UpdatedAt: "2026-04-03 15:05:00", + Lines: []string{"Snapshot abc123 saved"}, }, Profiles: []ProfileCard{ { - Name: "documents", - Source: "local:/Users/test/Documents", - StoreRef: "remote", - Enabled: true, - Status: ProfileStatusReady, - StoreHealth: StoreHealthReady, - BackupState: BackupFreshnessRecent, - LastBackup: "2026-04-03 11:05", - LastRef: "snapshot/abc123", + Name: "documents", + Source: "local:/Users/test/Documents", + StoreRef: "remote", + Enabled: true, + Status: ProfileStatusReady, + StoreHealth: StoreHealthReady, + Reachability: StoreReachabilityReachable, + Repository: RepositoryStateInitialized, + BackupState: BackupFreshnessRecent, + LastBackup: "2026-04-03 11:05", + LastRef: "snapshot/abc123", Actions: []ProfileAction{ {Kind: ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true}, {Kind: ActionKindCheck, Key: "c", Label: "Press c to run repository check", Enabled: true}, @@ -68,6 +72,8 @@ func TestRenderDashboard(t *testing.T) { "2026-04-03 11:05 (recent)", "Ref", "abc123", + "Check", + "passed at 2026-04-03 15:05:00", "success", "Run backup (profile documents)", "Scanning", @@ -148,16 +154,18 @@ func TestDashboardLinesWidth_TruncatesForNarrowTerminals(t *testing.T) { }, Profiles: []ProfileCard{ { - Name: "google-test", - Source: "gdrive-changes:/Very Long Shared Drive Name/Extremely Long Folder Name", - StoreRef: "default-store", - AuthRef: "google-google-test", - Enabled: true, - Status: ProfileStatusReady, - StoreHealth: StoreHealthReady, - BackupState: BackupFreshnessRecent, - LastBackup: "2026-04-03 14:53", - LastRef: "snapshot/c9a98d85cd65e691c427554664c612c4014ff25572644e4ce4a158ecd593a773", + Name: "google-test", + Source: "gdrive-changes:/Very Long Shared Drive Name/Extremely Long Folder Name", + StoreRef: "default-store", + AuthRef: "google-google-test", + Enabled: true, + Status: ProfileStatusReady, + StoreHealth: StoreHealthReady, + Reachability: StoreReachabilityReachable, + Repository: RepositoryStateInitialized, + BackupState: BackupFreshnessRecent, + LastBackup: "2026-04-03 14:53", + LastRef: "snapshot/c9a98d85cd65e691c427554664c612c4014ff25572644e4ce4a158ecd593a773", Actions: []ProfileAction{ {Kind: ActionKindBackup, Key: "b", Label: "Press b to run backup", Enabled: true}, {Kind: ActionKindCheck, Key: "c", Label: "Press c to run repository check", Enabled: true},