diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-13 14:02:05 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-13 14:02:05 +0200 |
| commit | 9fc6ca0d9a9cdba9d5808951ce290e559a9e8554 (patch) | |
| tree | fab4f7bb606b38efaf00e22937c3c5a0f5cc0f57 /mail-overview | |
| parent | 87cd101eb3c55d0ad7e8f2142fcce90865da7d39 (diff) | |
| download | quickshell-9fc6ca0d9a9cdba9d5808951ce290e559a9e8554.tar.gz quickshell-9fc6ca0d9a9cdba9d5808951ce290e559a9e8554.zip | |
feat(mail-overview): show mail-watcher health in the drawer
The watcher already writes ~/.local/state/mail-watcher.heartbeat every
60s, so the drawer reads it and shows a dot: green when idling, yellow
when a folder gave up, red when the heartbeat is stale or missing.
Backoff stays green because it is normal recovery and the watcher's own
health check treats it that way. The staleness rule is reimplemented in
a few lines rather than shelling out to --status; the heartbeat file is
the documented cheap-to-poll surface.
Read once per open rather than watched: the heartbeat is written by
atomic replace, so an inotify watch held on the old inode dies with it.
"Sync now" is removed with its Process. The watcher triggers syncs, the
cron tick is the backstop, and waybar's right-click still runs
mailsync.sh.
Diffstat (limited to 'mail-overview')
| -rw-r--r-- | mail-overview/Accounts.qml | 24 | ||||
| -rw-r--r-- | mail-overview/MailPanel.qml | 78 | ||||
| -rw-r--r-- | mail-overview/README.md | 36 |
3 files changed, 107 insertions, 31 deletions
diff --git a/mail-overview/Accounts.qml b/mail-overview/Accounts.qml index 27cdcf9..0ae6886 100644 --- a/mail-overview/Accounts.qml +++ b/mail-overview/Accounts.qml @@ -184,32 +184,12 @@ Singleton { accounts = next; } - // Launching qtmaildir, and syncing. qtmaildir takes no arguments, so - // there is nothing to tell it about the account or thread clicked. + // Launching qtmaildir. It takes no arguments, so there is nothing to tell + // it about the account or thread clicked. Process { id: openProc; command: [`${Quickshell.env("HOME")}/bin/qtmaildir`] } function openClient() { openProc.running = false; openProc.running = true; } - - property bool syncing: false - - // mailsync.sh is already lock-protected against a concurrent cron run, so - // this does not need its own guard beyond not stacking clicks. - Process { - id: syncProc - command: [`${Quickshell.env("HOME")}/bin/mailsync.sh`] - onExited: { - root.syncing = false; - root.refresh(); - } - } - - function sync() { - if (syncing) return; - syncing = true; - syncProc.running = false; - syncProc.running = true; - } } diff --git a/mail-overview/MailPanel.qml b/mail-overview/MailPanel.qml index 7457fb2..0a7f36a 100644 --- a/mail-overview/MailPanel.qml +++ b/mail-overview/MailPanel.qml @@ -10,6 +10,7 @@ // GNU General Public License for more details. import Quickshell +import Quickshell.Io import Quickshell.Wayland import QtQuick @@ -20,6 +21,45 @@ Scope { property string monitor: "DP-1" property bool open: false + // mail-watcher's heartbeat, written every 60s. Read once per open: the + // drawer is a LazyLoader, so closing and reopening rebuilds the FileView + // and reads the current file. Watch is deliberately not used, because the + // heartbeat is written by atomic replace (tmpfile + rename) and an inotify + // watch held on the old inode dies with it. + property bool watcherAlive: false + property int watcherDead: 0 + property int watcherExpected: 0 + + // The same staleness rule as mail-watcher's heartbeat_is_healthy: dead + // threads or a heartbeat older than 300s mean the watcher needs a look. + // Backoff is healthy, so it never turns the dot. + function readHeartbeat(payload) { + root.watcherAlive = false; + root.watcherDead = 0; + root.watcherExpected = 0; + + let data = null; + try { data = JSON.parse(payload); } catch (e) { return; } + if (!data || typeof data.ts !== "string") return; + + const ts = Date.parse(data.ts); + if (isNaN(ts) || (Date.now() - ts) / 1000 > 300) return; + + root.watcherAlive = true; + root.watcherDead = Number(data.dead) || 0; + root.watcherExpected = Number(data.expected) || 0; + } + + readonly property color watcherColor: + !watcherAlive ? Theme.red + : watcherDead > 0 ? Theme.yellow + : Theme.green + + readonly property string watcherText: + !watcherAlive ? "watcher not running" + : watcherDead > 0 ? `${watcherDead} folder(s) dead, check the log` + : `watcher ok · ${watcherExpected} folders` + readonly property var screenObj: Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] @@ -95,6 +135,13 @@ Scope { // the drawer. MouseArea { anchors.fill: parent } + FileView { + id: heartbeat + path: `${Quickshell.env("HOME")}/.local/state/mail-watcher.heartbeat` + onLoaded: root.readHeartbeat(text()) + onLoadFailed: root.readHeartbeat("") + } + Column { id: content anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16 } @@ -230,17 +277,36 @@ Scope { Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + // Watcher health, above the button. Green when idling, + // yellow when a folder gave up, red when there is no fresh + // heartbeat at all. + Item { + width: parent.width + implicitHeight: 22 + + Rectangle { + id: watcherDot + anchors.verticalCenter: parent.verticalCenter + width: 8; height: 8; radius: 4 + color: root.watcherColor + } + + Text { + anchors { left: watcherDot.right; leftMargin: 10; verticalCenter: parent.verticalCenter } + text: root.watcherText + color: Theme.subtext + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize - 2 + } + } + + Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } + Row { anchors.right: parent.right spacing: 10 Button { - text: Accounts.syncing ? "Syncing..." : "Sync now" - enabled: !Accounts.syncing - onClicked: Accounts.sync() - } - - Button { text: "Open qtmaildir" onClicked: { Accounts.openClient(); root.close(); } } diff --git a/mail-overview/README.md b/mail-overview/README.md index daf2bf9..68ad051 100644 --- a/mail-overview/README.md +++ b/mail-overview/README.md @@ -16,7 +16,9 @@ Clicking the icon opens the drawer; Escape or a click outside closes it. │ ● Account D 0 │ │ ● Account E 0 │ ├──────────────────────────────────────────────────┤ - │ [Sync now] [Open qtmaildir] │ + │ ● watcher ok · 25 folders │ + ├──────────────────────────────────────────────────┤ + │ [Open qtmaildir] │ └──────────────────────────────────────────────────┘ ## Running it @@ -145,8 +147,36 @@ open. `startup_account` in its config is a static setting, not a flag, so here was rejected rather than deferred: it would write the database behind a possibly running client. -"Sync now" runs `~/bin/mailsync.sh`, which is already lock-protected against a -concurrent cron run, and the watcher picks up whatever it commits. +"Open qtmaildir" launches the client. There is no "Sync now" button: the +watcher triggers a sync the moment mail lands, the cron tick is the backstop, +and `on-click-right` on the waybar module runs `~/bin/mailsync.sh` for a manual +pull. + +## The watcher status dot + +Below the accounts, above the button, a coloured dot reports whether +`mail-watcher` is alive and sane, read from its heartbeat at +`~/.local/state/mail-watcher.heartbeat`: + +- **green** (`watcher ok · N folders`) heartbeat fresh, no dead threads. +- **yellow** (`N folder(s) dead, check the log`) heartbeat fresh, but a folder + gave up permanently. +- **red** (`watcher not running`) heartbeat missing, unparseable, or older than + 300s. + +Backoff never turns the dot: it is normal recovery from a dropped IDLE +connection, and the watcher's own health check treats it as healthy. The +staleness rule is the same one `mail-watcher` uses (`heartbeat_is_healthy`), +reimplemented here in a few lines rather than shelling out to +`mail-watcher.py --status` on every open. + +The heartbeat is read once per open. The drawer is a `LazyLoader`, so closing +and reopening rebuilds the `FileView` and reads the file current. A file watch +is deliberately not used: the heartbeat is written by atomic replace (tmpfile +then rename), so an inotify watch held on the old inode dies with it, which is +the same trap as watching a file inside the Xapian directory. The cost is that +a drawer left open does not update until reopened, which for 60s heartbeat data +is not worth a timer. ## Theme and blur |
