feat(tray): reflect backup status in the tray icon (#1255)
@@ -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 {
|
||||
@@ -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))
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,14 @@ 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)
|
||||
|
||||
@@ -25,6 +28,9 @@ func onReady() {
|
||||
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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||