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
87 changes: 61 additions & 26 deletions cmd/cloudstic/cmd_tui_activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ func runTUIActionIntoDashboard(ctx context.Context, r *runner, profilesFile stri
screen := r.out
if profile, ok := selectedTUIProfile(dashboard); ok {
if profileNeedsInit(profile) {
log.Start("Initialize store", fmt.Sprintf("profile %s", profile.Name))
log.Printf("Initializing store for profile %s", profile.Name)
} else {
log.Start("Run backup", fmt.Sprintf("profile %s", profile.Name))
log.Printf("Running backup for profile %s", profile.Name)
}
}
Expand All @@ -44,28 +46,31 @@ func runTUIActionIntoDashboard(ctx context.Context, r *runner, profilesFile stri
return
case <-ticker.C:
live := dashboard
live.ActivityLines = log.Lines()
live.Activity = log.Snapshot()
_ = renderTUIScreenWidth(screen, live, tuiWidth(r))
}
}
}()

if err := runSelectedTUIAction(ctx, r, profilesFile, dashboard, log); err != nil {
log.Fail(err.Error())
log.Printf("Action failed: %v", err)
} else {
log.Succeed("completed successfully")
log.Printf("Action completed successfully")
}
close(stop)
<-done

dashboard.ActivityLines = mergeTUIActivityLines(dashboard.ActivityLines, log.Lines())
dashboard.Activity = log.Snapshot()
return dashboard
}

func runTUICheckIntoDashboard(ctx context.Context, r *runner, profilesFile string, dashboard tui.Dashboard) tui.Dashboard {
log := newTUIActionState(10)
screen := r.out
if profile, ok := selectedTUIProfile(dashboard); ok {
log.Start("Run repository check", fmt.Sprintf("profile %s", profile.Name))
log.Printf("Running repository check for profile %s", profile.Name)
}

Expand All @@ -81,33 +86,26 @@ func runTUICheckIntoDashboard(ctx context.Context, r *runner, profilesFile strin
return
case <-ticker.C:
live := dashboard
live.ActivityLines = log.Lines()
live.Activity = log.Snapshot()
_ = renderTUIScreenWidth(screen, live, tuiWidth(r))
}
}
}()

if err := runSelectedTUICheck(ctx, r, profilesFile, dashboard, log); err != nil {
log.Fail(err.Error())
log.Printf("Check failed: %v", err)
} else {
log.Succeed("completed successfully")
log.Printf("Check completed successfully")
}
close(stop)
<-done

dashboard.ActivityLines = mergeTUIActivityLines(dashboard.ActivityLines, log.Lines())
dashboard.Activity = log.Snapshot()
return dashboard
}

func mergeTUIActivityLines(existing, recent []string) []string {
merged := append([]string{}, recent...)
merged = append(merged, existing...)
if len(merged) > 10 {
merged = merged[:10]
}
return merged
}

type crlfWriter struct {
w io.Writer
}
Expand Down Expand Up @@ -144,6 +142,7 @@ type tuiActionState struct {
limit int
buf bytes.Buffer
phase *tuiPhaseState
panel tui.ActivityPanel
}

