feat: initial Z.AI Tray Checker implementation #1

Merged
bjoern merged 4 commits from feature/initial-implementation into main 2026-06-28 17:54:21 +02:00
Member

Z.AI Tray Checker — Initial Implementation

A KDE Plasma system tray tool for monitoring Z.AI GLM Coding Plan usage.

What's included

Component Description
API client (api_client.py) Z.ai Monitor API client — fetches 5-hour quota + weekly usage stats
Peak hours (peak_hours.py) Detects 14:00–18:00 UTC+8 peak window (3× quota multiplier)
Config (config.py) Persistent settings in ~/.config/zai-tray-checker/settings.json
Credentials (credentials.py) API key stored in KDE Wallet via keyring (Secret Service API)
GUI (main.py) QSystemTrayIcon + popup panel with usage bars, peak banner, color-coded states
Assets 3 SVG tray icons (green/amber/red), .desktop autostart entry, systemd user unit

Features

  • 📊 5-hour rolling quota bar with color coding (green < 70%, amber 70-90%, red ≥ 90%)
  • 📈 Weekly prompt count + token usage
  • ⚠️ Peak hours banner showing current multiplier (3× peak / 2× off-peak) and time until status change
  • 🔐 API key stored securely in KDE Wallet — never written to disk
  • 🎨 System theme aware (follows KDE dark/light automatically)
  • 🔄 Auto-refresh every 2 minutes + manual refresh button
  • 🖱️ Click tray icon → popup panel; right-click → context menu

API endpoints used

Reverse-engineered from the zai-usage-tracker VS Code extension:

Endpoint Purpose
GET /api/monitor/usage/quota/limit 5-hour rolling window token quota
GET /api/monitor/usage/model-usage Prompt/token counts for a time range

Auth: bare token in Authorization header.

Tests

35 unit tests, all passing:

  • test_api_client.py (13 tests) — quota parsing, usage stats, auth errors, header format, fetch_all
  • test_peak_hours.py (14 tests) — peak detection, boundary conditions, midnight wrap, time-until-change
  • test_config.py (5 tests) — load/save, defaults, invalid JSON, API key never persisted
============================== 35 passed in 0.14s ==============================

How to run

pip install -e ".[dev]"
pytest                    # run tests
python -m zai_tray_checker.main  # launch app (requires display + system tray)

Roadmap (future PRs)

  • Notifications when quota crosses thresholds (70%, 90%)
  • RPM packaging for Fedora
  • Release pipeline (Forgejo Actions — auto-attach binary on release)
  • Full icon polish

Notes

  • The .desktop file is in assets/ — copy to ~/.config/autostart/ for autostart
  • System packages needed on Fedora KDE: python3-pyside6 python3-keyring python3-keyring-kwallet
  • The tray fetches data in a background QThread to avoid blocking the UI
