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
12 changes: 12 additions & 0 deletions cmd/backrest/backrest.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ var (
commit = "unknown"
)

// onOpLogReady, when set, is invoked with the OpLog once it is constructed.
// The tray build (-tags tray) uses it to drive a status-reflecting menu bar
// icon. It stays nil in non-tray builds, so this adds no behavior there.
var onOpLogReady func(*oplog.OpLog)

func runApp() {
flag.Parse()
if *printVersion {
Expand Down Expand Up @@ -86,6 +91,13 @@ func runApp() {
}
defer opLogStore.Close()

// onOpLogReady lets the system tray (when built with -tags tray) observe
// operation status in-process to reflect backup state in its icon. It is
// nil in non-tray builds.
if onOpLogReady != nil {
onOpLogReady(opLog)
}

logStore, unsubscribeLogStore, err := newLogStore(opLog)
if err != nil {
zap.L().Fatal("error creating log store", zap.Error(err))
Expand Down
Binary file added cmd/backrest/icon_error.ico
Binary file not shown.
Binary file added cmd/backrest/icon_error.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added cmd/backrest/icon_ok.ico
Binary file not shown.
Binary file added cmd/backrest/icon_ok.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added cmd/backrest/icon_syncing.ico
Binary file not shown.
Binary file added cmd/backrest/icon_syncing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions cmd/backrest/icon_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,31 @@ import _ "embed"

//go:embed icon.png
var icon []byte

//go:embed icon_ok.png
var iconOK []byte

//go:embed icon_syncing.png
var iconSyncing []byte

//go:embed icon_warning.png
var iconWarning []byte

//go:embed icon_error.png
var iconError []byte

// statusIcon returns the tray icon for the given backup state.
func statusIcon(s trayState) []byte {
switch s {
case stateRunning:
return iconSyncing
case stateOK:
return iconOK
case stateWarning:
return iconWarning
case stateError:
return iconError
default:
return icon
}
}
Binary file added cmd/backrest/icon_warning.ico
Binary file not shown.
Binary file added cmd/backrest/icon_warning.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions cmd/backrest/icon_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,31 @@ import _ "embed"

//go:embed icon.ico
var icon []byte

//go:embed icon_ok.ico
var iconOK []byte

//go:embed icon_syncing.ico
var iconSyncing []byte

//go:embed icon_warning.ico
var iconWarning []byte

//go:embed icon_error.ico
var iconError []byte

// statusIcon returns the tray icon for the given backup state.
func statusIcon(s trayState) []byte {
switch s {
case stateRunning:
return iconSyncing
case stateOK:
return iconOK
case stateWarning:
return iconWarning
case stateError:
return iconError
default:
return icon
}
}
10 changes: 8 additions & 2 deletions cmd/backrest/tray.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,24 @@ import (
)

func startTray() {
status := newTrayStatus()
// Observe oplog status in-process so the icon can reflect backup state.
onOpLogReady = status.attach
go runApp()
systray.Run(onReady, func() {})
systray.Run(func() { onReady(status) }, func() {})
}

func onReady() {
func onReady(status *trayStatus) {
systray.SetTooltip("Backrest")
systray.SetIcon(icon)

mOpenUI := systray.AddMenuItem("Open WebUI", "Open the Backrest WebUI in your default browser")
mOpenLog := systray.AddMenuItem("Open Log Dir", "Open the Backrest log directory")
mQuit := systray.AddMenuItem("Quit", "Kills the backrest process and exits the tray app")

// Now that the tray is live, reflect the current backup status.
status.markReady()

go func() {
for {
select {
Expand Down
167 changes: 167 additions & 0 deletions cmd/backrest/tray_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
//go:build tray

package main

import (
"errors"
"fmt"
"sync"
"sync/atomic"
"time"

"fyne.io/systray"
v1 "github.com/garethgeorge/backrest/gen/go/v1"
"github.com/garethgeorge/backrest/internal/oplog"
)

// trayState is the overall backup state reflected by the tray icon.
type trayState int

const (
stateIdle trayState = iota // no backups recorded yet
stateRunning // a backup is currently in progress
stateOK // most recent backup succeeded
stateWarning // most recent backup completed with warnings
stateError // most recent backup failed
)

// errStopQuery short-circuits an oplog query once the wanted op is found.
var errStopQuery = errors.New("stop")

// trayStatus tracks backup state from the oplog and updates the tray icon and
// tooltip to match (addresses #894). Updates are coalesced so a burst of
// progress events triggers at most one recompute per window.
type trayStatus struct {
mu sync.Mutex
log *oplog.OpLog
cur trayState
tooltip string
ready atomic.Bool
pending atomic.Bool
}

func newTrayStatus() *trayStatus {
return &trayStatus{cur: stateIdle, tooltip: "Backrest"}
}

// attach is invoked via the onOpLogReady hook once the oplog exists. It seeds
// the initial state and subscribes for subsequent changes.
func (t *trayStatus) attach(log *oplog.OpLog) {
t.mu.Lock()
t.log = log
t.mu.Unlock()

sub := oplog.Subscription(func(_ []*v1.Operation, _ oplog.OperationEvent) {
t.scheduleRefresh()
})
log.Subscribe(oplog.Query{}, &sub)
t.doRefresh()
}

// markReady is called once the systray is live so icon/tooltip writes land.
func (t *trayStatus) markReady() {
t.ready.Store(true)
t.apply()
}

// scheduleRefresh coalesces a burst of oplog events into one recompute.
func (t *trayStatus) scheduleRefresh() {
if t.pending.Swap(true) {
return
}
go func() {
time.Sleep(750 * time.Millisecond)
t.pending.Store(false)
t.doRefresh()
}()
}

func (t *trayStatus) doRefresh() {
t.mu.Lock()
log := t.log
t.mu.Unlock()
if log == nil {
return
}

state := stateIdle
tooltip := "Backrest — no backups yet"
running := false
var last *v1.Operation

// Reversed = newest first. Skip non-backup ops; a running backup flips the
// state, otherwise the first completed backup is the most recent result.
_ = log.Query(oplog.Query{Reversed: true}, func(op *v1.Operation) error {
// Only backup operations drive the headline status. Match on the oneof
// type rather than GetOperationBackup(), which is nil when the inner
// message is unset on an otherwise-backup op.
if _, ok := op.GetOp().(*v1.Operation_OperationBackup); !ok {
return nil
}
switch op.GetStatus() {
case v1.OperationStatus_STATUS_INPROGRESS, v1.OperationStatus_STATUS_PENDING:
running = true
return nil
default:
last = op
return errStopQuery
}
})

switch {
case running:
state = stateRunning
tooltip = "Backrest — backup in progress…"
case last != nil:
when := relativeTime(last.GetUnixTimeEndMs())
switch last.GetStatus() {
case v1.OperationStatus_STATUS_SUCCESS:
state, tooltip = stateOK, "Backrest — last backup succeeded "+when
case v1.OperationStatus_STATUS_WARNING:
state, tooltip = stateWarning, "Backrest — last backup finished with warnings "+when
case v1.OperationStatus_STATUS_ERROR, v1.OperationStatus_STATUS_SYSTEM_CANCELLED:
state, tooltip = stateError, "Backrest — last backup failed "+when
case v1.OperationStatus_STATUS_USER_CANCELLED:
state, tooltip = stateIdle, "Backrest — last backup was cancelled "+when
}
}

t.mu.Lock()
changed := state != t.cur || tooltip != t.tooltip
t.cur, t.tooltip = state, tooltip
t.mu.Unlock()
if changed {
t.apply()
}
}

func (t *trayStatus) apply() {
if !t.ready.Load() {
return
}
t.mu.Lock()
state, tooltip := t.cur, t.tooltip
t.mu.Unlock()

if ic := statusIcon(state); ic != nil {
systray.SetIcon(ic)
}
systray.SetTooltip(tooltip)
}

func relativeTime(unixMs int64) string {
if unixMs == 0 {
return ""
}
d := time.Since(time.UnixMilli(unixMs))
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
default:
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
}
102 changes: 102 additions & 0 deletions cmd/backrest/tray_status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//go:build tray

package main

import (
"testing"

v1 "github.com/garethgeorge/backrest/gen/go/v1"
"github.com/garethgeorge/backrest/internal/oplog"
"github.com/garethgeorge/backrest/internal/oplog/memstore"
)

func newTestOpLog(t *testing.T) *oplog.OpLog {
t.Helper()
log, err := oplog.NewOpLog(memstore.NewMemStore())
if err != nil {
t.Fatalf("NewOpLog: %v", err)
}
return log
}

var opStartTime int64 = 1_000

func backupOp(status v1.OperationStatus) *v1.Operation {
opStartTime += 1_000
return &v1.Operation{
InstanceId: "test-instance",
RepoId: "test-repo",
RepoGuid: "test-repo",
PlanId: "test-plan",
UnixTimeStartMs: opStartTime,
UnixTimeEndMs: opStartTime + 500,
Status: status,
Op: &v1.Operation_OperationBackup{OperationBackup: &v1.OperationBackup{}},
}
}

func nonBackupOp(status v1.OperationStatus) *v1.Operation {
opStartTime += 1_000
return &v1.Operation{
InstanceId: "test-instance",
RepoId: "test-repo",
RepoGuid: "test-repo",
PlanId: "test-plan",
UnixTimeStartMs: opStartTime,
UnixTimeEndMs: opStartTime + 500,
Status: status,
Op: &v1.Operation_OperationForget{OperationForget: &v1.OperationForget{}},
}
}

func TestTrayStatus(t *testing.T) {
log := newTestOpLog(t)
ts := newTrayStatus()
ts.attach(log)

// No operations yet.
ts.doRefresh()
if ts.cur != stateIdle {
t.Fatalf("empty oplog: got %v, want stateIdle", ts.cur)
}

steps := []struct {
name string
op *v1.Operation
want trayState
}{
{"successful backup", backupOp(v1.OperationStatus_STATUS_SUCCESS), stateOK},
{"failed backup is newest", backupOp(v1.OperationStatus_STATUS_ERROR), stateError},
{"non-backup success is ignored", nonBackupOp(v1.OperationStatus_STATUS_SUCCESS), stateError},
{"warning backup is newest", backupOp(v1.OperationStatus_STATUS_WARNING), stateWarning},
{"in-progress backup shows running", backupOp(v1.OperationStatus_STATUS_INPROGRESS), stateRunning},
{"completed success after run", backupOp(v1.OperationStatus_STATUS_SUCCESS), stateOK},
}

for _, s := range steps {
if err := log.Add(s.op); err != nil {
t.Fatalf("%s: Add: %v", s.name, err)
}
ts.doRefresh()
if ts.cur != s.want {
t.Errorf("%s: got state %v, want %v", s.name, ts.cur, s.want)
}
}
}

func TestTrayStatusInProgressOverridesOlderResult(t *testing.T) {
log := newTestOpLog(t)
ts := newTrayStatus()
ts.attach(log)

if err := log.Add(backupOp(v1.OperationStatus_STATUS_ERROR)); err != nil {
t.Fatal(err)
}
if err := log.Add(backupOp(v1.OperationStatus_STATUS_INPROGRESS)); err != nil {
t.Fatal(err)
}
ts.doRefresh()
if ts.cur != stateRunning {
t.Errorf("in-progress over older error: got %v, want stateRunning", ts.cur)
}
}
Loading
Loading