type tuiPhaseState struct {
Expand All @@ -166,6 +165,35 @@ func (l *tuiActionState) Reporter() cloudstic.Reporter {
return tuiReporter{state: l}
}

func (l *tuiActionState) Start(action, target string) {
l.mu.Lock()
defer l.mu.Unlock()
l.panel.Status = tui.ActivityStatusRunning
if target != "" {
l.panel.Action = fmt.Sprintf("%s (%s)", action, target)
} else {
l.panel.Action = action
}
l.panel.Summary = ""
l.panel.UpdatedAt = ""
}

func (l *tuiActionState) Succeed(summary string) {
l.mu.Lock()
defer l.mu.Unlock()
l.panel.Status = tui.ActivityStatusSuccess
l.panel.Summary = summary
l.panel.UpdatedAt = time.Now().Local().Format("2006-01-02 15:04:05")
}

func (l *tuiActionState) Fail(summary string) {
l.mu.Lock()
defer l.mu.Unlock()
l.panel.Status = tui.ActivityStatusError
l.panel.Summary = summary
l.panel.UpdatedAt = time.Now().Local().Format("2006-01-02 15:04:05")
}

func (l *tuiActionState) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
Expand Down Expand Up @@ -194,11 +222,25 @@ func (l *tuiActionState) Lines() []string {
l.append(tail)
l.buf.Reset()
}
lines := append([]string{}, l.lines...)
if summary := l.phaseSummary(); summary != "" {
lines = append([]string{summary}, lines...)
return append([]string{}, l.lines...)
}

func (l *tuiActionState) Snapshot() tui.ActivityPanel {
l.mu.Lock()
defer l.mu.Unlock()
if tail := strings.TrimSpace(l.buf.String()); tail != "" {
l.append(tail)
l.buf.Reset()
}
panel := l.panel
panel.Lines = append([]string{}, l.lines...)
panel.Phase = l.phaseName()
if l.phase != nil {
panel.Current = l.phase.current
panel.Total = l.phase.total
panel.IsBytes = l.phase.isBytes
}
return lines
return panel
}

func (l *tuiActionState) append(line string) {
Expand All @@ -212,18 +254,11 @@ func (l *tuiActionState) append(line string) {
}
}

func (l *tuiActionState) phaseSummary() string {
func (l *tuiActionState) phaseName() string {
if l.phase == nil || l.phase.name == "" {
return ""
}
switch {
case l.phase.total > 0 && l.phase.isBytes:
return fmt.Sprintf("%s %s / %s", l.phase.name, formatBytes(l.phase.current), formatBytes(l.phase.total))
case l.phase.total > 0:
return fmt.Sprintf("%s %d / %d", l.phase.name, l.phase.current, l.phase.total)
default:
return l.phase.name
}
return l.phase.name
}

type tuiReporter struct {
Expand Down
15 changes: 9 additions & 6 deletions cmd/cloudstic/cmd_tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -530,11 +530,14 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) {
if s.dashboard.SelectedProfile != "docs" {
t.Fatalf("selected profile lost after refresh: %+v", s.dashboard)
}
if len(s.dashboard.ActivityLines) == 0 {
if len(s.dashboard.Activity.Lines) == 0 {
t.Fatalf("expected activity lines after action")
}
if !strings.Contains(strings.Join(s.dashboard.ActivityLines, "\n"), "Action completed successfully") {
t.Fatalf("missing completion activity: %+v", s.dashboard.ActivityLines)
if s.dashboard.Activity.Status != tui.ActivityStatusSuccess {
t.Fatalf("unexpected activity status: %+v", s.dashboard.Activity)
}
if !strings.Contains(strings.Join(s.dashboard.Activity.Lines, "\n"), "Action completed successfully") {
t.Fatalf("missing completion activity: %+v", s.dashboard.Activity)
}
}

Expand All @@ -551,7 +554,7 @@ func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) {

s := newTUISession(&runner{}, "profiles.yaml", tui.Dashboard{
SelectedProfile: "docs",
ActivityLines: []string{"running"},
Activity: tui.ActivityPanel{Status: tui.ActivityStatusRunning, Lines: []string{"running"}},
Profiles: []tui.ProfileCard{{Name: "docs"}},
})
if err := s.refresh(context.Background()); err != nil {
Expand All @@ -560,8 +563,8 @@ func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) {
if s.dashboard.SelectedProfile != "docs" {
t.Fatalf("selection not preserved: %+v", s.dashboard)
}
if len(s.dashboard.ActivityLines) != 1 || s.dashboard.ActivityLines[0] != "running" {
t.Fatalf("activity not preserved: %+v", s.dashboard.ActivityLines)
if len(s.dashboard.Activity.Lines) != 1 || s.dashboard.Activity.Lines[0] != "running" {
t.Fatalf("activity not preserved: %+v", s.dashboard.Activity)
}
}

Expand Down
4 changes: 2 additions & 2 deletions cmd/cloudstic/tui_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,13 @@ func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, e

func (s *tuiSession) refresh(ctx context.Context) error {
selected := s.dashboard.SelectedProfile
activity := append([]string{}, s.dashboard.ActivityLines...)
activity := s.dashboard.Activity
dashboard, err := tuiBuildDashboard(ctx, s.profilesFile)
if err != nil {
return err
}
dashboard.SelectedProfile = selected
dashboard.ActivityLines = activity
dashboard.Activity = activity
s.dashboard = ensureSelectedProfile(dashboard)
return nil
}
23 changes: 22 additions & 1 deletion internal/tui/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,31 @@ type Dashboard struct {
StoreCount int
AuthCount int
SelectedProfile string
ActivityLines []string
Activity ActivityPanel
Profiles []ProfileCard
}

type ActivityStatus string

const (
ActivityStatusIdle ActivityStatus = ""
ActivityStatusRunning ActivityStatus = "running"
ActivityStatusSuccess ActivityStatus = "success"
ActivityStatusError ActivityStatus = "error"
)

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

type ActionKind string

const (
Expand Down
93 changes: 88 additions & 5 deletions internal/tui/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,7 @@ func RenderDashboardWidth(w io.Writer, d Dashboard, width int) error {
return err
}

activity := d.ActivityLines
if len(activity) == 0 {
activity = []string{fmt.Sprintf("%sNo recent activity.%s", ui.Dim, ui.Reset)}
}
if err := renderBoxExact(w, "Activity", activity, panelWidth(width)); err != nil {
if err := renderBoxExact(w, "Activity", renderActivityPanel(d.Activity), panelWidth(width)); err != nil {
return err
}

Expand Down Expand Up @@ -166,6 +162,36 @@ func renderProfileList(d Dashboard) []string {
return lines
}

func renderActivityPanel(activity ActivityPanel) []string {
lines := []string{}
if activity.Status != ActivityStatusIdle {
lines = append(lines, profileDetailLine("Status", activityStatusLabel(activity.Status)))
}
if activity.Action != "" {
lines = append(lines, profileDetailLine("Action", activity.Action))
}
if activity.Phase != "" {
lines = append(lines, profileDetailLine("Phase", activity.Phase))
}
if bar := progressBarLine(activity, 28); bar != "" {
lines = append(lines, profileDetailLine("Progress", bar))
}
if activity.Summary != "" {
lines = append(lines, profileDetailLine("Result", activity.Summary))
}
if activity.UpdatedAt != "" {
lines = append(lines, profileDetailLine("Updated", activity.UpdatedAt))
}
if len(lines) > 0 && len(activity.Lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, activity.Lines...)
if len(lines) == 0 {
return []string{fmt.Sprintf("%sNo recent activity.%s", ui.Dim, ui.Reset)}
}
return lines
}

func renderSelectedProfile(d Dashboard) []string {
profile, ok := selectedProfileCard(d)
if !ok {
Expand Down Expand Up @@ -325,6 +351,63 @@ func backupFreshnessLabel(state BackupFreshness) string {
}
}

func activityStatusLabel(status ActivityStatus) string {
switch status {
case ActivityStatusRunning:
return "running"
case ActivityStatusSuccess:
return "success"
case ActivityStatusError:
return "failed"
default:
return ""
}
}

func progressBarLine(activity ActivityPanel, width int) string {
if activity.Total <= 0 || activity.Current < 0 || width <= 0 {
return ""
}
current := activity.Current
if current > activity.Total {
current = activity.Total
}
ratio := float64(current) / float64(activity.Total)
filled := int(ratio * float64(width))
if filled > width {
filled = width
}
if filled < 0 {
filled = 0
}
bar := strings.Repeat("=", filled) + strings.Repeat("-", width-filled)
if activity.IsBytes {
return fmt.Sprintf("[%s] %s / %s", bar, formatBytesLabel(current), formatBytesLabel(activity.Total))
}
return fmt.Sprintf("[%s] %d / %d", bar, current, activity.Total)
}

func formatBytesLabel(b int64) string {
const (
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
tb = 1024 * gb
)
switch {
case b >= tb:
return fmt.Sprintf("%.1f TiB", float64(b)/float64(tb))
case b >= gb:
return fmt.Sprintf("%.1f GiB", float64(b)/float64(gb))
case b >= mb:
return fmt.Sprintf("%.1f MiB", float64(b)/float64(mb))
case b >= kb:
return fmt.Sprintf("%.1f KiB", float64(b)/float64(kb))
default:
return fmt.Sprintf("%d B", b)
}
}

func visibleLen(s string) int {
n := 0
inEscape := false
Expand Down
Loading
Loading