## Z.AI Tray Checker — Initial Implementation A KDE Plasma system tray tool for monitoring Z.AI GLM Coding Plan usage. ### What's included | Component | Description | |---|---| | **API client** (`api_client.py`) | Z.ai Monitor API client — fetches 5-hour quota + weekly usage stats | | **Peak hours** (`peak_hours.py`) | Detects 14:00–18:00 UTC+8 peak window (3× quota multiplier) | | **Config** (`config.py`) | Persistent settings in `~/.config/zai-tray-checker/settings.json` | | **Credentials** (`credentials.py`) | API key stored in KDE Wallet via `keyring` (Secret Service API) | | **GUI** (`main.py`) | `QSystemTrayIcon` + popup panel with usage bars, peak banner, color-coded states | | **Assets** | 3 SVG tray icons (green/amber/red), `.desktop` autostart entry, systemd user unit | ### Features - 📊 5-hour rolling quota bar with color coding (green < 70%, amber 70-90%, red ≥ 90%) - 📈 Weekly prompt count + token usage - ⚠️ Peak hours banner showing current multiplier (3× peak / 2× off-peak) and time until status change - 🔐 API key stored securely in KDE Wallet — never written to disk - 🎨 System theme aware (follows KDE dark/light automatically) - 🔄 Auto-refresh every 2 minutes + manual refresh button - 🖱️ Click tray icon → popup panel; right-click → context menu ### API endpoints used Reverse-engineered from the [zai-usage-tracker VS Code extension](https://github.com/melon-hub/zai-usage-tracker): | Endpoint | Purpose | |---|---| | `GET /api/monitor/usage/quota/limit` | 5-hour rolling window token quota | | `GET /api/monitor/usage/model-usage` | Prompt/token counts for a time range | Auth: bare token in `Authorization` header. ### Tests **35 unit tests, all passing:** - `test_api_client.py` (13 tests) — quota parsing, usage stats, auth errors, header format, fetch_all - `test_peak_hours.py` (14 tests) — peak detection, boundary conditions, midnight wrap, time-until-change - `test_config.py` (5 tests) — load/save, defaults, invalid JSON, API key never persisted ``` ============================== 35 passed in 0.14s ============================== ``` ### How to run ```bash pip install -e ".[dev]" pytest # run tests python -m zai_tray_checker.main # launch app (requires display + system tray) ``` ### Roadmap (future PRs) - Notifications when quota crosses thresholds (70%, 90%) - RPM packaging for Fedora - Release pipeline (Forgejo Actions — auto-attach binary on release) - Full icon polish ### Notes - The `.desktop` file is in `assets/` — copy to `~/.config/autostart/` for autostart - System packages needed on Fedora KDE: `python3-pyside6 python3-keyring python3-keyring-kwallet` - The tray fetches data in a background `QThread` to avoid blocking the UI
- PySide6 system tray app for KDE Plasma
- Z.AI Monitor API client (quota + weekly usage)
- Peak hours detection (14:00-18:00 UTC+8, 3x multiplier)
- API key stored securely in KDE Wallet via keyring
- Popup panel with usage bars and peak status banner
- System dark/light theme aware
- .desktop autostart entry + systemd user unit
- 35 unit tests (API client, peak hours, config) — all passing
Owner

please don't make up future release tags in the readme. In particular for things we haven't talked about.

One question though: The forgejo runner does have a python install. As long as you use

  • run: |
    python3 -m venv .venv
    . .venv/bin/activate
    pip install

it will work. It is Debian's PEP-668 "externally-managed" Python, so a plain global pip install will error out but above should work. You could potentially run therefore the tests in the cicd here directly

please don't make up future release tags in the readme. In particular for things we haven't talked about. One question though: The forgejo runner does have a python install. As long as you use - run: | python3 -m venv .venv . .venv/bin/activate pip install <pkg> it will work. It is Debian's PEP-668 "externally-managed" Python, so a plain global pip install <pkg> will error out but above should work. You could potentially run therefore the tests in the cicd here directly
Author
Member

🤖 Hermes automated review: minor comments

Reviewed base ffc3e01 → head 35648b1 (19 files, +1669/−1). Ran the test suite locally: 35/35 passed in 0.14s. This is a solid initial implementation with notably good security hygiene for a credentials-handling app:

  • API key is stored in the system keyring (KDE Wallet) and never written to diskSettings.save() explicitly pops api_key before serializing (config.py ~line 43).
  • No hardcoded secrets, no eval/exec, no pickle, no shell injection, no os.system. Network calls use urllib.request with a timeout and structured error handling (api_client.py _request).
  • peak_hours.py correctly handles timezone conversion, midnight-wrap ranges, and naive-vs-aware datetimes; well-tested.
  • The FetchWorker(QThread) keeps the Qt event loop responsive during network fetches.

A few minor observations (non-blocking):

Minor

  • Stale/concurrent fetches on rapid refreshmain.py _fetch_data (~line 470): each refresh creates a new FetchWorker assigned to self._worker, overwriting any previous reference. If the user clicks "Refresh Now" while a fetch is in flight, the old worker keeps running and its finished_signal (still connected) will emit a stale UsageData after the newer one, causing the UI to briefly show outdated values. Consider guarding with if self._worker and self._worker.isRunning(): return (or disconnect the old signal and quit()/wait() the previous worker before starting a new one).
  • FetchWorker defined inside the methodmain.py ~line 475: the QThread subclass and the from PySide6.QtCore import QThread, Signal as QSignal import live inside _fetch_data, so the class is redefined on every fetch. Works fine, but hoisting the class to module scope (and the import to the top) is the conventional pattern and makes it easier to extend (e.g. error signal, cancellation).
  • load_api_key swallows all exceptions as "no key"credentials.py ~line 35: except Exception: return None means a genuinely broken keyring (missing backend, DBus error, etc.) is indistinguishable from "no key stored", and the app will silently prompt for re-entry. Acceptable for resilience in a tray app, but a debug-level log of the swallowed exception would help users diagnose why they're repeatedly asked for the key.
  • fetch_usage_stats weekly window uses UTC end-of-dayapi_client.py ~line 140: end = now.replace(hour=23, minute=59, second=59, ...) is computed in UTC, then start = end - timedelta(days=days). Since Z.ai's quota window is UTC+8, the "7-day" boundary is off by up to 8 hours. Almost certainly immaterial for a weekly prompt/token tally, but worth noting if the numbers ever need to align with Z.ai's own dashboard.
  • Settings.api_key fieldconfig.py ~line 26: the field exists on the persisted dataclass with a comment "Only used transiently; key stored in keyring." Since save() pops it, it's safe, but having a transient field on a @dataclass that is also load()ed (via cls(**data)) is slightly surprising. If someone ever writes the key into settings JSON by another code path, load() would happily hydrate it. A property or a separate transient holder would be more self-documenting, but this is cosmetic.

main.py (GUI) is understandably untested; the pure-logic modules (api_client, peak_hours, config) have good unit coverage. No blocking issues found.

Automated daily review. I never merge PRs. Note: this is a PR conversation comment with file:line references, not a formal Forgejo review approval — the MCP integration cannot create inline review comments or approval states.

## 🤖 Hermes automated review: minor comments Reviewed base `ffc3e01` → head `35648b1` (19 files, +1669/−1). Ran the test suite locally: **35/35 passed** in 0.14s. This is a solid initial implementation with notably good security hygiene for a credentials-handling app: - ✅ API key is stored in the system keyring (KDE Wallet) and **never written to disk** — `Settings.save()` explicitly `pop`s `api_key` before serializing (`config.py` ~line 43). - ✅ No hardcoded secrets, no `eval`/`exec`, no `pickle`, no shell injection, no `os.system`. Network calls use `urllib.request` with a timeout and structured error handling (`api_client.py` `_request`). - ✅ `peak_hours.py` correctly handles timezone conversion, midnight-wrap ranges, and naive-vs-aware datetimes; well-tested. - ✅ The `FetchWorker(QThread)` keeps the Qt event loop responsive during network fetches. A few minor observations (non-blocking): ### Minor - **Stale/concurrent fetches on rapid refresh** — `main.py` `_fetch_data` (~line 470): each refresh creates a new `FetchWorker` assigned to `self._worker`, overwriting any previous reference. If the user clicks "Refresh Now" while a fetch is in flight, the old worker keeps running and its `finished_signal` (still connected) will emit a stale `UsageData` *after* the newer one, causing the UI to briefly show outdated values. Consider guarding with `if self._worker and self._worker.isRunning(): return` (or disconnect the old signal and `quit()`/`wait()` the previous worker before starting a new one). - **`FetchWorker` defined inside the method** — `main.py` ~line 475: the `QThread` subclass and the `from PySide6.QtCore import QThread, Signal as QSignal` import live inside `_fetch_data`, so the class is redefined on every fetch. Works fine, but hoisting the class to module scope (and the import to the top) is the conventional pattern and makes it easier to extend (e.g. error signal, cancellation). - **`load_api_key` swallows all exceptions as "no key"** — `credentials.py` ~line 35: `except Exception: return None` means a genuinely broken keyring (missing backend, DBus error, etc.) is indistinguishable from "no key stored", and the app will silently prompt for re-entry. Acceptable for resilience in a tray app, but a debug-level log of the swallowed exception would help users diagnose why they're repeatedly asked for the key. - **`fetch_usage_stats` weekly window uses UTC end-of-day** — `api_client.py` ~line 140: `end = now.replace(hour=23, minute=59, second=59, ...)` is computed in UTC, then `start = end - timedelta(days=days)`. Since Z.ai's quota window is UTC+8, the "7-day" boundary is off by up to 8 hours. Almost certainly immaterial for a weekly prompt/token tally, but worth noting if the numbers ever need to align with Z.ai's own dashboard. - **`Settings.api_key` field** — `config.py` ~line 26: the field exists on the persisted dataclass with a comment "Only used transiently; key stored in keyring." Since `save()` pops it, it's safe, but having a transient field on a `@dataclass` that is also `load()`ed (via `cls(**data)`) is slightly surprising. If someone ever writes the key into settings JSON by another code path, `load()` would happily hydrate it. A property or a separate transient holder would be more self-documenting, but this is cosmetic. `main.py` (GUI) is understandably untested; the pure-logic modules (`api_client`, `peak_hours`, `config`) have good unit coverage. No blocking issues found. *Automated daily review. I never merge PRs. Note: this is a PR conversation comment with file:line references, not a formal Forgejo review approval — the MCP integration cannot create inline review comments or approval states.*
fix: address PR review feedback
All checks were successful
CI / test (pull_request) Successful in 13s
5ab1db56ee
Björn's feedback:
- Remove made-up roadmap/version tags from README
- Add CI workflow to run tests on Forgejo Actions (venv for PEP-668)

Automated review (non-blocking, all addressed):
- Guard against stale concurrent fetches in _fetch_data
- Hoist FetchWorker to module scope (was redefined per call)
- Add debug logging to load_api_key/delete_api_key exception paths
- Fix weekly usage window to use UTC+8 (Z.ai timezone) not UTC
- Remove api_key field from Settings dataclass entirely
- Add field filtering in Settings.load() to reject unknown keys
- Add test for unknown field filtering (36 tests total)
Author
Member

Thanks for the feedback! All addressed in 5ab1db5:

README roadmap removed — you're right, I shouldn't make up version tags for things we haven't discussed. Stripped the entire Roadmap section.

CI workflow added.forgejo/workflows/ci.yml runs pytest on every push/PR using the venv pattern you suggested:

python3 -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"
pytest -v

Uses runs-on: docker (Debian runner). The tests are pure-logic (no display needed), so they should run cleanly.

Also addressed the automated review's non-blocking items while I was at it:

  • Guarded _fetch_data against stale concurrent fetches (if self._worker.isRunning(): return)
  • Hoisted FetchWorker to module scope (was redefined on every call)
  • Added logger.debug() to load_api_key/delete_api_key exception paths
  • Fixed weekly usage window to compute in UTC+8 (Z.ai's timezone) instead of UTC
  • Removed api_key field from Settings dataclass entirely — it was never needed there, and now load() also filters unknown keys for forward-compatibility

36 tests, all passing.

Thanks for the feedback! All addressed in `5ab1db5`: **README roadmap removed** — you're right, I shouldn't make up version tags for things we haven't discussed. Stripped the entire Roadmap section. **CI workflow added** — `.forgejo/workflows/ci.yml` runs `pytest` on every push/PR using the venv pattern you suggested: ```yaml python3 -m venv .venv . .venv/bin/activate pip install -e ".[dev]" pytest -v ``` Uses `runs-on: docker` (Debian runner). The tests are pure-logic (no display needed), so they should run cleanly. **Also addressed the automated review's non-blocking items while I was at it:** - Guarded `_fetch_data` against stale concurrent fetches (`if self._worker.isRunning(): return`) - Hoisted `FetchWorker` to module scope (was redefined on every call) - Added `logger.debug()` to `load_api_key`/`delete_api_key` exception paths - Fixed weekly usage window to compute in UTC+8 (Z.ai's timezone) instead of UTC - Removed `api_key` field from `Settings` dataclass entirely — it was never needed there, and now `load()` also filters unknown keys for forward-compatibility 36 tests, all passing.
Author
Member

🤖 Hermes automated review: no blocking issues found

Re-reviewed the fix commit 5ab1db5 (base ffc3e01, delta from previous head 35648b1). CI/CD: CI / test (13s) passed for head 5ab1db5 — local build/test skipped per CI policy.

All items from the previous review and from @bjoern's feedback are properly addressed:

  • README roadmap removed — no more made-up version tags
  • CI workflow added (.forgejo/workflows/ci.yml) — uses the venv pattern Björn suggested; tests run cleanly on the Debian runner
  • Stale concurrent fetch guardif self._worker and self._worker.isRunning(): return prevents race-condition UI updates
  • FetchWorker hoisted to module scope — no longer redefined on every fetch
  • Keyring exception logginglogger.debug() added to load_api_key/delete_api_key catch paths
  • Weekly usage window timezone fix — now computes in UTC+8 (Z.ai's timezone) instead of UTC
  • api_key field removed from Settingsload() now filters unknown keys for forward-compatibility; the dataclass no longer has a misleading transient field

The security posture remains solid: no hardcoded secrets, API key stays in the keyring, network calls use urllib with timeout and structured error handling.

Minor (non-blocking)

  • .gitattributes:1root = true is not a valid git attribute — this is an EditorConfig directive. Git emits " is not a valid attribute name" on every operation. The root = true line belongs in .editorconfig, not .gitattributes. Purely cosmetic (the text=auto eol=lf and indent rules still work), but removing it silences the warning. (Note: this line was present in the previous commit too — 5ab1db5 did not introduce it.)

No blocking issues found. This is not a formal Forgejo approval — the MCP integration cannot create review approval states.


Automated daily review. I never merge PRs.

## 🤖 Hermes automated review: no blocking issues found Re-reviewed the fix commit `5ab1db5` (base `ffc3e01`, delta from previous head `35648b1`). CI/CD: **CI / test** ✅ (13s) passed for head `5ab1db5` — local build/test skipped per CI policy. All items from the previous review and from @bjoern's feedback are properly addressed: - ✅ **README roadmap removed** — no more made-up version tags - ✅ **CI workflow added** (`.forgejo/workflows/ci.yml`) — uses the venv pattern Björn suggested; tests run cleanly on the Debian runner - ✅ **Stale concurrent fetch guard** — `if self._worker and self._worker.isRunning(): return` prevents race-condition UI updates - ✅ **`FetchWorker` hoisted to module scope** — no longer redefined on every fetch - ✅ **Keyring exception logging** — `logger.debug()` added to `load_api_key`/`delete_api_key` catch paths - ✅ **Weekly usage window timezone fix** — now computes in UTC+8 (Z.ai's timezone) instead of UTC - ✅ **`api_key` field removed from `Settings`** — `load()` now filters unknown keys for forward-compatibility; the dataclass no longer has a misleading transient field The security posture remains solid: no hardcoded secrets, API key stays in the keyring, network calls use `urllib` with timeout and structured error handling. ### Minor (non-blocking) - **`.gitattributes:1` — `root = true` is not a valid git attribute** — this is an EditorConfig directive. Git emits `" is not a valid attribute name"` on every operation. The `root = true` line belongs in `.editorconfig`, not `.gitattributes`. Purely cosmetic (the `text=auto eol=lf` and indent rules still work), but removing it silences the warning. (Note: this line was present in the previous commit too — `5ab1db5` did not introduce it.) No blocking issues found. This is not a formal Forgejo approval — the MCP integration cannot create review approval states. --- *Automated daily review. I never merge PRs.*
bjoern merged commit 50efb8cd4f into main 2026-06-28 17:54:21 +02:00
bjoern deleted branch feature/initial-implementation 2026-06-28 17:54:21 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/zai-tray-checker!1
No description provided.